Reverse Engineering U-Boot for fun and profit
Note: this post was written for my employer, Digifors GmbH, and a link to the original post on their website will appear here as soon as it is available.
The premise
We are currently in the process of researching a baby monitor from Amazon. The order process went something like this:
- Go to Amazon
- Search “baby monitor”
- Sort by best rated
- Order
The product consists of two devices. One, a handheld, that communicates and controls the camera via radio, and two, the camera. The camera can also be controlled through the “cloud” via an app.
Our goal is to completely take over the camera, without any prior access.
And apparently they are one of the few vendors who are doing something right, much to our demise. Here is part of that story:
The problem
During research copious amounts of logs are always beneficial. It allows insight into what the device is doing and how it reacts to certain inputs. And initially gaining logs from the device was relatively easy. Opening up the outer shell, putting the device on the bench, figuring out the pinout with a multimeter and bam. We had a serial connection.
The boot logs looked like this (shortened a bit for brevity):
U-Boot SPL 2013.07 (Jul 24 2023 - 16:37:26)
Timer init
...
sdram init finished
SDRAM init ok
watchdog open!
board_init_r
image entry point: 0x80100000
U-Boot 2013.07 (Jul 24 2023 - 16:37:26)
Board: ISVP (Ingenic XBurst T31 SoC)
DRAM: 128 MiB
Top of RAM usable for U-Boot at: 84000000
Reserving 430k for U-Boot at: 83f94000
Reserving 32784k for malloc() at: 81f90000
Reserving 32 Bytes for Board Info at: 81f8ffe0
Reserving 124 Bytes for Global Data at: 81f8ff64
Reserving 128k for boot params() at: 81f6ff64
Stack Pointer at: 81f6ff48
Now running in RAM - U-Boot at: 83f94000
MMC: msc: 0
the manufacturer 20
SF: Detected XM25QH128C
In: serial
Out: serial
Err: serial
the manufacturer 20
SF: Detected XM25QH128C
Hit any key to stop autoboot: 2 1 0
mmc power off ...
the manufacturer 20
SF: Detected XM25QH128C
--->probe spend 5 ms
SF: 2621440 bytes @ 0x40000 Read: OK
--->read spend 842 ms
## Booting kernel from Legacy Image at 80600000 ...
Image Name: Linux-3.10.14__isvp_swan_1.0__
Image Type: MIPS Linux Kernel Image (lzma compressed)
Data Size: 2518547 Bytes = 2.4 MiB
Load Address: 80010000
Entry Point: 8032d610
Verifying Checksum ... OK
Uncompressing Kernel Image ... OK
Starting kernel ...
As you might notice, the log stop after the kernel boots. Which sucks, because that is the part that is actually interesting for our research. The actual application of the camera only launches after the Linux kernel and prints its logs to the same output as the kernel1. If the kernel doesn’t log anything that sucks for us.
You might have noticed, that U-Boot allows to interrupt the boot sequence by hitting any key on the prompt Hit any key to stop autoboot:
. Unfortunately, that also doesn’t work. So we don’t get logs and the autoboot can’t be stopped. So we decided to dump the flash. The flash can be found in the top right of the board, and is a simple SOP8 flash chip. Attaching a SOP8 clip and trying to read the chip with flashrom
only worked after compiling flashrom
from source, as support for the chip not yet in a release.
flashrom -p linux_spi:dev=/dev/spidev0.0,spispeed=1000 -r dump.bin
Note: You can see 2 raspi’s in the image. That is because we first tried to directly attack to the board with a Flipper Zero as a bridge, but that lead to shitty connection and broken flash images.
After dumping the flash we analyzed the image using binwalk
, giving this result (there were a lot of false positive JBOOT STAG
and Zlib
headers, they were left out):
DECIMAL HEXADECIMAL DESCRIPTION
--------------------------------------------------------------------------------
194312 0x2F708 LZO compressed data
197812 0x304B4 Android bootimg, kernel size: 0 bytes, kernel addr: 0x70657250, ramdisk size: 543519329 bytes, ramdisk addr: 0x6E72656B, product name: "mem boot start"
262144 0x40000 uImage header, header size: 64 bytes, header CRC: 0x137445C4, created: 2023-09-07 11:30:20, image size: 2518547 bytes, Data Address: 0x80010000, Entry Point: 0x8032D610, data CRC: 0x963EF0EB, OS: Linux, CPU: MIPS, image type: OS Kernel Image, compression type: lzma, image name: "Linux-3.10.14__isvp_swan_1.0__"
262208 0x40040 LZMA compressed data, properties: 0x5D, dictionary size: 33554432 bytes, uncompressed size: -1 bytes
2883584 0x2C0000 Squashfs filesystem, little endian, version 4.0, compression:xz, size: 7222750 bytes, 136 inodes, blocksize: 131072 bytes, created: 2023-10-12 03:19:12
15990784 0xF40000 JFFS2 filesystem, little endian
And extracting each part:
dd if=./dump.bin of=./uboot.img bs=1 skip=0 count=$((0x40000))
dd if=./dump.bin of=./kernel.img bs=1 skip=$((0x40000)) count=$((0x2C0000-0x40000))
dd if=./dump.bin of=./squashfs.img bs=1 skip=$((0x2C0000)) count=$((0xF40000-0x2C0000))
dd if=./dump.bin of=./jffs2.img bs=1 skip=$((0xF40000))
Back to the problem at hand; Finding why we don’t get logs: Serial logging can be configured for the kernel, for example, by setting boot arguments, that get passed to the kernel during boot. U-Boot also supports this via the bootargs
variable. Running a string search for this over uboot.img
reveals
bootargs=console=/dev/null mem=64M@0x0 rmem=64M@0x4000000 init=/linuxrc mtdparts=jz_sfc:256k(BOOT),2560k(sys),7680k(app),5120k(recove),640k(cfg),64k(enc),64k(sysflg)
This tells the kernel to serial log to /dev/null
, the Linux null driver that makes everything disappear.
We now have 2 options:
- Change the
bootargs
by patching the binary and flashing it back to the device - Figure out a way to stop the autoboot and change the
bootargs
via U-Boot
Option 1 seems like a whole lot more work, than…
Fault Injection cue dramatic music.
When U-Boot tries to boot, it checks the bootcmd
variable, giving it the commands to run to load the program to be executed into memory. It tries to load data from the SPI flash, that we dumped previously. If it can’t find something to boot, U-Boot will automatically drop into the U-Boot shell to allow debugging and whatnot. So if we can convince U-Boot that the flash is not available, it should drop us into a shell. We can accomplish this by “fault injection”, i.e. jamming a piece of metal between the CLK
and DI
pin of the chip, shorting the pins, stopping it from answering the CPU. Then U-Boot thinks it can’t communicate with the flash and drops to a shell. Lo and behold, it actually works.
U-Boot SPL 2013.07 (Jul 24 2023 - 16:37:26)
...
board_init_r
image entry point: 0x80100000
U-Boot 2013.07 (Jul 24 2023 - 16:37:26)
...
Hit any key to stop autoboot: 2 1 0
mmc power off ...
SF: Unsupported manufacturer 00
Failed to initialize SPI flash at 0:0
--->probe spend 7 ms
No SPI flash selected. Please run `sf probe'
Wrong Image Format for bootm command
ERROR: can't get kernel image!
watchdog close!
PASSWD :
The log was once again shortened, but as you can see, it fails to detect the SPI chip, fails to get the kernel image and drops to the shell. But what is that? That is now how a shell is supposed to look like. Apparently they are the only vendor, that protects their device not only by disabling interrupting the autoboot, but also with a password. And even more, this is not standard U-Boot functionality, but a custom patch. And sadly, password
, root
, god
, did not work.
Snark remark: In theory, they would have to give people access to their modified source code, because U-Boot is licensed under GPLv2, but even on request they denied that access. So they are in violation of the U-Boot license.2
Now we had, again, 2 options:
- Brute force
- Reverse Engineering U-Boot
For number 1 we wrote a short program that takes a wordlist and pushes that data over serial, reading back the response and checking if it is still at the password prompt. Being an online attack over serial on an embedded device, this was not particularly fast.
For number 2: For this check to work, the password, or at least a hash, has to be found in the binary. And in the assembly there has to be the procedure to check the user input against that password or hash. If we can identify that place, we might be able to find the password, giving easy access, or the hash, allowing an offline brute force attack with our password cracking server, making the brute force a lot more efficient. This would, in turn, allow us to change the bootargs
, and a whole lot of other shenanigans, by giving access to the U-Boot console.
While options 1 was crunching the wordlist we loaded uboot.img
into Cutter
, and open source reverse engineering toolkit, just setting the architecture, endianess and address length. Looking through the strings in the binary the PASSWD :
string could be found, but sadly no cross references were found. In general Cutter
seemed to struggle with the disassembly, even though it uses the Ghidra
decompiler under the hood. So to get a second opinion, the image was loaded into Ghidra
, where the disassembly looked better, but still no references could be found. This left us thinking on why that could be the case.
The boot process on embedded systems mostly look like the following:
- CPU boots, executes ROM code, that is stored on the board from the factory and that can’t be changed, doing stuff like bare minimum hardware initialization.
- Jumps to a pre-determined location in memory (this might be actual memory, that has been initialized by the ROM code, or memory that is mapped to the associated storage medium), executing the user supplied first stage bootloader, which does further setup in order to support it’s own second stage, like setting up the C runtime requirements and loading the second stage into memory
- Jumps to the second stage, which does the actual booting of a program or OS, fully running from memory.
- Jumps to the loaded program.
The image we are analyzing makes up step 2 and 3. U-Boot contains, in fact, two U-Boots. The first stage, which runs from the flash storage of the system, which then loads its second stage into memory, on a set address. And the second stage, which then runs completely from memory, setting up and executing the OS. So we are working with at least 2 different base locations, one for the first and one for the second stage, that can be anywhere in the memory space, as the datasheet for the CPU also doesn’t provide a proper memory map and there is no official documentation for the board. So we had to figure out the base address on our own.
A deep dive into U-Boot (pun intended)
Our best bet for figuring out the base address is finding functions, where strings are used and trying to figure out, why there are no cross references from there to the strings location. Probably a problem with offsets and the wrong base address. The problem with the loaded binary, as there are no string references, the only way for finding functions and starting reverse engineering is cross referencing the C code of U-Boot with the disassembly and hoping to find similarities. But being an embedded system, there are a lot of “constant” values, other than strings one can search for. Like memory mapped register addresses for configuring timers and such. In addition to using constants, operations like writing to memory mapped registers also maps relatively closely to raw assembly, by being relatively atomic operations, like reading and writing from/to memory.
Looking through an unofficial U-Boot fork for the Ingenic T31 XBurst CPUs 3 one such suitable function could be
void reset_timer(void)
{
tcu_writel(0, TCU_OSTCNTH);
tcu_writel(0, TCU_OSTCNTL);
tcu_writel(0, TCU_OSTDR);
}
This function resides in the arch/mips/cpu/xburst/timer.c
file of the U-Boot source tree. This file is generally concerned with managing and configuring timers, like resetting, initializing and providing a timer to the caller. This could be really helpful, as timers are needed in a lot of different places, hopefully providing a good starting point for reverse engineering the rest and finding a place were strings are used.
The tcu_writel
function is defined as
static void tcu_writel(uint32_t val, uint32_t off)
{
writel(val, (void __iomem *)TCU_BASE + off);
}
taking a value and an offset. The offset gets added to TCU_BASE
, another constant and is then passed, together with a value, to the writel
function. We could try and go deeper down the callchain, but it is safe to assume, that this will, at most, compile down to a store instruction, with maybe one or two checks. Using the offset TCU_OSTCNTH
and the base value of TCU_BASE
it is possible to calculate the absolute value, which is then probably inlined together with the store instruction. TCU_BASE
is defined once in the U-Boot code as 0xb0002000
and TCU_OSTCNTH
as 0xe8
, resulting in a memory address of 0xb000200e8
. Searching for this value yields this result
lui $v0, 0xb000 {0xb0000000}
sw $zero, 0x20e8($v0) {0x0} {0xb00020e8}
sw $zero, 0x20e4($v0) {0x0} {0xb00020e4}
sw $zero, 0x20e0($v0) {0x0} {0xb00020e0}
jr $ra
nop
showing a store instruction of the value 0
to our memory address. We can also see 2 other store instructions, which map perfectly to the rest of the reset_timer
function in the source code. We just identified our first board specific function. This function also luckily has a cross reference to another function, which according to the source code should be
int timer_init(void)
{
multiple = CONFIG_SYS_EXTAL / USEC_IN_1SEC / OST_DIV;
reset_timer();
tcu_writel(OSTCSR_CNT_MD | OSTCSR_PRESCALE | OSTCSR_EXT_EN, TCU_OSTCSR);
tcu_writew(TER_OSTEN, TCU_TESR);
return 0;
}
I’ll show you the pseudo code of the disassembled function, instead of the assembly, as that makes stuff easier.
int32_t sub_801004c8()
{
*(uint32_t*)0x8012f710 = 6;
reset_timer();
*(uint32_t*)0xb00020ec = 0x800c;
*(uint32_t*)0xb0002014 = 0x8000;
return 0;
}
This too looks exactly like what we would expect. So this is likely timer_init
.
Two things to note:
- Things that are close together in source code often end up together in the binary.4
- Small functions often get inlined by the compiler to avoid unnecessary jumps.
By looking around a bit in the binary and in the source code, we can identify the function directly after timer_init
to be the get_timer
function. It looks a bit different, because the get_timer64
call is inlined and lldiv
, only being a small wrapper around __div64_32
, also being inlined.
This is the pseudo c disassembly of the following callchain:
uint32_t get_timer(uint32_t base)
{
int32_t $a2 = *(uint32_t*)0xb00020fc;
void* const var_20 = 0x8013a1b0;
uint32_t var_18 = *(uint32_t*)0xb00020e4;
int32_t var_14 = $a2;
if ($a2 == 0)
trap(0);
__div64_32(&var_18, 0); // this was already renamed by me to make this
// clearer
return (var_18 - base);
}
// timer.c
static uint64_t get_timer64(void)
{
uint32_t low = tcu_readl(TCU_OSTCNTL);
uint32_t high = tcu_readl(TCU_OSTCNTHBUF);
return ((uint64_t)high << 32) | low;
}
ulong get_timer(ulong base)
{
return lldiv(get_timer64(), (USEC_IN_1SEC/CONFIG_SYS_HZ) * multiple) - base;
}
// include/div64.h
# define do_div(n,base) ({ \
uint32_t __base = (base); \
uint32_t __rem; \
(void)(((typeof((n)) *)0) == ((uint64_t *)0)); \
if (((n) >> 32) == 0) { \
__rem = (uint32_t)(n) % __base; \
(n) = (uint32_t)(n) / __base; \
} else \
__rem = __div64_32(&(n), __base); \
__rem; \
})
static inline uint64_t lldiv(uint64_t dividend, uint32_t divisor)
{
uint64_t __res = dividend;
do_div(__res, divisor);
return(__res);
}
and one can see the similarities. The subtraction at return (var_18 - base);
and the reads from the memory addresses being the most obvious.
Most importantly, this gives us access to a function that is used all over the place, get_timer
. Looking at the cross references there are quite a few. Searching the U-Boot source code we can find a place, where get_timer
is used, and a “lot” of strings are referenced. The do_load
function in fs/fs.c
.
This function starts with a check for the argc
count
if (argc < 2)
return CMD_RET_USAGE;
if (argc > 7)
return CMD_RET_USAGE;
checking the cross references of get_timer
we can quickly identify a function that also starts with this check
if ((arg3 < 2 || arg3 >= 8))
return 0xffffffff;
The > 7
check did not really persist, but was decompiled as arg3 >= 8
, which is functionally equivalent. Also the CMD_RET_USAGE
is defined as -1
, which would be 0xffffffff
as an signed int. This, together with the fact, that the rest of the structure also seems the same, makes this very likely our do_load
function.
In the whole function the last part holds the most string references, with a whole lot of six strings being used. Wow.
printf("%d bytes read in %lu ms", len_read, time);
if (time > 0) {
puts(" (");
print_size(len_read / time * 1000, "/s");
puts(")");
}
puts("\n");
setenv_hex("filesize", len_read);
How does Ghidra
handle this. Does it show us strings?
// this is the printf of the previous code snippet
FUN_00014718(DAT_000389e0 + -0xe0c,uVar3,uVar4);
No! Instead it shows a function, that takes a pointer + an offset and 2 other arguments. The uVar3
and uVar4
is the len_read
and time
variable from the source code, so the first part has to be the printf
format string, which we know to be %d bytes read in %lu ms
. That exact same string can also be found in the strings of the binary, at address 0x359f4
. One might think, that the first argument might resolve to that address, but quick maths suggest it is 0x37bd4
that the argument points to. Taking a close look at the disassembly
lw param_1,-0x7fd0(gp)=>DAT_000389e0 = 80130000h
lw t9,-0x7fd4(gp)=>DAT_000389dc = 8010DF18h
addiu param_1,param_1,-0xe0c
we can see, that the value pointed to by DAT_000389e0
is 0x80130000
. So maybe, just maybe, rebase to that value.
drum roll
It still doesn’t work. Calculating the offset again, this time using 0x80130000
for the calculation instead of DAT_000389e0
and the new location of the string in memory we see the offset is 0x36800
. Still not the right location, but that 3
in the value matches suspiciously to the 3
in our base address we choose just because why not. Rebasing again to 0x80100000
, it still doesn’t match, but the offset is now, to no ones surprise, 0x6800
. The address always points to early in memory. By about 0x6800
bytes to early. Taking this into account, for our new base address we get 0x800f9800
. Calculating the offset now, we land exactly on the string. But Ghidra
still displays this:
FUN_8010df18(iRam000389e0 + -0xe0c,uVar3,uVar4);
which totally sucks. This is likely through the two step address calculation as shown in the assembly listing above, but we also found no way to remedy this in Ghidra
. Curious about trying other disassemblers, and with Ghidra
also constantly crashing 5, we asked “Vector 35” nicely, if they could provide us with a test version of BinaryNinja
(as the free trial does not allow MIPS), which they graciously provided without much hassle.
Loading the binary into BinaryNinja, copying over the function locations and rebasing the image provided us with this glorious image:
printf("%d bytes read in %lu ms", $v0_11, $v0_12);
if ($v0_12 != 0)
{
puts(" (", $k0);
if ($v0_12 == 0)
trap(0);
sub_8011f444((($v0_11 / $v0_12) * 0x3e8), 0, "/s");
puts(")", $k0);
}
puts("\n", $k0);
setenv_hex("filesize", $v0_11);
return 0;
Strings, we can finally see strings!
Now it was just a question of following the cross references for the PASSWD :
string, in order to find the password check. The cross references point us to this code snippet:
while (true)
{
if (read("PASSWD :") > 0)
sub_801212b8(&data_80135190, 0x8013ac34);
if (strcmp(&data_80135190, "pps_password") == 0)
break;
puts("passwd error\n", $k0);
}
Here I already renamed the functions, but it was obvious that this is the password check. We are in a endless loop, that only breaks
if the something involving the word pps_password
is 0
. If that condition doesn’t match, we print an error and start from the beginning. Coincidentally the strcmp
function usually returns 0
, if the 2 inputs are the same. So it seems likely, that the password is pps_password
. Trying that, we get the following output from U-Boot:
...
PASSWD :************
isvp_t31#
We have acquired the shell. The password is pps_password
and is not hashed.
The future
This now allows for basically unlimited access to the system, as we control the boot environment.
We can now continue our research with copious amounts of logs.
Snark remark: We definitely didn’t look at the
pps_password
string a hundred times while looking at thePASSWD :
string and pondering the addresses.
If you have any questions, because you are in a similar boat to this, have any questions or are just curious, feel free to reach out to me at tmp-website@groundzeno.net
- Of course not the same output, but in this case functionally equivalent 1
- According to the FAQ here: https://www.gnu.org/licenses/gpl-faq.html#GPLRequireSourcePostedPublic 1
- Found here: https://github.com/unofficial-ingenic-t31/uboot 1
- This is of course not always true 1
- This was later fixed by updating the wayland compositor the git version 1