OOB Accesses Using the Logging System
Vulnerability Details
Huawei's security hypervisor is a privileged component that supervises the kernel at runtime by executing at EL2. In a nutshell, it ensures access permissions of physical memory are enforced by making use of a second stage of address translation, as well as trapping modifications of system registers. We have extensively detailed its inner workings in a dedicated blogpost and you're invited to have a look if you want more information on this component.
The kernel is able to call into the security hypervisor by executing the HVC
instruction. As a rule of thumb, the latter should distrust any information coming from the former. During our assessment of this component, we observed that this was almost always the case, except in one place: the logging system. This system uses multiple buffers of shared memory that can be accessed both by the kernel and the hypervisor.
On the kernel side, the hhee_logger_init
function of the "hisi hhee exception" driver retrieves the physical addresses of these log buffers by invoking the HHEE_LOGBUF_INFO
, HHEE_CRASHLOG_INFO
, and HHEE_PMFBUFLOG_INFO
HVCs.
▸ drivers/hisi/hhee/hhee_log.c
int hhee_logger_init(void)
{
// ...
/* get logging information */
/* normal buffer */
ret_res = hhee_fn_hvc((unsigned long)HHEE_LOGBUF_INFO, 0ul, 0ul, 0ul);
ret =cb_init(ret_res.a0, ret_res.a1, &normal_cb, NORMAL_LOG);
// ...
/* crash buffer */
ret_res = hhee_fn_hvc((unsigned long)HHEE_CRASHLOG_INFO, 0ul, 0ul, 0ul);
ret =cb_init(ret_res.a0, ret_res.a1, &crash_cb, CRASH_LOG);
// ...
/* monitor buffer */
ret_res = hhee_fn_hvc((unsigned long)HHEE_PMFBUFLOG_INFO, 0ul, 0ul, 0ul);
ret =cb_init(ret_res.a0, ret_res.a1, &monitor_cb, MONITOR_LOG);
// ...
}
Each log buffer is mapped by the cb_init
function and is then ready to be accessed by the kernel.
▸ drivers/hisi/hhee/hhee_log.c
int cb_init(uint64_t inlog_addr, uint64_t inlog_size,
struct circular_buffer **incb, unsigned int logtype)
{
// ...
log_addr = (uint64_t)(uintptr_t)ioremap_cache(inlog_addr, inlog_size);
// ...
tmp_cb = (struct circular_buffer *) kzalloc(sizeof(struct circular_buffer),
GFP_KERNEL);
// ...
tmp_cb->virt_log_addr = log_addr;
tmp_cb->virt_log_size = inlog_size;
tmp_cb->logtype = logtype;
*incb = tmp_cb;
return 0;
}
On the hypervisor side, the initialization functions of the log buffers are called from init_log_buffers
.
void init_log_buffers(saved_regs_t *regs) {
// ...
init_irq_infobuf();
init_logbuf_info(0x134e0000, 0x10000);
init_crashlog_info(0x134f0000, 0x1000);
init_monitorlog_info(0x134f1000, 0xf000);
init_pmfbuflog_info(0x134d4000, 0xc000);
// ...
}
For example, the LOGBUF_INFO
buffer (that spans over the region 0x134E0000-0x134F0000
) is initialized by the function init_logbuf_info
.
void init_logbuf_info(log_buffer_t *buf, int64_t size) {
buf->data_size = size - 0x28;
buf->head_size = 0x28;
buf->total_off = 0x28;
buf->field_18 = 0;
buf->data_ptr = (char *)buf + 0x28;
g_logbuf_info = buf;
}
Each log buffer region starts with a control structure, log_buffer_t
, defined as follows:
typedef struct log_buffer {
int64_t data_size; /* Size of the data section */
int64_t head_size; /* Size of the header section */
int64_t curr_off; /* Current offset in the buffer */
// ...
void* data_ptr; /* Pointer to the data section */
} log_buffer_t;
This control structure contains a pointer, an offset and two sizes fields, as shown on the following figure:
Strings can be written to this log buffer by the hypervisor using the write_str_to_logbuf_info
function. After formatting the arguments of the string, this function writes into the log buffer one character at a time by calling the write_chr_to_logbuf_info
function.
int write_str_to_logbuf_info(const char *fmt, ...) {
char buf[0x88];
// Format the log string arguments.
// ...
hvc_log_lock();
int count = 0;
char *buf_ptr = buf;
// Iterate over the characters of `buf`.
for (char c = *buf_ptr; c; buf_ptr++) {
// Increment the count of written characters.
count++;
// Append the character to the log buffer.
write_chr_to_logbuf_info(c);
}
hvc_log_unlock();
return count;
}
write_chr_to_logbuf_info
writes the character into the circular buffer and updates the control structure fields.
int write_chr_to_logbuf_info(char c) {
uint64_t head_size;
uint64_t curr_offset;
uint64_t data_off;
// ...
// Ensure the log buffer is initialized.
if (!g_logbuf_info)
return -1;
// Get the `head_size` and `curr_offset` fields of the control structure.
head_size = g_logbuf_info->head_size;
curr_offset = g_logbuf_info->curr_offset;
// Check if the circular buffer needs to wrap around.
if (curr_offset >= head_size + g_logbuf_info->data_size) {
// Reset the current offset to the beginning of the buffer.
g_logbuf_info->curr_offset = head_size;
data_off = 0;
} else {
// Compute the offset of the next character.
data_off = curr_offset - head_size;
}
// Write the character at the current offset.
*(char *)(g_logbuf_info->data_ptr + data_off) = c;
// Increment the current offset.
++g_logbuf_info->curr_offset;
return c;
}
Because the control structure is located in shared memory, and the values of its fields are not verified by the security hypervisor, the kernel can change them so that the log string is written at a chosen address in the security hypervisor's address space.
The logging functions are similar for the other log buffers initialized by init_crashlog_info
and init_pmfbuflog_info
.
Exploitation
This section describes the exploit we developed to get arbitrary code execution in the security hypervisor from the kernel.
Constrained Write Primitive
When a string is logged, each of its characters is written at address data_ptr - head_size + curr_off
, where curr_off
ranges:
- from offset
head_size
, the end of the header and start of the data section; - to
head_size + data_size
, the end of the data section and thus of the log buffer.
The log buffers are circular, so when curr_off
reaches the end, it gets reset to head_size
.
While it is possible to write the string at an arbitrary address simply by modifying the data_ptr
field, the string that is logged is not controlled by the kernel. However, in our exploit, we only needed this capability to write a single non-zero byte. The reason behind it will be explained in the following section on the forced integer underflow. But for now, let's explain how we constructed this primitive.
The handler of the ARM_STD_HVC_VERSION
(0x8400FF03) HVC calls a function that logs the "PMF:cpu %lu\ttid %u\tts %llu\n"
string into the LOGBUF_INFO
buffer. By setting the data_size
field of the control structure to 1, only one character will be needed to fill the buffer and circle back to the beginning. In addition, each character will overwrite the previous one, leaving only the last byte of the string in memory. This is the new line character ('\n'
, i.e 0x0A
) in our case. The figure below shows what the log buffer looks like in this configuration.
Forcing an Integer Underflow
Using our constrained write primitive, we decided to target the stage 2 page tables. They are allocated by the hypervisor using a simple heap allocator. The heap region spans the addresses 0x12F14C00-0x134D1000, and the function that performs these allocations is alloc_memory_inner
.
#define HEAP_START 0x12F14C00
#define HEAP_SIZE 0x5BA400
uint64_t alloc_memory_inner(uint64_t alignment, uint64_t size) {
uint64_t alignment_mask = alignment - 1;
uint64_t heap_current_ptr = HEAP_START + g_heap_offset;
uint64_t padding = -heap_current_ptr & alignment_mask;
// Return if the remaining size is too small to make the allocation.
if (HEAP_SIZE - g_heap_offset < padding + size)
return 0;
// Update the global heap offset.
g_heap_offset += size + padding;
// Log the global heap offset in another log buffer.
if (snprintf(buf, 0x18, 0x17, "heap_offset 0x%lx.\n", g_heap_offset) >= 0)
write_str_to_pmfbuflog_info(buf, 0x18);
return heap_current_ptr + padding;
}
During normal use, the check HEAP_SIZE - g_heap_offset < padding + size
is not vulnerable and correctly prevents the allocator from returning memory outside of the heap region. However, if the offset is made greater than the heap size (for example, by using our write primitive), the check will pass due to an integer overflow on HEAP_SIZE - g_heap_offset
, and the allocator will return memory that is outside of the heap region.
Since the allocations have a size of 0x1000 bytes, the first allocation made outside the heap region will be at 0x134D0000 (and not 0x134CF000 because of the padding). Fortunately for us, this region is located in the log buffer, which is mapped by both the hypervisor and the kernel.
To trigger the allocation of a stage 2 page table, we need to reach the change_stage2_software_attrs_per_va_range
function using an HVC. This function takes an intermediate physical address range as an argument that it will change the permissions of. If this range is unmapped in the second stage, or currently mapped as a block, changing its permissions will require the allocation of new page tables.
Step 0: The initial state (assuming no allocation has been made).
Step 1: We fill the heap up until g_heap_offset
is equal to HEAP_SIZE
by mapping and changing the permissions of physical memory regions located after 0x10000000000. We can monitor the heap offset using the log message written by alloc_memory_inner
.
Step 2: We bump g_heap_offset
past HEAP_SIZE
using our write primitive, by changing its value from 0x5BA400 to 0x5BA40A. As a result, HEAP_SIZE - g_heap_offset
is now equal to 0xfffffffffffffff6, which is bigger than padding + size
.
Step 3: We trigger the allocation that will be made at 0x134D0000, outside of the heap region and accessible by the kernel, by mapping and changing the permissions of the memory block starting at 0x10000000.
Double-Mapping the Hypervisor
After executing our 3 steps, we end up with a stage 2 page table that is writable from the kernel. By changing the descriptors contained in this page, we can map the hypervisor's physical address range a second time and also make this mapping writable. All that is left is to patch the hypervisor's code and call the modified function to get arbitrary code execution at EL2.
Step 0: The initial content of the kernel-accessible stage 2 page table.
Step 1: We modify the output addresses of the descriptors to map the IPA range 0x10000000 to 0x10200000 to the PA range 0x12F00000 to 0x13100000. We also change the attributes to make the mapping read-write.
Step 2: Using our writable mapping, we patch the HHEE_HVC_LIVEPATCH
(0xC6001088) HVC handler with a simple shellcode that returns the current exception level.
Affected Devices
We have verified that the vulnerability impacted the following device(s):
- Kirin 710: P30 Lite (MAR)
- Kirin 810: P40 Lite (JNY)
Please note that other models might have been affected.
Patch
This vulnerability was assigned CVE-2021-39979 and patched in the October 2021 security update.
Timeline
- Jul. 09, 2021 - A vulnerability report is sent to Huawei PSIRT.
- Jul. 22, 2021 - Huawei PSIRT acknowledges the vulnerability report.
- Oct. 01, 2021 - The issue is fixed in the October 2021 update.