Metadata-Version: 2.4
Name: rcekit
Version: 3.15.0
Summary: RCE detection & confirmation toolkit for authorised penetration testing
Author: Ahmad Kabiri
License-Expression: MIT
Project-URL: Homepage, https://github.com/kabiri-labs/rcekit
Project-URL: Documentation, https://github.com/kabiri-labs/rcekit/blob/main/docs/guide.md
Project-URL: Changelog, https://github.com/kabiri-labs/rcekit/blob/main/CHANGELOG.md
Project-URL: Issues, https://github.com/kabiri-labs/rcekit/issues
Keywords: rce,command-injection,ssti,oob,pentesting,security-tools
Classifier: Development Status :: 5 - Production/Stable
Classifier: Environment :: Console
Classifier: Intended Audience :: Information Technology
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: Security
Requires-Python: >=3.8
Description-Content-Type: text/markdown
License-File: LICENSE
Dynamic: license-file

![RCEKit — RCE detection and confirmation toolkit for authorised testing](https://raw.githubusercontent.com/kabiri-labs/rcekit/main/logo/rcekit-banner.png)

# RCEKit

**`executed` means the target executed the input. `negative` means the probes reached it.**

**Version 3.15.0** · MIT · Python 3.8+ · zero third-party dependencies

RCEKit is an **RCE detection &amp; confirmation toolkit** for authorised penetration
testing, red teaming and security research. Point it at a target you are allowed
to test — a URL or a captured HTTP request — and every finding comes back with
the tier it earned.

Every `executed` rests on a value RCEKit generated at random for that probe and
that reflection cannot produce. It comes back through one of 3 channels: a
computed result **present in the response** and absent from a payload-free
control; an **out-of-band callback** carrying a token only the target ever held;
or a value the target stored and RCEKit **fetched back** in a second request —
the token `file` has it write to a path it serves, the product `write`'s
one-liner computes when the target interprets the file it stored, and the product
`include` writes *through the inclusion sink itself* and then fetches back. Weaker signals
keep their own tiers and are never promoted into it. And a run that could not
test something never reports it as clean.

---

## Proof, not "maybe"

RCEKit confirms RCE through **multiple methods** under one CLI. Below it is pointed
at real production software — publicly-documented CVEs, and builds that
demonstrate a method without reproducing one — with every verdict differenced
against a payload-free control:

| Target | Advisory | RCE class | `--methods` | Verdict | Bench case | Recording |
|---|---|---|---|---|---|---|
| Webmin 1.910 | CVE-2019-15107 | OS command injection (results-based) | `reflected` | **`executed`** | yes | GIF |
| Apache Struts2 | S2-001 | Expression injection (OGNL) | `eval` | **`executed`** | yes | GIF |
| Apache Solr 8.11.0 (Log4j 2.14.1) | CVE-2021-44228 | Expression-lookup (Log4Shell/JNDI) | `lookup` | `lookup-sink` | yes | GIF |
| Webmin 1.910 | CVE-2019-15107 | Blind command injection (no output) | `time` | `timing-sink` | yes | GIF |
| Webmin 1.910 | CVE-2019-15107 | OS command injection (self-OOB read-back) | `file` | **`executed`** | yes | — |
| OpenTSDB 2.4.1 | CVE-2023-25826 | Blind command injection (gnuplot) | `oob` | **`executed`** | yes | — |
| OpenTSDB 2.4.1 | CVE-2023-25826 | Blind command injection (gnuplot) | `time` | `timing-sink` | as a control | — |
| Apache HugeGraph 1.2.0 | — | Expression injection (Gremlin/Groovy) | `eval` | **`executed`** | yes | — |
| Apache HugeGraph 1.2.0 | — | OS command injection | `reflected` | **`executed`** | yes | — |
| Spring Boot on fastjson 1.2.83 | — | Deserialization sink | `deser` | `deserialization-sink` | yes | — |
| Apache Tomcat 8.5.19 | CVE-2017-12615 | Write primitive (PUT a JSP) | `write` | **`executed`** | yes | — |
| mongo-express 0.53.0 on MongoDB 3.4 | — | Predicate sink, nothing rendered (Mongo `$where`) | `boolean` | `evaluation-sink` | yes | — |
| GitList 0.6.0 | CVE-2018-1000533 | Argument injection (no-shell `argv` sink) | `argv` | **`executed`** | yes | — |
| PHP 7.1.3 inclusion sink | — | File inclusion (arbitrary read) | `include` | `file-read` | yes | — |
| PHP 7.1.3 inclusion sink | — | File inclusion reaching execution (bare `include` sink) | `include` | **`executed`** | yes | — |
| ThinkPHP 6.0.12 inclusion sink | — | File inclusion reaching execution (loader appends `.php`) | `include` | **`executed`** | yes | — |

3 of those columns say how much weight the row carries, and they are the ones
worth reading before the rest.

**Verdict** is what the run reported against that build. Measured, not what the
method could reach in principle.

**Bench case** says whether [`tests/bench/`](tests/bench/) reproduces the row —
bringing the target up under Docker and checking the verdict **and** its negative
control. All 16 rows do; `python tests/bench/runner.py --all` was last green at
**3.0.1** (2026-10-01), 10/10 cases in 55m48s. That is a point-in-time claim:
the benchmark runs on a cadence, not on every change.

The suite holds 17 cases now, and the 7 added last were each run on their own
rather than as part of an `--all` sweep: `mongo-express-where-boolean` passed in
29.9s, `webmin-cve-2019-15107-enumerated` in 4m32s,
`webmin-cve-2019-15107-time` in 21m21s, `gitlist-cve-2018-1000533-argv` in
18.3s, and the three `include` cases in one cold sweep —
`php-inclusion-file-read` in 2.2s, `php-inclusion-include-execute` in 2.0s and
`thinkphp-lang-include-execute` in 2.7s. So the figure above still describes the last full sweep, which is the
claim it was making.

The controls are not in this table and two of them are the point. On a Webmin
1.910 and an OpenTSDB 2.4.1 that are genuinely vulnerable, `time` reaches its
own proven tier and still does not reach `executed` — a ceiling holding against
real software rather than against a fixture. The OpenTSDB one is the row
reading *as a control* above; the Webmin one now has a case of its own, where
`time` is the subject and the control injects into a parameter on the same
request that no shell reads — so the slope has to be attributable to the sink
rather than to the host. The full 17, controls included, are in
[`tests/bench/README.md`](tests/bench/README.md#status).

**Advisory** is empty where the verdict does not depend on the patch. Both
HugeGraph rows and the fastjson row are `—` deliberately: HugeGraph 1.3.0 answers
the arithmetic exactly as 1.2.0 does — its Gremlin API evaluates Groovy
unauthenticated by design, which was checked by pulling the patched image and
running it — and fastjson resolving an `Inet4Address` is documented autoType
behaviour. Those rows prove a **method** against real software, which is worth
recording; calling them CVE reproductions would be the overclaim this table
exists to avoid.

Each control is the row's real test. Struts2 probed with `reflected` comes back
`negative`, because S2-001 re-evaluates OGNL and there is no shell behind it.
Webmin's `time` signal is held at `timing-sink` on a target where it happens to
be right. And Solr probed with `oob` comes back `negative` **although it is
exploitable** -- `oob` builds shell commands and a `${jndi:...}` sink runs none
of them, which is the gap `lookup` exists to close, measured rather than
asserted.

The Log4Shell row says `lookup-sink`, not `executed`: what the callback proves
is that the sink resolved a URI RCEKit chose. Reaching RCE needs a server that
answers the lookup with a loadable class, and at the default risk tier only
`jndi:dns://` goes out -- a name lookup, with no connection past it for such a
server to answer on.

<details open>
<summary><b><code>reflected</code> — OS command injection, Webmin CVE-2019-15107 → <code>executed</code></b></summary>

<br>

![RCEKit confirming OS command injection on Webmin 1.910 (CVE-2019-15107): the shell computes arithmetic on random operands, the result is reflected in the response and absent from a payload-free control](confirmation-gifs/reflected-webmin-cve-2019-15107.gif)

</details>

<details>
<summary><b><code>eval</code> — OGNL expression injection, Apache Struts2 S2-001 → <code>executed</code></b></summary>

<br>

![RCEKit confirming OGNL expression injection on Apache Struts2 (S2-001): the payload %{a*b} evaluates to the product in the response while the literal a*b does not](confirmation-gifs/eval-struts2-s2-001.gif)

</details>

<details>
<summary><b>out-of-band — blind Log4Shell (CVE-2021-44228) via a DNS callback → <code>lookup-sink</code></b></summary>

<br>

![RCEKit correlating a blind Log4Shell (CVE-2021-44228) DNS callback back to the exact payload that produced it: the token in the queried name is one only the target could have learned by resolving the URI it was handed](confirmation-gifs/oob-log4shell-cve-2021-44228.gif)

</details>

<details>
<summary><b><code>time</code> — blind command injection, Webmin CVE-2019-15107 → <code>timing-sink</code></b></summary>

<br>

![RCEKit measuring a linear timing response on Webmin 1.910 (CVE-2019-15107): response time tracks a controlled 0/N/2N delay series — a proven timing-sink, never executed on its own](confirmation-gifs/time-webmin-cve-2019-15107.gif)

</details>

---

## Quick start

**Install it** — `pipx` keeps the CLI in its own environment, which is what you
want for a tool rather than a library:

```bash
pipx install rcekit          # or: pip install rcekit
```

**Or take just the one file.** The payload corpus is built into the module, so
`rcekit.py` runs on its own with nothing beside it — no install step, no
site-packages, nothing to leave behind. On a client jump box, an air-gapped
host, or anywhere `pip install` is not an option:

```bash
curl -O https://raw.githubusercontent.com/kabiri-labs/rcekit/main/rcekit.py
```

Working from a checkout is the third way, and needs no install either:

```bash
git clone https://github.com/kabiri-labs/rcekit.git
cd rcekit                    # Python 3.8+, standard library only
```

All three run the same code and report the same verdicts. Confirm the corpus
before you point it at anything, spelling the command the way your own path
provides it:

```bash
rcekit --doctor              # installed with pipx or pip
python rcekit.py --doctor    # the single file, or a checkout
```

Every example below uses the second spelling, which works from a checkout and
from the single downloaded file. An installed copy answers to `rcekit` with
exactly the same arguments.

RCEKit takes a target in **three shapes**, and none is a fallback for another. A
`--verify-url` carries a URL and nothing else; `-r` takes a request saved from
your proxy whole — method, path, headers, body and cookies — which is what a
replayable session needs; and `--verify-chain` takes an ordered flow of requests,
which is what a session the tool has to *establish* needs — a login, a CSRF
token read out of one response and spent in the next, a payload in an upload's
content, a trigger that runs it later. Mark the injection point with `FUZZ` (or
`*`), or name a field with `-p`. The scenarios below are shown in the first two
shapes; the third is [Multi-step chains](docs/guide.md#multi-step-chains), and
with `--methods` it carries the same oracles as the other two.

**Internal targets usually need `--insecure`.** A self-signed or mismatched
certificate fails the handshake, and RCEKit reports that as `error`, never as
`negative` — it will not let a TLS failure read as "not vulnerable". The flag
drops every TLS assurance, including OpenSSL's security level and minimum
protocol version, because dated software needs that to complete a handshake at
all.

**The first four scenarios ask what you can observe**, because that decides
which oracle can prove anything and how strong an answer you can get. The fifth
asks a different question — what the sink *is* — and it is there because two
sinks answer nothing on the first axis: both return output, and neither runs a
command. Then the widest run and the narrowest.

### 1. Output comes back in the response — the first pass

Cheap, safe, no state change, and between them `reflected` and `eval` cover
command injection and expression injection. Start here on every target.

```bash
# query string
python rcekit.py --acknowledge-consent \
  --verify-url "https://target.example/lookup?host=FUZZ" \
  --methods reflected,eval --insecure
```

```bash
# captured request
python rcekit.py --acknowledge-consent \
  -r search.req -p q \
  --methods reflected,eval --insecure
```

With no `--environments` this sweeps every sink dialect RCEKit knows — POSIX,
`cmd.exe`, PowerShell and the language runtimes that reach a shell — because
which one sits behind the parameter is the thing you do not know yet. The run
prints its request count before anything fires, and scenario 7 cuts it.

### 2. Nothing comes back, but the target has egress

A `negative` from `reflected` and `eval` is an answer about the **channel**, not
about the sink: a sink that returns no output has nowhere to render the computed
value. `oob` reaches `executed` anyway, by making the target carry a token to a
listener RCEKit runs in-process.

```bash
# query string — a bare IP the target can reach is enough: the token rides in
# the URL path, so the HTTP channel alone reaches executed
python rcekit.py --acknowledge-consent \
  --verify-url "https://target.example/render?q=FUZZ" \
  --methods oob --oob-host 203.0.113.9 \
  --verify-active-risk intrusive --insecure
```

```bash
# captured request — a delegated name adds the DNS shapes, which often cross
# egress filtering that blocks outbound HTTP, and is the only form lookup and
# deser can use at all
python rcekit.py --acknowledge-consent \
  -r search.req -p q \
  --methods oob,lookup,deser --oob-host oob.yourdomain.example \
  --listen-dns-port 53 --verify-active-risk intrusive --insecure
```

`--listen-dns-port 53` needs root and an NS record pointing at the listener.
Handed an address instead, `lookup` and the `deser` gadget build no probes and
say so — a DNS label is the only place either can put a token. `lookup` reaches
`lookup-sink`, and `deser` reaches `deserialization-sink` with the gadget and
`needs-review` without it; none of the three is execution.
[Blind targets](docs/guide.md#blind-targets).

### 3. No output and no egress, but something writable you can read back

`file` writes a random token through the sink and fetches it back in a second
request. The read-back channel does not have to be a web root — `{name}`,
`{path}` and `{path_enc}` cover a download handler, an export endpoint or an LFI
parameter just as well. Every finding prints its own cleanup line.

```bash
# query string — a writable web root, served by the same host
python rcekit.py --acknowledge-consent \
  --verify-url "https://target.example/lookup?host=FUZZ" \
  --methods file \
  --webroot /var/www/html --web-base-url "https://target.example" \
  --verify-active-risk stateful --insecure
```

```bash
# captured request — a writable /tmp plus any endpoint that reads a path back
python rcekit.py --acknowledge-consent \
  -r search.req -p q \
  --methods file \
  --file-write-path /tmp \
  --file-read-url "https://target.example/download?f={path_enc}" \
  --verify-active-risk stateful --insecure
```

With neither egress nor a read-back channel, `--methods time` still measures a
controlled 0/N/2N delay series — but it reports `timing-sink`, which proves the
target honoured a delay and not that a shell ran.
[No-egress targets](docs/guide.md#no-egress-targets).

### 4. Your own request stores the file

An upload, or a `PUT` the server accepts. Nothing in the response is computed,
so `reflected` and `eval` report `negative` against a target that is fully
exploitable. `write` inverts the question: it stores a one-liner that *computes*
a product, fetches the file back, and reads which of three things happened — the
product (`executed`), the source verbatim (`file-write`, an arbitrary write that
is served but not interpreted), or neither.

```bash
# query string — PUT a JSP into the web root, the CVE-2017-12615 shape
python rcekit.py --acknowledge-consent \
  --verify-url "https://target.example/probe.jsp/" \
  --verify-method PUT --verify-data FUZZ --verify-body-location raw \
  --methods write --write-url-template "https://target.example/probe.jsp" \
  --verify-active-risk stateful --insecure
```

```bash
# captured request — a multipart upload, with FUZZ as the part's content
python rcekit.py --acknowledge-consent \
  -r upload.req \
  --methods write --write-url-template "https://target.example/uploads/probe.jsp" \
  --verify-active-risk stateful --insecure
```

`--write-url-template` has to point at exactly the file your own request writes —
it is the channel the proof comes back on. The first of those two is the
CVE-2017-12615 shape and one of the benchmark's cases.
[Upload and write-primitive targets](docs/guide.md#upload-and-write-primitive-targets).

### 5. Nothing you inject works, and the app looks correct

The four above ask what you can observe. This one asks what the sink **is**,
because two sinks return output and still answer nothing to `reflected` and
`eval`: one spawns a program with no shell, and one opens a file. Both reach
`executed`, and on both a results-based shell method reports a clean `negative`
against a target that is fully exploitable — so this is the scenario for a
target you are confident about and cannot move.

**Your value becomes one argument of a program.** `escapeshellarg` quoted it
exactly as documented, every separator arrives as a literal, and no shell ever
sees them. What quoting cannot stop is that argument being read as an *option*,
and an option can name a command the program then runs.

```bash
# query string — a search endpoint that shells out to git
python rcekit.py --acknowledge-consent   --verify-url "https://target.example/repo/tree/master/search?query=FUZZ"   --methods argv --argv-binary git --insecure
```

```bash
# captured request — sweep the catalogue when you cannot name the binary
python rcekit.py --acknowledge-consent   -r search.req -p q   --methods argv --argv-binary auto --insecure
```

`--argv-binary` is required, because the option that does this is specific to
the program: there is no generic flag that makes an arbitrary binary run a
command. `auto` tries the whole catalogue and costs one probe per shape.

**Your value names a file the application opens.** Nothing runs, so there is no
computed value to look for — which is why this needs its own method rather than
a probe shape inside `reflected`.

```bash
# query string — the reads, and they need nothing from you
python rcekit.py --acknowledge-consent   --verify-url "https://target.example/view.php?file=FUZZ"   --methods include --insecure
```

```bash
# captured request — reaching execution, which needs a URL to read the file back
python rcekit.py --acknowledge-consent   -r view.req --verify-url-location raw   --methods include   --include-write-url "https://target.example/{name}"   --include-write-param file   --verify-active-risk stateful --insecure
```

The first command reports `file-read` — arbitrary file read, CWE-22 / CWE-98, a
vulnerability on its own terms and not a weaker RCE. What it is worth depends on
which paths you can reach, so try them before writing a severity down.

The second writes a computed one-liner *through* the sink and fetches the file
back, which is the one route that survives `allow_url_include` being off — off
by default since PHP 5.2, so a wrapper carrying code (`data://`, `php://input`)
is not it. `--verify-url-location raw` is not optional there: the payload is the
whole query string, because the script being included takes its arguments from
it. [When your value names a file](docs/guide.md#when-your-value-names-a-file).

### 6. Everything the tool has — the widest run

**Injection-point enumeration** (`--auto-params`) is reachable only from a
captured request, so the widest single-request run starts from `-r` — worth
knowing before concluding a target is clean. A sink whose session RCEKit has to
*establish* rather than replay is the other direction: that is
`--verify-chain --methods`, which drives these same oracles through an ordered
flow, and is in [Multi-step chains](docs/guide.md#multi-step-chains).

```bash
# query string — the widest a single point can carry
python rcekit.py --acknowledge-consent \
  --verify-url "https://target.example/lookup?host=FUZZ" \
  --methods reflected,eval,time,oob,lookup,deser,boolean,include \
  --oob-host oob.yourdomain.example --listen-dns-port 53 \
  --probe-depth full --confirm-depth every --bridges auto \
  --observe-url "https://target.example/profile" \
  --verify-active-risk stateful --insecure --detect-json findings.json
```

```bash
# captured request — the same, plus every injection point in the capture
python rcekit.py --acknowledge-consent \
  -r search.req --auto-params all --point-order thorough --include-path-segments \
  --methods reflected,eval,time,oob,lookup,deser,boolean,include \
  --oob-host oob.yourdomain.example --listen-dns-port 53 \
  --probe-depth full --confirm-depth every --bridges auto \
  --verify-active-risk stateful --insecure --detect-json findings.json
```

That is 8 of the 11 methods, and the three it leaves out each need something
only you can supply: a write path for `file`, the URL your own upload lands at
for `write`, and the binary whose options to inject for `argv` — scenarios 3, 4
and 5. `include` is in the 8 and reads without being told anything; reaching
`executed` through it needs `--include-write-url` as well, which is scenario 5
too. `--auto-params all` takes every query value,
JSON leaf — booleans and `null` included — XML element, attribute and CDATA
section, form field, multipart part and upload filename, cookie and header, with
each occurrence of a repeated query or form key counted separately, and the
fields inside an encoded blob — a base64 JSON document or a JWT — reached
through their carrier; `--point-order thorough` adds every non-hop-by-hop
header;
`--confirm-depth every` maps every shape the sink accepts instead of stopping at
the first; `--bridges auto` carries the command probes through SQL and XML query
languages; `--observe-url` catches execution that lands on a different request.

**This is a lot of requests, and it changes state.** The cost line prints before
anything fires, and `--max-points` / `--max-payloads` bound it. Run it against
an instance you are allowed to break: `--verify-active-risk stateful` is the
tier for a disposable target, not for production.

No external infrastructure and no config file — the OOB listener runs in-process.

### 7. The target is known — narrow it and go fast

Everything above widens the sweep because the sink is unknown. Once you know it,
pinning the dialect, the context and the shape turns a thousand requests into a
few dozen. Measured at **3.1.2** against a confirming Unix sink: the scenario 1
command sent **1154** probes, `--environments unix` cut that to **124**, and
adding `--contexts raw` to **17**.

```bash
# query string — a known POSIX sink that runs the input as the whole command
python rcekit.py --acknowledge-consent \
  --verify-url "https://target.example/lookup?host=FUZZ" \
  --methods reflected \
  --environments unix --contexts raw \
  --sink-env unix --sink-shape raw \
  --probe-depth quick --evade none --insecure
```

```bash
# captured request — a .NET app on Windows whose sink is PowerShell, with the
# value landing inside single quotes
python rcekit.py --acknowledge-consent \
  -r search.req -p q \
  --methods reflected \
  --environments dotnet --sink-env powershell --sink-shape sq \
  --probe-depth quick --max-payloads 50 --insecure
```

`--environments` names the *runtime* the corpus probes are written for and
`--sink-env` the *shell* behind it, which is why those two differ here: a .NET
application is not itself a shell. Pinning the pair wrong is the one way to
narrow into nothing, and the run says `NOTHING WAS TESTED` rather than
`negative` when it happens.

An example can only name one value per flag, and the rest are in the
reference rather than here: the
[environments](docs/reference.md#environments) — sixteen, and **not the same
set in both modes** — the [sink shapes](docs/reference.md#sink-shape), the
[contexts](docs/reference.md#contexts) and the
[encodings](docs/reference.md#encodings). Each of those lists is checked
against the corpus by a test, which is the reason to read it there rather
than in a second copy here. `--max-points N` bounds the candidate count
under `--auto-params`, where `--max-payloads` bounds the probes per point.

**`--max-payloads` means two different things**, and a budget set for politeness
quietly becomes a coverage gap: on a single point it is one allowance shared by
every method, so `--methods reflected,deser --max-payloads 5` can spend all five
on `reflected` and never ask about deserialization at all. Under `--auto-params`
it is a budget per point per question instead.
[Keeping the run quiet](docs/guide.md#keeping-the-run-quiet) has the measured
numbers, and [Choosing methods](docs/guide.md#choosing-methods) has the per-method
costs.

### What a result looks like

One run, so the output is familiar before you read a real one. The evidence line
is the whole point: the value was computed from operands RCEKit drew at random
for that probe, and it is absent from the payload-free control that every point
carries.

```
[detect] methods: reflected, eval
[detect] sent 1154 probes (1154 result(s)): executed=32, negative=1122

[detect] EXECUTED (32):
  [reflected/unix/attribute] "; echo RKLWBIO$((601914+888755))RKKGSTT$(echo RKICXCE)RKLWBIO"
      (target computed 'RKLWBIO1490669RKKGSTTRKICXCERKLWBIO' (random operands, absent from control))
  [reflected/unix/shell_subshell] $(echo RKGNUKK$((386983+688354))RKSKOKD$(echo RKMNMMP)RKGNUKK)
      (target computed 'RKGNUKK1075337RKSKOKDRKMNMMPRKGNUKK' (random operands, absent from control))
  [reflected/nodejs/shell_backtick] `echo RKFASJY$((586881+137779))RKRROJW$(echo RKGYNAW)RKFASJY`
      (target computed 'RKFASJY724660RKRROJWRKGYNAWRKFASJY' (random operands, absent from control))
  ...
```

Those 32 are one sink answering in 32 probe shapes — the same parameter reached
as a quoted attribute, through a subshell, through a backtick — not 32 findings.
Add `--detect-json` for the same verdicts as machine-readable JSON; the exit
status does not distinguish a confirmation from a clean negative.

**Don't take the GIFs on trust** — [reproduce them yourself](docs/verify-it-yourself.md)
against dockerised Webmin and Struts2 targets in about five minutes.

**Next:** the [**field guide**](docs/guide.md) walks the real situations — captured
requests, WAFs, filtered separators, quoted sinks, blind and no-egress targets —
one worked example each.

---

## What a verdict means

Finding an RCE *candidate* is easy. Reporting one that survives someone else's
retest is the hard part, and it fails in two directions: a "possibly vulnerable"
that turns out to be reflection, and a "not vulnerable" from a run that never
actually tested anything.

RCEKit answers with **thirteen verdicts that are never collapsed into each
other**. Every one of them is named for **what the target did**, not for how
sure RCEKit is:

| Verdict | What it asserts |
|---|---|
| **`executed`** | The target executed the input. It returned a value it could not produce otherwise — computed from operands random to that probe — and that value is absent from a payload-free control. |
| **`timing-sink`** | The target honoured a delay RCEKit injected: response time tracked a randomised `0/N/2N` series with the request index modelled out. Proven. What waited is not shown — a sandbox implementing `sleep` answers the same way — so it is not execution. |
| **`file-write`** | The target stored a file at a path RCEKit chose, and served it back uninterpreted. Arbitrary file write, proven by reading it back (CWE-434) — **a vulnerability in its own right**, not a weaker RCE. |
| **`file-read`** | The target opened a path RCEKit named and returned what it read, and that content is absent from a payload-free control. What proves it is a signature RCEKit knew in advance rather than a value the target computed, which is exactly why it stops here. Arbitrary file read (CWE-22 / CWE-98) — **a vulnerability in its own right**, and what it is worth depends on which paths you can reach. |
| **`evaluation-sink`** | An evaluator consumed the input and partitioned the response on it, through randomised true/false predicates anchored either side. Proven. *Which* evaluator is not shown, and that is the whole limit — so this is **a lead, not a finding**: a MongoDB `$where` sandbox and a SQL engine comparing two numbers answer identically, and only one is a vulnerability. Identify the evaluator before reporting. |
| **`deserialization-sink`** | The target reconstructed an attacker-supplied object graph (CWE-502). Proven, but about a *different property*: reaching RCE from there depends on classpath gadgets, so it is never called RCE. **A confirmed sink** — report it; the gadget analysis is the next piece of work, not a reason to withhold it. |
| **`lookup-sink`** | The target resolved a URI RCEKit handed it — a `${jndi:…}` expression reached a lookup, proven on a callback carrying a token only that probe held. It is a sink, not execution: reaching RCE from there needs a server answering with a loadable class. **A confirmed sink** whose remediation is known regardless — the Log4Shell shape. |
| **`needs-review`** | A real signal that is not proof on its own: the parser fingerprint `deser` reads without a listener, and the static-signature match `--verify-chain` reads in band, which has no payload-free control behind it. The one verdict here that is genuinely a candidate. |
| **`inconclusive`** | Nothing here can be attributed to execution. Either the evidence appeared and the payload-free control carried it too, or the run never gathered it — a response channel too unsteady to carry an answer, or a measurement `--max-payloads` could not afford to finish. It outranks `negative`, because a run that did not look is not a run that found nothing. |
| **`negative`** | Probes were built, reached the target, and found nothing. |
| **`blocked`** | A filter refused the payload where the payload-free control got through, so the sink never saw it. The probes reached something; it was not the target. |
| **`error`** | Nothing reached the target. |
| **`nothing-tested`** | No probes were built at all. |

The moment `executed` and `maybe` blur, `executed` stops meaning anything — so
nothing is ever promoted upward. A timing regression stays `timing-sink` however
clean the slope. A deserialization callback stays `deserialization-sink` however
certain you are that the classpath is exploitable. A response shape that tracks a
predicate stays `evaluation-sink` however cleanly it partitions: measured against
a sandboxed `eval` sink and against a plain SQLite comparison, that oracle
produced the same clean differential in 40 runs each, and only one of those two
is RCE.

**Nothing is rounded down either**, which is the half that used to be wrong.
Three of the rows above — `timing-sink`, `file-write`, `evaluation-sink` — were
reported as `needs-review` until 3.0.0. Each is a settled measurement, so
`needs-review` was RCEKit declaring itself unsure of something it had proven; a
`file-write` finding printed the words "ARBITRARY FILE WRITE confirmed" under a
verdict that said the opposite. A verdict now says what happened and stops
there.

### The other half: a run that tested nothing is never clean

The last three rows are the ones other tools do not have, and they matter more
than they look. A scanner that could not reach the target, that built no probes
because your flags excluded every one of them, or whose every payload was
refused by a WAF, has learned **nothing** about the target — and printing
`negative` there is a lie that reads exactly like safety.

So `blocked`, `error` and `nothing-tested` are first-class verdicts, and
RCEKit says which of them happened and why:

```
[!] No probes were built, so NOTHING WAS TESTED — this is not a negative result.
[!] None of the selected methods (reflected, file) apply to environment(s): sql.
```

It fires wherever a run can quietly become empty: a method that does not apply to
the selected environments, a `--sink-shape` rung the chosen shell has no syntax
for, a `--bridges` selection entirely held back by the safety ceiling, a request
body that broke delivery before it arrived.

**Branch on the verdict, not on the exit status.** The status says whether
RCEKit could carry the run out, not what the run found. `nothing-tested`
exits non-zero, because building no probes is a refusal to run and belongs
with an unusable corpus or a missing `FUZZ`. `error` and `blocked` exit **0**:
the run completed and the verdict is its result, so a wrapper that reads only
`$?` cannot tell an unreachable target from a clean negative. Read the
collapsed `verdict` that `--detect-json` carries, which exists for exactly
this.

A run that was only *partly* blinded gets the same treatment one level down. If
you asked for a second-order oracle and the observed endpoint never answered, the
probe verdicts still stand — but the run tells you they were decided without ever
reading the channel you pointed it at, rather than letting them pass for a
second-order negative.

---

## What it confirms

One CLI, one `--methods` flag, covering the main paths to RCE:

| RCE class | `--methods` | How RCEKit proves it |
|-----------|-------------|----------------------|
| **OS command injection** | `reflected` | Makes the shell compute `$((a+b))` on random operands and collapse `$(echo TAG)`; confirms the *result*, never the literal expression. Written in the sink's own dialect — POSIX, `cmd.exe` or PowerShell. One shape is not injected into a command at all: the `() { :;}` function definition a CGI server's bash parses out of a header it copied into the environment (Shellshock, CVE-2014-6271). |
| **Code / expression injection** — SSTI, SpEL, OGNL, Groovy, `eval()` (CWE-94) | `eval` | Injects `a*b` in every common template syntax (`${…}` `{{…}}` `#{…}` `%{…}` `<%=…%>` `@(…)`, bare); confirms the **product** appears while the literal `a*b` does not. |
| **Blind command injection** (no output) | `time` | Fires a controlled `0/N/2N` delay series and confirms the response time tracks the delay **linearly**; reported `timing-sink` — proven, and not execution: what waited is not shown. |
| **Internal / no-egress** targets | `file` | Writes a random token and fetches it back through *any* read-back path — a web root, an LFI parameter, a download or export handler, a `/tmp`-backed preview. Proves execution **plus** a write primitive, with no external listener. |
| **Upload / write primitive** — PUT-a-JSP, unchecked upload (CWE-434) | `write` | Writes a one-liner that *computes* a product through your own upload request, then fetches the file: the product is `executed` RCE, the source coming back verbatim is `file-write` — arbitrary file write, served but not interpreted. |
| **Deserialization sinks** — fastjson, shiro, weblogic (CWE-502) | `deser` | Proves the endpoint **deserializes** attacker data, via a non-executing DNS gadget or an error-shape differential. Reported as `deserialization-sink`, **never** as RCE. |
| **Blind / out-of-band** — exfil, async | `oob` | Built-in HTTP/DNS listener receives callbacks and correlates each to the exact payload; every probe carries its own token. |
| **Predicate sinks with nothing rendered** — MongoDB `$where`, filter and rule expressions | `boolean` | Fires randomised true/false comparisons on random operands and reads the *shape* of the response, anchored either side so a target that merely drifts cannot answer in its place. Reported `evaluation-sink`: proven that an evaluator consumed the input, and no more — a query engine comparing two numbers produces the same differential. |
| **Expression-lookup sinks** — Log4Shell/JNDI | `lookup` | The sink resolves a `${jndi:…}` URI instead of running a command, so `oob`'s shell probes reach nothing. Proves it on the callback alone and reports `lookup-sink`, **never** `executed`. Only `jndi:dns://` is sent — a name lookup and nothing else — so what is proven is the lookup, not a gadget chain. |
| **Argument injection** — a no-shell `argv` sink | `argv` | The value becomes one ARGUMENT of a program spawned without a shell, so every separator is inert and the application may be doing the correct thing — `escapeshellarg` quotes it exactly as documented. What it cannot stop is that argument being read as an *option*, and an option can name a command the program then runs. Confirms the same computed value `reflected` does, and names the option shape that worked. Needs `--argv-binary`. |
| **File inclusion** — a path the request names (CWE-22 / CWE-98) | `include` | The sink opens the path and returns what it read. Sweeps the traversal depth from an absolute path downward, because which one works is a property of the sink's own directory, and reads through a PHP stream filter as well as plainly. Proven on content absent from the payload-free control, and the path itself is the *forbidden* literal: a sink that cannot open it echoes it back inside its own error. The read rungs report `file-read` and never `executed` — their evidence is a signature known in advance, not a value the target computed. Given `--include-write-url` it also writes a computed one-liner *through* the sink and fetches the file back, which reaches `executed`, or `file-write` where the file is served verbatim — each depth writing its own file and computing its own product, so a depth that failed cannot confirm on one that worked. Both traversal shapes go out, because whether the path must name `pearcmd.php` or `pearcmd` depends on whether the sink appends an extension of its own, and at most one can confirm on a given sink. A wrapper carrying code (`data://`, `php://input`) is not that route: both need `allow_url_include`, off by default since PHP 5.2. |

Four things widen where those methods can reach, without changing what any of
them will call `executed`:

- **Multi-step chains** (`--verify-chain` with `--methods`) — a sink behind a
  login, a CSRF token read from one response and spent in the next, a payload
  carried in an upload's content, a trigger that runs it later. The chain is the
  *delivery* the engine sends through, so the oracles are the ones above and the
  payload-free control is a chain run of its own. `time` and `boolean` are
  refused by name there: both read the shape of a response series, which a
  chain's setup steps and per-probe session change rather than the sink.
- **Second-order execution** (`--observe-url`) — when the payload lands on one
  request and runs on another: stored SSTI rendered on a profile page, a payload
  written to a log a template engine later renders, a queued job. The observed
  endpoint is differenced against a snapshot taken *before* any probe was sent,
  and the finding keeps the method's own tier: the observed channel changes where
  the evidence appeared, not what it proves, so a file read seen there is
  `file-read` and not execution.
  Where nothing runs until something asks — a queue a worker drains, a report
  generated on demand, a "run now" button — `--observe-trigger` is the missing
  third act: inject, **trigger**, read. It fires before every observed read —
  once per probe, once per poll, once for the control — because a store that
  overwrites keeps one value, so triggering only at the end would test the last
  probe and none of the rest. Its own response is never read for evidence.
- **Query-language bridges** (`--bridges`) — `COPY … FROM PROGRAM`,
  `xp_cmdshell`, `expect://`. A bridge is a carrier, not an oracle: it wraps the
  command the methods already build, so the same tiers apply through it.
- **Injection-point enumeration** (`-p all`) — query, JSON leaves (every scalar,
  **booleans and `null` included**), XML and SOAP elements, attributes and CDATA
  sections, form fields, multipart parts and the **filenames** their uploads
  declare, cookies, headers and path segments, each encoded for where it lands,
  with the probe cost printed before anything fires. A query or form key posted
  more than once has **each occurrence** as its own point, addressed by position:
  addressing by name alone rewrote the first one for every candidate, so the
  later ones were counted as covered and probed nowhere. An **encoded blob** —
  a base64 or base64url JSON document, or a **JWT** — is one opaque string to
  every other kind, so the fields inside it get points of their own, named
  `state>role`, `tok>sub`. The marker cannot go inside the encoding, because
  base64 destroys it and the request would go out carrying the blob unchanged;
  it replaces the whole carrier value, and the payload is put inside the blob
  when the request is rendered, then re-encoded for the carrier. A JWT's
  signature is invalidated by construction — an application that accepts the
  rewritten token is not verifying it, which is the finding, and one that
  rejects it reports `blocked` rather than a clean negative.
  A GraphQL body is ordered by what can actually confirm: the `variables` a
  resolver reads before the operation document itself. An XML body is spliced at
  the value's offsets rather than re-emitted, so a whitespace-significant
  envelope survives and a DOCTYPE in an XXE capture is never resolved by RCEKit
  itself; a *signed* envelope is not probed, and the run says so instead of
  reporting zero points. A filename is rewritten in the part *header* while the
  upload's content is left alone, and a newline is never put there: in a part
  header it forges a new part rather than corrupting the request, which is an
  effect no notice afterwards can undo.

Mix methods freely: `--methods reflected,eval,time` runs all three and reports each
tier separately.

> **Honest scope.** RCEKit confirms RCE that is reachable by **injecting into a
> request** and interpreted by a shell, an evaluator, or the option parser of a
> program spawned without one. It does **not** cover memory-corruption bugs
> (buffer overflow, UAF) — a different problem. **Deserialization gadget chains
> stay out of scope too**: `--methods deser` proves an endpoint *deserializes*
> attacker data and says so in its own tier, but which gadget (if any) turns
> that into execution depends on the target's classpath, and RCEKit does not
> claim to know. It aims to be excellent at the injection-driven RCE classes
> above rather than mediocre at everything.
>
> Argument injection was in that excluded list until 3.7.0, and the reason it
> moved is worth stating: it is not a weaker case of command injection but the
> case where the application did the **correct** thing. `escapeshellarg` quotes
> the value exactly as documented, every separator is inert, and `reflected`
> comes back `negative` on a target that is exploitable — because the value is
> one argument of a program whose own options can name a command to run. That
> is a sink the separator ladder cannot reach by construction, not one it
> happens to miss.

---

## How RCEKit compares

The other tools in this space are built to get you **in**. RCEKit is built so the
finding **survives someone else's scrutiny** — the client's retest, the triage
queue, the report review. That difference shows up three times.

### 1. One injection point, every class, one run

You rarely know the class before you test. Covering an unknown sink with
single-class tools means running each in turn and rebuilding the request for each
one:

| Can confirm | RCEKit | [commix](https://github.com/commixproject/commix) | [SSTImap](https://github.com/vladko312/SSTImap) | [Nuclei](https://github.com/projectdiscovery/nuclei) |
|---|---|---|---|---|
| OS command injection — `reflected` | ✅ | ✅ *(its whole scope)* | — | per template |
| Expression injection / SSTI — `eval` | ✅ | via its eval-based technique | ✅ *(its whole scope)* | per template |
| Blind — timing — `time` | ✅ *as a separate tier* | ✅ | ✅ | — |
| Blind — out-of-band — `oob` | ✅ *built-in listener* | — | — | via [interactsh](https://github.com/projectdiscovery/interactsh) |
| Expression-lookup — Log4Shell/JNDI — `lookup` | ✅ *own tier, never called RCE* | — | — | per template |
| No-egress — write &amp; fetch back — `file` | ✅ *any read-back path* | ✅ *(web root)* | — | — |
| `cmd.exe` and PowerShell sinks | ✅ *per-dialect probes* | ✅ *(cmd)* | — | per template |
| Upload → write-then-execute — `write` | ✅ *write vs. execute, separate tiers* | — | — | per template |
| Predicate sink, nothing rendered — `boolean` | ✅ *response-shape differential, own tier, never RCE* | — | — | — |
| Second-order — lands here, runs there | ✅ | — | — | — |
| Query-language bridge to the OS | ✅ | — | — | per template |
| Deserialization sink — `deser` | ✅ *own tier, never called RCE* | — | — | per template |
| Argument injection — no-shell `argv` sink — `argv` | ✅ *option shapes from the corpus, named in the finding* | — | — | — |
| File inclusion — read, and write-then-interpret — `include` | ✅ *reads keep their own tier; execution proven by fetching the file back* | — | — | per template |
| **All of the above, one CLI, one run** | **✅** | — | — | — |

<sub>Coverage per each project's own documented technique list. SSTImap is the
maintained successor to <a href="https://github.com/epinna/tplmap">tplmap</a>,
which its author has marked unmaintained.</sub>

```bash
# Command injection, expression injection and blind timing against the same
# parameter, in one pass, with zero infrastructure
python rcekit.py --acknowledge-consent -r request.txt -p host --methods reflected,eval,time
```

### 2. It argues with its own results

A tool reports what it found. RCEKit also reports **what it refused to believe** —
`inconclusive` is a verdict of its own, for anything it cannot attribute to
execution: evidence that showed up in the payload-free control too, and equally
a measurement the run never finished gathering. Either would have been someone
else's finding.

Five mechanisms produce that verdict, and they run on every confirmation:

- **A payload-free control request.** Evidence must be present *with* the payload
  and absent *without* it. Anything in both is `inconclusive`, not a finding.
- **A same-token inert control.** A second request carries the identical random
  token in a non-executing form. A target that merely echoes input fails here —
  which is how a reflection is separated from an execution.
- **Random operands, never fixed strings.** The oracle is a tag-wrapped sum or a
  boundary-fenced product computed fresh each run. Echoing the payload returns
  the literal `$((a+b))`; only execution returns the value.
- **Encoding-aware evidence search.** A sink that base64-, hex-, URL-, HTML- or
  unicode-escapes its output still confirms — the raw body is checked first, so
  decoding only ever turns a missed hit into a hit, never the reverse.
- **Whole-response evidence search.** The computed value is looked for in every
  channel of the response — body, application headers, cookie values, the
  redirect target, the HTTP reason phrase, and each leaf of a JSON error
  envelope — and the finding names the channel that carried it. The control
  differential is applied to every channel too, so widening where RCEKit looks
  does not widen what it will call `executed`.

The same instinct runs the other way. Timing **never self-confirms**, a
deserialization callback is **never** called RCE, a response shape that tracks a
predicate is **never** called execution — a query engine comparing two numbers
produces the same shape — and a run that built no probes is **never** called
negative.

### 3. It is built for an authorised engagement, not a lab

The controls a client's rules of engagement actually ask about, in the tool
rather than in your notes:

| | |
|---|---|
| **Consent gate** | Nothing exploitative generates or fires without `--acknowledge-consent`. |
| **Execution plan** | Prints the exact probe count, sink shapes, safety tiers and any outbound callback destinations **before** the first request goes out. |
| **Safe by default** | Reverse shells, credential access, cloud metadata, lateral movement and container escape are held back until you raise `--verify-active-risk`; persistence and backdoors need a second flag on top. Bridges that create an object on the target are held to the same ceiling. |
| **Cleanup commands** | `file`, `write` and the stateful bridges change target state, so every finding — including a `file-write` — prints what to run to undo it. |
| **Credentials stay put** | The `file` read-back fetch carries the run's `Authorization`/`Cookie` headers only to the *same origin*, and says so out loud when it withholds them. The observed-channel fetch sends none at all unless you hand it a request with `--observe-request`. |
| **Redacted audit trail** | Every run lands in `exploit_audit.log`, recording that a credential header was sent, never its value. |
| **Watermarking** | `--watermark` stamps a traceable token into each payload, so a payload found in the client's logs months later is attributable to your run. |
| **No third-party callbacks** | The OOB listener is yours. Nothing is routed through a public interaction server, which some engagements forbid outright. |
| **One stdlib file** | `rcekit.py` runs alone — jump box, air-gapped host, anywhere `pip install` is not an option. |

### When to reach for something else

Want a shell rather than a verdict? commix and SSTImap continue into
post-exploitation; RCEKit stops at proof by design. Sweeping thousands of hosts
for known CVEs? That is Nuclei's job — and RCEKit *writes* Nuclei templates
(`--output-format nuclei`), so it feeds your scanner instead of competing with it.
Already know the injection is SQL and want the database itself?
[sqlmap](https://github.com/sqlmapproject/sqlmap) owns that ground — RCEKit's
bridges exist to prove the **OS** is reachable from a text parameter, not to
exploit the database.

---

## Find your situation

Two different questions, and they are worth keeping apart. The first is **which
method** — what the target lets you observe decides that, and it decides how
strong an answer you can get. The second is **getting the payload to land**,
which is the same method fighting a filter, a quote or an encoding.

### Which method, and what it needs from you

| What you can observe | Method | You must already have | Reaches |
|---|---|---|---|
| Output comes back in the response | `reflected`, `eval` | nothing | **`executed`** |
| Nothing comes back; the target has egress | `oob` | a listener it can reach — a bare IP is enough. The DNS shapes also want a delegated name and port 53 | **`executed`** |
| Nothing comes back, no egress; somewhere writable you can also read back | `file` | the write path, and any URL that returns it — a web root, an LFI parameter, a download or export handler | **`executed`** |
| Your own request stores a file | `write` | the URL the file lands at | **`executed`** |
| Nothing comes back and none of the above | `time` | nothing | `timing-sink` |
| The sink interpolates `${…}` rather than shelling out | `lookup` | a delegated domain | `lookup-sink` |
| The parameter carries a serialized object | `deser` | nothing for the shape fingerprint; the DNS gadget needs a delegated name and a listener | `needs-review`, or `deserialization-sink` with the gadget |
| The sink evaluates but renders nothing of it | `boolean` | nothing; `OR` shapes need `stateful` | `evaluation-sink` |
| Your value becomes an argument of a program spawned with no shell | `argv` | the binary it is spawned as, for `--argv-binary` | **`executed`** |
| Your value names a file the application opens | `include` | nothing for the read; the URL a file written through the sink is served at, for `--include-write-url` | `file-read`, or **`executed`** with the write-back URL |

Only `reflected`, `eval`, `oob`, `file`, `write`, `argv` and `include` reach
**`executed`**. `timing-sink`, `lookup-sink`,
`file-read` and `deserialization-sink` are proven findings about something else --
`file-read` and `file-write` are vulnerabilities on their own terms, `lookup-sink`
and `deserialization-sink` are confirmed sinks with known remediation, and
`timing-sink` is a measurement waiting to be attributed. `evaluation-sink` is the
one that is a *lead* rather than a finding: identify the evaluator before it goes
in a report. `needs-review` is the exception: it is the parser fingerprint `deser`
reads when it has no gadget to fire — and, off this table, the static signature
`--verify-chain` matches in band without a control. Both are real candidates and
**not proof** — review them by hand rather than reporting them. [What a verdict
means](#what-a-verdict-means) spells each one out,
[Choosing methods](docs/guide.md#choosing-methods) has the costs, and the
[Quick start](#quick-start) scenarios have a worked command for each row in both
target shapes.

### Getting the payload to land

| Situation | Go to |
|---|---|
| I have a URL and a parameter | [Point at a URL](docs/guide.md#point-at-a-url) |
| I have a request saved from Burp | [Point at a captured request](docs/guide.md#point-at-a-captured-request) |
| The app is JSON / the payload keeps getting mangled | [Landing the payload intact](docs/guide.md#landing-the-payload-intact) |
| The body is XML or a SOAP envelope | [Enumerating injection points](docs/reference.md#enumerating-injection-points) |
| A parameter, cookie or token carries a base64 blob or a JWT | [Enumerating injection points](docs/reference.md#enumerating-injection-points) |
| The sink strips `;` | [When the sink filters separators](docs/guide.md#when-the-sink-filters-separators) |
| My input lands inside `'quotes'` | [Injecting inside quotes](docs/guide.md#injecting-inside-quotes) |
| The sink runs my input as the whole command | [Whole-command sinks](docs/guide.md#whole-command-sinks) |
| Nothing I inject works, and the app looks like it quotes correctly | [When no separator works because there is no shell](docs/guide.md#when-no-separator-works-because-there-is-no-shell) |
| The target is Windows or the sink is PowerShell | [Windows and PowerShell sinks](docs/guide.md#windows-and-powershell-sinks) |
| The endpoint is a CGI script and the header never reaches a command | [The Shellshock shape](docs/reference.md#the-shellshock-shape) |
| There's a WAF | [Working around a WAF](docs/guide.md#working-around-a-waf) |
| No output comes back at all | [Blind targets](docs/guide.md#blind-targets) |
| No output *and* no egress | [No-egress targets](docs/guide.md#no-egress-targets) |
| The request stores a file instead of running anything | [Upload and write-primitive targets](docs/guide.md#upload-and-write-primitive-targets) |
| The app builds a path or shells out with the upload's filename | [Enumerating injection points](docs/reference.md#enumerating-injection-points) |
| The app opens a file my value names | [When your value names a file](docs/guide.md#when-your-value-names-a-file) |
| The payload runs later, on a different request | [When execution happens on another request](docs/guide.md#when-execution-happens-on-another-request) |
| The injection point is SQL and the sink is the database host | [Query-language bridges](docs/reference.md#query-language-bridges) |
| The endpoint takes a serialized object | [Deserialization sinks](docs/reference.md#deserialization-sinks-and-the-verdict-that-is-not-rce) |
| The sink evaluates my input but renders nothing of it | [When the sink answers yes or no](docs/reference.md#when-the-sink-answers-yes-or-no-and-nothing-else) |
| The sink is behind a login or a file upload | [Multi-step chains](docs/guide.md#multi-step-chains) |
| I got a weaker tier / `inconclusive` / `error` | [Reading the results](docs/guide.md#reading-the-results) |
| It says the corpus is unusable | [Troubleshooting](docs/guide.md#troubleshooting) |

---

## Documentation

| | |
|---|---|
| [**Verify it yourself**](docs/verify-it-yourself.md) | Reproduce the confirmations above on your own machine, against dockerised vulnerable targets. **Five minutes.** |
| [**Field guide**](docs/guide.md) | Example-driven walkthrough of every real situation, from a first probe to multi-step chains. **Start here.** |
| [**Payload generation &amp; exports**](docs/generation.md) | RCEKit as a payload generator: target profiles, and Burp / ffuf / Nuclei exports. |
| [**Reference**](docs/reference.md) | Every flag, environment, category, context, encoding and code-execution sink. |
| [**CHANGELOG.md**](CHANGELOG.md) | What changed in each release, and what to re-check when upgrading. |
| [CONTRIBUTING.md](CONTRIBUTING.md) | How to add sinks, categories, encodings and detection methods. |
| [SECURITY.md](SECURITY.md) | Reporting a vulnerability in RCEKit itself. |

---

## Safety &amp; ethics

**RCEKit exploits, and that is the point.** A vulnerability is confirmed by
making the target do the thing, because that is the only evidence a signature
cannot fake and a patched build cannot produce by accident. What bounds a run is
not reluctance to exploit. It is two structural facts and one switch.

**It takes no arbitrary payload from you.** Probes are built by the engine to
serve an oracle — arithmetic on operands random to that probe, a name only this
run could have chosen. There is no input that turns detection into something
else, because there is no such input to give.

**Anything reaching past computing a value declares the tier it needs**, so one
flag decides how far a run goes: `--verify-active-risk safe | intrusive |
stateful`. A method or a single probe *shape* above that tier is held back **by
name**, with the flag that would send it — a ladder that shrinks quietly is
indistinguishable from a target with nothing to find. Against a disposable
instance, raise the tier and get everything the tool has.

- **Consent gate** — exploitation generation and verification require
  `--acknowledge-consent`; `--detection-only` is benign and does not.
- **Safe by default** — verification fires only low-impact proofs; reverse shells,
  download-execute, credential access, lateral movement, container escape,
  cloud-metadata and OOB payloads are held back until you raise
  `--verify-active-risk`. Destructive payloads (persistence, backdoors) are never
  fired without `--verify-allow-destructive`. An **execution plan** prints exactly
  what will be sent before anything fires.
- **Safety tiers** — `safe` / `intrusive` / `stateful`. Corpus payloads are
  filtered by `--max-safety`; detection methods and their probe shapes declare
  the same rungs and are filtered by `--verify-active-risk`, so a method that
  makes the target reach out or leaves something behind is held to the same
  ordering as every corpus payload. The pre-flight names the tier each held-back
  item actually needs. `file` and `write` are gated by their own configuration
  instead: neither does anything until you name a directory to write into and a
  URL to read it back from. `include`'s write-through-the-sink shape is gated the
  same way and declares `stateful` besides, so its read rungs run at `safe` and
  nothing is written until `--include-write-url` says where to read it back.
- **Audit &amp; logging** — every exploitation/verification run is recorded in
  `exploit_audit.log`; `--watermark` embeds a traceable token; execution logs go to
  `rcekit.log`.
- **Corpus integrity** — a corpus that is corrupt, or an explicit
  `--template-file` that is missing, makes RCEKit refuse to run and exit non-zero
  rather than silently generate nothing (`--doctor` checks it). Only an absent
  *default* corpus file falls back to the built-in copy, and it says so when it
  does.

This toolkit is intended for authorised penetration testing, security research,
education, and defensive training only. **Never use it against systems without
explicit permission** — unauthorized testing is illegal.

## Development

```bash
python -m unittest discover -s tests   # dependency-free test suite
```

Contributions welcome — new sinks/categories, encodings, environments, detection
methods, bug fixes, and docs. Payload bases live in editable JSON templates
(`templates/payloads.json`), so most coverage extends without touching the Python
source. After changing the corpus, refresh the built-in copy that ships inside
`rcekit.py`:

```bash
python tools/embed_corpus.py    # --check verifies it is current
```

The test suite fails if the two ever drift. See [CONTRIBUTING.md](CONTRIBUTING.md).

## License

MIT — see [LICENSE](LICENSE).
