Assembler


Introduction

In addition to standard file format modifications, it may be necessary to patch code with custom assembly. This functionality is available through the function:
elf: lief.ELF.Binary

syscall_addresses = [
    inst.address for inst in elf.disassemble(0x400090) if inst.is_syscall
]

for syscall_addr in syscall_addresses:
    elf.assemble(
        syscall_addr,
        """
        mov x1, x0;
        str x1, [x2, #8];
        """,
    )

Warning

The assembler works well for AArch64/ARM64E, x86/x86-64, and RISC-V but support for other architectures is currently limited.

Technical Details

Similar to the disassembler, this assembler is based on the LLVM MC layer.

The assembly text is consumed by the llvm::MCAsmParser object, and we intercept the raw generated assembly bytes from the llvm::MCObjectWriter.

We also resolve llvm::MCFixup for a vast majority of the generated fixups. An important feature introduced in LIEF 0.17.0 is support for resolving symbols or labels on the fly.

Contextual Assembly Patching

Given assembly code and a target address, we might want to use a context to resolve symbols referenced in the assembly listing.

For example, consider the following patch:

elf: lief.ELF.Binary

elf.assemble(
    elf.entrypoint,
    """
    mov rdi, rax;
    call a_custom_function;
    """,
)

In this example, a_custom_function is undefined, so the assembler engine cannot resolve it and raises the following error:

warning: Fixup not resolved:
    call a_custom_function
LIEF exposes a interface that can be used to configure the engine and to dynamically resolve symbols used in the assembly listing:
class MyConfig(lief.assembly.AssemblerConfig):
    def __init__(self):
        super().__init__()  # Important!

    @override
    def resolve_symbol(self, name: str) -> int | None:
        if name == "a_custom_function":
            return 0x1000
        return None

elf: lief.ELF.Binary

elf.assemble(
    elf.entrypoint,
    """
    mov rdi, rax;
    call a_custom_function;
    """,
    MyConfig(),
)
This interface can be used to wrap a context, such as a generic :
import lief
class MyConfig(lief.assembly.AssemblerConfig):
    def __init__(self, target: lief.Binary):
        super().__init__()  # Important!

        self._target = target

    @override
    def resolve_symbol(self, name: str) -> int | None:
        addr = self._target.get_function_address(name)
        if isinstance(addr, lief.lief_errors):
            return None
        return addr

elf: lief.ELF.Binary
config = MyConfig(elf)

elf.assemble(
    elf.entrypoint,
    """
    mov rdi, rax;
    call a_custom_function;
    """,
    config,
)
The Rust bindings do not offer the same flexibility to capture the . Nevertheless, the closure associated with the lief::assembly::AssemblerConfig::symbol_resolver can capture most of its context:
let elf: &mut lief::elf::Binary = some_elf;

let mut config = lief::assembly::AssemblerConfig::default();

let sym_map: HashMap<String, u64> = elf
    .exported_symbols()
    .map(|sym| (sym.name(), sym.value()))
    .collect();

let resolver = Arc::new(move |symbol: &str| sym_map.get(symbol).copied());

config.symbol_resolver = Some(resolver);

elf.assemble_with_config(
    elf.entrypoint(),
    r#"
    mov rdi, rax;
    call a_custom_function;
    "#,
    &config,
);

Use Cases

Patching code with the assembler is a fast alternative to editing raw bytes by hand: it is useful for bypassing a check while reverse engineering, hot-patching a bug in a shipped executable, or inserting instrumentation.

Disabling an Instruction

To strip a single instruction like a call to a telemetry or anti-debugging routine without shifting the rest of the function we can overwrite it with as many nop bytes as it occupied. Pairing the disassembler with lets you do this:
elf: lief.ELF.Binary

# Overwrite the first call in the region (e.g. a call to an anti-debugging
# routine) with nops
for inst in elf.disassemble(0x401200):
    if inst.is_call:
        elf.assemble(inst.address, "nop\n" * inst.size)
        break

elf.write("patched.bin")

Note

The snippets above assume x86/x86-64, where a nop is a single byte, so inst.size of them fill the slot exactly. On fixed-width instruction set such as AArch64 every instruction is 4 bytes, so we would emit inst.size / 4 instead.

In-Memory Assembler

In addition to patching binaries on disk or within standard file formats, the assembly engine is also available for JIT compilation and in-memory operations. This is exposed through the runtime API , as detailed in the Runtime Memory documentation.

Python API

C++ API

Rust API