SSTI1
Goal: an announcement board with a single text field. Whatever you submit comes back on the page.
Approach
Section titled “Approach”The tell for this class of bug is that arithmetic survives the round trip. Submit {{7*7}} and a page that is printing your input returns it unchanged, while a page that is rendering it returns 49. From there {{7*'7'}} narrows the engine: coming back as 7777777 is string repetition, which is Python behaviour, where a template language such as Twig would multiply and answer 49 again. The response header naming Werkzeug settles the rest, because that is Flask, and Flask renders with Jinja2.
Jinja2 evaluates its expressions against real Python objects, so the input is no longer text. It is a foothold in the interpreter, and the rest is finding a way from the objects a template can name to something that runs commands.
{{ ''.__class__.__mro__[1].__subclasses__()[356]("cat /home/ctf/flag.txt", shell=True, stdout=-1).communicate()[0] }}Every step of that chain is doing one small thing. '' is a string, so ''.__class__ is str. A class’s __mro__ is its method resolution order, and for str that is exactly (str, object), which makes [1] the root of the type hierarchy. object.__subclasses__() then lists every class that inherits from it directly, and subprocess.Popen is declared as a plain class, so it is in there.
The 356 is not a constant and should not be treated as one. It is a position in a list that exists only at runtime, and the list grows as the process imports things, so the index depends on the image and on what has already been loaded. Worth confirming rather than copying: on a local interpreter here the same list held 246 entries with Popen at 245, and no Popen in it at all until subprocess was imported.
It is also worth running ls through the same payload before committing to a path. communicate()[0] is standard output alone, so a cat that fails writes to standard error and the page renders an empty result rather than a complaint.
b'<flag>'picoCTF{s4rv3r_s1d3_t3mp14t3_1nj3ct10n5_4r3_c001_3066c7bd} The b and the quotes are not part of it.
Why it works
Section titled “Why it works”The vulnerability is not that the application trusts input, it is that it concatenates input into the template before rendering rather than passing it in as a value. A parameter is data that the renderer substitutes. A concatenated string becomes part of the program the renderer executes. Same characters, entirely different privilege.
The escalation from there is a property of Python rather than of Jinja2. Templates deliberately expose no imports and no __builtins__, so there is no os to reach for, but they do let you touch attributes of the objects you already have, and every object carries a route back to its class and from there to the whole type graph. That is why the payload starts from an empty string: it needs no variables, no context and nothing the application chose to expose, only a literal that Python is obliged to give a type. The remaining pieces are ordinary Popen arguments, with shell=True because the command is one string and stdout=-1 because that is the numeric value of subprocess.PIPE, which the template has no name for. Without the pipe, communicate() hands back nothing to render.