
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).
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.
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();
| ^~~~
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.
This release introduces pre-compiled packages for several new architectures and platforms:
x86-64, ARM64 & arm-v7ariscv64gc (musl), riscv64a23 (glibc)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.
C++ Standard
LIEF Extended and its components are now built using C++23. The supported platforms are:
ARM64, x86-64 (RISC-V soon)ARM64 & x86-64ARM64 & x86-64ARM64 & 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.
-> 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:
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:
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}
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}")
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

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