By Assaf Carlsbad & Itai Liba
Hello and welcome back to yet another post in our blog post series covering UEFI & SMM security. This is the 6th (!) entry in the series, and it’s a good spot to pause for a second and look back to better estimate the vast distance we covered: from the baby steps of merely dumping and peeking at UEFI firmware, through the development of emulation infrastructure for it, and up to the point where we learned how to proactively hunt for SMM vulnerabilities. This post will continue where we left last time and will further explore SMM vulnerabilities, albeit from a slightly different angle.
So far, the SMM bug hunting methodology we came up with is mostly manual and goes roughly as follows:
Needless to say, this process is extremely slow, inaccurate, and cumbersome. After doing it repetitively over and over again, we were so unsatisfied with it that we decided to take the intuition and rules-of-thumb we developed and codify them into the form of an automated tool. The outcome of this endeavor is an IDA-based vulnerability scanner for SMM binaries we named Brick. For the benefit of the firmware security community, we decided to publish it as an open-source project that is readily available on GitHub.
In this post, we’ll introduce readers to Brick, its internal architecture, and its bug-hunting capabilities. Afterward, we’ll present a case study where we demonstrate how Brick was used to discover 6 different vulnerabilities affecting the firmware of some HP laptops. By doing so, we hope to encourage more people in the community to contribute back to Brick, as well as to educate the readers about the potential strengths (and weaknesses) of automated vulnerability hunting.
Enjoy the read!
As we said, Brick was developed to pinpoint certain vulnerabilities and anti-patterns inside SMM binaries. To effectively pull this off, its execution lifecycle is split into three different phases: harvest phase, analysis phase, and summary phase. Following is a detailed description of each phase.
In the vast majority of cases, it’s most useful to give Brick a complete UEFI firmware image to scan. Doing so allows the researcher to “squeeze” the most vulnerabilities out of it while also gaining a bird-eye view of the code quality of the firmware as a whole. Alas, a typical UEFI firmware image is a complex beast that contains much more than SMM binaries. Among other things, it usually includes other stuff such as
Because of that, our first task is to separate the wheat from the chaff. In Brick’s terminology, this is accomplished by the harvest phase. During this phase, Brick will parse the firmware image and extract out of it just the SMM binaries we’re interested in.
To invoke Brick and kickstart the harvest phase, just pass the full path of the firmware’s image to the Brick.py
script:
Internally, the harvest phase is implemented by offloading most of the actual work to two external tools/libraries:
The reason we use two different solutions for this phase is that we encountered several cases where one of them struggled to properly parse a UEFI image, while the other succeeded without any hurdles. Thus, the strategy of using one of them and falling back to the other in case of failure gives us just the right amount of redundancy we need to successfully handle the vast majority of firmware images encountered in the wild.
At the end of the harvest phase — given that all went well — the output directory should contain several dozens of SMM binaries waiting for further examination.
Note that in addition to full UEFI firmware images, Brick also supports other input formats in case you want to limit bug hunting to a narrower scope. These include
foo.efi
)At this point, we have a directory filled with the SMM binaries we’re interested in analyzing. The rough idea was to open each SMM binary in IDA and — after the initial autoanalysis completes — run some custom IDAPython scripts on top of it to do the actual bug hunting. This must be done intelligently, as a naive solution for this problem would suffer from two severe downsides:
Luckily for us, it didn’t take us too long to bump into a project called idahunt that solves these exact two problems. Put in the author’s own words:
“idahunt is a framework to analyze binaries with IDA Pro and hunt for things in IDA Pro. It is a command-line tool to analyze all executable files recursively from a given folder. It executes IDA in the background so you don’t have to manually open each file. It supports executing external IDA Python scripts.”
The IDAPython scripts executed by idahunt on behalf of Brick are known as Brick modules and come in three different flavors:
While developing these Brick modules, we found the raw IDAPython API to be a bit rough at times, so for the most part the modules were developed on top of a wrapper framework called Bip. One of the major highlights of this framework is that it also exposes wrapper functions for the Hex-Rays Decompiler API, which allows writing analysis routines in a fairly high-level notion.
After all SMM images in the input directory were scanned, Brick will move on to collect the output emitted by individual modules and merge them into a single, browsable HTML report.
Note that in addition to the scan’s verdict, the report file also includes links to some useful resources such as the annotated IDB file (necessary for validating the correctness of the results), the raw IDA log file (useful for troubleshooting and debugging), as well as a separate report file generated by efiXplorer.
Throughout the past year, we were using Brick extensively to review various firmware images from almost all leading manufacturers in the industry. So far, this campaign is definitely paying off and has already given birth to no less than 13 different CVEs (see Appendix A). In this case study, we would like to put a spotlight on several such vulnerabilities found while auditing one particular firmware image from HP (version 01.04.01 Rev.A for HP ProBook 440 G8 Notebook). After Brick’s scan was completed, we opened the resulting report file and were faced with a rather intriguing entry:
This entry immediately drew our attention because, if confirmed correct, it means that the SMI handler installed by the SMM image 0155.efi
does not validate certain pointers that are nested within its communication buffer. As we explained in the previous post, that in turn implies the handler can be exploited by attackers to corrupt or disclose the contents of SMRAM.
In this section, we’ll elaborate on how Brick managed to find such vulnerability in a completely automated fashion. For that, we’ll walk you through the internal workings of some Brick modules that were involved in making this verdict. Note that due to the medium of a written article, the case study will be presented using snapshots of the IDA database, before and after each module invocation. In reality, however, all modules will be executed automatically one after another, without any user interaction.
The first Brick module that is called to handle any input file is called the preprocessor. The preprocessor sets up the ground for the next modules in the chain and takes care of the following:
.text
section read-write, which prevents the decompiler from performing some excessive optimizations.Right after the preprocessor, Brick moves on to load and run the efiXplorer plugin. As we mentioned countless times throughout the series, efiXplorer has tons of functionality and serves as the de-facto standard way of analyzing UEFI binaries with IDA. Among other things, it takes care of the following:
Last but not least, efiXplorer is also capable of locating and renaming SMI handlers. In its recent editions, it prefixes all CommBuffer-based SMIs with SmiHandler’, and all legacy software SMIs with ‘SwSmiHandler’. As can be seen, in the case of 0155.efi
, only one SMI handler seems to exist:
Following efiXplorer, control is passed to the postprocessor. The postprocessor is a module that is in charge of completing the analysis performed earlier by efiXplorer. Among other things, this includes:
GetVariable()
/SetVariable()
In the context of this case study, the most important feature of the postprocessor is the handling of calls to EFI_SMM_ACCESS2_PROTOCOL. In a nutshell, this protocol is used to control the visibility of SMRAM on the platform. As such, it exposes the respective methods to open, close, and lock SMRAM.
In addition to those, this protocol also exposes a method called GetCapabilities(), which can be used by clients to query the memory controlled for the exact location of SMRAM in physical memory. Upon return, this function fills in an array of EFI_SMRAM_DESCRIPTOR
structures that informs the caller what regions of SMRAM exist, what is their size, state (open vs. close), etc.
In EDK2 and its derived implementations, the common practice is to store these EFI_SMRAM_DESCRIPTORS
as global variables so that they could be consumed by other functions in the future. As part of its operation, the postprocessor scans the input file for calls to GetCapabilities()
and marks the SMRAM descriptors in a way that will make it easy to recover them afterward. This includes both retyping them as 'EFI_SMRAM_DESCRIPTOR *'
as well as renaming them to have a unique, known prefix. The significance of this operation will be clarified shortly.
Initially, the type assigned to the CommBuffer in the SMI handler’s signature is VOID *
. This is adequate, as the structure of the CommBuffer is not known in advance and it’s the responsibility of the handler to correctly interpret it. Still, figuring out the internal layout of the Communication Buffer will be of great aid because it will let us know whether or not it contains nested pointers.
Usually, such tasks are completed manually as part of the reverse engineering process, but in Brick we needed to pull this off automatically. The two most prominent and successful IDA plugins for doing so are HexRaysPyTools and HexRaysCodeXplorer. Based on our experience, HexRaysPyTools produced more accurate results, while HexRaysCodeXplorer is better suited for non-interactive use. Eventually, the scriptability capabilities of HexRaysCodeXplorer tipped the scale in its favor and so it was incorporated into Brick.
At this stage, all SMI handlers present in the image were already identified so Brick can iterate over them and invoke HexRaysCodeXplorer on the associated CommBuffer to reconstruct its internal structure. Doing so for the SMI handler from 0155.efi
yields the following structure, which holds two members (field_18
and field_28
) that are presumably pointers by themselves:
How did HexRaysCodeXplorer get to this conclusion? To answer this question, let’s take a closer look at the handler’s code itself:
As can be seen, during the course of its operation, the handler passes CommBuffer->field_18
as the 2nd argument to the function sub_17AC
. This function, in turn, forwards it to CopyMem()
, where it is used as the destination buffer. Based on the signature of CopyMem()
, we know the destination buffer is in fact a pointer. That means the argument for sub_17AC
is also a pointer by itself and therefore — due to the transitivity of assignments — CommBuffer->field_18
must be a pointer as well! The same logic also applied to field_28
, even though we won’t show it here.
Now that it knows the CommBuffer does contain some nested pointers, Brick moves on and checks if these pointers are being sanitized properly. That is a two-fold operation:
SmmIsBufferOutsideSmmValid()
in the input binary.Let’s start with resolving SmmIsBufferOutsideSmmValid()
. As we mentioned in the previous part, SmmIsBufferOutsideSmmValid()
is statically linked to the binary and thus locating it is not a trivial problem. To pull this off, we compiled a heuristic comprised of three conditions. Brick will iterate over all of the functions in the IDA database and try to find a function that matches all three. The heuristic goes as follows:
def check_arguments(f: BipFunction): # The arguments of the function must match (EFI_PHYSICAL_ADDRESS, UINT64) if (f.type.nb_args == 2 and \ isinstance(f.type.get_arg_type(0), BTypeInt) and \ isinstance(f.type.get_arg_type(1), BTypeInt)): return True else: return False
Figure 21 – Matching the arguments of SmmIsbufferOutsideSmmValid
BOOLEAN
value. From the perspective of the decompiler, BOOLEAN values are just plain integers, so if we want to make this distinction we must go over all the return statements in the function and check if the returned value is a member of the set {0,1}. In Bip, this can also be accomplished very easily:
def check_return_type(f: BipFunction): if not isinstance(f.type.return_type, BTypeInt): # Return type is not something derived from an integer. return False def inspect_return(node: CNodeStmtReturn): if not isinstance(node.ret_val, CNodeExprNum) or node.ret_val.value not in (0, 1): # Not a boolean value. return False # Run 'inspect_return' on all return statements in the function. return f.hxcfunc.visit_cnode_filterlist(inspect_return, [CNodeStmtReturn])
Figure 22 – Checking the function actually returns a BOOLEAN value
SmmIsBufferOutsideSmmValid()
uses an array of EFI_SMRAM_DESCRIPTORS
to keep track of active SMRAM ranges, so we expect the candidate function to reference at least one of them. Because global EFI_SMRAM_DESCRIPTORS
were already marked earlier by the postprocessor, checking for xrefs between the function and the descriptors becomes straightforward:
def references_smram_descriptor(f: BipFunction): # The function must reference at least one SMRAM descriptor. for smram_descriptor in BipElt.get_by_prefix('gSmramDescriptor'): if f in smram_descriptor.xFuncTo: return True else: # No xref to SMRAM descriptor. return False
Figure 23 – Checking the function references an EFI_SMRAM_DESCRIPTOR
Are these heuristics bulletproof and guarantee they will always match SmmIsBufferOutsideSmmValid()
in the binary? Of course not! But more often than not they do the trick, and that’s what matters. In the HP case, the heuristics didn’t fail and managed to find a proper match:
Once SmmIsBufferOutsideSmmValid()
is matched, Brick verifies it is being used properly by the SMI handler. For that, it iterates over all calls to SmmIsBufferOutsideSmmValid()
and tries to deduce if all nested pointers are being covered by it. In 0155.efi
, it notices there is only one call to SmmIsBufferOutsideSmmValid()
that is used to validate field_28
. That implies no validation takes place over the second nested pointer, namely field_18
, so it flags the handler as vulnerable.
To be fair, we were quite lucky to encounter such a clear-cut case as the one above. If the control flow was a bit more convoluted, there is a decent chance Brick’
s verdict would become more ambiguous.
We already saw that depending on the exact flow the handler takes, it might end up calling sub_17AC
. This function gets an argument that is derived from CommBuffer->field_18
and will later forward it as the destination address for CopyMem()
. The contents of the CommBuffer are fully controllable by the attacker and, leveraging the missing validation, he or she can craft a buffer whose field_18
points to an arbitrary SMRAM address of their choice. As a result, the SMRAM region pointed to by that address will get corrupted by the time CopyMem()
gets called.
How to cause the handler to actually call sub_17AC
, and how to promote this memory corruption into an arbitrary code execution in SMM are left as exercises to the diligent reader.
In addition to the nested pointer vulnerability present in 0155.efi
, the HP firmware image also suffered from 5 additional, less severe issues that enable attackers to corrupt the low portion of SMRAM. All five vulnerabilities are isomorphic to each other, so we’ll focus on the simplest case found in 017D.efi:
As we mentioned in the previous post, these vulnerabilities arise when an SMI handler writes data to the communication buffer without first validating its size. Attackers can place the CommBuffer just below SMRAM, which will cause unintended corruption once the handler performs the write to it.
We also noted that SMI handlers can shield themselves from these problems by performing one or both of the following actions:
SmmIsBufferOutsideSmmValid
on the CommBuffer with the exact size expected by the handler.CommBufferSize
argument (a pointer to an integer value holding the size of the buffer), then comparing the result against the expected size.Therefore, to detect this class of vulnerabilities, Brick searches for SMI handlers that omit both checks. Unlike the previous case, this time the heuristics employed to resolve SmmIsBufferOutsideSmmValid()
bore no fruit, so Brick simply assumes it’s absent from the binary and moves on to check if CommBufferSize
is being dereferenced. This is achieved by traversing the AST associated with the handler, looking for nodes that correspond to a dereference operation (cot_ptr in the Hex-Rays terminology). The child node of a dereference operation in the tree represents the variable being dereferenced, so Brick can check if it’s CommBufferSize
.
If such a pair of nodes is found, it tells us that the C source code for the handler contained the expression: *CommBufferSize
, so we can assume the programmer intended to compare that value against some anticipated size.
Using Bip, implementing this heuristic is easy and only takes a handful of Python lines:
def dereferences_CommBufferSize(handler: BipFunction): # CommBufferSize is the 3rd argument of the SMI handler CommBufferSize = handler.hxcfunc.args[2] if not CommBufferSize._lvar.used: # CommBufferSize is not touched at all. return False def inspect_dereference(node: CNodeExprPtr): child = node.ops[0].ignore_cast if isinstance(child, CNodeExprVar) and child.lvar == CommBufferSize: # This is confusing, we return False just to signal the search to stop. return False # Run 'insepct_dereference' on all dereference expressions in the function. return not handler.hxcfunc.visit_cnode_filterlist(inspect_dereference, [CNodeExprPtr])
Figure 29 – Implementing the heuristic in python
Unfortunately, this heuristic yields no results, so Brick now knows CommBufferSize
is not being dereferenced and as a result marks the handler as vulnerable.
As can be judged by the number of CVEs it has already generated, we believe Brick is a very promising project that takes a big step in the right direction of harnessing automation to streamline the bug hunting process. This feeling we have was even reinforced recently when a related project called FwHunt was released. FwHunt attempts to solve the same set of problems as Brick, only using strict rule-sets rather than more relaxed heuristics.
Using automation, rules, heuristics, and other static code analysis techniques to crack through complex problems are very much desirable, but it’s always important to remember that reality is more complex than how we describe it. As such, occasional edge conditions that cause Brick and other automated tools to generate false positives and false negatives from time to time are inevitable.
That is perfectly acceptable, as long as we keep in mind that these tools were never intended to fully replace a human analyst, but rather empower him to handle larger and larger quantities of data. Eventually, it’s not the tool itself that makes the difference, but rather the human being that chooses how to use it, on what targets, and how to interpret its findings.
If you’re interested in learning more about the subject, come attend the upcoming Insomnihack conference, where we will be delivering a talk about some more SMM vulnerabilities, found this time in the Intel codebase.
See you there!
CVE ID | CVSS score | Vendor |
CVE-2021-36342 | 7.5 | Dell |
CVE-2021-44346 | ? | Gigabyte |
CVE-2021-0157 | 8.2 | Intel |
CVE-2021-0158 | 8.2 | Intel |
CVE-2021-42055 | 6.8 | ASUS |
CVE-2021-3599 | 6.7 | Lenovo |
CVE-2021-3786 | 5.5 | Lenovo |
CVE-2022-23956 | 8.2 | HP |
CVE-2022-23953 | 7.9 | HP |
CVE-2022-23954 | 7.9 | HP |
CVE-2022-23955 | 7.9 | HP |
CVE-2022-23957 | 7.9 | HP |
CVE-2022-23958 | 7.9 | HP |