| name | sast-rce |
| description | Detect Remote Code Execution (RCE) vulnerabilities in a codebase using a three-phase approach: recon (find dangerous execution sinks), batched verify (trace user input to sinks in parallel subagents, 3 sinks each), and merge (consolidate batch results). Covers OS command injection, eval-like sinks, and unsafe deserialization. Requires sast/architecture.md (run sast-analysis first). Outputs findings to sast/rce-results.md. Use when asked to find RCE, command injection, or unsafe deserialization bugs. |
Remote Code Execution (RCE) Detection
You are performing a focused security assessment to find Remote Code Execution vulnerabilities in a codebase. This skill uses a three-phase approach with subagents: recon (find dangerous execution sinks), batched verify (trace whether user-supplied input reaches each sink in parallel batches of 3), and merge (consolidate batch results into the final report).
Prerequisites: sast/architecture.md must exist. Run the analysis skill first if it doesn't.
What is Remote Code Execution
Remote Code Execution (RCE) occurs when an attacker can cause the application to execute arbitrary OS commands or application-level code that they control. This is typically the highest-severity vulnerability class, often resulting in complete server compromise.
RCE arises from three primary root causes:
- OS Command Injection: User input is embedded unsafely into an OS command string, allowing shell metacharacters to inject additional commands.
- Code Injection (eval-like): User input is passed to functions that interpret it as executable code (
eval, exec, Function(), etc.).
- Unsafe Deserialization: User-supplied serialized data is deserialized using a gadget-prone deserializer, triggering arbitrary code execution via crafted payloads.
What RCE IS
- Passing user input directly or indirectly into OS command execution functions with shell interpretation enabled
- Using
eval(), exec(), Function(), or equivalent constructs with user-controlled strings
- Deserializing user-supplied bytes/strings with inherently unsafe deserializers (pickle, PHP unserialize, Java native serialization, Ruby Marshal, etc.)
- Using
yaml.load() without a safe loader on user-supplied content
- Dynamic
require()/import() with user-controlled module paths
- PHP file inclusion (
include/require) with user-controlled paths
What RCE is NOT
Do not flag these as RCE:
- SSRF: Making HTTP requests to attacker-controlled URLs — different vulnerability class (no code execution)
- Path Traversal: Reading/writing arbitrary files — separate class (unless the read file is then executed/deserialized)
- SSTI: Template injection via template engines — a separate though related class; flag as SSTI, not RCE
- XSS: JavaScript execution in a victim's browser — client-side only, not server-side RCE
- SQL Injection: Injecting into database queries — different class (even if
xp_cmdshell can lead to OS commands, flag it as SQLi)
- Safe subprocess list-form calls:
subprocess.run(["ls", user_arg]) with a list and no shell=True — arguments are passed directly to the OS without shell expansion; not vulnerable to command injection
- Safe deserialization:
json.loads(), yaml.safe_load(), xml.etree.ElementTree.parse() — these formats have no code execution semantics
Patterns That Prevent RCE
When you see these patterns, the code is likely not vulnerable:
1. Subprocess list form without shell interpretation
# Python — list args, no shell=True
subprocess.run(["convert", "-resize", size, input_file, output_file])
subprocess.Popen(["git", "clone", repo_url])
# Node.js — spawn with separate args (no shell)
child_process.spawn("ffmpeg", ["-i", inputFile, outputFile])
# Java — ProcessBuilder with list
new ProcessBuilder("ls", "-la", dir).start()
# Ruby — system() with multiple args (not a single interpolated string)
system("ffmpeg", "-i", "input.mp4", "-f", format, "output")
2. Safe deserialization formats
# Python — JSON instead of pickle
import json
data = json.loads(user_input) # no code execution semantics
# Python — safe YAML loader
import yaml
data = yaml.safe_load(user_input) # restricts to basic types only
# Java — Jackson without enableDefaultTyping, with concrete target type
ObjectMapper mapper = new ObjectMapper();
MyClass obj = mapper.readValue(json, MyClass.class); # safe
3. Strict allowlist before command construction
# Python — allowlist for dynamic arguments
ALLOWED_FORMATS = {"png", "jpg", "webp"}
if fmt not in ALLOWED_FORMATS:
return abort(400)
subprocess.run(["convert", infile, f"output.{fmt}"])
# Node.js — allowlist for dynamic args
const ALLOWED_COMMANDS = ['ls', 'pwd'];
if (!ALLOWED_COMMANDS.includes(cmd)) return res.status(400).end();
spawn(cmd, []);
Vulnerable vs. Secure Examples
OS Command Injection — Python
@app.route('/ping')
def ping():
host = request.args.get('host')
result = subprocess.run(f"ping -c 1 {host}", shell=True, capture_output=True, text=True)
return result.stdout
def convert_image(filename):
size = request.form.get('size')
os.system(f"convert {filename} -resize {size} output.jpg")
@app.route('/ping')
def ping():
host = request.args.get('host')
result = subprocess.run(["ping", "-c", "1", host], capture_output=True, text=True, timeout=5)
return result.stdout
OS Command Injection — Node.js
app.get('/search', (req, res) => {
const query = req.query.q;
exec(`grep -r "${query}" /var/log/app/`, (err, stdout) => {
res.send(stdout);
});
});
function runScript(userScript) {
return execSync('node scripts/' + userScript);
}
app.get('/search', (req, res) => {
const query = req.query.q;
const proc = spawn('grep', ['-r', query, '/var/log/app/']);
proc.stdout.on('data', (data) => res.write(data));
proc.on('close', () => res.end());
});
OS Command Injection — PHP
function generateThumbnail($file) {
$size = $_GET['size'];
shell_exec("convert {$file} -resize {$size} thumb.jpg");
}
function checkHost() {
$host = $_POST['host'];
$result = `ping -c 1 $host`;
return $result;
}
function generateThumbnail($file) {
$size = escapeshellarg($_GET['size']);
$file = escapeshellarg($file);
shell_exec("convert $file -resize $size thumb.jpg");
}
OS Command Injection — Ruby
get '/convert' do
format = params[:format]
system("ffmpeg -i input.mp4 -f #{format} output")
end
def check_dns
`nslookup #{params[:host]}`
end
get '/convert' do
format = params[:format]
ALLOWED = %w[mp4 avi mkv]
return 400 unless ALLOWED.include?(format)
system("ffmpeg", "-i", "input.mp4", "-f", format, "output")
end
Code Injection — Python eval/exec
@app.route('/calculate')
def calculate():
expr = request.args.get('expr')
result = eval(expr)
return str(result)
@app.route('/run')
def run_code():
code = request.json.get('code')
exec(code)
return "ok"
from ast import literal_eval
@app.route('/parse')
def parse():
data = request.args.get('data')
result = literal_eval(data)
return str(result)
Code Injection — JavaScript eval / Function
app.post('/formula', (req, res) => {
const formula = req.body.formula;
const result = eval(formula);
res.json({ result });
});
function compute(userExpression) {
const fn = new Function('x', `return ${userExpression}`);
return fn(42);
}
const vm = require('vm');
app.post('/eval', (req, res) => {
const result = vm.runInNewContext(req.body.code);
res.json({ result });
});
const { evaluate } = require('mathjs');
app.post('/formula', (req, res) => {
const result = evaluate(req.body.);
res.({ result });
});
Unsafe Deserialization — Python pickle
@app.route('/load', methods=['POST'])
def load_session():
data = request.get_data()
session = pickle.loads(data)
return jsonify(session)
@app.route('/profile')
def profile():
session_cookie = request.cookies.get('session')
data = base64.b64decode(session_cookie)
user = pickle.loads(data)
return render_template('profile.html', user=user)
@app.route('/profile')
def profile():
session_cookie = request.cookies.get('session')
user = json.loads(base64.b64decode(session_cookie))
return render_template('profile.html', user=user)
Unsafe Deserialization — Java
@PostMapping("/deserialize")
public ResponseEntity<?> deserialize(@RequestBody byte[] data) throws Exception {
ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(data));
Object obj = ois.readObject();
return ResponseEntity.ok(obj);
}
ObjectMapper mapper = new ObjectMapper();
mapper.enableDefaultTyping();
MyData data = mapper.readValue(userJson, MyData.class);
ObjectMapper mapper = new ObjectMapper();
MyData data = mapper.readValue(userJson, MyData.class);
Unsafe Deserialization — PHP
function loadProfile() {
$data = base64_decode($_COOKIE['profile']);
$user = unserialize($data);
return $user;
}
$obj = unserialize($_POST['data']);
function loadProfile() {
$data = base64_decode($_COOKIE['profile']);
$user = json_decode($data, true);
return $user;
}
Unsafe Deserialization — Ruby Marshal
post '/restore' do
data = Base64.decode64(params[:state])
object = Marshal.load(data)
object.process
end
post '/restore' do
data = JSON.parse(Base64.decode64(params[:state]))
end
Unsafe Deserialization — Node.js
const serialize = require('node-serialize');
app.post('/restore', (req, res) => {
const obj = serialize.unserialize(req.body.data);
res.json(obj);
});
const yaml = require('js-yaml');
const data = yaml.load(userInput);
const data = yaml.safeLoad(userInput);
Unsafe YAML — Python
import yaml
data = yaml.load(user_input)
data = yaml.safe_load(user_input)
Execution
This skill runs in three phases using subagents. Pass the contents of sast/architecture.md to all subagents as context.
Phase 1: Find Dangerous Execution Sinks
Launch a subagent with the following instructions:
Goal: Find every location in the codebase where OS commands are executed, code is dynamically evaluated, or data is deserialized using an unsafe deserializer. Flag ANY dynamic variable passed to these sinks, regardless of where it originates. Write results to sast/rce-recon.md.
Context: You will be given the project's architecture summary. Use it to understand the tech stack, language, frameworks, and any serialization patterns in use.
Category 1 — OS Command Execution Sinks
Look for functions that execute OS commands where the command string or arguments may be dynamically constructed. Flag when any non-constant variable appears in a dangerous position:
Python:
os.system(var) — always flag if any variable
os.popen(var) — always flag if any variable
subprocess.run(var, shell=True), subprocess.call(var, shell=True), subprocess.Popen(var, shell=True), subprocess.check_output(var, shell=True) — flag if shell=True AND a variable appears in the command string, OR if the command is a string (not a list) with any variable
subprocess.run(f"cmd {var}") without shell=True — flag: passing a string (not list) to subprocess can still be unsafe
commands.getoutput(var), commands.getstatusoutput(var) — always flag
Node.js / JavaScript:
child_process.exec(var), child_process.execSync(var) — flag if any variable in command string
child_process.execFile(var, ...) — flag if command or args contain variables
child_process.spawn(var, ...) or spawn(cmd, args) with shell: true and variable in command — flag
shelljs.exec(var), execa(var) — flag if variable in command
PHP:
exec(var), system(var), passthru(var), shell_exec(var), popen(var, ...), proc_open(var, ...) — flag if any variable in command string
- Backtick operator: or — always flag
After Phase 1: Check for Candidates Before Proceeding
After Phase 1 completes, read sast/rce-recon.md. If the recon found zero sinks (the summary reports "Found 0" or the "Sinks Found" section is empty or absent), skip Phase 2 and Phase 3 entirely. Instead, write the following content to sast/rce-results.md, delete sast/rce-recon.md, and stop:
# RCE Analysis Results
No vulnerabilities found.
Only proceed to Phase 2 if Phase 1 found at least one potential sink.
Phase 2: Trace User Input to Sinks (Batched)
After Phase 1 completes, read sast/rce-recon.md and split the sinks into batches of up to 3 sinks each (numbered sections under ## Sinks Found: ### 1., ### 2., etc.). Launch one subagent per batch in parallel. Each subagent traces taint only for its assigned sinks and writes results to its own batch file.
Batching procedure (you, the orchestrator, do this — not a subagent):
- Read
sast/rce-recon.md and count the numbered sink sections (### 1., ### 2., ...).
- Divide them into batches of up to 3. For example, 8 sinks → 3 batches (1-3, 4-6, 7-8).
- For each batch, extract the full text of those sink sections from the recon file.
- Launch all batch subagents in parallel, passing each one only its assigned sinks.
- Each subagent writes to
sast/rce-batch-N.md where N is the 1-based batch number.
- Identify the project's primary language/framework from
sast/architecture.md and select only the matching examples from the "Vulnerable vs. Secure Examples" section above. For example, if the project is Python-focused, include the Python OS command, eval, pickle, and YAML subsections that apply. Include these selected examples in each subagent's instructions where indicated by [TECH-STACK EXAMPLES] below.
Give each batch subagent the following instructions (substitute the batch-specific values):
Goal: For each assigned RCE sink, determine whether a user-supplied value reaches the dangerous argument. Our goal is to find code execution vulnerabilities. Write results to sast/rce-batch-[N].md.
Your assigned sinks (from the recon phase):
[Paste the full text of the assigned sink sections here, preserving the original numbering]
Context: You will be given the project's architecture summary. Use the architecture to understand request entry points, middleware, and how data flows through the application.
RCE reference — what to look for:
Trace each sink's dynamic argument(s) back to their origin. RCE requires attacker-controlled data to reach a dangerous sink (OS command with shell interpretation, eval-like execution, or unsafe deserialization).
What RCE is NOT — do not flag these as RCE:
- SSRF, path traversal, SSTI, XSS, SQLi — other classes (see skill preamble).
- Safe subprocess list-form with no shell: arguments passed without shell expansion are not command injection.
- Safe formats:
json.loads, yaml.safe_load, ast.literal_eval — no code execution semantics.
Mitigations that prevent exploitation — if present and effective, the sink is likely safe:
- Subprocess list form without shell:
subprocess.run(["cmd", var]) without shell=True — no shell metacharacter injection.
- Strict allowlist before use: fixed set of safe values only.
- Safe deserialization: JSON,
yaml.safe_load, concrete typed Jackson reads without default typing.
Vulnerable vs. secure examples for this project's tech stack:
[TECH-STACK EXAMPLES]
For each sink, trace the dynamic argument(s) backwards to their origin:
-
Direct user input — the variable is assigned directly from a request source with no transformation:
- HTTP query params:
request.GET.get(...), req.query.x, params[:x], $_GET['x'], c.Query("x")
Phase 3: Merge — Consolidate Batch Results
After all Phase 2 batch subagents complete, read every sast/rce-batch-*.md file and merge them into a single sast/rce-results.md. You (the orchestrator) do this directly — no subagent needed.
Merge procedure:
- Read all
sast/rce-batch-1.md, sast/rce-batch-2.md, ... files.
- Collect all findings from each batch file and combine them into one list, preserving the original classification and all detail fields.
- Count totals across all batches for the executive summary.
- Write the merged report to
sast/rce-results.md using this format:
# RCE Analysis Results: [Project Name]
## Executive Summary
- Sinks analyzed: [total across all batches]
- Vulnerable: [N]
- Likely Vulnerable: [N]
- Not Vulnerable: [N]
- Needs Manual Review: [N]
## Findings
[All findings from all batches, grouped by classification:
VULNERABLE first, then LIKELY VULNERABLE, then NEEDS MANUAL REVIEW, then NOT VULNERABLE.
Preserve every field from the batch results exactly as written.]
- After writing
sast/rce-results.md, delete all intermediate batch files (sast/rce-batch-*.md) and delete sast/rce-recon.md.
Important Reminders
- Read
sast/architecture.md and pass its content to all subagents as context.
- Phase 2 must run AFTER Phase 1 completes — it depends on the recon output.
- Phase 3 must run AFTER all Phase 2 batches complete — it depends on all batch outputs.
- Batch size is 3 sinks per subagent. If there are 1-3 sinks total, use a single subagent. If there are 10, use 4 subagents (3+3+3+1).
- Launch all batch subagents in parallel — do not run them sequentially.
- Each batch subagent receives only its assigned sinks' text from the recon file, not the entire recon file. This keeps each subagent's context small and focused.
- Phase 1 is purely structural: flag any sink where a non-constant variable appears in a dangerous position, regardless of where that variable comes from. Do not trace user input in Phase 1.
- Phase 2 is purely taint analysis: for each sink found in Phase 1, trace the dynamic argument back to its origin. If it comes from a user-controlled source, the site is a real vulnerability.
- For deserialization sinks: any externally-controllable byte stream is dangerous — HTTP bodies, cookies, file uploads, WebSocket frames, queue messages. Be conservative and flag all deserialization sinks where data flow from an external source cannot be ruled out.
- For OS command sinks:
subprocess.run(["cmd", var]) with list form and no shell=True is NOT command injection — the argument is passed directly to the process without shell interpretation. Only flag when shell interpretation is possible (string command + shell=True, or exec()/system() equivalents).
- For
eval-like sinks: there is almost no safe way to use eval() with user input. Any eval-like sink receiving external data should be flagged Vulnerable.
- When in doubt, classify as "Needs Manual Review" rather than "Not Vulnerable". False negatives are worse than false positives in security assessment.
- Taint can flow indirectly through middleware, helper functions, class attributes, and intermediate variables. Trace the full chain.
- Second-order RCE is possible: a value stored from user input may later be deserialized or evaluated in a different code path (e.g., a user-supplied config stored in DB and later
eval()'d by a cron job).
- For Java deserialization: the presence of dangerous gadget libraries in the classpath (Apache Commons Collections, Spring Framework, etc.) determines exploitability. Flag the deserialization call; note any relevant libraries from .