The Binarly research team presents the first public research on Cross-Silicon Exploitation in the UEFI ecosystem: From x86 to ARM.
Traditionally, disclosures of vulnerabilities in UEFI firmware have predominantly focused on the x86 ecosystem, particularly on devices powered by Intel or AMD. However, with the explosion of the ARM device market, there has been a push towards expansion and unification, leading to the development of UEFI firmware for ARM hardware as well. This has inevitably led to the first public disclosure in the history of UEFI specification related to the ARM device ecosystem. In January 2023 BINARLY disclosed multiple vulnerabilities affecting Qualcomm reference code and impacting different device vendors and IBVs: Multiple Vulnerabilities in Qualcomm and Lenovo ARM-based Devices
This was the result of research to determine whether the attacks and classes of bugs can be the same on both ARM and x86 devices. Our work included the reconstruction of the boot flow on ARM devices with UEFI firmware, as well as its security features and mitigations in comparison to x86. We discuss the coverage and shortcomings of the security protections applied, and achieving arbitrary code execution during boot as the result of exploitation of a vulnerability in the DXE phase with the help of ROP technique (to bypass DEP).
Last May at OffensiveCon (Video), the Binarly REsearch team covered various aspects of the unification of firmware development with frameworks like UEFI and the security implications from an attack and defense perspective. In this blog, we provide a deep dive of the major ideas emerging from this research project.
Talk recording:
Slides: https://github.com/binarly-io/Research_Publications/tree/main/OffensiveCon_2023
AMI described the very first ideas of developing UEFI firmware for ARM based devices at UEFI plugfest in 2016:
https://uefi.org/sites/default/files/resources/UEFI_Plugfest_March_2016_AMI.pdf
Besides moving to a new CPU architecture, the most obvious difference was implementing an isolated execution environment for SMM-like boot time and runtime services inside ARM’s TrustZone. In the end, every device would have a bootROM (not updatable) SoC-specific firmware, which is typical for ARM CPUs. The system firmware would consist of three parts: UEFI firmware for boot time environment, hypervisor and TrustZone firmware usually build on ARM Trusted Firmware (TF) and Trusted Execution Environment (TEE) supplemented by a lot of code to support UEFI management mode (MM) functionality and various features including security.
Keeping in mind how a perfect concept could differ from reality, we decided to check out a real UEFI firmware for a real ARM-based device.
As mentioned above, the target device we chose was Microsoft Windows Dev Kit 2023 (aka project “Volterra”) with Qualcomm Snapdragon 8cx Gen 3 CPU. We chose this device because we were sure that the firmware on this device contains the vulnerabilities we are interested in. Moreover, this device is one of the most affordable ones on the market.
The only disadvantage is this device is difficult to be Linux-friendly, so we decided to conduct our experiments from Windows and from UEFIShell (using our own EFI drivers and applications).
Before any experiments, it is best to get a UEFI firmware image for the current device to search for vulnerabilities, examine boot flow, check for static configurations, etc.
In general, there are 3 ways to do so:
We decided to exclude hardware interference where possible and get the firmware dump with a software way. For Microsoft devices we can extract UEFI firmware images from the recovery packages. For example, in the case of the Microsoft Surface Pro X device, we can find Surface_UEFI_*.bin
file inside install.swm
file:
Unfortunately we were not able to find the UEFI firmware image in the recovery package for Windows Dev Kit 2023. However, after booting into UEFIShell we discovered the following:
In order to read BIOS region content from physical memory, we only need to obtain the start address and end address. It is possible to get this information with device
command from UEFIShell:
If we will read 200 bytes by start address using dmem
command, we can make sure that the BIOS region is actually located at address 0x9f000000
(since data match the EFI Firmware Volume Header structure):
As we can see from device
command output, the size of the BIOS region is 0x3e0000
. However, we have managed to dump 0x5d0000
bytes – maximum possible size to read in a row.
Since it is not possible to dump memory contents to a file with standard UEFIShell commands, we implemented a simple tool (similar to dmem but with additional functionality): DumpMem.
After that we dumped firmware with the following command:
DumpMem.efi 0x9f000000 0x5d0000 uefi.bin
Below is the part of the dumped firmware, parsed with the UEFITool):
As mentioned before, we discovered that some parts of the firmware (specific files/volumes) were mounted to different FS during the boot. Below is a description of what and where it is mounted:
fs2
: EFI files from the firmware imagefs0
: Decompressed contents of 9E21FD93-9C72-4C15-8C4B-E77F1DB2D792
;fs1
: Decompressed contents of 8ACF1180-6AF9-436D-A8EE-F7043C19AF18;
fs7
: Static ACPI tables;fs8
: TZAPPS (trusted applications);fs9
: Firmware for Qualcomm Hexagon (Audio DSP);This way SEC or DXE modules, configuration files, TZ apps and other firmware components can simply be copied to the USB without any memory dumping.
A visual representation of the different FS is shown below:
Hence, any UEFI research should be started with booting into UEFIShell.
Another gift for us was the uefiplat.cfg
file. This file contains the following configuration items:
Config
MemoryMap
RegisterMap
ConfigParameters
This file is write protected and it is not possible to change the platform configuration by rewriting this file. However, this file contains a lot of useful information, which simplified our research and helped to reconstruct the boot flow.
With this information and some reverse-engineering, we managed to reconstruct the entire trusted boot chain. As mentioned before, powering on the ARM CPU leads to it running the firmware from its BootROM which is a Root of Trust. The exception level is lowest (the privileges are highest) at this point: EL3. Without switching to any other exception level it verifies and runs:
Here is the scheme of the given trusted boot chain part:
The boot of the Secure Worlds continues with bringing up of TrustZone (TZ) environment, which means verifying and running:
The Normal World’s goal is to prepare in turn an OS boot environment:
The entire Secure World and Normal World trusted boot chain is shown on the following scheme:
Since all disclosed vulnerabilities are related to improper handling of EFI NVRAM variables, we have to also sort out how accessing the EFI variables is implemented on ARM.
On x86 it was quite clear:
Data-Only Attacks Against UEFI BIOS
On ARM, given the architecture specifics, the way to read and write them is different:
This knowledge allows us to prove that at least one of the discovered classes of vulnerabilities is fully portable from x86 to ARM in exploitation terms.
This is quite a new attack vector: OOB read (memory leak) with GetVariable
/SetVariable
pattern. The vulnerable code snippet in general looks like this:
DataSize
is updated by gRT->GetVariable()
to the actual size of the requested EFI variable;DataSize
is not re-initialized prior to gRT->SetVariable();
Variable1
is larger than the original DataSize
value (N
), the (DataSize - N
) additional bytes will be written to Variable2
NVRAM variable on the call to gRT->SetVariable()
.To exploit such a vulnerability, the attacker simply has to change the first variable (to change the DataSize
). The exploitation of such vulnerabilities will work in a similar way on x86 and AArch64, and does not require binary exploitation skills.
A demo of PoC for such class of vulnerabilities is here:
Since we smoothly moved to a discussion of discovered vulnerabilities and how to trigger them (access variables on ARM) it would be also nice to think about the exploitation path and to check what mitigations have been actually enabled on ARM.
On x86, the security model is described in one of our previous research project:
Data-Only Attacks Against UEFI BIOS
TLDR; a lot of mitigation mechanisms are introduced but almost none of them are enabled in the wild.
However, the ARM UEFI firmware we looked at contained applied mitigations to prevent stack buffer overflow exploitation. During this research, for the first time we found firmware with stack canary implemented in production UEFI firmware!
Here is the comparison of mitigations applied on x86 and ARM:
ASLR is not applied on both x86 and ARM (based on our experience). Moreover, ASLR is not supported even in the EDK2 reference code. Work in this direction has been ongoing for a long time:
https://github.com/jyao1/SecurityEx/tree/master/AslrPkg, https://edk2-docs.gitbook.io/a-tour-beyond-bios-mitigate-buffer-overflow-in-ue/address_space_layout_randomization/enable_aslr_for_uefi_in_edkii.
DEP is partially applied on x86 and fully applied on ARM. We have seen cases on x86 platforms where code execution on the stack/heap was possible and not possible. There are other ways to store shellcode that will survive reboot. For example, via NVRAM.
Earlier we demonstrated that vulnerabilities in DXE on x86 can be exploited easier than you can imagine. Here is one of the attack scenario that relies on the fact that the code can be executed in MMIO:
On our target ARM device, such an attack would not work. Based on our experiments, the code cannot be executed anywhere except the code segments of the UEFI modules. Any attempt to execute code in the NX region causes Synchronous Exception. As well as any access attempt to memory that is protected from reading:
As previously mentioned, we saw stack canary implemented in the target firmware. We think that it is a Qualcomm implementation (since we have seen it on Qualcomm devices from various OEMs).
Let’s look at how its works:
ModuleEntryPoint
function:b898d8dc-080a-40f7-99e3-31627b806a5a
is exist, gCanary
value (pointed to by gCanaryPtr
) will be obtained from this table;gCanary
will be obtained in the function UpdateCanary()
and installed in EFI Configuration Table with GUID b898d8dc-080a-40f7-99e3-31627b806a5a
:UpdateCanary()
function will call GetRndValue()
function, which will return random value obtained via one of SMCs (a kind of RNG service);gCanary
value will be saved to local variable (before return address) and at the end of each function it will be checkedThis implementation seems simple and straightforward. But the question immediately arises: since the value of the canary is updated only when the Configuration Table with GUID b898d8dc-080a-40f7-99e3-31627b806a5a
has not yet been created, will the value of the canary be the same for all modules? To check this, we dumped two modules with the canary value initialized. And it turned out that the values are actually the same.
SemmMenu.efi:
DfciMenu.efi:
Another problem we noticed is the coverage. We discovered that not all functions are covered with canary checking (only functions that use unsafe functions from MemLib/PrintLib
are covered). E . g. 9/382 function covered in QcomChargerDxeWp
DXE module:
We also found that not all modules from the firmware were covered with canary initialization/checking. E.g. MsNetwork DXE module does not contain canary initialization routine at all:
The same coverage problem was described by researchers at Quarkslab in their blog (also on Qualcomm-based device):
https://blog.quarkslab.com/attacking-the-arms-trustzone.html:
Thus, the following conclusions can be drawn in terms of exploitation:
On the Windows Dev Kit 2023 device, we identified three vulnerabilities that may be interesting in terms of exploitation. The following is a discussion of each of issue.
Vulnerability in QcomChargerDxeWp
is a classical double GetVariable()
vulnerability when DataSize
and variable buffer (in case of heap buffer) is not re-initialized after first call to gRT->GetVariable()
:
But as we can see from the vulnerable function pseudocode, in this case 5 values of the variables in a row will be obtained. It means that an attacker can use any pair of variables (10 variants in total). For example, if PrintChargerAppDbgMsg
, ChargerPDLogLevel
and ChargerPDLogTimer
variables are not set, an attacker can exploit a vulnerability via DISABLEBATTERY
and ForcePowerTesting
variables.
As we can see from the vulnerable function pseudocode, this function is not covered by stack canary mitigation. However, exploitation of this vulnerability is complicated by the fact that the return address
is higher on the stack (with less stack offset) than Value, which we can overwrite with a buffer of an arbitrary length:
The left-hand figure shows the location of the return address and the data to be overwritten (we obtained this dump using the hook to gRT->GetVariable())
. The right-hand image shows locations of stack variables in the IDA’s stack view for the vulnerable function.
Such cases can still be exploited using the following approaches:
Vulnerability in PILDxe module is very similar to the previous one:
The attacker can overflow the stack (via Value
buffer) through any pair of variables, where VariableName will have the following format: {Section}.{Setting}
.
Section – is any section from uefipil.cfg
file (ADSPPD
, SPSS
, ACPI
, QUPV3FW
, etc):
Possible Setting value enumerated below:
Type, FwName, PartiLabel, PartiRootGuid, PartiGuid, ImagePath, SubsysID, ResvRegionStart, ResvRegionSize, ImageLoadInfo, Unlock, OverrideElfAddr, ProxyGuid, BackupPartiLabel, BackupPartiRootGuid, BackupPartiGuid
The vulnerable function allows to overwrite the PIL configuration during the boot (originally specified in the uefipil.cfg
configuration file).
The funny thing is that the developers forgot to initialize the DataSize
. Thus, the DataSize
value will be filled with garbage value from the stack:
In our case DataSize
will be filled with 0x9F3CF100
(some stack pointer). This value is too big for NVRAM variable size, so gRT->GetVariable()
will always return EFI_OUT_OF_RESOURCES
. However, on other devices or driver versions this value may be small enough to allow this vulnerability to be exploited.
It should be noted that this function is also covered by the canary check.
Vulnerability in the UsbConfigDxe module – yet another double GetVariable()
problem. It proved to be the easiest to exploit. The vulnerable pattern is present in the UsbConfigStartUsbLoopback
event callback function:
This is how it looks:
The vulnerability again lies in the fact that after the first call to gRT->GetVariable()
the DataSize can be rewritten if the actual variable size will be greater than initial DataSize
value (In this case the DataSize
is initialized with a value of 1).
Then, after the second call to gRT->GetVariable()
, the value of the stack variable PortValue can be overflowed.
Writing a proof-of-concept for DOS is fairly straightforward:
UsbConfigPrimaryPort
and UsbConfigSecondaryPort
, and make these values big enough to corrupt the stackUsbConfigStartUsbLoopback
event (unfortunately, this event was not signaled by default during the boot as it is a part of the diagnostic feature). To trigger this event we decided to write a simple module. This module will signal UsbConfigStartUsbLoopback
event it by Event handle:However, our goal was to show that we can execute arbitrary code in DXE. So we decided to write a simple ROP chain.
We previously showed how to use ROP/JOP in SMM to bypass SMM_Code_Chk_En
mitigation:
The advantage of x86 in terms of exploiting vulnerabilities in SMM using ROP/JOP is the rich choice of gadgets:
We haven’t seen the same abundance of gadgets in ARM firmware code. However, in order to execute a simple primitive - a function call with a few arguments - the gadgets were enough.
We decided to display the message on the screen using the following function: gST->ConOut->OutputString(gST->ConOut, Message)
.
Where:
gST->ConOut
– known address (0x9439f320
)gST->ConOut->OutputString
– known address (0x92fd8cc8
)The ROP chain we built for this is as follows:
The code to craft UsbConfigSecondaryPort
variable value using this ROP chain show below (link):
After execution of the PoC, the value of the UsbConfigSecondaryPort
variable will look like this:
The final size of the second variable will be 0xD9
. So the UsbConfigPrimaryPort
variable can take any value of length 0xD9 bytes.
A demo of this PoC is here:
As a result of this research, we came to the following outcomes:
Binarly provides FwHunt rules to detect vulnerable devices at scale to help the industry recover from firmware security repeatable failures.
FwHunt Community Scanner: https://github.com/binarly-io/fwhunt-scan
FwHunt detection rules: https://github.com/binarly-io/FwHunt/tree/main/rules
The Binarly team is constantly working to protect the firmware supply chain and reduce the attack surfaces of our customers industry-wide by delivering innovative technologies to the market. Based on our experience we understand that fixing vulnerabilities for a single vendor is not enough. As a result of the complexity of the firmware supply chain, there are gaps that are difficult to close on the manufacturing end since it involves issues beyond the control of the device vendors.
Are you interested in learning more about Binarly Platform or other solutions? Don't hesitate to contact us at [email protected].