As we've previously discussed, QSEE is extremely privileged - not only can it interact directly with the TrustZone kernel and access the hardware-secured TrustZone file-system (SFS), but it also has some direct form of access to the system's memory.
In this blog post we'll see how we can make use of this direct memory access in the "Secure World" in order to hijack the Linux Kernel running in the "Normal World", without even requiring a kernel vulnerability.
Interacting with QSEE
As we've seen in the previous blog post, when a user-space Android application would like to interact with a trustlet running in QSEE, it must do so by using a special Linux Kernel device, "qseecom". This device issues SMC calls which are handled by QSEOS, and are passed on to the requested trustlet in order to be handled.
However, there are some special use-cases in which a faster mode of communication is required - for example, when decrypting (or encrypting) large DRM-protected media files, the communication cost must be as small as possible in order to enable "smooth" playback.
Moreover, some devices include trustlets which are meant to assure the device's integrity (mostly in corporate settings). For example, Samsung provides "TrustZone-based Integrity Measurement Architecture" (TIMA) - a framework used to assure device integrity. According to Samsung, TIMA performs (among other things) periodic measurements of the "Normal World" kernel, and verifies that they match the original factory kernel.
So... Trustlets need fast communication with the "Normal World" and also need some ability to inspect the system's memory - sounds dangerous! Let's take a closer look.
Sharing (Memory) is Caring
Continuing our research on the "widevine" trustlet, let's take a look at the command used to DRM-encrypt a chunk of memory:
As we can see above, the function receives two pointers denoting the "input" and "output" buffers, respectively. These can be any arbitrary buffers provided by the user, so it stands to reason that some preparation would be needed in order to access them. Indeed, we can see that the preparation is done by calling "cacheflush_register", and, once the encryption process is done, the buffers are released by calling "cacheflush_deregister".
Upon closer inspection, "cacheflush_register" and "cacheflush_deregister" are simple wrappers around a couple of QSEE syscalls (each):
So what do these syscalls do?
Looking at the relevant handling code in QSEOS reveals that these names are a little misleading - in fact, "qsee_prepare_shared_buf_for_secure_read" merely invalidates the given range in data cache (so that QSEE will observe the updated data), and similarly "qsee_prepare_shared_buf_for_nosecure_read" clears the given range from the data cache (so that the "Normal World" will see the changes made by QSEE).
As for "qsee_register_shared_buffer" - this syscall is used to actually map the given ranges into QSEE. Let's see what it does:
After some sanity checks, the function checks whether the given memory region is within the "Secure World". If that's the case, it could be that the trustlet is trying to attack the TrustZone kernel by mapping-in and modifying memory regions used by TZBSP or QSEOS. Since that would be extremely dangerous, only a select few (six) specific regions within the "Secure World" can be mapped into QSEE. If the given address range is not within any of these special "tagged" regions, the operation is denied.
However - for any address in the "Normal World", there are no extra checks made! This means that QSEOS will happily allow us to use "qsee_register_shared_buffer" in order to map in any physical address in the "Normal World".
...Are you pondering what I'm pondering?
Hijacking the Linux Kernel
Since QSEE has read-write access to all of the "Normal World"'s memory (all it needs to do is ask), we should theoretically be able to locate the running Linux Kernel in the "Normal World" directly in physical memory and inject code into it.
As a fun exercise, let's create a QSEE shellcode that doesn't require any kernel symbols - this way it can be used in any QSEE context in order to locate and hijack the running kernel.
Recall that after booting the device, the bootloader uses the data specified in the Android boot image in order to extract the Linux Kernel into a given physical address and execute it:
The physical load address of the Linux Kernel is then available to any process via the world-readable file /proc/iomem:
However, simply knowing where the kernel is loaded does not absolve us from the need to find kernel symbols - there is a large amount of kernel images and an equally large amount of symbols per kernel. As such, we need some way to find the all of the kernel's symbols dynamically using the running kernel's memory. However, all is not lost - remember that the Linux Kernel keeps a list of all kernel symbols internally (!), and allows kernel modules to lookup these symbols using a special lookup function - "kallsyms_lookup_name". So how does this work?
As we've previously seen - the names in the kernel's symbol table are compressed using a 256-entry huffman coding generated at build time. The huffman table is stored within the kernel's image, alongside the descriptors for each symbol denoting the indices in the huffman table used to decompress it's name. And, of course, the actual addresses for all of the symbols are similarly stored in the kernel's image.
In order to access all the information in the symbol table, we must first find it within the kernel's image.
As luck would have it, the first region of the symbol table - the "Symbol Address Table", always begins with two pointers to the kernel's virtual load address (which can be easily calculated from the kernel's physical load address since there's no KASLR). Moreover, the symbol addresses in the table are monotonically nondecreasing addresses within the kernel's virtual address range - a fact which we can use to confirm our suspicion whenever we find two such consecutive pointers to the kernel's virtual load address.
|Symbol Address Table|
Now that we can find the symbol table within the kernel's image, all we need to do is implement the decompression scheme in order to be able to iterate over it and lookup any symbol. Great!
Using the method above to find the kernel's symbol table, we can now locate and hijack any kernel function from QSEE. Following the tradition from the previous kernel exploits, let's hijack an easily accessible function pointer from a very rarely-used network protocol - PPPOLAC.
The function pointers relating to this protocol are stored in the following kernel structure:
Putting it all together
Now that we have all the pieces, all we need to do to gain code execution within the Linux Kernel is to:
- Achieve QSEE code execution
- Map-in all the kernel's memory in QSEE using "qsee_register_shared_buffer"
- Find the kernel's symbol table
- Lookup the "pppolac_proto_ops" symbol in the symbol table
- Overwrite any function pointer to our user-supplied function address
- Flush the changes made in QSEE using "qsee_prepare_shared_buf_for_nosecure_read"
- Cause the kernel to call our user-supplied function by using a PPPOLAC socket
As always, you can find the full code here:
I should note that the code currently only reads memory one DWORD at a time, making it quite slow. I didn't bother to speed it up, but any and all improvements are more than welcome (for example, reading large chunks of memory at a time would be much faster).
In the next blog post, we'll continue our journey from zero-to-TrustZone, and attempt to gain code execution within the TrustZone kernel.