Skip to content

[BUG] JS protocol templates silently drop matches under load #6894

@AuditeMarlow

Description

@AuditeMarlow

Is there an existing issue for this

  • I have searched the existing issues.

Current Behavior

Running the full JS template set against a default Redis instance
(no password, Docker: docker run -d -p 6379:6379 redis:latest) with
nuclei -t ~/nuclei-templates/javascript/ -target localhost:6379 -no-mhe -silent
produces 6 matches (redis-default-logins x 5 passwords + redis-info).
Running the same scan against 26 targets (1 Redis + 25 non-Redis hosts with
open ports) produces 0 matches. All results are silently dropped.

Expected Behavior

JS protocol templates should produce consistent results regardless of how many
other targets are being scanned. The Redis target should always produce its
matches.

Steps To Reproduce

1. Start a Redis container

docker run -d --name redis-repro -p 6379:6379 redis:latest

2. Start 25 TCP listeners

Save as listeners.py:

#!/usr/bin/env python3
"""Start TCP listeners that accept connections but don't speak Redis."""
import socket
import threading
import signal
import sys
import time

PORT_START = 20000
COUNT = int(sys.argv[1]) if len(sys.argv) > 1 else 25
DELAY = 15

def handle_conn(conn):
    try:
        conn.recv(1024)
        time.sleep(DELAY)
        conn.sendall(b"-ERR not redis\r\n")
    except Exception:
        pass
    finally:
        conn.close()

def listener(port):
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    s.bind(('127.0.0.1', port))
    s.listen(5)
    while True:
        try:
            conn, _ = s.accept()
            threading.Thread(target=handle_conn, args=(conn,), daemon=True).start()
        except OSError:
            break

for p in range(PORT_START, PORT_START + COUNT):
    threading.Thread(target=listener, args=(p,), daemon=True).start()

print(f"Listening on {COUNT} ports ({PORT_START}-{PORT_START + COUNT - 1}), delay={DELAY}s")
signal.pause()

Run with:

python3 listeners.py 25

These simulate hosts with open ports that don't speak Redis. Each listener
accepts a connection, waits 15 seconds, then responds with garbage. The
15-second delay is within Nuclei's default JS execution timeout
(JsCompilerExecutionTimeout = 2 x dial timeout = 20s), but long enough to
hold pool slots while the JS libraries attempt protocol negotiation. The open
ports cause isPortOpen pre-conditions to pass, so JS templates execute against
all 25 listeners and consume pool slots.

3. Generate target list

25 haystack + 1 Redis at the end:

python3 -c "
for i in range(20000, 20025):
    print(f'localhost:{i}')
print('localhost:6379')
" > targets.txt

4. Baseline -- single target

$ nuclei -t ~/nuclei-templates/javascript/ -target localhost:6379 -no-mhe -silent
redis-default-logins  localhost:6379 passwords=""
redis-default-logins  localhost:6379 passwords="root"
redis-default-logins  localhost:6379 passwords="password"
redis-default-logins  localhost:6379 passwords="admin"
redis-default-logins  localhost:6379 passwords="iamadmin"
redis-info            localhost:6379

6 matches.

5. Under load -- 26 targets, full JS template set

$ nuclei -t ~/nuclei-templates/javascript/ -l targets.txt -no-mhe -silent
(no output)

0 matches. All results silently dropped. Consistently reproducible across
multiple runs.

Relevant log output

# Single target (3 runs, consistent):
$ nuclei -t ~/nuclei-templates/javascript/ -target localhost:6379 -no-mhe -silent | wc -l
6

# 26 targets (3 runs, consistent):
$ nuclei -t ~/nuclei-templates/javascript/ -l targets.txt -no-mhe -silent | wc -l
0

Environment

  • OS: macOS
  • Nuclei: v3.7.0
  • 103 JS templates from nuclei-templates
  • redis:latest Docker image

Anything else

Both Export and non-Export templates are affected. The baseline produces matches
from redis-default-logins (non-Export) and redis-info (Export) -- both drop
to 0 under load.

Both execution paths are affected

JS templates are routed to one of two execution paths based on whether they use
Export()/ExportAs():

  • Non-Export templates (e.g. vnc-default-login, redis-default-logins) go
    through executeWithoutPooling -- hardcoded 20-slot limit
    (NonPoolingVMConcurrency), not configurable.
  • Export templates (e.g. redis-info) go through
    executeWithPoolingProgram -- 80 slots (configurable via -jsc, but values
    below 100 are silently reset to 100; pooling slots = -jsc minus 20).
  • isPortOpen pre-conditions are non-Export scripts and share the 20-slot
    path.

With 103 JS templates and 26 targets, that's up to ~2,600 JS executions
competing for these slots. Nuclei's default batch size is 25, so all haystack
targets are dispatched in the first batch.

Zombie goroutines exhaust both pools

When scripts connect to non-matching targets, JS library network calls hang
because they use context.TODO(). The outer ExecFuncWithTwoReturns timeout
(20s) abandons the caller, but the goroutine keeps running as a zombie -- still
holding its pool slot via Add()/Done(). Both pooljsc.Add() and
ephemeraljsc.Add() block with context.Background(), so queued goroutines
also pile up indefinitely rather than failing fast when their deadline has
passed.

Partial fix: #6896

#6896 fixes pool slot starvation — the primary cause of the silent match drops.
It propagates the deadline context into ExecuteProgram, replaces Add() with
AddWithContext(ctx) for fail-fast slot acquisition, and adds a watchdog
goroutine that releases the pool slot when the deadline expires even if the
zombie is still running.

Results with the fix (same repro as above, 3 consecutive runs):

Run dev (567794f) fix branch
1 0 matches 15 matches
2 0 matches 33 matches
3 0 matches 9 matches

All runs recover redis-info (Export/pooled path). redis-default-logins
(non-Export/20-slot path) recovers in 2 of 3 runs.

The remaining variance is caused by context.TODO() in the 16 JS library
files. Zombie goroutines still hold TCP connections for up to 15s (the listener
delay) even after the watchdog frees their pool slot. Propagating the execution
context into JS library network calls would make zombies exit at the deadline
instead of waiting for the remote host to respond. That is a separate follow-up.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions