HTB DutyBound Writeup — Hack The Box Season 12

Introduction

This HTB DutyBound writeup walks through a medium Linux machine from Hack The Box Season 12, where several seemingly unrelated security weaknesses combine into a complete system compromise.

The initial foothold comes from a boarding-pass verification endpoint that accepts a structured Bar Coded Boarding Pass (BCBP). Although the application validates mandatory boarding-pass fields, attacker-controlled airline-use data reaches an SQL query constructed through unsafe string interpolation.

The resulting SQL injection allows database-level investigation and, because of the PostgreSQL role’s pg_execute_server_program membership, command execution as the operating-system account running PostgreSQL.

From there, access to an active X11 desktop session provides a route to the kiosk user. Finally, the kiosk user’s membership in a group that can modify a duty-rate CSV combines with a root-owned systemd path-triggered service and an unsafe eval statement to achieve root access.

The machine’s central lesson is that security boundaries must be enforced at every layer. Input validation, SQL parameterization, database privileges, desktop-session isolation, file permissions, and privileged service design all matter.
If you enjoyed this, see my HTB Autopilot writeup, another Season 12 machine.

TL;DR

The attack chain was:

  1. Discover the AirSnacks web application and its boarding-pass verification endpoint.
  2. Construct a valid BCBP that passes the application’s mandatory field checks.
  3. Exploit SQL injection in the audit logging path through attacker-controlled airline-use data.
  4. Abuse the PostgreSQL pg_execute_server_program role membership to execute a command as the postgres operating-system user.
  5. Interact with the active Openbox desktop through X11/XTEST to obtain a shell as dutyfree-kiosk.
  6. Abuse write access to /opt/vrs/data/duty-rates.csv, which triggers a root-owned systemd service containing unsafe shell evaluation.
  7. Obtain root access and recover the machine’s flags.

Reconnaissance: Nmap Scan and Web Enumeration

The first step was to identify the exposed network services.

A full TCP scan revealed three open ports:

PortServiceObservation
22/tcpOpenSSH 8.9p1SSH service
80/tcpnginxRedirects to HTTPS
443/tcpnginx 1.18.0Main web application

The scan was performed with:

nmap -Pn -n -p- --min-rate 1000 -T4 10.129.19.28

I mapped the target IP to its virtual hostname locally:

echo '10.129.19.28 airsnacks.htb' | sudo tee -a /etc/hosts

Visiting http://airsnacks.htb redirected to HTTPS. The website presented itself as AirSnacks, a self-service checkout application.

The main application flow was:

  • /browse — browse available products.
  • /cart — inspect the basket.
  • /checkout/boarding-pass — submit a boarding pass.
  • /exit and /health — additional application routes.

A broader path search did not reveal a separate application or another exposed service. Instead, the most promising lead came from examining the frontend JavaScript.

Discovering the verification endpoint

The JavaScript file /static/app.js contained the scanner request:

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

The interface displayed a message saying that no barcode had been detected. However, the request was still sent to the backend.

This meant the endpoint could be investigated directly using an HTTP client or Burp Suite Repeater, without depending on a physical barcode scanner.

The application also required a basket item before checkout. I created one using the cart endpoint:

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

The cookie jar was used to preserve the lab session between requests. Do not publish real session cookies or authentication tokens in a public write-up.

Reading the error messages

An empty verification request returned BCBP_ERR_FORMAT_CODE, with an error indicating that the input was expected to begin with M at offset zero.

The checkout page also stated that boarding-pass details were recorded in a sales audit log for customs and VAT processing.

These observations provided two useful leads:

  1. The backend expected a structured BCBP rather than arbitrary input.
  2. Successful verification interacted with an audit logging mechanism, making the parsing and database-writing path worth investigating.

Crafting a Valid BCBP Boarding Pass

BCBP is a structured format used to encode boarding-pass information. The endpoint checked mandatory fields before accepting a pass.

The validation messages revealed several constraints:

TestError or resultWhat I learned
Empty inputBCBP_ERR_FORMAT_CODEThe input must begin with M.
Invalid seat formatBCBP_ERR_SEAT_FORMATThe seat value must follow the expected format, such as 012A.
Invalid sequenceBCBP_ERR_SEQUENCE_FORMATThe sequence field must match the required digit-and-letter structure, such as 0001A.
Missing conditional sectionBCBP_ERR_CONDITIONAL_REQUIREDThe conditional section must be present.
Incorrect item 10BCBP_ERR_ITEM10_MISMATCHThe conditional data must contain an accepted value.
Incorrect item 17BCBP_ERR_ITEM17_MISMATCHAnother required value must match the parser’s expectations.

The lesson here is important: a useful test must reach the code path being investigated. If the parser rejects a payload first, it cannot tell us whether the later database operation is vulnerable.

Building a consistent payload

The write-up’s helper generated the fixed fields and conditional section while calculating the conditional section’s hexadecimal length.

The basic structure was:

Fixed BCBP fields
        +
Two-character hexadecimal conditional length
        +
Conditional section

The fixed portion was 58 characters in the one-leg example. The conditional section began with the expected marker and mandatory data.

A successful verification returned HTTP 200, an audit reference, a destination, and an eligibility token. For the test route from LHR to JFK, the response indicated OFF_AIRPORT and exempt: false.

These results confirmed that the application accepted the constructed boarding pass. They did not, by themselves, demonstrate SQL injection.

An interesting application behavior

After identifying the store as AirSnacks — HTB International T2 during database enumeration, I changed the boarding-pass origin to HTB.

The application then returned an INTERNATIONAL regime with exempt: true. The basket reflected a £13.30 tax-free saving.

However, the payment page still displayed a static “Payment unavailable” message and directed customers to a staff-operated till. This behavior did not provide the initial foothold.


SQL Injection in the BCBP Audit Logger

The decisive vulnerability was in the processing of the BCBP conditional section.

After the validated prefix, the application accepted additional airline-use data. The write-up’s helper appended this value to the conditional section and recalculated the section length.

A single quote produced a PostgreSQL error wrapped in the application’s DCS_ERR_INTERNAL response. The error included an unterminated quoted-string message.

This was a strong indication that user-controlled data was being inserted into an SQL string without proper parameterization.

Identifying the database role

An error-based SQL expression caused PostgreSQL to attempt an integer conversion of the current database username. The resulting error exposed:

airsnacks_app

The important observation was not simply that the application returned HTTP 500. It was that the error included a database-specific type-conversion failure and the database role’s name.

This established a more specific finding than an ordinary BCBP validation failure: attacker-controlled data was affecting SQL expression evaluation.

Enumerating the database

The same error-based technique was used in the reported solve to investigate database metadata and application records.

The public schema contained these tables:

  • airport
  • duty_rate
  • airline_ref
  • interline_partner
  • catalog_item
  • store_config
  • bp_scan_audit

The bp_scan_audit table contained a kiosk identifier:

dutyfree-kiosk7

The store_config record identified the site as:

AirSnacks — HTB International T2

These records connected the web application to the duty-free checkout environment and provided useful context for the later privilege-escalation stages.

The critical privilege check

The next important discovery was the PostgreSQL role membership:

pg_execute_server_program

According to the solve results, the application role was not a PostgreSQL superuser and did not possess the server-file read or write roles. However, it had membership in pg_execute_server_program.

That distinction mattered because this role can permit PostgreSQL server-side programs to be executed through features such as COPY ... TO PROGRAM, subject to the database version, role privileges, and execution context.

The write-up also reports that a stacked-statement probe succeeded. Together, these findings showed that the impact went beyond database information disclosure.

Root cause in the audit logger

After obtaining access to the host, inspection of:

/opt/vrs/airsnacksdf/dcs_bridge/audit.py

revealed the underlying coding error.

The record_export_evidence function constructed an INSERT INTO bp_scan_audit statement using Python % string interpolation. The bp.airline_use value was placed inside a quoted SQL value before the statement was executed with cur.execute(sql).

The essential pattern was:

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

cur.execute(sql)

The problem was not that Python’s % operator is inherently unsafe. The problem was using string formatting to insert untrusted data directly into SQL syntax.

The parser validated mandatory BCBP fields, but the trailing airline-use value remained attacker-controlled when the audit query was assembled. As a result, SQL syntax supplied through that value could alter the intended query structure.

Why this vulnerability was critical

The issue combined three weaknesses:

  1. Insufficient trust-boundary enforcement: The trailing airline-use data was not treated as untrusted at the database boundary.
  2. Unsafe SQL construction: String interpolation allowed input to change the structure of the query.
  3. Excessive database capability: The application’s database role had a powerful server-program execution privilege.

A SQL injection vulnerability that only permits reading a few application records is already serious. When the same database role can execute server-side programs, the potential impact can extend to the operating-system account running PostgreSQL.


From postgres to Kiosk User via X11

The next stage involved the graphical desktop session.

The shell obtained through PostgreSQL ran as the operating-system account:

postgres

The account could not directly read the kiosk user’s home directory. Process enumeration, however, revealed that dutyfree-kiosk was already logged into an Openbox desktop on X display :0.

The desktop session was running Firefox in kiosk mode against the AirSnacks website.

Investigating the X11 session

The Openbox configuration at:

/etc/xdg/openbox/rc.xml

bound Ctrl+Alt+T to launch xterm.

The environment also had Python Xlib and its XTEST extension available. Although xdotool was absent, XTEST provided a way to synthesize keyboard events through the active X display.

The write-up reports that this mechanism opened an xterm owned by dutyfree-kiosk. Commands could then be entered into that terminal, resulting in a shell running under the kiosk user’s identity.

The user flag was recovered from:

/home/dutyfree-kiosk/user.txt

The result demonstrated a significant isolation failure: a lower-privileged service account with access to the desktop’s X server could interact with another user’s active graphical session.

Why this worked

X11 was designed around a model in which clients that can access a display may be able to observe or influence session activity. Exposing a live desktop display to a service account can therefore undermine the separation between processes that appear to run under different users.

The lesson is broader than this particular machine: desktop-session access is a security boundary. It should not be granted casually to background services or database processes.


Privilege Escalation: Writable CSV and systemd Path Unit

With access as dutyfree-kiosk, the next task was to identify local privilege-escalation opportunities.

The kiosk account belonged to the pos-ops group. A search for group-owned resources revealed a duty-rate CSV file:

/opt/vrs/data/duty-rates.csv

The file was owned by root:pos-ops and group-writable. Its parent directory was also writable by the group.

The file contained ordinary duty and VAT entries such as:

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

On its own, a writable CSV file might appear to be a data-integrity issue. The more important discovery was how the system consumed it.

The systemd path unit

An active systemd path unit named:

vrs-duty-apply.path

watched:

/opt/vrs/data/duty-rates.csv

When the file changed, the path unit triggered:

vrs-duty-apply.service

The service ran:

/opt/vrs/bin/apply-duty-rates.sh

as root.

This established a direct trust relationship between a file writable by the kiosk group and a script executing with root privileges.

The unsafe eval statement

The shell script read the CSV and checked that the second and third fields contained digits. It then evaluated an assignment constructed from the first field:

eval "${tax_class}=${duty_pct}"

The validation was incomplete. Although the numeric fields were checked, the first field was interpreted as shell syntax.

This was the key vulnerability.

The script treated the CSV as data during parsing but subsequently passed attacker-controlled content to a shell evaluator. The dangerous operation was not merely reading a CSV file; it was executing a string derived from that file.

Why the required variables mattered

The script later checked for required tax-class variables and loaded the rates into PostgreSQL.

The reported exploit preserved the original rows after the crafted first row so that the required-variable checks could still succeed. Because the eval operation occurred before the later database-loading stage, execution could happen before the service processed the rest of the file.

This is a classic example of a data-to-code vulnerability: input that should have remained a value was interpreted as a command.


Root via Unsafe eval

The final privilege-escalation stage combined the group-writable CSV, the active systemd path unit, and the unsafe eval statement.

After modifying the watched file, the path unit triggered the service, which ran as root. The malicious content was evaluated by the privileged shell script before the later checks and database import completed.

The write-up reports that this led to a root shell and access to:

/root/root.txt

The root flag was recovered successfully.

The final escalation did not depend on a conventional SUID binary or an obvious sudo misconfiguration. Instead, it came from a chain involving:

  • Group-based write permissions.
  • A systemd path-triggered service.
  • A privileged shell script.
  • Incomplete input validation.
  • Unsafe use of eval.

Cleanup

The original CSV was restored from a backup, and the restored file was compared with the saved copy. The write-up also notes that the clean file was written again to retrigger the service after an earlier failed invocation.

In an authorized lab, cleanup is part of responsible testing. It is important to restore modified inputs, close temporary listeners and shells, and verify that the service has returned to its normal state.


07. Complete attack-chain summary

The DutyBound compromise can be represented as a sequence of trust-boundary failures:

AirSnacks web application
        |
        v
BCBP verification endpoint
        |
        v
SQL injection in audit logging
        |
        v
PostgreSQL server-program privilege
        |
        v
Shell as postgres
        |
        v
Access to the active X11 desktop
        |
        v
Shell as dutyfree-kiosk
        |
        v
Write access to duty-rates.csv
        |
        v
systemd path-triggered root service
        |
        v
Unsafe shell eval
        |
        v
Root access

The most important feature of the chain is that each stage exposed the next boundary. The SQL injection provided a route into the database host; the database process could reach the desktop session; the kiosk user could modify a privileged service’s input file; and the service interpreted that file as shell code.


How to Defend Against This Attack Chain

DutyBound provides several practical lessons for developers, database administrators, and Linux system administrators.

1. Use parameterized SQL

Never construct SQL statements by interpolating untrusted values into a query string.

Instead, use parameter placeholders and pass values separately through the database driver’s parameter-binding API. This ensures that a value such as airline_use remains data rather than becoming executable SQL syntax.

2. Minimize PostgreSQL privileges

Application roles should have only the privileges required for their intended functions.

In particular:

  • Avoid granting pg_execute_server_program to application roles.
  • Separate application database access from administrative operations.
  • Review role memberships and inherited privileges regularly.
  • Treat database-server command execution capabilities as highly privileged.

3. Isolate the graphical session

A database service should not be able to control an interactive user’s desktop session.

Where graphical interfaces are necessary:

  • Isolate X11 or Wayland sessions.
  • Avoid exposing display sockets and authentication credentials to unrelated service accounts.
  • Run kiosk applications with restricted privileges.
  • Separate background service processes from interactive desktop users.

4. Do not evaluate data as shell code

The duty-rate importer should not use eval to process CSV fields.

Safer alternatives include:

  • Validating the first field against a fixed allowlist of permitted tax classes.
  • Parsing each field as data.
  • Using explicit mappings instead of dynamic shell assignments.
  • Rejecting malformed records before making any changes.

Numeric validation of two CSV fields does not make the remaining fields safe to evaluate.

5. Review file and directory permissions

A file consumed by a root service should not be writable by an untrusted or lower-privileged account unless that access is strictly required and safely constrained.

Review both the file’s permissions and those of its parent directories. Also examine all users and groups that can modify the file, replace it, or influence how it is consumed.

6. Harden systemd services

Path-triggered services should be treated like any other privileged execution mechanism.

Useful measures include:

  • Running services with the least privilege necessary.
  • Applying systemd sandboxing and filesystem restrictions.
  • Avoiding privileged shell interpreters when a safer implementation is possible.
  • Validating input before privileged processing.
  • Ensuring that untrusted users cannot modify files consumed by privileged services.

7. Improve error handling and audit logging

Detailed database errors should not be returned to application clients. The application should return a generic error while recording useful diagnostic information in restricted server logs.

Audit logging must also be implemented safely. Logging is not exempt from input validation or SQL parameterization simply because its purpose is monitoring rather than business logic.


09. Key takeaways

DutyBound reinforced several important offensive-security and defensive-security concepts:

  • Structured input validation can be bypassed at later processing stages when not every field is handled safely.
  • SQL injection can become a host-compromise vulnerability when the database role has excessive privileges.
  • Database processes should not have unnecessary access to interactive desktop sessions.
  • Writable files become dangerous when privileged services trust their contents.
  • A systemd path unit can provide an automatic execution trigger when its watched file is modified.
  • Shell eval is especially risky when its input originates from a file writable by a lower-privileged user.
  • Good penetration-test reporting should distinguish observed evidence from conclusions inferred from the exploit chain.

The overall lesson is that privilege escalation often results from a series of individually overlooked trust relationships rather than a single isolated misconfiguration.

Frequently Asked Questions

Is DutyBound an easy or hard HTB machine?

DutyBound is rated Medium. It has several stages (SQL injection, an X11 pivot and a systemd privilege escalation), but each stage is a single well-defined weakness.

What is BCBP and why is it vulnerable here?

BCBP is the Bar Coded Boarding Pass format. The application validated the mandatory fields but placed the trailing airline-use data into an SQL query by string interpolation.

What does pg_execute_server_program allow?

It lets a role run programs on the database server, for example through COPY ... TO PROGRAM, so SQL injection can become OS command execution as the postgres user.

How do you get root on DutyBound?

As dutyfree-kiosk, write a crafted first field into the group-writable duty-rates.csv. The root-owned vrs-duty-apply.service evaluates it with eval.

How do you prevent this kind of SQL injection?

Use parameterized queries, remove unnecessary database role privileges, and never pass untrusted data to eval.

Conclusion

DutyBound was a useful demonstration of how application-layer and operating-system weaknesses can combine into a complete compromise.

The initial weakness was unsafe SQL construction in the boarding-pass audit path. Excessive PostgreSQL privileges extended the impact to command execution on the database host. Access to the active X11 session provided the kiosk user’s context, and the writable duty-rate CSV combined with a root-triggered service and unsafe eval enabled the final escalation.

For defenders, the priority is to break this chain at multiple points: parameterize SQL, remove unnecessary database privileges, isolate desktop sessions, restrict write access to privileged service inputs, and eliminate unsafe shell evaluation.

For penetration testers, DutyBound is a reminder to follow trust boundaries across the entire application and host—not just to stop when the first shell is obtained.

New to offensive security? Start with the cybersecurity learning path. For the Active Directory side, read the Active Directory attacks guide.

Machine completed: DutyBound — Hack The Box Season 12
Date: 25 October 2026
Platform: Linux · Medium

This write-up describes an authorized Hack The Box lab exercise. The techniques should only be reproduced on systems you own or have explicit permission to test.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top