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
eval — OGNL expression injection, Apache Struts2 S2-001 → executed
out-of-band — blind Log4Shell (CVE-2021-44228) via a DNS callback → lookup-sink
time — blind command injection, Webmin CVE-2019-15107 → timing-sink
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-chainwith--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.timeandbooleanare 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 isfile-readand not execution. Where nothing runs until something asks — a queue a worker drains, a report generated on demand, a "run now" button —--observe-triggeris 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 andnullincluded), 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, namedstate>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 reportsblockedrather than a clean negative. A GraphQL body is ordered by what can actually confirm: thevariablesa 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 deserproves 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.
escapeshellargquotes the value exactly as documented, every separator is inert, andreflectedcomes backnegativeon 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-onlyis 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.fileandwriteare 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 declaresstatefulbesides, so its read rungs run atsafeand nothing is written until--include-write-urlsays where to read it back. - Audit & logging — every exploitation/verification run is recorded in
exploit_audit.log;--watermarkembeds a traceable token; execution logs go torcekit.log. - Corpus integrity — a corpus that is corrupt, or an explicit
--template-filethat is missing, makes RCEKit refuse to run and exit non-zero rather than silently generate nothing (--doctorchecks 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)
| 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)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| 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 logRelease 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 logSecurity 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.
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 logA 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.