U-Boot as an attack surface on a Xiaomi L09G Smart Speaker
Hi there! Today’s post documents the process of obtaining a root shell on a Xiaomi L09G smart speaker without hardware modifications beyond a UART connection. The path to get there turned out to be longer than what I originally expected and it required:
- bootloader analysis
- Runtime memory patching
- Bypassing secure boot and injecting a custom Kernel image
Introduction
The target device is a Xiaomi L09G smart speaker, powered by an Amlogic A113X SoC running a custom Android-based firmware. The NAND flash is a 2 Gbit KIOXIA TC58NVG1S3HTA00. The goal was straightforward: obtain a usable interactive shell. To accomplish this goal, I set the following pre-conditions:
- No firmware modification via JTAG.
- No chip-off.
- Use just what was reachable through the serial interface.
The Goal: Getting a Shell
First Contact via UART
As the device does not expose any ways to connect (such as a telnet or ssh service), I decided to open it.
After disassembling it, the first step was to identify its main components, and try to spot any serial connections available.
On the top right corner, the device has a serial connector with the labels Rx, Gnd, Tx which strongly suggests that it may be a UART interface.
I attached my UART-TTL adapter to the pins, connected to a picocom terminal and captured the full boot log.
The device initialization process was normal, and then it “hung” after loading the Wi-Fi driver, as seen in the following screenshot:
After that, no further output. The UART console went silent.
As I saved the output to a file, I started looking for useful information.
Grepping for some default strings showed something regarding Secure Boot references, though nothing conclusive was shown at this stage:
$ cat UART_log_booting | grep -i secure
NOTICE: BL31: AXG secure boot!
secure task start!
[imgread]securekernelImgSz=0x9d9000The presence of AXG secure boot! from BL31 and a securekernelImgSz field in the kernel load path indicated that some form of image verification was active. How strict it actually was would only become clear later.
Interrupting the Boot Sequence
One line in the boot log stood out immediately: Hit Enter or space or Ctrl+C key to stop autoboot -- : 0.
This is the U-Boot autoboot countdown. If the bootloader environment variable bootdelay was set to -1, the bootloader won’t give us any time to interrupt its boot sequence. But this was not the case for this device, allowing us to press a key and drop into the U-Boot shell.
U-Boot (BL33 in ARM Trusted Firmware terminology) is the first normal-world (Non-Secure Environment) bootloader stage. It is responsible for loading and launching the Linux kernel, and its interactive shell usually exposes several commands for reading memory, inspecting flash partitions, and modifying environment variables.
After pressing Enter at the right moment, I landed inside the U-Boot shell (screenshot below):
Persistent Environment Variables and the Secure Boot Implications
The first thing worth verifying was whether saveenv (a command used to save variables into non-volatile memory) actually persisted across reboots. Setting a test variable and rebooting confirmed it. After a reboot, zzz=TEST was still present.
This discovery had a direct security implication for Secure Boot: an attacker could modify bootcmd itself, or write to certain memory areas in order to bypass some mitigations as we are going to see later.
To clarify, the bootcmd environment variable stores“the command that is run if the user does not enter the shell during the boot delay”.
So an attacker could replace it with an arbitrary script that executes before or instead of the normal boot chain.
For Secure Boot to be meaningful end-to-end, the environment storage should be integrity-protected or locked.
Understanding the Boot Chain
Inspecting the content of the bootcmd environment variable with printenv showed the full boot flow:
bootcmd=run storeboot
storeboot=if imgread kernel ${boot_part} ${loadaddr}; then bootm ${loadaddr}; fi;run update;bootcmd runs storeboot, which loads the kernel image from the NAND flash into RAM with imgread, then hands off to bootm, which is going to boot whatever is at a defined address. In this case loadaddr.
Another interesting variable is the bootargs one, which contains the command line arguments passed when booting the operating system or binary image.
Attempt 1: Modifying bootargs to Replace init
The first thing I tried was to replace init=/init with init=/bin/sh in the bootargs environment variable before the device started booting:
axg_s420_v1_gva#setenv bootargs rootfstype=ramfs init=/bin/sh [...]
axg_s420_v1_gva#printenv bootargs
bootargs=rootfstype=ramfs init=/bin/sh [...]But the output of the kernel command line displayed at boot time told me that the change to bootargs didn’t have the desired effect:
axg_s420_v1_gva#imgread kernel ${boot_part} ${loadaddr}
[imgread]szTimeStamp[2021122714180026]
[imgread]securekernelImgSz=0x9d9000
axg_s420_v1_gva#bootm
aml log : R-2048 check pass!
aml log : R2048 check pass!
aml log : R2048 check pass!
aml log : R2048 check pass!
avb2: 0
save_power_post ...
avb2: 0
## Booting Android Image at 0x01080000 ...
reloc_addr =1dedc650
copy done
kernel command line: rootfstype=ramfs init=/init console=ttyS0,115200 no_console_suspend earlycon=aml_uart,0xff803000
[store]Is good fdt check header, no need decrypt!
load dtb from 0x1000000 ......
Amlogic Multi-DTB tool
Single DTB detected
[...]
Start of kernel LogsThe init=/init was still there. The kernel was ignoring the modified bootargs entirely.
This was due to the way the Android boot image format works: according to the AOSP, the boot_img_hdr struct embeds the kernel command line directly inside the image at a fixed offset (cmdline field is at offset 0x40 inside the image header).
When Amlogic’s bootm implementation parses the ANDROID! Magic bytes, it reads the command line from that struct field, not from the U-Boot bootargs environment variable. The environment variable is simply discarded.
Locating the Embedded kernel Command Line in RAM
After loading the kernel with imgread, inspecting the first bytes at ${loadaddr} (0x1080000) confirmed the Android boot image magic (ANDROID!) bytes and the command line embedded at offset 0x40 inside the boot_img_hdr struct, exactly where the format specifies it:
Attempt 2: Patching the kernel Command Line in RAM
With the command line bytes located at 0x1080056, I overwrote init to point at /sh instead:
axg_s420_v1_gva#mw.b 0x1080056 0x2f # /
axg_s420_v1_gva#mw.b 0x1080057 0x73 # s
axg_s420_v1_gva#mw.b 0x1080058 0x68 # h
axg_s420_v1_gva#mw.b 0x1080059 0x00 # null
axg_s420_v1_gva#mw.b 0x108005a 0x00 # nullVerification confirmed the bytes changed:
01080050: 20 69 6e 69 74 3d 2f 73 68 00 00 20 63 6f 6e 73 init=/sh.. consRunning bootm immediately afterwards:
aml log : R-2048 check fail with ERR = 1376The signature check failed, so any modification to the loaded image was caught before execution.
Before attempting anything else, I decided to take a better look at how this device worked.
Understanding how Android devices boot
After the bootloader is loaded, there is a “bootm” command which loads the kernel image and gives control to the kernel itself to later load the root filesystem.
This device, as well as many others, has an Android boot image, boot.img. Usually, this image contains a kernel image, an initramfs and a DTB (Device Tree Blob).
The initramfs is essentially a compressed archive that contains a directory structure similar to a regular filesystem.
The /init file is the main script that gets executed when the initramfs is mounted. Its primary purpose is to provide the necessary drivers, tools and scripts to mount the real root filesystem. In our case, a squashFS one.
After reviewing this, I decided to switch the attack target and point to this temporary filesystem.
At first I tried to dump the kernel partition (boot), but it was encrypted, and in order to do what I wanted to do, I needed a decrypted image. So, I started working to obtain a decrypted version.
Obtaining and Reversing BL33 (U-Boot)
Flash Partition Map
Before dumping anything, it was useful to check the MTD (Memory Technology Device) layout. For those that don’t know, MTD refers to a type of file used to interact with memory technology devices, including parallel NOR, NAND, and SPI-NAND flash memories, and this command
The MTD was visible from the U-Boot shell by issuing the MTD command, which is used to interact directly with raw flash memory . The partition layout was:
axg_s420_v1_gva#mtd
0x000000000000-0x000000200000 : "bootloader"
0x000000800000-0x000001000000 : "tpl"
0x000001000000-0x000001a00000 : "recovery"
0x000001a00000-0x000002600000 : "boot"
0x000002600000-0x000003600000 : "system"
0x000003600000-0x000007a00000 : "chrome"
0x000007a00000-0x000007e00000 : "factory"
0x000007e00000-0x000010000000 : "data"The bootloader partition (2 MB) is where BL33 lives on flash.
If we try to read it with the “nand read” command, we get the following error:
axg_s420_v1_gva#nand read 0x01000000 0x000000 0x200000
aml_nand_read_page_hwecc 2979 read ecc failed here at at page:0, blk:0 chip[0] aml_nand_read_page_hwecc 2979 read ecc failed here at at page:128, blk:2 chip[0] aml_nand_read_page_hwecc 2979 read ecc failed here at at page:256, blk:4 chip[0] aml_nand_read_page_hwecc 2979 read ecc failed here at at page:384, blk:6 chip[0] aml_nand_read_page_hwecc 2979 read ecc failed here at at page:512, blk:8 chip[0] aml_nand_read_page_hwecc 2979 read ecc failed here at at page:640, blk:10 chip[0] aml_nand_read_page_hwecc 2979 read ecc failed here at at page:768, blk:12 chip[0] aml_nand_read_page_hwecc 2979 read ecc failed here at at page:896, blk:14 chip[0]
NAND read from offset 0 failed -74The bootloader partition is written by Amlogic’s own tooling using a different ECC scheme than what the standard MTD driver expects. The nand read command simply cannot decode these pages. nand read.raw can bypass ECC and pull raw page data, but the resulting dump turned out to be encrypted, making it unanalyzable as-is.
Dumping BL33 from RAM After Relocation
U-Boot is compiled to run at a fixed link address, but during early boot it relocates itself to a higher DRAM address to avoid overlapping with the kernel load region. This is standard U-Boot behavior: after relocation, all absolute addresses inside the binary shift by the relocation offset, and U-Boot updates its own GOT (Global Offset Table) entries accordingly. The relocation offset was visible in the boot log: Relocation Offset is: 1eec3000. Starting from dtb_mem_addr=0x1000000 as the link base:
0x01000000 + 0x1EEC3000 = 0x1FEC3000Inspecting that address:
axg_s420_v1_gva#md.b 0x1FEC3000 0x50
1fec3000: 0a 00 00 14 1f 20 03 d5 ...I found valid AArch64 instructions. So I could dump 2MB from this address.
The full BL33 image was captured by logging the md.b output over serial, then parsing the ASCII hex dump back to binary with a custom parse.py script:
picocom -b 115200 /dev/ttyUSB0 -g new_dump_1.bin
md.b 0x1fec3000 0x13d000At first I dumped way too much RAM, and inspecting it with Binwalk revealed that I grabbed some garbage, so I trimmed the dump to contain just the bootloader. The final size was 0x13d000.
After parsing:
$ ll new_dump_1.bin
-rw-rw-r-- 1 mfumega mfumega 1298432 Apr 11 00:41 new_dump_1.bin1298432 bytes = 0x13d000. Size matches exactly.
Get Kulkan Security’s stories in your inbox
Join Medium for free to get updates from this writer.
Based on U-Boot documentation, the kernel must be decrypted and decompressed before jumping to and executing it. So, now that we had U-Boot, the main goal was to identify where this sequence was happening, to interrupt it.
Patching BL33 in Ghidra
Setting the Image Base
I loaded the BL33 dump on Ghidra with the image base set to 0x1FEC3000.
What we’re doing is telling Ghidra where U-Boot was relocated to in DRAM at runtime, so that virtual addresses in the disassembly match the physical addresses shown by md.b output and can patch directly without manual offset calculation.
As bootm is the last command we run before losing control and jumping to the kernel, that is the command we are going to look for in Ghidra.
According to u-boot code, the call chain for booting an Android image is the following:
do_bootm_states()
boot_jump_linux()
cleanup_before_linux()
armv8_switch_to_el2() <-- one-way jumparmv8_switch_to_el2 never returns. It performs the final CPU state setup and then jumps directly into the kernel entry point, which begins execution at Exception Level 1 (EL1).
To find do_bootm_states in the dumped file, I searched for strings present in the boot output, specifically ## Booting Android Image. That string was located inside FUN_1fec9580.
Patching bootm to Return Instead of Jumping to the kernel
The goal here was to make U-Boot load and decrypt the kernel image, but then hand control back to the shell instead of jumping into the kernel. Inside do_bootm_states, the call responsible for the final jump was identified as FUN_1feca680.
So we now want to replace this entire function with a ret instruction. Patching the function prologue at 0x1FECA680 with an ARM ret instruction causes it to return immediately:
axg_s420_v1_gva#mw.l 0x1FECA680 0xD65F03C0
axg_s420_v1_gva#imgread kernel ${boot_part} ${loadaddr}
axg_s420_v1_gva#bootm ${loadaddr}The kernel was loaded, the image decrypted and control returned to the U-Boot shell. From the logged output, the decrypted kernel spanned from 0x1080000 to 0x02251200, a total of 0x11D1200 bytes. That region was then dumped entirely.
Here I made a little mistake, because I successfully returned to the u-boot shell after decrypting and decompressing the kernel. In order to be able to modify the kernel content, we needed to have the compressed image.
To obtain a fully compressed image for modification, the decompression step inside do_bootm_states needs to be bypassed.
A conditional branch managing the decompression call was located in Ghidra and NOP’d with 0xd503201f:
Let’s go one more time
As we can see here, we had a valid `GZIP` header. The NOP caused U-Boot to skip decompression and the kernel image was now sitting in RAM decrypted, ready to be dumped.
Building and Uploading a Custom kernel Image
Unpacking the Boot Image
The kernel was dumped using md.b 0x1080000 0x9d9000, parsed and then unpacked with unpack_bootimg.py:
After unpacking it, we now have several files such as the kernel itself compressed with `GZIP` and the ramdisk, compressed with `XZ`. If we decompress this file, we obtain a CPIO archive.
A CPIO is a file format used in Unix-like systems to bundle multiple files and directories into a single.
After extracting the CPIO archive we can use the cpio tool to get the filesystem, and now the whole initramfs is accessible:
The first process started by the kernel is init, and there is a file called init.rc, which provides the generic initialization instructions.
Modifying init.rc
I could map our init.rc file into known behavior as shown on the following screenshot:
On the left we have the contents of the init.rc file, and on the right we have the UART logs up to the moment it stops printing data.
My first attempt of modification was to change line 314, from start console to exec busybox /bin/sh, but it had a little issue: After modifying this, I repacked the ramdisk and the kernel image and it was ready to upload. But what I didn’t know was that my change was partially breaking the state of the device.
The “init” language, as stated here, has a few reserved words, and “exec” is one of them. So the definition for this word, according to the documentation, is as follows:
Fork and execute command with the given arguments. The command starts after “ — ” so that an optional security context, user, and supplementary groups can be provided. No other commands will be run until this one finishes. seclabel can be a — to denote default. Properties are expanded within argument. Init halts executing commands until the forked process exits.
This means that our replaced command will work, but it will halt the startup sequence of the device, meaning that many processes and necessary pieces are not going to be started up.
So instead, I decided to put a telnet call inside the file to start on port 2323, like this:
service telnetd /sbin/busybox telnetd -l /sbin/busybox -p 2323 -F
user root
class service
# in the on boot section:
start telnetdRepacking the kernel image is a two-step process: the modified initramfs and the kernel are reassembled into a standard Android boot image using AOSP’s mkbootimg, and then a final step wraps the whole image with a custom header. That header composed by a magic value CHDR (“custom header”), followed by the image size and a CRC32, is what the transfer protocol described below checks on the receiving end to confirm the image arrived intact.
Transferring the Custom Image via Serial
The U-Boot on the device doesn’t have any way to transfer files to RAM, so one option was to write the binary directly into memory with repeated mw.l <data> <addr> commands. That works for small files, but it’s impractical for a large kernel image (e.g., 10 MB), especially if uploads must be repeated due to errors or an unreliable serial connection.
I decided to create a tiny C helper program that makes use of serial_getc and serial_putc to read from the serial connection and write it to RAM.
The plan was:
Stop U-Boot > Load a C binary > Load the kernel image > run
The transfer uses this tiny protocol mentioned before. Each transfer begins with a small header composed of the magic CHDR, the payload size, and its CRC32.
The C program receiver validates the CRC before signaling success, which matters here because serial transfers at 115200 baud over picocom are not inherently reliable and a silent corruption would result in a kernel that boots to garbage. So this CHDR ensures the image arrives intact before handing control to bootm.
U-Boot has some functions made to interact with the serial system, as listed here https://docs.u-boot-project.org/en/latest/api/serial.html.
What I ended up doing was to search for these functions’ addresses within our U-Boot binary, and make use of them.
After doing some reversing on the BL33 dump, I had the following addresses:
#define UBOOT_GETC_ADDR 0x1ff25c80
#define UBOOT_TSTC_ADDR 0x1ff25c98
#define UBOOT_PUTC_ADDR 0x1ff25cb0
#define UBOOT_PUTS_ADDR 0x1ff25cd8As we are loading and executing this binary before any kind of user space, it’s compiled as a freestanding AArch64 binary with no libc, no startup code, and a custom linker script that places it at the target load address in DRAM (0x04000000).
The compilation flags are minimal. No stack protector, no unwind tables, no default libraries, because this binary runs inside U-Boot’s address space without an OS underneath it.
The code for the loader program can be found here.
The output is a raw flat binary with all ELF headers stripped. U-Boot’s go command jumps directly to the binary’s first byte, so there is no ELF loader involved. The binary must be self-contained and position-independent relative to its load address.
The image below shows my host machine sending the payload:
After uploading the C program with a sequence of memory writes (mw), we could jump to our binary and start the listener.
As expected, when loading our modified kernel, the signature check message fired again. The image transferred correctly (CRC match confirmed), but bootm rejected it because its signature did not match what the device expected. So it’s time to tackle the last piece of the puzzle.
Patching the Signature Verification in BL33
Here we can see the conditional branch that determines if the signature is ok or not, at address 0x1fec8e24.
We are going to patch the current instruction 04 f0 ff 97 bl FUN_1fec4e34. This is a branch with link (bl) to FUN_1fec4e34, which is the signature verification function.
I replaced that with 0x52800000 (MOV W0, #0), which never calls the verification function, leaves W0 = 0 as the return value, and continues to the next instruction.
The result of the signature check was stored in iVar3. By forcing W0 = 0, iVar3 becomes 0, the condition evaluates as false, and verification is silently passed.
So the final patch was mw.l 0x1fec8e24 0x52800000.
The custom kernel image loaded. The device booted into the modified initramfs and started the telnet daemon on port 2323.
Here is an nmap scan showing the telnet service open on 2323.
Connecting from the host:
Root shell obtained.
The kernel image upload part could be easily replaced by soldering a type-C USB connector to the board, and using the fastboot protocol. But the first time I tried to solder it ,I broke the pads, so I decided to give this method a go. I certainly learned a lot and had a good time.
Conclusion
The Xiaomi L09G does implement Secure Boot in the sense that bootm verifies the kernel image signature before execution. However, the attack surface exposed through the interactive U-Boot shell is substantial. Because bootdelay is set to 1 and saveenv has no limitations on where it’s allowed to write, an attacker with physical UART access can:
- Inject persistent U-Boot scripting that survives reboots without modifying any signed binary.
- Dump the relocated BL33 from DRAM, reverse it, and locate security-critical functions.
- Patch those functions at runtime using
mw.lto bypass signature verification. - Load and boot a fully custom kernel image.
None of these steps require chip-off, JTAG, or soldering beyond the UART connection itself. Physical access is a prerequisite, which limits the scope somewhat. The mitigations that would close this attack path are: locking the bootloader environment against unauthenticated writes, setting bootdelay=-1, and extending Secure Boot coverage to the U-Boot environment region.
The custom image used here only adds a telnet daemon as a research tool. The same technique could be used to load any kernel image, with or without the original system, since signature verification is entirely neutralized at the BL33 level.
Matias Fumega [LinkedIn]
Security Consultant @ Kulkan
About Kulkan
Kulkan Security (www.kulkan.com) is a boutique offensive security firm specialized in Penetration Testing. If you’re looking for a Pentest partner, we’re here. Reach out to us via our website, at www.kulkan.com
More on Kulkan at:
Subscribe to our newsletter at:








