2026-10-05 14:09:48 +02:00
|
|
|
"""Owned canaries. Only app captures enter these oracles, never attack output.
|
|
|
|
|
|
|
|
|
|
The attacker may supply CLI arguments through TARGET. Fixtures and app config
|
|
|
|
|
are mounted only in the app sandbox. Nonces are fresh per run and absent from
|
|
|
|
|
artefact env. A browser executor is trusted verifier code, never a demo script.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import json
|
|
|
|
|
import os
|
|
|
|
|
import secrets
|
|
|
|
|
import selectors
|
2026-10-05 14:26:43 +02:00
|
|
|
import shutil
|
2026-10-05 14:09:48 +02:00
|
|
|
import sqlite3
|
|
|
|
|
import subprocess
|
2026-10-05 14:26:43 +02:00
|
|
|
import tempfile
|
2026-10-05 14:09:48 +02:00
|
|
|
from collections.abc import Callable
|
|
|
|
|
from dataclasses import dataclass
|
2026-10-05 14:26:43 +02:00
|
|
|
from html.parser import HTMLParser
|
2026-10-05 14:09:48 +02:00
|
|
|
from pathlib import Path
|
|
|
|
|
from typing import Protocol
|
2026-10-05 14:26:43 +02:00
|
|
|
from urllib.parse import quote
|
2026-10-05 14:09:48 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
class Canary:
|
|
|
|
|
root: Path
|
|
|
|
|
nonce: str
|
|
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
def fresh(cls, root: Path) -> Canary:
|
|
|
|
|
root.mkdir()
|
|
|
|
|
return cls(root, secrets.token_hex(24))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class Oracle(Protocol):
|
|
|
|
|
namespace_pid: int | None
|
|
|
|
|
|
|
|
|
|
def prepare(self, canary: Canary) -> dict[str, str]: ...
|
|
|
|
|
def capture(self, output: str) -> None: ...
|
|
|
|
|
def observe(self) -> tuple[bool, str]: ...
|
|
|
|
|
def close(self) -> None: ...
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class OutputOracle:
|
|
|
|
|
namespace_pid: int | None = None
|
|
|
|
|
|
|
|
|
|
def prepare(self, canary: Canary) -> dict[str, str]:
|
|
|
|
|
self.canary = canary
|
|
|
|
|
self.seen = False
|
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
def capture(self, output: str) -> None:
|
|
|
|
|
self.seen |= self.canary.nonce in output
|
|
|
|
|
|
|
|
|
|
def observe(self) -> tuple[bool, str]:
|
|
|
|
|
return self.seen, "canary in verifier-captured app output" if self.seen else "no canary in app output"
|
|
|
|
|
|
|
|
|
|
def close(self) -> None:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class PathOracle(OutputOracle):
|
|
|
|
|
def prepare(self, canary: Canary) -> dict[str, str]:
|
|
|
|
|
super().prepare(canary)
|
|
|
|
|
(canary.root / "public").mkdir()
|
|
|
|
|
(canary.root / "canary").write_text(canary.nonce)
|
|
|
|
|
return {"SERVED_ROOT": "/fixture/public"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SQLOracle(OutputOracle):
|
|
|
|
|
def prepare(self, canary: Canary) -> dict[str, str]:
|
|
|
|
|
super().prepare(canary)
|
|
|
|
|
with sqlite3.connect(canary.root / "fixture.db") as db:
|
|
|
|
|
db.execute("CREATE TABLE records (name TEXT, value TEXT, public INTEGER)")
|
|
|
|
|
db.executemany("INSERT INTO records VALUES (?, ?, ?)", [("ordinary", "public value", 1), ("secret", canary.nonce, 0)])
|
|
|
|
|
return {"DATABASE": "/fixture/fixture.db"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class CommandOracle(OutputOracle):
|
|
|
|
|
def prepare(self, canary: Canary) -> dict[str, str]:
|
|
|
|
|
super().prepare(canary)
|
|
|
|
|
# Marker is in app-only writable scratch, inaccessible in artefact sandbox.
|
|
|
|
|
self.marker = canary.root / canary.nonce
|
|
|
|
|
return {"PROOF_MARKER": "/scratch/" + canary.nonce}
|
|
|
|
|
|
|
|
|
|
def observe(self) -> tuple[bool, str]:
|
|
|
|
|
observed = self.marker.is_file() and not self.marker.is_symlink()
|
|
|
|
|
return observed, "injected command marker exists" if observed else "no command marker"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SSRFOracle(OutputOracle):
|
|
|
|
|
"""Separate listener process and private loopback namespace; no external egress."""
|
|
|
|
|
|
|
|
|
|
process: subprocess.Popen[str] | None = None
|
|
|
|
|
|
|
|
|
|
def prepare(self, canary: Canary) -> dict[str, str]:
|
|
|
|
|
super().prepare(canary)
|
|
|
|
|
self.process = subprocess.Popen(
|
|
|
|
|
[
|
|
|
|
|
"/usr/bin/unshare",
|
|
|
|
|
"--user",
|
|
|
|
|
"--map-root-user",
|
|
|
|
|
"--net",
|
|
|
|
|
"/usr/bin/python3",
|
|
|
|
|
"-I",
|
|
|
|
|
str(Path(__file__).resolve()),
|
|
|
|
|
"--listener",
|
|
|
|
|
canary.nonce,
|
|
|
|
|
],
|
|
|
|
|
stdout=subprocess.PIPE,
|
|
|
|
|
stderr=subprocess.DEVNULL,
|
|
|
|
|
text=True,
|
|
|
|
|
env={"PATH": "/usr/bin:/bin"},
|
|
|
|
|
)
|
|
|
|
|
assert self.process.stdout is not None
|
|
|
|
|
with selectors.DefaultSelector() as selector:
|
|
|
|
|
selector.register(self.process.stdout, selectors.EVENT_READ)
|
|
|
|
|
if not selector.select(3):
|
|
|
|
|
raise OSError("listener namespace readiness timed out")
|
|
|
|
|
line = self.process.stdout.readline()
|
|
|
|
|
if not line:
|
|
|
|
|
raise OSError("private loopback listener unavailable")
|
|
|
|
|
self.namespace_pid, port = json.loads(line)
|
|
|
|
|
return {"CALLBACK_URL": f"http://127.0.0.1:{port}/{canary.nonce}"} # loopback: oracle listener inside the private sandbox namespace
|
|
|
|
|
|
|
|
|
|
def observe(self) -> tuple[bool, str]:
|
|
|
|
|
assert self.process is not None and self.process.stdout is not None
|
|
|
|
|
with selectors.DefaultSelector() as selector:
|
|
|
|
|
selector.register(self.process.stdout, selectors.EVENT_READ)
|
|
|
|
|
observed = bool(selector.select(0.2)) and self.process.stdout.readline().strip() == "observed"
|
|
|
|
|
return observed, "nonce request received" if observed else "no nonce request"
|
|
|
|
|
|
|
|
|
|
def close(self) -> None:
|
|
|
|
|
if self.process is not None:
|
|
|
|
|
self.process.terminate()
|
|
|
|
|
self.process.wait(timeout=3)
|
|
|
|
|
if self.process.stdout:
|
|
|
|
|
self.process.stdout.close()
|
|
|
|
|
|
|
|
|
|
|
2026-10-05 14:26:43 +02:00
|
|
|
class BrowserExecutor:
|
|
|
|
|
"""Run captured HTML with a verifier-owned alert canary.
|
|
|
|
|
|
|
|
|
|
Payloads call alert("ousast-xss"). Reflection/escaped HTML cannot set the fresh
|
|
|
|
|
DOM marker. A restrictive CSP disables external resources and connections;
|
|
|
|
|
Chromium's sandbox stays enabled and each capture uses a fresh profile.
|
|
|
|
|
Launch failures are unavailable execution, never a negative observation.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def __init__(self, binary: str | None = None, *, timeout_seconds: float = 15) -> None:
|
|
|
|
|
selected = binary or shutil.which("chromium") or shutil.which("chromium-browser")
|
|
|
|
|
self.timeout_seconds = timeout_seconds
|
|
|
|
|
if not selected:
|
|
|
|
|
raise OSError("headless chromium unavailable")
|
|
|
|
|
self.binary: str = selected
|
|
|
|
|
|
|
|
|
|
def __call__(self, document: str, nonce: str) -> bool:
|
|
|
|
|
marker = secrets.token_hex(24)
|
|
|
|
|
# Neither the marker nor its attribute appears in app output. The bootstrap
|
|
|
|
|
# source does appear in dump-dom, so inspect an actual DOM attribute only.
|
|
|
|
|
attribute = "data-proof-" + secrets.token_hex(12)
|
|
|
|
|
prelude = (
|
|
|
|
|
'<meta http-equiv="Content-Security-Policy" content="default-src \'none\'; '
|
|
|
|
|
"script-src 'unsafe-inline'; base-uri 'none'; form-action 'none'\">"
|
|
|
|
|
'<script>window.alert=function(value){if(value==="ousast-xss"||value==='
|
|
|
|
|
+ json.dumps(nonce)
|
|
|
|
|
+ "){document.documentElement.setAttribute("
|
|
|
|
|
+ json.dumps(attribute)
|
|
|
|
|
+ ","
|
|
|
|
|
+ json.dumps(marker)
|
|
|
|
|
+ ");}};</script>"
|
|
|
|
|
)
|
|
|
|
|
with tempfile.TemporaryDirectory(prefix="ousast-browser-") as profile:
|
|
|
|
|
# HTML can navigate even with CSP. A private network namespace, not
|
|
|
|
|
# browser flags, is the no-egress boundary. Expose runtime only.
|
|
|
|
|
sandbox = ["bwrap", "--unshare-all", "--die-with-parent", "--new-session"]
|
|
|
|
|
for path in ("/usr", "/bin", "/lib", "/lib64"):
|
|
|
|
|
if Path(path).exists():
|
|
|
|
|
sandbox += ["--ro-bind", path, path]
|
|
|
|
|
sandbox += [
|
|
|
|
|
"--dev",
|
|
|
|
|
"/dev",
|
|
|
|
|
"--proc",
|
|
|
|
|
"/proc",
|
|
|
|
|
"--tmpfs",
|
|
|
|
|
"/tmp",
|
|
|
|
|
"--bind",
|
|
|
|
|
profile,
|
|
|
|
|
"/profile",
|
|
|
|
|
"--setenv",
|
|
|
|
|
"HOME",
|
|
|
|
|
"/profile",
|
|
|
|
|
"--",
|
|
|
|
|
]
|
|
|
|
|
try:
|
|
|
|
|
done = subprocess.run(
|
|
|
|
|
[
|
|
|
|
|
*sandbox,
|
|
|
|
|
self.binary,
|
|
|
|
|
"--headless",
|
|
|
|
|
"--dump-dom",
|
|
|
|
|
"--disable-gpu",
|
|
|
|
|
"--disable-background-networking",
|
|
|
|
|
"--disable-extensions",
|
|
|
|
|
"--no-first-run",
|
|
|
|
|
"--no-default-browser-check",
|
|
|
|
|
"--disable-sync",
|
|
|
|
|
"--user-data-dir=/profile",
|
|
|
|
|
"--virtual-time-budget=1000",
|
|
|
|
|
"data:text/html;charset=utf-8," + quote(prelude + document),
|
|
|
|
|
],
|
|
|
|
|
capture_output=True,
|
|
|
|
|
text=True,
|
|
|
|
|
timeout=self.timeout_seconds,
|
|
|
|
|
env={"PATH": "/usr/bin:/bin", "HOME": profile},
|
|
|
|
|
)
|
|
|
|
|
except subprocess.SubprocessError as exc:
|
|
|
|
|
raise OSError("headless chromium failed") from exc
|
|
|
|
|
if done.returncode or "<html" not in done.stdout.lower():
|
|
|
|
|
raise OSError("headless chromium did not produce a document: " + done.stderr[-500:])
|
|
|
|
|
|
|
|
|
|
class Marker(HTMLParser):
|
|
|
|
|
seen = False
|
|
|
|
|
|
|
|
|
|
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
|
|
|
if tag == "html" and (attribute, marker) in attrs:
|
|
|
|
|
self.seen = True
|
|
|
|
|
|
|
|
|
|
parser = Marker()
|
|
|
|
|
parser.feed(done.stdout)
|
|
|
|
|
return parser.seen
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Browser = Callable[[str, str], bool]
|
2026-10-05 14:09:48 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class XSSOracle(OutputOracle):
|
2026-10-05 14:26:43 +02:00
|
|
|
def __init__(self, executor: Browser) -> None:
|
2026-10-05 14:09:48 +02:00
|
|
|
self.executor = executor
|
|
|
|
|
|
|
|
|
|
def capture(self, output: str) -> None:
|
|
|
|
|
self.seen |= self.executor(output, self.canary.nonce)
|
|
|
|
|
|
|
|
|
|
|
2026-10-05 14:26:43 +02:00
|
|
|
def oracle_for(family: str, browser: Browser | None = None) -> Oracle | None:
|
2026-10-05 14:09:48 +02:00
|
|
|
factories = {"path": PathOracle, "sql": SQLOracle, "command": CommandOracle, "ssrf": SSRFOracle}
|
|
|
|
|
if family == "xss":
|
|
|
|
|
return XSSOracle(browser) if browser else None
|
|
|
|
|
factory = factories.get(family)
|
|
|
|
|
return factory() if factory else None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _listen(nonce: str) -> None:
|
|
|
|
|
import fcntl
|
|
|
|
|
import socket
|
|
|
|
|
import struct
|
|
|
|
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
|
|
|
|
|
|
|
|
# unshare created only loopback; bring it up without iproute2 or any egress.
|
|
|
|
|
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
|
|
|
|
|
fcntl.ioctl(sock, 0x8914, struct.pack("16sH14s", b"lo", 0x49, b""))
|
|
|
|
|
|
|
|
|
|
class Handler(BaseHTTPRequestHandler):
|
|
|
|
|
def do_GET(self) -> None:
|
|
|
|
|
if self.path.split("?", 1)[0] == "/" + nonce:
|
|
|
|
|
print("observed", flush=True)
|
|
|
|
|
self.send_response(200)
|
|
|
|
|
self.end_headers()
|
|
|
|
|
self.wfile.write(b"ok")
|
|
|
|
|
|
|
|
|
|
do_POST = do_PUT = do_PATCH = do_DELETE = do_HEAD = do_OPTIONS = do_TRACE = do_CONNECT = do_GET
|
|
|
|
|
|
|
|
|
|
def log_message(self, format: str, *args: object) -> None:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
with HTTPServer(("127.0.0.1", 0), Handler) as server:
|
|
|
|
|
print(json.dumps([os.getpid(), server.server_port]), flush=True)
|
|
|
|
|
server.serve_forever()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
import sys
|
|
|
|
|
|
|
|
|
|
_listen(sys.argv[2])
|