Skip to main content

RCEKit

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

Version 3.12.1 · MIT · Python 3.8+ · zero third-party dependencies

RCEKit is an RCE detection & 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/ 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.

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.

reflected — OS command injection, Webmin CVE-2019-15107 → executed

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

eval — OGNL expression injection, Apache Struts2 S2-001 → executed

RCEKit confirming OGNL expression injection on Apache Struts2 (S2-001): the payload %{ab} evaluates to the product in the response while the literal ab does not

out-of-band — blind Log4Shell (CVE-2021-44228) via a DNS callback → lookup-sink

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

time — blind command injection, Webmin CVE-2019-15107 → timing-sink

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


Quick start

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

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:

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:

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:

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, 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.

# query string
python rcekit.py --acknowledge-consent \
  --verify-url "https://target.example/lookup?host=FUZZ" \
  --methods reflected,eval --insecure
# 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.

# 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
# 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.

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.

# 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
# 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.

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.

# 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
# 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.

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.

# 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
# 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.

# 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
# 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.

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.

# 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
# 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.

# 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
# 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 — sixteen, and not the same set in both modes — the sink shapes, the contexts and the 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 has the measured numbers, and 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 against dockerised Webmin and Struts2 targets in about five minutes.

Next: the field guide 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 error and nothing-tested are first-class verdicts, the run exits non-zero, 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.

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 SSTImap 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
Expression-lookup — Log4Shell/JNDI — lookup ✅ own tier, never called RCE — — per template
No-egress — write & 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 ✅ — — —

Coverage per each project's own documented technique list. SSTImap is the maintained successor to tplmap, which its author has marked unmaintained.

# 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 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 spells each one out, Choosing methods has the costs, and the 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
I have a request saved from Burp Point at a captured request
The app is JSON / the payload keeps getting mangled Landing the payload intact
The body is XML or a SOAP envelope Enumerating injection points
A parameter, cookie or token carries a base64 blob or a JWT Enumerating injection points
The sink strips ; When the sink filters separators
My input lands inside 'quotes' Injecting inside quotes
The sink runs my input as the whole command Whole-command sinks
Nothing I inject works, and the app looks like it quotes correctly When no separator works because there is no shell
The target is Windows or the sink is PowerShell Windows and PowerShell sinks
The endpoint is a CGI script and the header never reaches a command The Shellshock shape
There's a WAF Working around a WAF
No output comes back at all Blind targets
No output and no egress No-egress targets
The request stores a file instead of running anything Upload and write-primitive targets
The app builds a path or shells out with the upload's filename Enumerating injection points
The app opens a file my value names When your value names a file
The payload runs later, on a different request When execution happens on another request
The injection point is SQL and the sink is the database host Query-language bridges
The endpoint takes a serialized object Deserialization sinks
The sink evaluates my input but renders nothing of it When the sink answers yes or no
The sink is behind a login or a file upload Multi-step chains
I got a weaker tier / inconclusive / error Reading the results
It says the corpus is unusable Troubleshooting

Documentation

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

Safety & 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 & 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

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:

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

The test suite fails if the two ever drift. See CONTRIBUTING.md.

License

MIT — see LICENSE.

Metadata

Release files for rcekit 3.12.1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for rcekit 3.12.1
File Size Uploaded Provenance Actions
rcekit-3.12.1.tar.gz 745.6 kB With provenance Details for rcekit-3.12.1.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for rcekit 3.12.1
File Interpreter ABI Platform Actions
rcekit-3.12.1-py3-none-any.whl Python 3 none any Details for rcekit-3.12.1-py3-none-any.whl

Total release size: 986.7 kB

Release files / rcekit-3.12.1.tar.gz

Download URL rcekit-3.12.1.tar.gz
Size 745.6 kB
Tags Source
SHA-256 checksum
How to use checksums
e2f5c012e843428b292a7ae999df969761d9d902123a565de1178862d510e804
BLAKE2b-256 checksum
How to use checksums
44feb13d0ab03fc7ae058394ffd6b59b5d5e21d8feed852db7cc99ee644582cb
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 31, 2026.

Transparency log

Release files / rcekit-3.12.1-py3-none-any.whl

Download URL rcekit-3.12.1-py3-none-any.whl
Size 241.1 kB
Tags Python 3
SHA-256 checksum
How to use checksums
605fb849a6192bb521c6fe5a22f5c5478c6a593acafdb90abefcbf2ad917c98c
BLAKE2b-256 checksum
How to use checksums
522e9334761fd2b0b627668eb70bf56edf508d87905dd38266db90ee877bfd9e
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 31, 2026.

Transparency log

Security for rcekit 3.12.1

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

Both files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for rcekit-3.12.1.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 31, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page