Avatar

PE Support Enhancements

ionicons-v5-k Romain Thomas February 16, 2025
Wave
I'm pleased to share that LIEF's PE support has been significantly improved on both aspects: parsing and writing.

Featuring Image

Parsing Improvements

One of the significant updates in LIEF is the enhanced processing of the IMAGE_LOAD_CONFIG_DIRECTORY which now includes a detailed parsing of the underlying structures it references.

For instance, the Dynamic Value Relocation Table1 is now fully supported and can be accessed through the Python, C++, and Rust APIs:

1import lief
2
3pe = lief.PE.parse("win11_arm64x_Windows.Media.Protection.PlayReady.dll")
4
5dyn_reloc_table = pe.load_configuration.dynamic_relocations
6
7for r in dyn_reloc_table:
8    print(r)
1Dynamic Value Relocation Table (version: 1)
2Symbol VA: 0x0000000000000006 (RELOCATION_ARM64X)
3Fixup RVAs (ARM64X)
4  [0000] RVA 0x0000010c, 2 bytes, target value 64:86
5  [0001] RVA 0x00000130, 4 bytes, target value d0:bc:57:00
6  [0002] RVA 0x00000190, 4 bytes, target value 30:22:d8:00
7  [0003] RVA 0x00000194, 4 bytes, target value 30:01:00:00
8  ...

All the structures involved in these dynamic relocations can be accessed through an API, including support for special relocations:

  • IMAGE_DYNAMIC_RELOCATION_ARM64X
  • IMAGE_DYNAMIC_RELOCATION_FUNCTION_OVERRIDE
  • IMAGE_DYNAMIC_RELOCATION_ARM64_KERNEL_IMPORT_CALL_TRANSFER
  • IMAGE_DYNAMIC_RELOCATION_GUARD_IMPORT_CONTROL_TRANSFER

With the introduction of this new dynamic relocation support, LIEF can now offer a relocated view of the ARM64EC binary that is embedded within an ARM64X binary. To achieve this, simply parse the ARM64X PE binary with all options enabled.

1let pe = lief::pe::Binary::parse_with_config(
2    "win11_arm64x_Windows.Media.Protection.PlayReady.dll",
3    lief::pe::ParserConfig::with_all_options(),
4).unwrap();
5
6if let Some(nested_arm64ec) = pe.nested_pe_binary() {
7  println!("{:?}", nested_arm64ec.header());
8}

or in Python:

1pe = lief.PE.parse("win11_arm64x_Windows.Media.Protection.PlayReady.dll",
2                   lief.PE.ParserConfig.all)
3
4if nested_arm64ec := pe.nested_pe_binary:
5    print(nested_arm64ec.header)
1Signature:               50 45 00 00
2Machine:                 AMD64
3Number of sections:      19
4Pointer to symbol table: 0x0
5Number of symbols:       0
6Size of optional header: 0xf0
7Characteristics:         EXECUTABLE_IMAGE, LARGE_ADDRESS_AWARE, DLL
8Timtestamp:              1966221527

Parser Config

To maintain the performance of LIEF v0.17.0 in line with previous versions, the parsing of nested ARM64EC binaries must be explicitly enabled during the lief.PE.parse() operation (the default is false).

LIEF provides helper functions such as: lief.PE.Binary.is_arm64x() and lief.PE.Binary.is_arm64ec() to determine whether a given PE is ARM64 emulation compatible (EC) or if it’s a fat ARM64X.

These helper functions rely on the CHPE metadata (Compiled Hybrid Portable Executable) which is accessible through LIEF for both: ARM64/ARM64EC binaries and legacy x86 binaries executed via WoW64:

 1import lief
 2
 3# ARM64 CHPE
 4pe = lief.PE.parse("arm64x_ImagingEngine.dll")
 5
 6chpe: lief.PE.CHPEMetadataARM64 = pe.load_configuration.chpe_metadata
 7print(chpe_metadata.auxiliary_delay_import)
 8print(chpe_metadata.code_ranges)
 9
10# x86/WoW64 CHPE
11pe = lief.PE.parse("Windows.Media.dll")
12
13chpe: lief.PE.CHPEMetadataX86 = pe.load_configuration.chpe_metadata
14print(chpe_metadata.compiler_iat_pointer)
15print(chpe_metadata.wowa64_dispatch_ret_function_pointer)

Furthermore, LIEF can now process (in-depth) exception information for x86-64 and ARM64 binaries as well as COFF strings and COFF symbols that are still used by some toolchains.

Writing Improvements

As mentioned at the beginning of this blog post, LIEF’s PE modification engine, also named Builder, has been completely refactored to enhance its reliability and consistency when modifying PE binaries. To support this update, the documentation includes a dedicated section detailing the types of modifications supported by LIEF and their limitations:

For instance, PE import modification has been completely redesigned to avoid using trampolines.

PE Import Table for MSVC Binaries

Likewise, LIEF can now commit changes made to the export table. This enables the creation of new exports that can be used to expose reverse-engineered functions or to deceive disassemblers, as mentioned in The Poor Man’s Obfuscator

BinaryNinja Result

BinaryNinja ARM64EC Support

The ARM64EC ABI2, created by Microsoft, was developed to ease interoperability between ARM64 and x86-64 architectures, particularly for parts of the code that require emulation (EC = Emulation Compatible). The specifications of this ABI have a somewhat counterintuitive aspect: the targeted architecture indicated in the PE header is AMD64, while the majority of the functions are compiled for ARM64.

Darek Mihocka, one of the authors of the ARM64EC ABI, details some of these design choices in a great blog post3 that also provides an historical background about the support of new architectures in Windows.

Because the PE header specifies an x86_64 architecture, which does not accurately reflect most of the functions compiled in the binary, many disassemblers become confused when trying to analyze such a binary:

To address this incomplete support, I developed a BinaryNinja plugin designed to identify additional functions and accurately determine the architecture for which each function has been compiled.

For example, after using this plugin, we obtain a feature map in BinaryNinja showing that a majority of the functions have been correctly recognized:

The plugin is written in Rust using both LIEF & BinaryNinja Rust bindings. This also serves as an opportunity to evaluate the LIEF Rust API and its integration into other projects.

First, we can get a LIEF PE instance from a BinaryView with:

1fn enhance_with_lief(bv: &BinaryView) {
2    let filename = bv.file().filename();
3    let pe = lief::pe::Binary::parse_with_config(
4        filename.as_str(),
5        // This is required to get access to the exceptions info that are used later
6        lief::pe::ParserConfig::with_all_options(),
7    ).unwrap();
8}

Memory Footprint

Memory-wise, parsing the PE loaded by BinaryNinja with LIEF is not ideal since the same binary exists in memory twice: once for the BinaryNinja instance and once for the LIEF instance. I think that the BinaryNinja team will enhance the support for ARM64EC binaries at some point, so in the meantime this memory overhead is acceptable.

Once we have this instance, we can use the exceptions table as a source of function addresses allowing BinaryNinja to begin disassembling the binary from these addresses:

 1let pe: &lief::pe::Binary;
 2let bv: &BinaryView;
 3
 4let windows_arm64 = Platform::by_name("windows-aarch64").unwrap().to_owned();
 5let windows_x64 = Platform::by_name("windows-x86_64").unwrap().to_owned();
 6
 7let imagebase = pe.optional_header().imagebase();
 8
 9for exception in pe.exceptions() {
10    match exception {
11        lief::pe::RuntimeExceptionFunction::X86_64(x64) => {
12            let addr: u64 = imagebase + (x64.rva_start() as u64);
13            bv.add_auto_function(&windows_x64, addr);
14        }
15
16        lief::pe::RuntimeExceptionFunction::AArch64(arm64) => {
17            let addr: u64 = imagebase + (arm64.rva_start() as u64);
18            bv.add_auto_function(&windows_arm64, addr);
19        }
20    }
21}
22
23bv.update_analysis();

This code leads to the feature map shown in the screenshot above. Although the algorithm is straightforward, it conceals an important aspect of the ARM64EC ABI:

The exceptions table referenced by the data directory at the index: IMAGE_DIRECTORY_ENTRY_EXCEPTION points to the X86_64 table. To access the ARM64 exceptions table we need to refer to the CHPE metadata:

1let loadconfig = pe.load_configuration().unwrap();
2
3if let Some(lief::pe::CHPEMetadata::ARM64(arm64)) = loadconfig.chpe_metadata() {
4    let arm64_exception_table_start = arm64.extra_rfe_table();
5    let arm64_exception_table_size = arm64.extra_rfe_table_size();
6    println!("{:#x} ({} bytes)", arm64_exception_table_start, arm64_exception_table_size);
7}

LIEF abstracts the processing of CHPEMetadata and provides an iterator that outputs one of the following exceptions object:

  • lief::pe::exception_x64::RuntimeFunction
  • lief::pe::exception_aarch64::RuntimeFunction

You can find more details about the exception support by LIEF in the dedicated PE changelog: https://lief.re/doc/latest/changelog/pe-0-17-0.html#pe-0170-changelog

The source code for the BinaryNinja plugin is available on GitHub: lief-project/lief-binaryninja-arm64ec. It contains additional features, but the code should be read through the lens of a proof of concept.

Avatar
Romain Thomas Posted on February 16, 2025