Memory Leak From TrustZone
It is easy to assume that once you have a root shell, you can uncover all of a device's secrets. After all, you can read files, inspect processes, and copy entire storage devices. When I began analyzing an unnamed embedded device, I also expected those privileges to let me quickly understand the structure of its encrypted partitions.
But the internal partitions were encrypted. Even after desoldering the storage for analysis, I found that the partition information I needed was protected by TrustZone-backed encryption. Ultimately, I would need to leak a root key from within TrustZone.
What we commonly call Android root access is uid=0, the highest level of user privilege from the operating system's perspective. On ARM-based devices, however, critical cryptographic operations can run in the Secure World, isolated from the operating system. Android, its applications, and its system services run in the Normal World. ARM TrustZone provides the execution environment that separates these two worlds.
Applications running in the Secure World are called Trusted Applications (TAs). Unlike ordinary applications, TAs do not appear in Android's process list. Obtaining a root shell in the Normal World does not let you attach a debugger to TA memory or read its memory dumps through /proc. Requests must cross into the secure environment through defined interfaces, and only the results returned by the TA come back to the Normal World.
Normal World Secure World
──────────────────── ────────────────────
Android apps and system services TrustZone runtime and TAs
Root shell (uid=0) Separate code and memory
Client library ── secure call ───────→ Command processing
Normal-world response buffer ←─ result Output permitted by the TA
A vulnerability that enables command execution in an ordinary operating-system service and a vulnerability that causes a secure application to expose its own memory occur at different security boundaries. The former gave me a foothold for investigating the TA. The latter had to be found separately in the TA's code.
The Secure World is also difficult to observe. Unlike an ordinary application, it is hard to pause execution and inspect registers or the stack. A malformed request can terminate the TA or reboot the device. Errors in kernel logs do not necessarily reveal which internal TA command caused them. For this reason, throughout the analysis I repeatedly correlated firmware instructions, call paths, and actual responses from the physical device.
Obtaining a Root Shell in the Normal World
The investigation began with ADB and an administrative Binder service. The device had a locally accessible, unauthenticated ADB path, but several connectivity and privilege issues still had to be resolved to obtain a stable root shell from a PC. The real breakthrough came from input handling in a service that appeared to support factory or administrative functions.
One of the service's methods accepted a filename for audio recording and inserted it into a tinycap command string. The command reconstructed from the binary had the following form:
tinycap %s -D 0 -d 0 -i %d -c %d -r 48000 -b 16 -t %d
The first %s was the filename supplied through an external Binder request. Rather than passing that value safely as a single filename argument to the shell command, the code concatenated it into the command string without filtering shell metacharacters. Consequently, input intended to be a filename could be interpreted as a separate command.
Initially, this gave me the ability to execute commands in the service's security context. According to the early investigation records, the subsequent steps involved attaching an agent to system_server and replacing the existing ADB daemon with one that ran with root privileges. Afterward, the ADB shell returned uid=0(root), and the new daemon ran in the SELinux context u:r:su:s0. Configuring access to survive a reboot was a separate step added later.
External input becomes part of a shell command
→ Command execution in the administrative service's context
→ ADB daemon replacement through system_server
→ Root shell obtained in the Normal World
With this root shell, I could inspect the device's partitions and client libraries. I could access the driver used to invoke secure applications and observe requests constructed in the Normal World. But the root shell itself did not give me access to TrustZone memory. To cross the next boundary, I first needed to understand what inputs the TA accepted and what outputs it produced.
Where the Encrypted Partition Led
While examining the storage device, I obtained a raw dump of the userdata partition. I could determine its size and partition layout, but I could not directly read plaintext filesystem data from the raw image. By following the boot logs and key-related materials, I confirmed that the TEE's Keymaster was involved in the process of unlocking the userdata encryption keys. Having permission to copy a dump and possessing the key required to decrypt it are two different things.
During this investigation, another secure application caught my attention. One TA handled a 16-byte root key (RK). Here, root does not refer to the operating-system account obtained earlier. It refers to a cryptographic secret used to derive keys for multiple purposes.
Two less familiar concepts appeared along this TA's key-handling path. RPMB (Replay Protected Memory Block) is a protected storage area designed with write integrity and replay protection in mind. A HUK (Hardware Unique Key) is a secret bound to the device's hardware. The TA reads protected key material from RPMB, uses a HUK-bound decryption path, and works with the root key internally. Rather than exposing the raw root key, it derives purpose-specific keys for operations that need them.
Protected material in RPMB
→ Decryption and verification using the HUK inside the TA
→ 16-byte root key inside the TA
→ Purpose-specific key derivation function (KDF)
→ Keys used by individual functions
I did not assume that the userdata encryption key and this TA's root key were the same. What I verified for userdata was a Keymaster-assisted key-unlocking path; what I verified in this TA was a separate root-key handling path. Confusing the two would misrepresent both the partition encryption architecture and the impact of the vulnerability discussed here.
Tracing the Call Chain
Even after obtaining the TA binary, I could not immediately identify the vulnerable function. First, I had to determine which code paths carried a Normal World request into a TA function's arguments. A client library starts the secure application, and requests pass through /dev/qseecom, the kernel driver, and the TrustZone runtime before reaching the TA entry point and command dispatcher.
Normal-world client
→ Client library
→ /dev/qseecom and kernel driver
→ TrustZone runtime call wrapper
→ TA entry point
→ Command dispatcher
→ Actual cryptographic and storage operations
In the preserved call graph, libtee_util.so started the TA. From CElfFile_invoke in the secure environment, execution passed through a registered handler to sub_11F64, and then to the command dispatcher sub_470. The function of interest, rpmb_stor_read_parameter, was the parameter-reading function invoked by this dispatcher. This chain allowed me to distinguish between a function merely existing in the binary and an external request actually being able to reach it.
The architecture presented another trap. The Normal World userspace described in the early records was a 32-bit execution environment, whereas the TA I analyzed contained AArch64 code. I could not assume that pointer sizes and calling conventions were the same on both sides. Instead, I traced each boundary separately to determine which register or buffer in the TA corresponded to each request field sent from the Normal World.
Analysis Process
I assigned different roles to the analysis tools. Using REA (Reverse Engineer Anything), I opened the current TA extraction and reconfirmed that it was an ARM64 ELF. Its hash served as a reference for checking that the binary under analysis was not an older, different build.
To examine control flow, I used the preserved IDA Professional 9.0 and Hex-Rays analysis together with AArch64 objdump output generated from the original bytes. I also attempted to re-decompile functions through REA, but the IDA provider connection was unavailable in this environment, so I could not obtain the result. Accordingly, the vulnerable code shown below is supported not by newly generated REA pseudocode but by the actual instructions in the original TA. That is why I verified the address calculations and branches directly from assembly rather than relying only on convenient C-like decompiler output.
I also had to reconcile different address bases across the files. In the raw code fragment .b02, the file offset equals the code's virtual address (VA). In the full TA image and the current ELF extraction, the same code appears after a 0x3000-byte offset. For example, code VA 0x8b98 corresponds to offset 0x8b98 in .b02 but offset 0xbb98 in the full image. Missing this distinction can lead to inspecting an entirely different function even when the address appears to match.
The signed TA image was 172,032 bytes in size. Comparing hashes and code bytes first helped prevent me from incorrectly applying function addresses from an older build to the code running on the physical device.
One Parameter ID Went Out of Bounds
The TA exposed a command for reading parameters stored in RPMB by their IDs. Each parameter entry occupied 72 bytes (0x48). Naturally, the code multiplied the requested ID by 72 to calculate the entry's location. Up to this point, it was an ordinary table lookup.
The problem came next. The function read the data length from the calculated entry and copied that many bytes into the response. But there was no visible upper-bound check to ensure that the requested ID fell within the actual parameter area. As a result, the function could interpret memory outside the valid entries as though it were another parameter. This was an RPMB parameter-index out-of-bounds (OOB) read.
The following are the key AArch64 instructions extracted from the original TA. The leftmost values are code VAs; I added the comments for clarity.
8b90 mov w9, #0x48 // Size of one entry: 72 bytes
8b94 add x8, x8, #0x8 // Base of the entry table
8b98 umaddl x9, w19, w9, x8 // Base + (uint32)ID × 72
8b9c ldr w22, [x9, #4] // Read length from calculated location
8ba0 cbz w22, 0x8c04 // Skip copying if length is zero
...
8bac mov x0, x21 // Destination: response buffer
8bb0 mov x2, x22 // Copy length
8bb4 madd x23, x9, x10, x8 // Recalculate location using ID
8bb8 add x1, x23, #0x8 // Source: entry data region
8bbc bl 0x14318 // Call copy function
w19 contains the parameter ID supplied by the external request. The umaddl instruction zero-extends the 32-bit ID before multiplying and adding it. The cbz instruction only checks whether the retrieved length is zero. There is no comparison in between to ensure that the ID is smaller than the number of valid entries.
uint8_t *item = table + (uint64_t)id * 0x48;
uint32_t length = *(uint32_t *)(item + 4);
if (length != 0) {
copy(response, item + 8, length);
}
When I first saw this code, I could not immediately conclude that it allowed arbitrary-address reads. The ID selects locations in 72-byte increments. Moreover, the caller does not supply an independent copy length: the function reads it from four bytes near the beginning of the calculated entry. Even if the calculated address moves out of bounds, that address must still be accessible and contain a value that can be interpreted as a length before any data can actually be returned. My next task was to find such a location.
Initialization and Magic Checks Were No Substitute for Bounds Checking
At first glance, the function did not appear to be completely unprotected. Shortly after entry, it allocated a large local buffer and zero-initialized 8,192 bytes. After reading data from RPMB, it also compared a magic value near the beginning of the storage area. This was intended to verify that the stored data matched the expected format.
8a94 sub sp, sp, #0x2000 // Allocate 8 KB of local space
8aa4 mov x0, sp
8aa8 mov w1, wzr // Fill with zeroes
8aac mov w2, #0x2000 // 8,192 bytes
8ab0 bl 0x121dc // Call initialization function
...
8b84 cmp w8, w9 // Compare magic value in loaded data
8b88 b.ne 0x8bf0 // Error path if format does not match
But checking whether the storage area has a valid format and checking whether a requested ID falls within that area are different things. Once the magic value matches, the code proceeds directly to the ID × 72 address calculation. Zero-initializing the local buffer does not prevent a large ID from pointing beyond the end of that buffer and into the caller's stack. Two operations that initially looked like safeguards failed to enforce the index boundary that actually mattered.
This changed the direction of my analysis. Initially, I was focused on the logic that parsed protected storage data. But the values determining the outcome did not come exclusively from RPMB contents. The stack layout at the time of the function call could determine both the copy length and the source address. I therefore began calculating the caller's stack frame alongside the RPMB data layout.
The clue lay in the stack layout used by a preceding TA command. A value used by that command was left on the caller's stack, and the subsequent parameter-read command calculated an address from a lower position on the same stack. With ID 115, the location used by the read function as a length field overlapped a stack field from the earlier call.
The handler for the preceding command contained an instruction that stored a value assembled from request fields at offset +0x20 in the caller's stack. For ID 115, the subsequent read function interpreted the address four bytes after that stored value as the length. This did not mean that two different commands shared the same input structure. Instead, the same stack space was reused between commands, causing a temporary value from one command to appear as an out-of-bounds parameter length to another.
830 str x8, [sp, #32] // Earlier command: store value at caller SP+0x20
...
8b9c ldr w22, [x9, #4] // Later read: ID 115 reads SP+0x24 as length
This kind of stack reuse across separate points in time is easy to overlook when functions are analyzed in isolation. The earlier command's return status appears to indicate failure, while the parameter-read function looks like an ordinary routine for reading RPMB data. The connection became apparent only after I expressed both commands' stack addresses relative to the same reference point.
Here is the calculation. On entry, the parameter-read function saves 64 bytes and allocates another 0x2000 bytes for local storage. If the caller's stack pointer is P, the read function's stack pointer is P − 0x2040.
Length-field address
= read function SP + 0x0c + ID × 0x48
= (P − 0x2040) + 0x0c + 115 × 0x48
= P + 0x24
Start of data to be copied = P + 0x28
Thus, an ID outside the valid parameter range caused the function to treat P+0x24 as the length and the immediately following P+0x28 as the start of the data. This conclusion did not come from trusting a decompiler's names for temporary variables. It followed directly from the function's stack-allocation instructions and the ID × 0x48 arithmetic.
Such calculations are particularly important when analyzing TrustZone. Because a conventional debugger cannot simply display the physical device's Secure World stack, the stack-frame layout must be reconstructed from the binary: how far the stack pointer moves in the function prologue, where saved registers are placed, and how a subsequent call reuses the same frame. Even if the address calculation is correct, the actual response must confirm that the expected contents are present at that location.
The 384 Bytes Returned in the Response
On the physical device, I invoked the preceding command with mode=0x180, then read parameter ID 115. The earlier command returned status=6, but the subsequent read completed with status=0.
start_app ret=0
plant cmd18 mode=0000000000000180 ret=0 status=6 rlen=256
leak cmd11 id=115 ret=0 status=0 rlen=384
The crucial result was that the subsequent read returned 384 bytes. The SHA-256 hash of the original dump was:
728e2d99f893149acde80eaa738aed9782ffb1a6f0b2c7ac505f17ba970ca7f3
A matching length alone would not prove a stack leak. I interpreted the returned bytes as 64-bit little-endian values and found the frame pointer X29 and the return address X30 at the expected locations. X29 appeared at offset +0x18 in the response, and X30 at +0x20. These locations matched the saved-register positions relative to the previously calculated data start address, P+0x28.
There was no need to reinterpret the earlier command's status=6 as success. Its returned status and the fact that a value left on the stack was subsequently interpreted as a copy length were separate observations. The addresses predicted by static analysis, the response length observed on the physical device, and the register locations in the dump all pointed to the same conclusion.
This final confirmation was especially valuable in the Secure World. With an ordinary application, I might simply inspect memory a few more times in a debugger. Here, even a small input change could terminate the TA or reboot the device. I therefore compared the captured dump against the function's stack frame in the binary to determine what the returned bytes actually represented.
How the Return Address Revealed the TA's Location
The value of X30 in the dump was 0x73d8a1c4. On ARM64, X30 is the register that holds the code address to which execution returns when a function completes. If I could identify the corresponding location in the current TA binary, I could calculate where the TA had been loaded in memory for that execution.
ASLR (Address Space Layout Randomization) makes code locations harder to predict by changing their addresses between executions. But comparing a leaked return address with its relative offset in the binary reveals the base address for that particular execution.
Return address from device dump 0x73d8a1c4
Relative offset in binary - 0x121c4
──────────────────────────────────────────
TA base address for this execution 0x73d78000
This brings us back to the original question. The root shell did not directly read Secure World memory. Instead, the TA's own parameter-reading code accepted an out-of-range ID and returned part of its stack and execution-address information in a response. The boundary failed not because of Android root privileges, but because of missing input validation inside the secure application.
Conclusion
This investigation was not about discovering a single command that could instantly compromise the secure environment. I first obtained a root shell through command injection in the Normal World, revisited TrustZone's role while examining encrypted partitions, and traced the call path from the client all the way into the TA. I used REA to confirm the format and identity of the target ELF, compared the preserved IDA analysis against the original AArch64 instructions, and finally validated the ID identified through stack arithmetic against responses from the physical device.
The resulting RPMB parameter-index OOB read returned 384 bytes from the caller's stack. A return address within that data allowed me to calculate the running TA's base address. The isolation between TrustZone and the ordinary operating system remains an important security boundary.
At this stage, however, the vulnerability has only demonstrated an ASLR bypass through address disclosure. Recovering the root key would require triggering additional vulnerabilities, potentially by chaining them together using techniques such as ROP (Return-Oriented Programming).