back to research
LARP-2026-008ctf4 min read

From a template literal to root through a poisoned Jinja partial

Input placed inside a JavaScript template literal before DOMPurify ran, an admin bot used as its own exfiltration channel, a filename path traversal, a PDF check that ignored trailing bytes, and an overwritten Jinja partial for SSTI as root.

Solving this challenge felt like Troy’s war. Getting the flag out of the box at the end was basically Odysseus trying to make it home.

It’s from the AFRICC 2026 qualifiers: three services behind one Nginx, a small marketplace, and a bot that visits any listing you report. Almost every step depended on noticing a small detail in the one before it.

Recon

The session was a JWT carrying id, exp and antiCSRFToken, and the cookie wasn’t marked HttpOnly. That alone made anything client-side worth testing. Once logged in, the marketplace had three features worth a look: adding an item, viewing product pages, and reporting a listing to the bot.

Template literal injection

Product pages handled the note field like this:

jsproduct page
let note = `USER CONTENT`;
const clean = DOMPurify.sanitize(note, {
    FORBID_ATTR: ['id', 'style'],
    USE_PROFILES: {html: true}
});
document.getElementById('note').innerHTML += clean;

My input was written into a JavaScript template literal, and DOMPurify only ran afterwards, on the result. Backticks were stripped and <, > and & were encoded, so I couldn’t close the literal or inject HTML. But ${...} survived, and inside a template literal that’s a JavaScript expression. It runs while the page’s own script is building the string, before DOMPurify is even called.

So this wasn’t HTML injection at all, and the sanitizer never had a chance to see it.

Exfiltration through the app

The usual move is to make the bot send its cookies to a server you control. I used the app instead. The expression went into a product, I reported the product so the bot opened it, and the bot wrote what I wanted into that same product. Then I read it back with my normal session. Nothing had to leave the network, which matters when the bot has no outbound access.

The first thing I pulled was document.cookie. It came back with the session JWT, and the decoded token said id: 1. The bot was the administrator.

File write through the filename

As admin I had contract uploads, and the upload endpoint didn’t sanitize the multipart filename. This one landed in the app’s static directory:

textupload filename
../static/js/pwn2.js

The responses gave me a small write oracle for free. Contract Added meant the write worked, Invalid PDF! meant the content failed validation.

PDF with an appended payload

The validator checked that the upload was a valid PDF and didn’t care about anything after the end of it. So every upload from then on was a minimal valid PDF with my bytes appended:

textupload body
%PDF-1.4
... minimal valid PDF ...
%%EOF
ZZ{{7*7}}ZZ

The app rendered Jinja templates. I pointed the traversal at the templates, and when ZZ49ZZ started appearing on pages I knew I had overwritten a shared layout partial. Jinja renders the PDF bytes as plain text and evaluates the expression after them.

jinjapayload
{{ cycler.__init__.__globals__.os.popen("id").read() }}

That came back as uid=0(root).

The gadget is short because of where it starts. cycler is a helper Jinja hands to every template, its __init__ is an ordinary Python function, and a function’s __globals__ is the namespace of the module it was defined in. That module imports os, so there’s no need for the usual walk through __subclasses__().

uWSGI workers

The app ran under uWSGI with several workers, and different requests hit different workers. Each one could hold a different copy of the poisoned template, most likely because each process keeps its own cache of compiled templates. Reading command output straight from the response became a coin flip.

The fix was to stop reading output from the response. The payload wrote its result into the static directory instead, so any one worker rendering the partial once was enough, and after that the output was a plain static file:

jinjaroughly: write the output somewhere fetchable
{{ cycler.__init__.__globals__.os.popen("id > <static-dir>/out.txt").read() }}

Full chain

textfull chain
template literal injection     JavaScript runs in the bot's browser
bot session theft              administrator
filename path traversal        write files where the app can
PDF check ignores the tail     put anything in those files
overwritten Jinja partial      SSTI, commands as root
output written to static       reliable across uWSGI workers, flag

The first bug didn’t give me the flag. It gave me the bot’s identity, which gave me the upload feature, which gave me a file write, which gave me SSTI, which finally gave me code execution.

cuddest
back to research