Best practices for writing and extending exploit modules in Rustsploit.
Always mention CVE IDs, vendor names, and affected products in:
- The module file docstring / top-level comments
- Output messages (e.g.,
[*] Testing CVE-2025-14847 on {}, target) - The Module Catalog
Validate server responses before declaring success — false positives hurt credibility:
if response.status() == 200 && body.contains("expected_indicator") {
println!("{} Confirmed vulnerable: {}", "[+]".green(), target);
} else {
println!("{} Not vulnerable or patched", "[-]".red());
}Note: Rustsploit is exploitation-only — the
check()/CheckResultverification phase was removed. Thecheck(...)snippets below are kept only to illustrate the error-handling discipline; apply the same patterns in yourrun(&ModuleCtx) -> Result<ModuleOutcome>body and emitFindings instead of returning aCheckResult.
Modules must surface transport and decode failures explicitly. The framework
treats a panicking module as a hard build/runtime failure, and silently
swallowing a failed send() or text() causes run() to lie about what
happened on the wire.
Forbidden patterns:
// .unwrap() / .expect() — panic on error.
let resp = client.get(&url).send().await.unwrap();
// .unwrap_or_default() on Result-returning HTTP calls — turns transport
// or decode failure into an empty body the caller assumes is the real one.
let body = resp.text().await.unwrap_or_default();
// `if let Ok(r) = ...` without an `else` arm — drops the network error
// and silently falls through to "NotVulnerable" / "not detected".
if let Ok(r) = client.get(&url).send().await {
/* … */
}Preferred patterns:
In check(...) -> CheckResult, return CheckResult::Error(…) for failures so
operators can distinguish "couldn't reach the host" from "host is patched":
match client.get(&url).send().await {
Ok(r) => match r.text().await {
Ok(body) if body.contains("foo") =>
CheckResult::Vulnerable("foo detected".into()),
Ok(_) => CheckResult::NotVulnerable("foo not present".into()),
Err(e) => CheckResult::Error(format!("body decode: {}", e)),
},
Err(e) => CheckResult::Error(format!("request failed: {}", e)),
}In run(...) -> anyhow::Result<()>, prefer ? with anyhow::Context for
abort-the-flow failures, and use match with crate::mprintln! when the
loop should report-and-continue:
use anyhow::Context;
let resp = client.get(&url).send().await.context("probe failed")?;
let body = resp.text().await.context("read body")?;
for path in candidate_paths {
match client.get(format!("{}{}", base, path)).send().await {
Ok(r) => { /* inspect r */ }
Err(e) => crate::mprintln!("{} {}: {}", "[-]".red(), path, e),
}
}Helpers available (all in src/utils/network.rs, re-exported as crate::utils::*):
http_get_status_body(&client, &url) -> anyhow::Result<(u16, String)>— single call that surfaces both transport and body-decode failures viaanyhow::Context. Use it inrun()withlet (_, body) = ... .await?;. Use it incheck()withmatch.http_get_status_headers_body(&client, &url) -> anyhow::Result<(u16, HeaderMap, String)>— same but also returns the response headers, for fingerprints that look atServer/Via/X-Powered-By.header_string(headers, "name") -> String— reads a header asString. Returns""if absent and the literal sentinel"<non-utf8>"if present-but-non-utf8, so the swallow that.to_str().ok().unwrap_or("")does silently is now visible in module output. Avoid the older.to_str().ok().unwrap_or("")chain — it hides the non-utf8 case.
The full list of banned patterns lives in BAD_PATTERNS.md — 91 regex patterns covering panicking error handling, silent error swallowing, lint suppression, panic vectors, lossy numeric casts, async/blocking pitfalls, logging discipline, HTTP-client policy, iterator glitches, style and embedded secrets. The doc ships with a copy-paste reproducer that returns a non-zero exit on any hit. Quick-reference banned highlights:
.unwrap(),.expect("…"),.unwrap_or_default(),.unwrap_or(…),.unwrap_or_else(…),.to_str().ok().unwrap_or(…)#[allow(dead_code)],#[allow(unused_imports)],#[allow(unused_variables)], any other#[allow(...)]panic!(),unreachable!(),todo!(),unimplemented!(),assert!,assert_eq!if let Ok(_) = ...without anelse(silent fall-through) — usematchErr(_) => …anonymous binding even when the arm has a side effect. Always bind the value and surface it viaDisplay— e.g.Err(elapsed) => anyhow::bail!("connect to {} timed out after {:?}: {}", addr, CONNECT_TIMEOUT, elapsed)let _ = <expression>;where the expression returnsResult— useif let Err(e) = ...and loge- Direct array index
arr[i]and slice range&buf[..n]— usearr.get(i)/buf.get(..n).context(...)? - Lossy numeric casts (
as u16,as u64, etc.) — usetry_into().with_context(...)? - Sync I/O in async functions (
std::fs::*,std::process::Command,std::thread::sleep,std::net::TcpStream) — usetokio::*
Native libraries (crate::native::*): prefer in-tree helpers over third-party crates / hand-rolled formatting:
crate::native::hex::encode(&bytes)for response-byte preview hex.crate::utils::url_encode(s)(delegates tocrate::native::url_encoding::encode) for query / form payload encoding.crate::native::network::*for raw-socket DoS modules.
If the exploit downloads or writes files (e.g., memory dumps, webshells):
- Store in the current working directory or a named subfolder
- Name files descriptively:
mongobleed_results_{target}.txt,nginx_pwner_results_{target}.txt - Inform the operator where output was written
If the exploit adds credentials or accounts (e.g., camera modules), document:
- The impact of the change
- How to revert (e.g., default creds to restore, commands to run)
Use cfg_prompt_* helpers from crate::utils if end-user input is needed. These respect the priority chain (API custom_prompts > global options > interactive stdin), ensuring modules work in shell, API, and CLI modes:
use crate::utils::{cfg_prompt_default, cfg_prompt_yes_no};
let command = cfg_prompt_default("command", "Command to execute", "id").await?;
let deploy = cfg_prompt_yes_no("deploy_webshell", "Deploy webshell?", true).await?;Mass-scan fan-out is handled by scheduler::run for every module. Modules do not
implement their own target iteration — the scheduler fans out Cidr / File /
Random / Multi targets and calls run() once per host with Target::Single.
-
Use target-specific filenames. When writing output files, include the target in the filename to avoid clobbering under concurrent mass scan:
// Bad — concurrent tasks overwrite each other let path = "results.txt"; // Good — each target gets its own file let safe = target.replace(['/', ':', '.', '[', ']'], "_"); let path = format!("results_{}.txt", safe);
Examples of modules already fixed:
mongobleed(vulnerable_mongodb_{timestamp}.txt),pachev_ftp(results_{target}.txt), ZTE (config_{host}.bin), Tomcat RCE (per-invocation temp directory), JWKS (jwks_{target}_{kid}_{i}.pem). -
Guard interactive/REPL code with
is_batch_mode():if crate::utils::is_batch_mode() { anyhow::bail!("Interactive REPL not supported in mass-scan mode."); }
Local-only exploits (e.g. Windows DWM CVE-2026-20805) should also bail:
if crate::utils::is_batch_mode() { anyhow::bail!("Local exploit generator — not suitable for mass scan."); }
-
Use framework network wrappers so
setg source_portis honoured across all concurrent fan-out tasks. See Source Port Awareness in Credential-Modules-Guide.
- No per-module
EXCLUDED_RANGESconstants orgenerate_random_public_ip()calls. The scheduler'scrate::exclusions::ExclusionSethandles this. - No
if is_mass_scan_target(target) { run_mass_scan(...) }branches. This pattern was removed in v0.5.1. The scheduler is the single fan-out engine. - No raw
TcpStream::connectorUdpSocket::bind. Use the wrappers incrate::utils::network.
- Safe check — writes/reads a test file to verify exploitability
- Unsafe reboot — reboots the device to confirm (destructive)
- Command exec — output retrieved, supports blind mode
- SSH shell — deploys Dropbear SSH on port 1337
- Sends malicious compressed packet with inflated
uncompressedSize - Parses error response to extract leaked memory chunks (field names / types)
- Prints any leaked strings (potential credentials / data) to console
- Includes deep-scan mode for extended analysis
- Authenticates via
/rest/login(token / cookie-based) - Creates a malicious workflow with expression injection payload
- Triggers via
/rest/workflows/{id}/run - Cleans up test workflow after execution
- 6 payload types: Info, Command, Environment, Read File, Write File, Reverse Shell
- SQL injection via
Authorization: Bearer ';{injection}header - Writes webshell via
SELECT INTO OUTFILE - Uses
.pthtrigger for Pythonchmodexecution - Interactive modes: deploy webshell, execute command, test SQLi only
- 10 checks: version disclosure, CRLF injection, PURGE method, variable leakage, merge slashes, header bypass / IP spoofing, alias traversal,
X-Accel-Redirectbypass, PHP detection, CVE-2017-7529 integer overflow - Results saved to
nginx_pwner_results_{target}.txt - Prints reminders for manual checks (Redis, CORS, request smuggling)
⚠️ Authorized testing only. These modules can cause service disruption.
| Module | Notes |
|---|---|
null_syn_exhaustion |
Raw socket, IP spoofing, XorShift128+ RNG, configurable PPS, >1M PPS capable |
connection_exhaustion_flood |
FD-bounded semaphore, supports infinite mode with graceful Ctrl+C |
tcp_connection_flood |
DNS pre-resolved, high-concurrency handshake stress, infinite mode |
http2_rapid_reset |
CVE-2023-44487 — HTTP/2 stream reset flood |
All exploit modules automatically benefit from the framework's multi-target dispatcher. There is no need to implement target iteration inside individual modules. The framework handles:
- Comma-separated targets —
192.168.1.1,192.168.1.2,192.168.1.3 - CIDR ranges —
192.168.1.0/24expands to all hosts in the subnet - File-based targets — pass a file path containing one target per line
- Random targets —
randomor0.0.0.0/0generates random public IPs with exclusion-set enforcement
The dispatcher calls the module's run() function once per resolved target. Modules only need to handle a single target string.