In short

Find the interpreter each value finally reaches and use that interpreter's data interface instead of building its syntax. Run programs from an argument list with no shell, and let the caller choose an id, never a path or option. Reject DOCTYPEs in untrusted XML, never deserialize native objects from requests, build NoSQL and LDAP filters from checked scalar values, and render only templates the application owns.

  • Removing the shell is half the fix: the called program still parses options, so pass server-chosen options and put -- before paths.
  • Turn off parser features the data does not need: DOCTYPEs and external entities in XML, arbitrary types in YAML, server-side JavaScript in MongoDB.
  • Never rebuild native objects from untrusted bytes (pickle, Java ObjectInputStream, BinaryFormatter); parse JSON into a checked schema.
  • A NoSQL filter copied from a JSON body accepts operators such as $ne; require each field to be the expected scalar type.
  • Prove rejected input had no effect: zero processes, file reads, outbound requests and objects created.

Verify it

  1. Send report; echo DEMO and --output=other.txt through every field that reaches a process and confirm the process log shows one command with no extra option.
  2. Post XML whose DOCTYPE references a local stub URL and confirm the parse fails with zero requests to the stub.
  3. Send {"$ne": null} and {"$where": "sleep(5000)"} in JSON fields that reach a query and confirm a 400 with no query run.
  4. Search the code for shell=True, Runtime.exec(, pickle.loads, ObjectInputStream, yaml.load(, render_template_string and parseExpression( and review each call.
  5. Confirm ordinary values with spaces, quotes and non-ASCII text still work end to end after the fix.
Applies to

Operating-system commands, XML and YAML parsers, native deserialization, NoSQL and LDAP queries, templates and expression languages. Each defense uses the target interpreter's own safe interface.

Fix it in your stack

Tested fixes: the vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

Learning goals and prerequisitesAbout 29 minutes: 11 to read and 18 for the exercise

What you will learn

  • Trace values through multiple interpreters.
  • Replace broad syntax with a narrow typed capability.
  • Verify rejected parser input caused no side effect.

Before you start

Backend developers who call other programs, parse XML or YAML, or build database, directory or template queries, and reviewers triaging injection findings.

  • Identify the interpreter receiving a value and distinguish syntax from data.

Refresh first: Fix SQL injection in Python sqlite3

Go to the guided practice

Identify every interpreter, not only the first input check

Injection happens when attacker-controlled values become instructions in an interpreter the application invokes. That interpreter may be a shell, SQL engine, template engine, XML parser, search language or object deserializer. An HTTP input validator cannot secure every later grammar with one blacklist. Follow the value from its source through transformations to the exact API that interprets it, and identify what capability the feature really needs. Include stored values, third-party API responses and messages from internal queues: trust comes from a validated contract, not from the last network hop being inside the organization.

For example, a report exporter wants to render a named file. Building the string render-report + filename and passing it to a shell gives the filename shell syntax. A semicolon can separate commands; quoting mistakes can make spaces or substitutions meaningful. Calling a program with a fixed executable and an argument array removes shell interpretation, but the invoked program still parses its own options. An attacker-supplied --output=/some/path may alter behavior without using any shell metacharacters. There are two boundaries, not one.

The interpreter can be far from the input. Log4Shell, CVE-2021-44228, turned a logged string such as a user agent into a JNDI lookup that loaded remote code, inside a logging library nobody had listed as a parser. React2Shell, CVE-2025-55182 with a CVSS score of 10.0, let one unauthenticated request to a React Server Functions endpoint run code while React deserialized it; it affected React 19.0.0 to 19.2.0 and was fixed in 19.0.1, 19.1.2 and 19.2.1. Both are in CISA's Known Exploited Vulnerabilities catalog.

Replace source construction with a typed operation

Prefer a library API that directly performs the required work without spawning a shell. If a subprocess is necessary, select the executable and supported options in server code, pass individual arguments with shell disabled, and constrain file identities to approved resources. Use -- as an end-of-options marker only when that particular program documents it. Limit the child process's identity, working directory, time, output size and network access. A safe-looking argument array is not a universal defense against argument injection, arbitrary file selection or excessive resource use: on Windows, a .bat or .cmd target runs through cmd.exe, which reparses its arguments, as the 2024 BatBadBut issues in Rust (CVE-2024-24576) and Node.js (CVE-2024-27980) showed. The guide at /domains/appsec/secure-coding/os-command-injection/ shows the argument-array API in seven languages, with option injection, the child environment and Windows batch files.

Template rendering has the same distinction: a fixed template with a supplied value is different from compiling a user-supplied template. Keep template source under application control and pass user values through the engine's data interface, for example render_template("report.html", name=name) rather than render_template_string(user_text). A template sandbox may still expose expensive operations or objects that should not be accessible. For search or filter features, accept a small structured schema and translate it into developer-defined operations rather than granting the entire backend query language. Parameterization addresses grammar separation; authorization and resource limits still decide which operations and records are allowed. For eval, expression languages such as SpEL and OGNL, and what template sandboxes can and cannot stop, see /domains/appsec/secure-coding/code-template-injection/.

The Python program below is the repaired report export. The caller supplies a report id; the server checks that the caller may read it, maps it to a stored file and runs a fixed renderer with a fixed option and -- before the path, from an argument list with no shell. Its self-test replaces the renderer with a stub that logs each run: R1 produces exactly one run with the expected arguments, while the semicolon, option-shaped, unknown and path-shaped ids start no process at all. Run it with python3 report_export.py; it prints PASS: one renderer run for R1; rejected ids started no process.

Fixedreport_export.pyPython
"""report_export.py: render an authorized report without a shell (Python 3.11+)."""
import subprocess
import sys
import tempfile
from pathlib import Path

REPORT_FILES = {"R1": "quarterly-summary.pdf"}  # server-side map: id -> stored file


def export_report(allowed: set[str], report_id: str, renderer: list[str], root: Path) -> bytes:
    # The caller picks a report id. The server picks the program, options and path.
    if report_id not in REPORT_FILES or report_id not in allowed:
        raise PermissionError("unknown or unauthorized report")
    source = root / REPORT_FILES[report_id]
    result = subprocess.run(
        [*renderer, "--max-pages=20", "--", str(source)],  # a list: no shell parses it
        capture_output=True, timeout=30, check=True,
    )
    return result.stdout


if __name__ == "__main__":
    with tempfile.TemporaryDirectory() as tmp:
        log = Path(tmp, "runs.log")
        stub = Path(tmp, "renderer.py")  # stands in for the real renderer
        stub.write_text(
            "import sys\n"
            f"open({str(log)!r}, 'a').write(repr(sys.argv[1:]) + '\\n')\n"
            "print('%PDF-stub')\n"
        )
        renderer = [sys.executable, str(stub)]
        assert export_report({"R1"}, "R1", renderer, Path(tmp)) == b"%PDF-stub\n"
        for bad in ["report; echo DEMO", "--output=other.txt", "R2", "../../etc/passwd"]:
            try:
                export_report({"R1"}, bad, renderer, Path(tmp))
                raise AssertionError(bad)
            except PermissionError:
                pass
        runs = log.read_text().splitlines()
        assert runs == [repr(["--max-pages=20", "--", str(Path(tmp, "quarterly-summary.pdf"))])]
        # Without a shell, metacharacters stay inside one literal argument.
        subprocess.run([*renderer, "report; echo DEMO"], check=True, capture_output=True)
        assert log.read_text().splitlines()[-1] == repr(["report; echo DEMO"])
    print("PASS: one renderer run for R1; rejected ids started no process")

Use the safe interface each language provides

Every common interpreter has a data interface that keeps values out of its grammar. The table pairs the patterns scanners flag with the replacement to use; a finding is fixed when the value travels through the right-hand column, not when a blocklist rejects one payload. The fix cards at /fix/command-injection/, /fix/xxe/ and /fix/deserialization/ have tested versions of the right-hand column for common frameworks.

Use the safe interface each language provides
InterpreterAvoid with untrusted valuesUse instead
Operating-system commandsos.system, subprocess.run(cmd, shell=True), child_process.exec, Runtime.exec(String) (deprecated)An argument list with no shell: subprocess.run([exe, '--', path]), execFile(exe, args), new ProcessBuilder(exe, arg), exec.Command(exe, args...), .NET ProcessStartInfo.ArgumentList
XML parsersDTD processing and external entities left onJava disallow-doctype-decl; .NET DtdProcessing.Prohibit; Python defusedxml; lxml resolve_entities=False; libxml2 without XML_PARSE_NOENT
Native deserializationpickle.loads, Java ObjectInputStream, .NET BinaryFormatter, PHP unserialize, Ruby Marshal.loadJSON into a checked schema; for Java serialization that cannot be removed, an ObjectInputFilter allowlist. BinaryFormatter throws on use from .NET 9
YAMLyaml.load(data, Loader=yaml.Loader), Ruby YAML.unsafe_load, SnakeYAML's general Constructoryaml.safe_load, Ruby YAML.safe_load, SnakeYAML new Yaml(new SafeConstructor(new LoaderOptions()))
MongoDB and other document storesA request object passed as a filter; $where and other server-side JavaScriptFilters built from checked scalar fields with $eq; security.javascriptEnabled: false
LDAPConcatenating input into a search filter or distinguished nameJNDI filter arguments, ctx.search(base, "(uid={0})", new Object[] {uid}, controls); ldap3 escape_filter_chars; RFC 4515 escaping for filters and RFC 4514 for names
Templatesrender_template_string(user_text), Template(user_text) or a template name taken from inputFixed template files rendered with user values as context; a sandbox such as Jinja2 SandboxedEnvironment only when users must author templates
Expression languagesSpEL with StandardEvaluationContext, OGNL or MVEL evaluating request textFixed expressions; SimpleEvaluationContext when data binding needs SpEL

Disable parser capabilities the format does not require

XML may support external entities, network retrieval or document types, depending on the parser and configuration. A feature that needs simple product records should not gain filesystem or network access merely because its XML parser supports those capabilities. Select a maintained parser configuration that disables them and verifies limits for nesting, text length and expansion, and apply the same configuration to secondary XML consumers such as XSLT processors, schema validators and SOAP stacks. Test observable file and network effects using controlled stubs; an eventual parse error does not establish that no external lookup already happened.

Object deserialization is another capability boundary. Formats that instantiate arbitrary language objects may invoke code during reconstruction, before a later type check runs. Use a data-only format and validate its schema instead of accepting untrusted native object serialization. Signing a serialized blob can help authenticate a trusted producer, but it does not make an attacker-controlled producer safe or remove the impact of a leaked signing key. Keep the data contract small enough to review.

The Java program below shows the hardened factory. It rejects any document that contains a DOCTYPE, which removes DTDs, entity expansion and external fetches in one setting, and it proves the point against a local HTTP stub: the hardened parser fails with zero requests to the stub, while a default DocumentBuilderFactory on Java 21 fetches the external entity. Compile and run it with javac SafeXml.java && java SafeXml. Python's standard xml.etree does not fetch external entities, but defusedxml also rejects entity-expansion tricks; lxml 5.0 and later refuse external entities by default.

Converters and document pipelines need the same treatment. Bound decompression, image dimensions, recursion depth and output size before the expensive step, and run converters in an isolated worker because a valid file can still exploit a parser defect. For URL import features, pair parser restrictions with connection-time destination controls from the SSRF guide at /domains/appsec/secure-features/ssrf-egress-boundaries/; input validation is not an egress firewall.

FixedSafeXml.javaJava
import com.sun.net.httpserver.HttpServer;
import java.io.StringReader;
import java.net.InetSocketAddress;
import java.util.concurrent.atomic.AtomicInteger;
import javax.xml.XMLConstants;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import org.xml.sax.InputSource;

public class SafeXml {
    // One hardened factory for untrusted XML: no DOCTYPE, so no DTD, entity or external fetch.
    static DocumentBuilder newBuilder() throws Exception {
        DocumentBuilderFactory f = DocumentBuilderFactory.newInstance();
        f.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
        f.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
        f.setXIncludeAware(false);
        f.setExpandEntityReferences(false);
        return f.newDocumentBuilder();
    }

    static String productName(DocumentBuilder b, String xml) throws Exception {
        return b.parse(new InputSource(new StringReader(xml)))
                .getDocumentElement().getElementsByTagName("name").item(0).getTextContent();
    }

    public static void main(String[] args) throws Exception {
        AtomicInteger stubReads = new AtomicInteger(); // a local stand-in for a file or URL
        HttpServer stub = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
        stub.createContext("/", ex -> { stubReads.incrementAndGet(); ex.sendResponseHeaders(200, -1); ex.close(); });
        stub.start();
        String url = "http://127.0.0.1:" + stub.getAddress().getPort() + "/secret";
        String xxe = "<!DOCTYPE p [<!ENTITY x SYSTEM \"" + url + "\">]><product><name>&x;</name></product>";
        try {
            check(productName(newBuilder(), "<product><name>Ada</name></product>").equals("Ada"), "normal");
            try {
                productName(newBuilder(), xxe);
                throw new AssertionError("DOCTYPE accepted");
            } catch (org.xml.sax.SAXParseException expected) {
                check(stubReads.get() == 0, "no external read");
            }
            // Control: a default factory fetches the entity, so the counter can detect it.
            productName(DocumentBuilderFactory.newInstance().newDocumentBuilder(), xxe);
            check(stubReads.get() == 1, "default parser fetched the entity");
            System.out.println("PASS: DOCTYPE rejected with 0 external reads; default parser read 1");
        } finally {
            stub.stop(0);
        }
    }

    static void check(boolean ok, String what) {
        if (!ok) throw new AssertionError(what);
    }
}

Build NoSQL and directory queries from typed values

Document databases are injectable through structure rather than quotes. If a login handler passes {"user": body.user, "password": body.password} to MongoDB and the JSON body supplies {"$ne": null} for the password, the query matches any account. Build NoSQL queries from expected scalar fields rather than accepting caller-supplied operator objects: check that each value is a string, number or allowed constant, wrap it in $eq, and reject unknown fields. Disable server-side JavaScript, which $where, mapReduce, $function and $accumulator need, unless the application uses it. Mongoose's sanitizeFilter option helps as a second layer, but it had a $nor bypass fixed in 6.13.9, 7.8.9, 8.22.1 and 9.1.6 (CVE-2026-42334), so keep the type checks.

LDAP filters have their own escaping rules: *, parentheses, backslash and NUL change a filter, and distinguished names use different rules. Use an API that escapes for the exact position, such as JNDI filter arguments or ldap3's escape_filter_chars. The same caution applies when JavaScript code merges request objects into existing ones: property names such as __proto__ reach the object model itself, which the prototype pollution guide at /domains/appsec/secure-coding/prototype-pollution-boundaries/ covers.

The module below builds a MongoDB filter for an invoice search. The tenant comes from the session, status must be one of three strings and customer must be a short string; everything else is rejected before any query runs. Run node invoice-filter.mjs; it accepts the two scalar queries and rejects five operator, array and extra-field queries.

Fixedinvoice-filter.mjsJavaScript
// invoice-filter.mjs (Node.js 22+): build a MongoDB filter from expected scalar fields only.
import assert from 'node:assert/strict';

const STATUSES = new Set(['open', 'paid', 'void']);

export function invoiceFilter(tenant, query) {
  const {status, customer, ...rest} = query;
  if (Object.keys(rest).length) throw new TypeError('unsupported field');
  const filter = {tenant: {$eq: tenant}}; // tenant comes from the session, not the request
  if (status !== undefined) {
    if (typeof status !== 'string' || !STATUSES.has(status)) throw new TypeError('bad status');
    filter.status = {$eq: status};
  }
  if (customer !== undefined) {
    if (typeof customer !== 'string' || customer.length > 64) throw new TypeError('bad customer');
    filter.customer = {$eq: customer}; // $eq keeps even "$ne"-looking text a literal value
  }
  return filter;
}

// A JSON body can carry operator objects; each must be refused, not passed to find().
assert.deepEqual(invoiceFilter('red', {status: 'open'}), {tenant: {$eq: 'red'}, status: {$eq: 'open'}});
assert.deepEqual(invoiceFilter('red', {customer: '{"$ne":null}'}).customer, {$eq: '{"$ne":null}'});
for (const query of [
  {customer: {$ne: null}},
  {customer: {$regex: '.*'}},
  {status: ['open', 'paid']},
  {$where: 'sleep(5000)'},
  {tenant: 'blue'},
])
  assert.throws(() => invoiceFilter('red', query), TypeError, JSON.stringify(query));
console.log('PASS: scalar fields accepted; 5 operator or extra-field queries rejected');

Test ordinary values, syntax-shaped values and resource edges

The ordinary report name Quarterly Summary contains a space and must remain one label. A semicolon-shaped input must not create a second command. A leading option must not become a renderer flag. A parser's unsupported external entity must trigger no file read or network connection. These are effect assertions, not merely substring checks on the resulting output.

Observe the actual sink: process creation arguments, renderer inputs, database statement shape or a resolver stub. Errors should identify the rejected contract without echoing secrets or invoking a fallback interpreter. A common partial fix catches an argument-array failure and retries with shell=true for compatibility; that restores the original vulnerability on precisely the difficult inputs. When diagnosing a regression, preserve the failing synthetic input and improve the typed interface or documented option mapping rather than expanding the set of untrusted syntax the application will execute. The bounded-data-parsing project below applies these XML and JSON rules to a real Python parser, and the native-memory-safety project shows how a sanitizer exposes a parser defect that an error message would hide.

Treat sanitizers as grammar-specific claims

A security helper called sanitize is too vague to review. Require a statement of its input grammar, output grammar and intended sink. Shell quoting for one shell is not a SQL binding mechanism; HTML entity encoding is not a URL allowlist; JSON parsing is not permission to instantiate native objects. A transformation may make one representation safe while a later decoder restores syntax. The final interpreter determines the relevant property.

For the report example, allowing spaces in a title is normal. A patch that rejects every space may break Quarterly Summary while leaving a leading-option attack intact. A typed reportId removes both ambiguities by letting the server choose the file and rendering operation. This is an architectural reduction of authority, not a longer blacklist. If the product really needs user-specified filter expressions, define and parse a deliberately restricted language with bounded complexity and explicit semantics.

Complexity limits belong alongside grammar limits. A data-only JSON parser avoids arbitrary object construction, but deeply nested input or a huge array can still consume excessive memory and CPU. A template with no file access can still perform expensive rendering. Set size and depth bounds before costly processing, and assert the same ordinary feature still works at supported boundaries. The negative result should include zero unintended commands, reads or outbound requests and bounded work, because a rejected input can still be harmful if rejection occurs after the expensive or privileged effect.

Guided practice

Review a report renderer and two parsers with fully supplied contracts.

Supplied scenario material
Plain text
Feature: caller selects reportId from authorized records {R1: Quarterly Summary}. Fixed template; max 20 pages.
Old code: shell('render-report '+filename).
Inputs: A='Quarterly Summary'; B='report; echo DEMO'; C='--output=other.txt'.
Repaired route accepts reportId only; R1 resolves to a server-generated path.
XML contract: product{name:string<=80}; DTD and external entities disabled; no network/file resolution. X includes an external entity pointing to controlled stub S.
Serialization contract: JSON object with only name:string. J={name:Ada}; K={__class__:RunCommand,args:[DEMO]}.
Effect counters initially: subprocesses=0, stubReads=0, createdObjects=0.
  1. 1. Name the interpreters

    Explain the different risks in B and C when passed to the old command and to an argument array.

    Expected result and reasoning

    B changes shell grammar in the old call. With shell disabled it is a literal argument, but C may still be interpreted as a program option. Removing a shell does not remove the callee's option parser.

  2. 2. Constrain the feature vocabulary

    Evaluate R1 and a submitted filename C under the repaired route.

    Expected result and reasoning

    R1 selects the authorized report and the renderer receives only server-derived inputs. A filename field is unsupported and rejected. The application never grants arbitrary renderer options to the caller.

  3. 3. Observe external parser effects

    Evaluate X under the XML contract and state the required stub counter.

    Expected result and reasoning

    Reject the unsupported DTD/entity and keep stubReads=0. A parser that reads S and only then reports an error violates the contract.

  4. 4. Validate data before interpretation

    Evaluate J and K under the JSON schema; explain why native object reconstruction followed by checking is unsafe.

    Expected result and reasoning

    J is accepted as data. K is rejected for unknown fields and creates no language object. If reconstruction could invoke a constructor first, a later validation failure would arrive after the forbidden effect.

Your deliverable: A source-to-interpreter map and exact zero-effect assertions for each rejected input.

Paper exercise: every input, contract and counter is supplied. The Python, Java and JavaScript programs above test the same command, XML and query rules in code.

Save a draft on this device to resume later. Automatic saving begins after you choose Save. Drafts are never sent by this application; use synthetic data.

No draft saved on this device yet.

Repair the code

SQL injection lab: Separate SQL structure from data with sqlite3

OS command injection lab: Run git without a shell

SSTI lab: Render a welcome banner without template injection

XXE lab: Parse an uploaded invoice without resolving external entities

Pickle deserialization lab: Load a session token without pickle

Run a local project

Download and unzip a starter with failing tests and a step-by-step README, edit the named exercise file, and run the tests until they pass. The reference solution is a separate download, so you can attempt the repair before you see it.

Python 3.13+ · Expat 2.6.0+ · standard library · 60–90 min

Parse bounded JSON and XML invoices safely

Repair a real JSON/XML parser boundary: reject duplicate fields, coercion, unknown schema, DTDs and oversized documents while keeping valid invoices usable.

In the extracted folder, run:

Shell
python3 -m unittest -v test_parsing.py

24 regression tests · The starter intentionally fails some cases.

Opens in a dev container (VS Code Dev Containers or GitHub Codespaces) with a pinned image, and includes Semgrep rules with their own tests in semgrep/.

Actual bounded JSON and Expat parsing; no arbitrary object deserialization, external entities, network requests or invoice authorization. Narrow invoice vocabulary, not a general XML schema engine.

Clang + Python 3.11+ · 45–75 min

Repair a native parser using sanitizer evidence

Compile a C parser with AddressSanitizer, observe a real out-of-bounds access, and repair allocation, length and output-capacity checks against boundary tests and 80 generated frames.

Needs: A local Clang with the AddressSanitizer and UndefinedBehaviorSanitizer runtimes.

In the extracted folder, run:

Shell
python3 reproduce.py
python3 test_parser.py

11 regression tests · The starter intentionally fails some cases.

Opens in a dev container (VS Code Dev Containers or GitHub Codespaces) with a pinned image.

Actual Clang address/undefined-behavior instrumentation on generated local files; 4 KiB harness input cap. Requires a compiler and sanitizer runtime.

Sources & further reading

The standards, specifications and documentation behind the guidance above. Each is the evidence for a claim on this page; consult it for detail beyond the defaults and versions listed here.

  1. OWASP Deserialization
  2. OWASP OS Command Injection Defense
  3. OWASP XML External Entity Prevention
  4. Python subprocess: security considerations. Python 3.15.
  5. OWASP LDAP Injection Prevention Cheat Sheet
  6. MongoDB: server-side JavaScript
  7. GitHub advisory GHSA-wpg9-53fq-2r8h: Mongoose sanitizeFilter $nor bypass
  8. React: critical security vulnerability in React Server Components (CVE-2025-55182). 3 December 2025.
  9. Apache Log4j security vulnerabilities
  10. lxml 5.0 changes: external entities no longer resolved by default

Reference information

SDLC phase
Development
ASVS 5.0.0 requirements
v5.0.0-1.2.4 · v5.0.0-1.2.5 · v5.0.0-1.2.6 · v5.0.0-1.3.2 · v5.0.0-1.3.7 · v5.0.0-1.5.1 · v5.0.0-1.5.2

Download this guide as Markdown