Introducing Nyxstone: An LLVM-based (Dis)assembly Framework

Product
June 26, 2024
Darius Hartlief and Tim Blazytko

At Emproof, our mission is to enhance the security and integrity of embedded systems through innovative binary rewriting techniques. We are committed to providing advanced solutions that protect (embedded) software against reverse engineering and exploitation. Our flagship product, Emproof Nyx, achieves this by offering static binary instrumentation to add protection measures such as code obfuscation, anti-debugging and anti-tamper checks as well as exploit mitigations such as stack canaries and control-flow integrity into customer binaries.

Binary rewriting is a compiler and programming-language agnostic approach, which provides us with a high level of flexibility to modify and harden customer code. At a high-level, binary rewriting follows three main steps: First, the existing binary is lifted into an intermediate representation, which is architecture-independent. Next, transformations are applied to this lifted representation. Finally the transformed representation is lowered to machine code and written back into the binary. To be able to effectively implement the first and third steps, Emproof Nyx heavily relies on functionality for assembling and disassembling instructions.

Initially, we relied on two established solutions for these tasks, namely Capstone for disassembling and Keystone for assembling. While these solutions were a good fit in the beginning, we shortly noticed that these frameworks are not well-suited for our needs, especially since Keystone lacked important features and is also error-prone. It often emitted machine code that was slightly incorrect and did not report errors during the assembly process properly. Another missing feature was the support of labels in the assembler: Any label referenced in the assembly needs to be a part of it and can not be defined to reference an address. To tackle these problems, we decided that we need an unified solution with strict error reporting while also boasting a small code base.

Consequently, we built Nyxstone, an assembly and disassembly library based on LLVM. After battle-testing it for over a year on multiple architectures, we are now happy to share it with the community and open source it under the MIT license. In this blog post, we will introduce you to Nyxstone. After highlighting its key features, we will showcase its usage as a standalone tool. Afterward, we demonstrate its usage as a library from C++, Rust, and Python.

Key Features

When building Nyxstone, we decided to base it on the LLVM compiler framework due to its robust internal assembler and disassembler capabilities, utilizing its machine code backend to emit code. By linking directly against LLVM, rather than cloning parts of its codebase, we ensured that Nyxstone remains small and maintainable. This approach also enables Nyxstone to support any architecture that LLVM supports, providing us with a flexible and scalable solution.

One of Nyxstone’s primary features is its unified assembly/disassembly capabilities, which simplifies the process of translating between machine and assembly code. Additionally, Nyxstone supports labels in the assembler, including the definition of arbitrary addresses and labels for instructions. These can be used to assemble conditional and unconditional jumps, as well as program counter-relative instructions. Furthermore, Nyxstone offers flexible and fine-granular configuration of CPUs and their features, allowing users to tailor the tool to specific ISA (Instruction Set Architecture) extensions and hardware characteristics.

In the following sections, we will provide more information about Nyxstone’s combined assembly/disassembly feature, its label support, and its configuration options.

Combined (Dis)Assembly Capabilities

One of the key features of Nyxstone is its integrated assembly and disassembly capabilities, unified within a single framework. By leveraging the LLVM compiler infrastructure, Nyxstone relies on its internal assembler and disassembler, providing comprehensive support for various architectures.

LLVM offers an API for disassembling machine code into human-readable assembly language. However, the features for assembling are embedded within LLVM’s internal machine code backend. Nyxstone accesses these capabilities by wrapping LLVM’s internal objects and hooking into the relevant APIs. This approach does not only enable Nyxstone to utilize LLVM’s assembly functionalities seamlessly, but also allows us to add additional error checks without the need to patch LLVM.

By leveraging LLVM’s backend, Nyxstone benefits from the detailed diagnostics emitted by LLVM. Treating any diagnostic from LLVM as an error allows Nyxstone to effectively report potential problems, including the specific location in the assembly code where the issue occurred. This ensures accurate and comprehensive error reporting, facilitating easier debugging and refinement.

Label Support

Another feature of Nyxstone is the support of labels in the assembler. Providing the option to define labels referring to a specific address—a feature missed by other frameworks such as Keystone—it provides more versatile applications, e.g., when assembling conditional jumps and PC-relative instructions.

Another use case is a more fine-granular laying out of instruction sequences. For example, consider the following assembly snippets implementing a shadow stack, a secondary stack that retains return addresses to prevent tampering by an attacker:

; addr 0x1000
    add r10, 4             ; r10 holds ptr to the shadow stack
    lea rbx, [rip + .ret]  ; load the return address
    mov [r10], rbx         ; store the return address on the shadow stack
    call some_fn           ; call into the function
.ret:
    ; ...

; some functions in-between

; addr 0x1400
some_fn:
    ; ...
    ; addr 0x1458
    mov rbx, [rsp]               ; load return address
    cmp [r10], rbx               ; ensure return address is unchanged
    jne .return_addr_compromised ; at 0x1800
    sub r10, 4                   ; clean up shadow stack
    ret

In this example, we aim to assemble the two snippets without modifying the intermediate instructions in-between. Without label support, it would not be possible to assemble control-flow instructions that jump to instructions not included in the assembly code. In this example, the call and jne instructions each refer to a label not part of the assembly snippets.

However, Nyxstone can assemble such snippets separately when supplied with the address of a missing label. In this case, the address of some_fn needs to be defined for the patch at 0x1000, and the address of the label .return_addr_compromised needs to be known for the assembly at address 0x1458.

For this use case, we needed to ensure that the emitted machine code would correctly jump to and reference the user-defined labels. Otherwise, instructions inserted into a binary might jump to an incorrect location, leading to unintended control flow or invalid memory accesses. When building and testing Nyxstone, we noticed that, in some cases in which the linker resolves relocations, LLVM generates machine code that references the address 0 instead of the address referenced in the label. As a consequence, to ensure that Nyxstone will not emit wrong instruction bytes, we added custom relocations and catch unhandled instructions.

Configuration options

Leveraging LLVM as the foundation for Nyxstone brings the advantage of directly exposing LLVM’s architecture configuration options. This allows users to configure Nyxstone for specific CPUs and enable various architecture extensions through CPU features.

On ARMv8, for example, floating point instructions are an extension of the base ISA and might not be supported by all ARMv8 CPUs. Configuring the CPU in Nyxstone enables its default extensions, allowing assembling and disassembling any extension instructions, like the floating point instructions on ARMv8.

Usage Examples

After understanding Nyxstone’s core features, let’s explore how to use them in practice. We will start by demonstrating these features in a client (CLI) tool, and then examine how to integrate Nyxstone as a library in various programming languages.

In our everyday work, we often need to debug machine code or determine the exact bytes of a given instruction. To support these tasks, we implemented a client tool for Nyxstone, facilitating both assembling and disassembling operations.

By specifying the desired operation with the -A/--assemble and -D/--disassemble flags, instructions can be easily translated between machine code and assembly language:

$ ./nyxstone -A "xor rax, rbx"
#   0x00000000: xor rax, rbx - [ 48 31 d8 ]

$ ./nyxstone -D "13 37"
#   0x00000000: adc esi, dword ptr [rdi] - [ 13 37 ]

$./nyxstone -A "xor rax, rbx; add rax, 10"
#   0x00000000: xor rax, rbx - [ 48 31 d8 ]
#   0x00000003: add rax, 10 - [ 48 83 c0 0a ]

Addresses and Labels

Nyxstone’s CLI allows us to specify the start address for assembly and to define labels. Using our earlier example, we can assemble the two assembly snippets separately:

$ ./nyxstone --address "0x1000" -A 
    "
    add r10, 4
    lea rbx, [rip + .ret]
    mov [r10], rbx
    call some_fn
    .ret:
    " --labels "some_fn=0x1400"
#   0x00001000: add r10, 4 - [ 49 83 c2 04 ]
#   0x00001004: lea rbx, [rip + .ret] - [ 48 8d 1d 08 00 00 00 ]
#   0x0000100b: mov qword ptr [r10], rbx - [ 49 89 1a ]
#   0x0000100e: call some_fn - [ e8 ed 03 00 00 ]

$ ./nyxstone --address "0x1458" -A 
    "
    mov rbx, [rsp + 4]
    cmp rbx, [r10]
    jne .return_addr_compromised
    sub r10, 4
    ret
    " --labels ".return_addr_compromised=0x1800"
#   0x00001458: mov rbx, qword ptr [rsp + 4] - [ 48 8b 5c 24 04 ]
#   0x0000145d: cmp rbx, qword ptr [r10] - [ 49 3b 1a ]
#   0x00001460: jne .return_addr_compromised - [ 0f 85 9a 03 00 00 ]
#   0x00001466: sub r10, 4 - [ 49 83 ea 04 ]
#   0x0000146a: ret - [ c3 ]

The --address flag configures the start address of the assembly, as shown in the address of the first instruction output by Nyxstone. With the --labels flag, you can define arbitrary labels. Multiple labels are specified as comma-separated key-value pairs, e.g., --labels "label0=0x1000,label1=0x1200". Nyxstone considers both the position within the code and the addresses of the provided labels when assembling PC-relative instructions, ensuring correct results.

Architecture and Configuration

To showcase the architecture and ISA-specific configurations, Nyxstone provides flexible options to accommodate various systems.

In the following, we demonstrate how to specify the architecture and support ISA-specific extensions. By default, Nyxstone assumes the architecture is x86_64, but it can be configured for any architecture supported by the linked LLVM library via the --arch flag. The input can be either the LLVM triple for the architecture or its shorthand. This allows you to assemble instructions for various architectures such as armv8m, armv8-thumb, aarch64, riscv32, and more:

$ ./nyxstone --arch "armv8m" -A "add r0, r1"
#   0x00000000: add r0, r0, r1 - [ 01 00 80 e0 ]

$ ./nyxstone --arch "thumb8" -A "add r0, r1"
#   0x00000000: add r0, r1 - [ 08 44 ]

$ ./nyxstone --arch "aarch64" -A "add w0, w1, w2"
#   0x00000000: add w0, w1, w2 - [ 20 00 02 0b ]

$ ./nyxstone --arch "riscv32" -A "add t0, t1, zero"
#   0x00000000: add t0, t1, zero - [ b3 02 03 00 ]

Architecture extensions are not enabled by default in LLVM, as it assumes that only the base ISA is implemented. Consequently, Nyxstone cannot, for example, assemble this floating point instruction on armv8m in its base setting:

$ ./nyxstone --arch "armv8m" -A "vadd.f32 s0, s1, s2"
# Could not assemble vadd.f32 s0, s0, s1 (= Error during assembly: error: instruction requires: VFP2
# vadd.f32 s0, s0, s1
# ^
# )

This is where the additional LLVM configuration options exposed by Nyxstone become useful. We can specify the exact CPU to enable its default features, but we can also enable specific ISA extensions for a given instruction set architecture. For example, for the given floating point instruction, we can specify either the CPU as Cortex-M7 (which features the ARM floating point extension), or we can enable the specific feature for the required extension.

Let us first specify the exact CPU architecture:

$ ./nyxstone --arch "armv8m" --cpu "cortex-m7" -A "vadd.f32 s0, s1, s2" 
#   0x00000000: vadd.f32 s0, s0, s1 - [ 30 ee 20 0a ]

Another option is to enable the specific ISA extension. Features are comma-separated values, with each value prepended by a plus or minus depending on whether the feature should be enabled or disabled. In this case, Nyxstone reports the required feature vfp2 via LLVM:

$ ./nyxstone --arch "armv8m" --features "+vfp2" -A "vadd.f32 s0, s0, s1"
#   0x00000000: vadd.f32 s0, s0, s1 - [ 20 0a 30 ee ]

Nyxstone as a Library

While the CLI is a great tool for assembling or disassembling any instructions one encounters in their day-to-day life, Nyxstone is, at its core, a library. Nyxstone is written in C++ to interoperate with LLVM and, thus, features a C++ API. Since we at Emproof enjoy memory-safe languages and use Rust to build our binary rewriting technology Emproof Nyx, we implemented Rust bindings for Nyxstone. However, since we wanted to enable many people to use Nyxstone quickly, effectively, and easily, we also implemented bindings for Python.

Using Nyxstone as a library is generally straightforward. The only important prerequisite to build and link Nyxstone is that LLVM version 15 libraries are installed on your system, and that the corresponding `llvm-config` is either in the path, or that you specify the location of the LLVM libraries via the `NYXSTONE_LLVM_PREFIX` environment variable. If you specify the LLVM path, Nyxstone/ will look for `llvm-config` in the path `$NYXSTONE_LLVM_PREFIX/bin/llvm-config`. Further build instructions and required packages are documented in the GitHub [README].

C++ Usage

The direct way to interface with Nyxstone is in C++. We use CMake to link Nyxstone and build the CLI as example code provided for the C++ API. An example for incorporating Nyxstone into your project using CMake is provided in the Nyxstone README. Additionally, it is possible to use Nyxstone without resorting to CMake; the Makefile we used before switching to CMake can be found here. Detailed information about using the C++ API can be found here

Rust Usage

Given that we at Emproof primarily build our in-house tools in Rust, Nyxstone’s Rust bindings are a priority for us. A goal for both the Rust and Python bindings is that they fit into their respective language, utilizing language features whenever they are available.

Following is a small example of using Nyxstone from Rust. In it, a Nyxstone instance is created. It is configured to use a hexadecimal representation for immediates in the disassembly, and otherwise uses the default configuration. Afterwards it is used to assemble jne .label to an Instruction object, which holds not only the machine code, but also address and assembly of each instruction. Finally, it is used to disassemble the instruction bytes 0x31 0xd8 to a readable instruction.

use std::collections::HashMap;

use anyhow::Result;

use nyxstone::{IntegerBase, Nyxstone, NyxstoneConfig, Instruction};

fn main() -> Result<()> {
    let nyxstone = Nyxstone::new(
        "x86_64",
        NyxstoneConfig {
            immediate_style: IntegerBase::HexPrefix,
            ..Default::default()
        },
    )?;

    // You can also assemble to [`u8`]s, via `assemble()`
    let instructions = nyxstone.assemble_to_instructions(
        "jne .label",
        0x1000,
        &HashMap::from([(".label", 0x1200)]),
    )?;

    assert_eq!(
        instructions,
        vec![Instruction {
            address: 0x1000,
            assembly: "jne .label",
            bytes: vec![0x0f, 0x85, 0xfa, 0x01, 0x00, 0x00]
        }]
    );

    // You can also disassemble to [`Instruction`] objects, via `disassemble_to_instructions()`
    let disassembly = nyxstone.disassemble(
        &[0x31, 0xd8],
        /* address= */ 0x0,
        // Number of instructions to disassemble (0 = all)
        /* count = */ 0,
    )?;

    assert_eq!(disassembly, "xor eax, ebxn".to_owned());

    Ok(())
}

With the release of Nyxstone, we have also published it as a crate on crates.io. To use Nyxstone in your Rust project, you can simply add it to your Cargo.toml. However, as mentioned earlier, you need to ensure that LLVM 15 is installed and llvm-config is available either through the environment variable or in the system path; otherwise, Nyxstone will not link correctly.

Python Usage

To facilitate easy experimentation, we also provide Python bindings using pybind11.

Hereโ€™s an example on how you can use Nyxstone from Python, in which we define a Nyxstone instance with specific configurations for the CPU and immediate style. Afterwards, we showcase how to assemble and disassemble some instructions using the Nyxstone instance.

from nyxstone import Nyxstone, IntegerBase, Instruction

# Create a Nyxstone instance for x86_64 using the corei7 cpu and a hexadecimal immediate printing style
nyxstone = Nyxstone("x86_64", cpu="corei7", immediate_style=IntegerBase.HexPrefix)

# Assemble `xor rax, rax` to bytes, represented as a list of ints.
nyxstone.assemble("xor rax, rax")
# = [0x48, 0x31, 0xc0]

# Assemble `jmp label` to instruction information, containing address, assembly, and machine code bytes.
nyxstone.assemble_to_instructions("jmp label", address=0x1000, labels={"label": 0x1080})
# = [Instruction(address=0x1000, assembly="jmp label", bytes=[0xeb, 0x7e])]

# Disassemble `0x13 0x37` to a string.
nyxstone.disassemble([0x13, 0x37])
# 'adc esi, dword ptr [rdi]n'

We publish Nyxstone for Python via PyPI. You can install it from PyPI by calling pip install nyxstone or install it from source by using pip install . in the python bindings subdirectory.

Conclusion

In this post, we introduced our open source (dis)assembler framework Nyxstone, which is built on top of LLVM. It offers comprehensive support for architecture and feature configuration, precise error reporting, and the ability to define arbitrary labels during assembly. Nyxstone is accessible through C++, Rust, and Python APIs, and includes a versatile CLI tool for everyday use.

We are excited to open-source Nyxstone under the MIT license. We invite you to explore its capabilities and integrate it into your projects. Contributions to enhance Nyxstone are welcome, whether it’s reporting bugs in our GitHub issue tracker, adding new features, or fixing bugs. Check out our roadmap for upcoming features and join us in refining Nyxstone into a leading open-source assembly framework.

We send out regular updates on new releases, industry insights and technical case studies

Privacy policy

ยฉ 2024 emproof B.V. All rights reserved. Design by Kava. Privacy PolicyTerms and ConditionsISO 26262 (ASIL B) certification