Avatar

LIEF v1.0.0

ionicons-v5-k Romain Thomas July 13, 2026
Wave

I’m really happy to announce the release of LIEF 1.0.0. Compared to previous versions, this release represents an important milestones for this project: stability, usability, and security.

LIEF started almost ten years ago. While the journey was driven by adding new features, it also involved correcting a handful of early design decisions that turned out to be wrong and have been corrected, release after release. I won’t pretend this version is bug-free or that it does not need further improvements, but it now rests on solid foundations, and the project has been adopted across a range of industries with positive feedback.

A special thanks goes to Quansight, which generously sponsored this project. Many thanks as well to Holepunch for their feature-based sponsorship, and to Calif for reviewing LIEF against frontier models (aka Mythos & GPT‑5‑Cyber).

Bindings: Rust & Python

The LIEF Python wheels are now built against the stable ABI and now offer a free-threaded variant. Building against the stable ABI lets a single wheel serve multiple interpreter versions, while the free-threaded variant lets you use LIEF with the GIL disabled. This variant is available for Python 3.14 and 3.15 onward. The C++ core is now thread-safe with respect to its few static variables, so it behaves correctly under free-threading.

On the Rust side, I refactored the bindings to drop the unmaintained autocxx dependency. They are now built directly on top of cxx, without any extra wrapper. This change dramatically reduces compilation and iteration time. The crates also moved from api/rust/cargo/ to api/rust/crates/, and the minimum supported Rust version is now 1.85.0.

Clang Lifetime Annotations

I1 have started annotating the LIEF API with Clang lifetime annotations [[clang::lifetimebound]] to strengthen the compile-time verification of the codebase.

These annotations enable the compiler to catch issues like this one:

1std::string lifetime_examples() {
2  Section* text = nullptr;
3  {
4    std::unique_ptr<Binary> elf = Parser::parse("/bin/ls");
5    text = elf->get_section(".text");
6  }
7  return text->name();
8}

When compiled under strict lifetime-analysis flags, Clang raises the following error:

  example.cpp:50:12: error: local variable 'elf' does not live long enough [clang-diagnostic-lifetime-safety-use-after-scope]
     50 |     text = elf->get_section(".text");
        |            ^~~
  example.cpp:51:3: note: local variable 'elf' is destroyed here
     51 |   }
        |   ^
  example.cpp:50:12: note: expression aliases the storage of local variable 'elf'
     50 |     text = elf->get_section(".text");
        |            ^~~~~
  example.cpp:50:12: note: result of call to 'get_section' aliases the storage of local variable 'elf'
     50 |     text = elf->get_section(".text");
        |            ^~~~~~~~~~~~~~~~~~~~~~~~~
  example.cpp:52:10: note: later used here
     52 |   return text->name();
        |          ^~~~

Executable File Format Improvements

Mach-O support has been extended to include load commands introduced in recent versions of dyld: LC_LAZY_LOAD_DYLIB_INFO, LC_FUNCTION_VARIANTS, and LC_FUNCTION_VARIANT_FIXUPS. LIEF can now also create a FAT (universal) binary from several thin Mach-O binaries, select a specific architecture out of an existing FAT binary, and write big-endian Mach-O files.

The ELF rewriter has been reworked to reduce the memory footprint of modified binaries: a rewritten ELF is now smaller than in previous releases, and LIEF can safely modify a binary it has already modified.

Finally, thanks thanks to a security review by Calif, the various parsers are now safer and keep tighter control over how much memory they allocate when facing malformed inputs.

Supported Platforms

This release introduces pre-compiled packages for several new architectures and platforms:

  • Python wheels are now available for Linux RISC-V 64-bit (including musl-based libc)
  • SDK and Rust pre-compiled packages are provided for:
    • Android x86-64, ARM64 & arm-v7a
    • Linux riscv64gc (musl), riscv64a23 (glibc)

Runtime API

One of the most exciting additions in this release is the new Runtime API. The motivation came from a recurring need: parsing ELF, Mach-O and PE binaries directly from memory. LIEF already had everything required to parse a binary from a raw pointer thanks to its BinaryStream abstraction, but it lacked a friendly, high-level bridge to reach it. The Runtime API is that bridge:

1import lief
2
3for module in lief.runtime.modules():
4    print(module)
5    binary = module.parse_from_memory()

These runtime features go well beyond parsing an executable from memory. They provide cross-platform, cross-language access to inspect and manipulate the process in which LIEF is loaded.

To make this concrete, here is a Python example that JIT-compiles and executes a Windows ARM64 “Hello World” using the new Runtime API.

First, we allocate a chunk of memory to hold the assembled code:

1import lief
2
3chunk = lief.runtime.Memory.mmap(
4    lief.runtime.Process.page_size,
5    lief.runtime.Memory.ANONYMOUS | lief.runtime.Memory.PRIVATE,
6    lief.runtime.Memory.READ | lief.runtime.Memory.WRITE | lief.runtime.Memory.EXEC,
7)

Then, we assemble our code straight into the chunk with assemble(...). This function is backed by LIEF’s assembly engine:

 1lief.runtime.assemble(
 2    chunk.addr,
 3    r"""
 4    .text
 5        .global win_arm64_hello
 6        .align 2
 7
 8    win_arm64_hello:
 9        stp     x29, x30, [sp, -64]!
10        mov     x29, sp
11        stp     x19, x20, [sp, 16]
12        stp     x21, x22, [sp, 32]
13        // -------------------------------
14        // Hello World code goes here
15        // -------------------------------
16        mov     x0, xzr
17        ldp     x21, x22, [sp, 32]
18        ldp     x19, x20, [sp, 16]
19        ldp     x29, x30, [sp], 64
20        ret
21    """,
22)

While we could implement the “Hello World” as pure, low-level shellcode issuing raw syscalls, we can also leverage the Contextual Assembly Patching feature to resolve symbols like GetStdHandle and WriteFile on the fly:

 1class Config(lief.assembly.AssemblerConfig):
 2    def resolve_symbol(self, name: str) -> int | None:
 3        kernel32 = lief.runtime.windows.dlopen("kernel32.dll")
 4        match name:
 5            case "GetStdHandle":
 6                return lief.to_int(kernel32.dlsym("GetStdHandle"))
 7            case "WriteFile":
 8                return lief.to_int(kernel32.dlsym("WriteFile"))
 9            case _:
10                return None
11
12config = Config()
13
14lief.runtime.assemble(
15    chunk.addr,
16    r"""
17    .text
18        .global win_arm64_hello
19        .align 2
20
21    win_arm64_hello:
22        stp     x29, x30, [sp, -64]!
23        mov     x29, sp
24        stp     x19, x20, [sp, 16]
25        stp     x21, x22, [sp, 32]
26        // -------------------------------
27        ldr     x19, =GetStdHandle
28        ldr     x20, =WriteFile
29        // -------------------------------
30        mov     x0, xzr
31        ldp     x21, x22, [sp, 32]
32        ldp     x19, x20, [sp, 16]
33        ldp     x29, x30, [sp], 64
34        ret
35    """, config,
36)

The same mechanism can also resolve data symbols. Let’s expose our message buffer and its length through the config, then actually call the two functions to print the string:

 1msg = b"Hello World\n"
 2ctype_msg = ctypes.create_string_buffer(msg)
 3
 4class Config(lief.assembly.AssemblerConfig):
 5    def resolve_symbol(self, name: str) -> int | None:
 6        kernel32 = lief.runtime.windows.dlopen("kernel32.dll")
 7        match name:
 8            case "GetStdHandle":
 9                return lief.to_int(kernel32.dlsym("GetStdHandle"))
10            case "WriteFile":
11                return lief.to_int(kernel32.dlsym("WriteFile"))
12            case "var_msg":
13                return ctypes.addressof(ctype_msg)
14            case "var_msg_len":
15                return len(msg)
16            case _:
17                return None
18
19config = Config()
20
21lief.runtime.assemble(
22    chunk.addr,
23    r"""
24    .text
25        .global win_arm64_hello
26        .align 2
27
28    win_arm64_hello:
29        stp     x29, x30, [sp, -64]!
30        mov     x29, sp
31        stp     x19, x20, [sp, 16]
32        stp     x21, x22, [sp, 32]
33        // -------------------------------
34        ldr     x19, =GetStdHandle
35        ldr     x20, =WriteFile
36        ldr     x21, =var_msg
37        ldr     w22, =var_msg_len
38
39        // GetStdHandle(STD_OUTPUT_HANDLE=-11) -> x0
40        mov     w0, -11
41        blr     x19
42
43        // WriteFile(x0, msg, len, &written, NULL)
44        mov     x1, x21
45        mov     w2, w22
46        add     x3, sp, 48
47        mov     x4, xzr
48        blr     x20
49        // -------------------------------
50        mov     x0, xzr
51        ldp     x21, x22, [sp, 32]
52        ldp     x19, x20, [sp, 16]
53        ldp     x29, x30, [sp], 64
54        ret
55    """, config,
56)

Once the code is assembled in memory, we flush the instruction cache, flip the region to read-execute, and call it like a regular function:

1lief.runtime.assemble(chunk.addr, "...", config)
2
3chunk.cache_flush()
4chunk.make_rx()
5
6void_void_func = ctypes.CFUNCTYPE(None)  # void(*)()
7hello_jit = void_void_func(chunk.addr)
8
9hello_jit()  # prints "Hello World"

The same example is available for the Rust bindings and the C++ API, and you’ll find the Linux, Android and macOS equivalents in the runtime documentation.

The Runtime API also works the other way around: you can disassemble live code directly from memory. Here is a Rust example that disassembles a function and flags every instruction that touches the RISC-V stack pointer (x2):

 1use lief::assembly::{Instructions, riscv::{Operands, Reg}};
 2
 3fn say_hello() {
 4  println!("Hello World");
 5}
 6
 7fn main() {
 8    for inst in lief::runtime::disassemble((say_hello as usize).try_into().unwrap()) {
 9        println!("{inst}");
10
11        let Instructions::RiscV(riscv) = &inst else {
12            continue;
13        };
14
15        let uses_sp = riscv
16            .operands()
17            .any(|op| matches!(op, Operands::Mem(mem) if mem.base() == Reg::X2));
18
19        if uses_sp {
20            println!("{inst} is a memory operation that uses x2 (sp)");
21        }
22    }
23}

Runtime API

You can explore the rest of the Runtime API in the runtime documentation. Because these features go beyond executable formats, they are only activated in the extended version of LIEF. However, you can also compile LIEF from source with the Runtime API enabled.

LIEF Extended

C++ Standard

LIEF Extended and its components are now built using C++23. The supported platforms are:

  • Linux: Ubuntu 24.04, Fedora 40, RHEL 10 / CentOS 10, Debian 13: ARM64, x86-64 (RISC-V soon)
  • macOS 15.0+ (Sequoia): ARM64 & x86-64
  • Windows 11+: ARM64 & x86-64
  • Android API 30+: ARM64 & x86-64 (including Python wheels)

This change does not affect the LIEF core library, which remains compatible with C++11 and only requires a C++17 compiler to build.

DWARF & PDB -> C/C++

LIEF Extended can now generate C/C++ declarations from DWARF and PDB debug information.

For example, consider this libdexprotector.so binary, enriched with DWARF debug info recovered via reverse engineering.

In Binary Ninja, it looks like this:

0000abc0  }.data (PROGBITS) section ended  {0xa150-0xabc0}.bss (NOBITS) section started  {0xabc0-0xabe0}0000abc0  char prctl_data[0x6] = "\x00\x00\x00\x00\x00"00000abc6                    00 00                                ..0000abc8  // pointer to the r_debug structure defined in the linker(64)0000abc8  struct r_debug_t* linker64_r_debug = nullptr0000abd0  int32_t err = 0x00000abd4              00 00 00 00                              ....0000abd8  // Pointer to the dynamically unpacked JNI_OnLoad0000abd8  jint (* JNI_OnLoad_ptr)(JavaVM* vmvoid* reserved) = nullptr

Calling to_decl() on a DWARF function, variable, or type generates its C/C++ declaration:

1import lief
2
3elf = lief.ELF.parse("libdexprotector.so.dwarf")
4dwarf = elf.debug_info
5
6linker64_r_debug = dwarf.find_variable("linker64_r_debug")
7print(linker64_r_debug.to_decl())

Which outputs:

1/*
2 * pointer to the r_debug structure defined in the linker(64)
3 * Addr: 0xabc8
4 * size: 0x0008
5 */
6static struct r_debug_t *linker64_r_debug;

C/C++ Declaration

Note that the Binary Ninja comment, the address, and the sizeof of the variable are also generated. You can control the output format using the configuration options accepted by to_decl.

to_decl also works with types:

 1import lief
 2
 3elf = lief.ELF.parse("libdexprotector.so.dwarf")
 4dwarf = elf.debug_info
 5
 6linker64_r_debug = dwarf.find_variable("linker64_r_debug")
 7print(linker64_r_debug.to_decl())
 8
 9ptr_type: lief.dwarf.types.Pointer = linker64_r_debug.type
10print(ptr_type.underlying_type.to_decl())

Which outputs:

 1struct r_debug_t {
 2    int r_version;
 3    char __padding1__[4];
 4    struct link_map *r_map;
 5    Elf64_Addr r_brk;
 6    enum {
 7        RT_CONSISTENT = 0U,
 8        RT_ADD = 1U,
 9        RT_DELETE = 2U
10    } r_state;
11    char __padding4__[4];
12    Elf64_Addr r_ldbase;
13}

You can also configure the output to include field offsets:

 1import lief
 2
 3elf = lief.ELF.parse("libdexprotector.so.dwarf")
 4dwarf = elf.debug_info
 5
 6linker64_r_debug = dwarf.find_variable("linker64_r_debug")
 7print(linker64_r_debug.to_decl())
 8
 9ptr_type: lief.dwarf.types.Pointer = linker64_r_debug.type
10- print(ptr_type.underlying_type.to_decl())
11+ opt = lief.DeclOpt()
12+ opt.show_field_offsets = True
13+ print(ptr_type.underlying_type.to_decl(opt))
 1struct r_debug_t {
 2    /* 0x00 */ int r_version;
 3    /* 0x04 */ char __padding1__[4];
 4    /* 0x08 */ struct link_map *r_map;
 5    /* 0x10 */ Elf64_Addr r_brk;
 6    /* 0x18 */ enum {
 7        RT_CONSISTENT = 0U,
 8        RT_ADD = 1U,
 9        RT_DELETE = 2U
10    } r_state;
11    /* 0x1c */ char __padding4__[4];
12    /* 0x20 */ Elf64_Addr r_ldbase;
13}

Additionally, a function containing basic block comments like this:

BinaryNinja - dp_derive_key

is translated as follows:

 1/*
 2 * Address: 0x063c
 3 */
 4r_debug_t *dp_derive_key(key_t *key) {
 5    /*
 6     * Stack addr: -0x00d0
 7     * size: 0x0080
 8     */
 9    struct_4 var_d0;
10    /* Start: 0x000988 */ {
11        /* Start: 0x00098c */ {
12          //This block derives the key based on the assembly of r_debug.r_brk.
13          //
14          //Frida hooks this function such as the regular "ret" is transformed by a trampoline
15        } /* End: 0x000990 */
16    } /* End: 0x0009a4 */
17}

The same feature works with PDB files:

1pdb = lief.pdb.load("ntdll.pdb/6192BFDB9F04442995FFCB0BE95172E14/ntdll.pdb")
2peb = pdb.find_type("_PEB")
3opt = lief.DeclOpt()
4opt.show_field_offsets = True
5print(peb.to_decl(opt))
 1
 2struct _PEB {
 3    /* 0x00 */ unsigned char InheritedAddressSpace;
 4    /* 0x01 */ unsigned char ReadImageFileExecOptions;
 5    /* 0x02 */ unsigned char BeingDebugged;
 6    /* 0x03 */ unsigned char BitField;
 7    /* 0x03 */ unsigned char ImageUsesLargePages : 1;
 8    /* 0x03 */ unsigned char IsProtectedProcess : 1;
 9    /* 0x03 */ unsigned char IsLegacyProcess : 1;
10    /* 0x03 */ unsigned char IsImageDynamicallyRelocated : 1;
11    /* 0x03 */ unsigned char SkipPatchingUser32Forwarders : 1;
12    /* 0x03 */ unsigned char SpareBits : 3;
13    /* 0x08 */ void Mutant;
14    /* 0x10 */ void ImageBaseAddress;
15    /* 0x18 */ struct _PEB_LDR_DATA *Ldr;
16    /* 0x20 */ struct _RTL_USER_PROCESS_PARAMETERS *ProcessParameters;
17    /* 0x28 */ void SubSystemData;
18    /* 0x30 */ void ProcessHeap;
19    [...]
20    /* 0x378 */ unsigned long TracingFlags;
21    /* 0x378 */ unsigned long HeapTracingEnabled : 1;
22    /* 0x378 */ unsigned long CritSecTracingEnabled : 1;
23    /* 0x378 */ unsigned long SpareTracingBits : 30;
24}

RISC-V, MIPS, eBPF & PowerPC

The disassembler can now expose the high-level semantics of instruction operands for RISC-V, MIPS, eBPF and PowerPC. Each operand is surfaced as a Register, Immediate, Memory or PCRelative object:

 1import lief
 2
 3elf = lief.parse("hello.bpf.o")
 4
 5section = elf.get_section("tp/syscalls/sys_enter_write")
 6instructions = list(elf.disassemble_from_bytes(bytes(section.content)))
 7
 8for inst in instructions:
 9    print(inst)
10    for idx, op in enumerate(inst.operands):
11        match op:
12            case lief.assembly.ebpf.operands.Register(value=reg):
13                print(f"  op[{idx}]: Register  -> {reg}")
14
15            case lief.assembly.ebpf.operands.Immediate(value=imm):
16                print(f"  op[{idx}]: Immediate -> {imm}")
17
18            case lief.assembly.ebpf.operands.Memory():
19                print(f"  op[{idx}]: Memory    -> {op}")
20
21            case lief.assembly.ebpf.operands.PCRelative(value=target):
22                print(f"  op[{idx}]: PCRelative -> {target}")

Last Word

The first internal version of LIEF was released in July 2016. Ten years later, the project is still alive and keeps growing, supported by a large and active community of users and contributors.

Thank you to everyone who has been a part of this journey.

The detailed changelog is available here: Changelog

Quansight

  1. The initial pass over the codebase to add these annotations was bootstrapped with the help of AI, then reviewed and adjusted by hand. ↩︎

Avatar
Romain Thomas Posted on July 13, 2026