Introduction

Emil is a project that I started in the practicum class for my Master’s degree at Georgia Tech. Inspiration for the project came to me when I was analyzing a program in Binary Ninja and came across a function that was obfuscating shellcode that was embedded in the binary. It was a simple obfuscation technique that checked for a debugger using ptrace and then decrypted a blob in place using a hardcoded key. The kind of obfuscation that would be extremely easy to emulate to dump the deobfuscated data.

I thought about reaching for Unicorn but that requires a lot of work to manually set up all of the memory segments from the ELF file that Binary Ninja already handled for me. It’s a lot of duplication of effort that shouldn’t be required. Ghidra has a built-in emulator that I assume can be used in situations like this. Ghidra is an amazing program and amazing deal. It offers unmatched features that you can’t find in any other free program. That being said, Binary Ninja is a much more modern reverse engineering tool that has learned from the shortcomings of Ghidra and is therefore a lot nicer to use. Additionally, if I’m paying close to $1,000 every year for Binary Ninja, I would like it to have some amount of feature parity with Ghidra.

These were the thoughts going through my head as I started the practicum class at Georgia Tech and needed to decide on a project. How hard could a Binary Ninja plugin for emulating programs be? I decided to find out.

Design Requirements

There were a few design decisions that I wanted to make sure were incorporated into Emil. These are based off of API and design decisions that I dislike in other emulators that I have used. I wanted to make an emulator that improved upon previous designs and that felt nice to use.

Intuitive User Experience

It’s a pet peeve of mine when you’re using an emulator and want to read a register only to discover that it’s a fallible operation. The emulator should have a similar API to that of the actual architecture. This means that reading and writing general purpose registers should never fail but memory reads and writes may fail due to an invalid access or unmapped memory. It’s unlikely that you’ll actually try to read an invalid register but it’s always nice to have a compiler error help you out as opposed to discovering a typo you made in a hook that only gets hit 20 seconds into emulation.

Python may not have the type system to provide these guarantees, but an emulator written in a strongly typed language should use the type system to make a developers life as easy as possible. A language like Rust or C, with a little extra effort, can use it’s type system to provide infallible access to registers.

Reading registers is just a specific example of the intuitive UI design I want to provide. The general idea is that the UI should be what you expect. There aren’t any functions that return an error because the type checking happens at runtime instead of compile time.

Performance Oriented

I did not set out with the intention of writing the fastest emulator on the market but I wanted to be conscious of how design decisions would affect performance. It is easy to add layers and layers of indirection to make an emulator very general purpose and support anything and everything you could throw at it, but then it becomes useless for emulating anything but the smallest snippets of code.

Ideally, Emil should be quick enough that the emulator speed is not a blocking factor to using it. Emil should be fast enough to emulate large programs with a reasonable slow down and be fast enough that you could emulate a single function millions of times if you needed to brute force parameters in some way.

Rely on Binary Ninja

Needing to manually load all of the memory segments in Unicorn creates a lot of friction to using it when all you want to do is emulate a function in an ELF. All of that loading requires replicating the work that Ghidra or Binary Ninja already did. Additionally, Unicorn doesn’t have access to any of the information in a Ghidra or Binary Ninja database so all functions need to be referenced by an address instead of the name it has in the database.

My ideal emulator would integrate well with Binary Ninja. That way loaders only need to be implemented in one place and symbols defined in Binary Ninja can be referenced in the emulator. Analysis can happen in both Binary Ninja and the emulator, but all information can be stored and tracked in the database.

Freestanding

Emil should be designed to work without access to the Binary Ninja database. Binary Ninja will be needed to lift a program to LLIL. Once Emil translates the LLIL to its internal representation, it should be possible to write that IL out to disk and not rely on Binary Ninja to reload the data. This will prevent a user from using symbols and analysis from the database but make it possible to use the emulator without a commercial license.

Emil’s Architecture

Language Choice

Emil is written in the Rust programming language. Binary Ninja has unstable Rust bindings that make Rust an option.

I chose Rust instead of Python or C++ since I needed a language that has a strong type system to get some of the guarantees that I wanted and I know Rust a lot better than C++.

Intermediate Language

Binary Ninja uses a number of intermediate languages for analysis and decompilation. The obvious choice is to use the standard low level intermediate language (LLIL) as the basis for emulation. It is composed of small self contained operations that are simple to emulate. The higher level intermediate languages add operations that would make implementation more difficult for limited payoff.

LLIL is a great choice for a basis for Emil but the direct type from the Binary Ninja API is not the best representation of the intermediate language. LLIL from the API is represented as an abstract syntax tree with an operation as the root node and expressions as children. That type also contains lots of extra information about what function the instruction is from and has a lifetime that ties it back to the database it came from. Having some kind of Emil specific intermediate language based on LLIL is necessary as a way to make programs exportable and to keep the performance ceiling as high as possible.

The tree structure of LLIL will negatively impact emulation performance. When an instruction is loaded to be emulated, not all of the required inputs to the instruction will be readily available. The emulator will have to traverse down the tree to resolve inputs and then return up the call stack to finally evaluate the instruction. All of that extra indirection will tank the performance of the emulator. A different repressentation is required that can be emulated linearly. A representation where all of the required inputs are immediately available.

This led to the creation of the specific intermediate language that Emil uses.

pub enum Emil<P: Page, Regs: RegState, E: Endian, I: Intrinsic> {
    /// No operation instruction.
    Nop,
    /// No return.
    NoRet,
    /// Perform a system call to the operating system.
    Syscall,
    /// Indicate that a breakpoint was hit.
    Bp,
    /// An undefined instruction was executed.
    Undef,
    /// Perform some kind of trap.
    Trap(u64),
    /// Set the value of an architectural register from an IL register.
    SetReg { reg: Regs::RegID, ilr: ILRef },
    /// Load an architectural register into an IL register.
    LoadReg { reg: Regs::RegID, ilr: ILRef },
    /// Set a temporary register.
    SetTemp { t: u8, ilr: ILRef },
    /// Load a temporary register into an IL register.
    LoadTemp { ilr: ILRef, t: u8 },
    //
    // ...
    // most of the instructions are elided here
    // ...
    //
    // The hook is not a Box<dyn FnMut(...)> because the project currently
    // relies on these instructions being copyable.
    /// Pseudo instruction to hook execution at a certain point in a program.
    ///
    /// This does not correspond to any specific instruction in LLIL. It is
    /// used to hook execution in a program so a user can run arbitrary code
    /// on the current state.
    Hook(
        fn(&mut dyn State<P, Registers = Regs, Endianness = E, Intrin = I>) -> HookStatus,
        usize,
    ),
    /// Breakpoint added by a user.
    ///
    /// This is a breakpoint that was not already present in the original
    /// program. This has extra information added to it so that emulation
    /// can stop at the breakpoint and then later continue through it.
    UserBp(usize),
}

The Emil type, or Emulation Intermediate Language, is a linear representation of LLIL with two extra instructions added: Hook and UserBp.

RegID is a reference to a specific architectural register used by an instruction. This type is parameterized by the specific architecture that is being lifted. This way the emulator knows that if a Emil variant has been parsed from LLIL any register references are valid and infallible.

One of the problems with translating from a tree based structure to linear is handling intermediate values. A tree directly passes values to where they are required. A linear structure cannot do that so the intermediate values need to be put somewhere so that they can be used by the instruction that consumes them. Emil solves this by saving intermediate values into an array of temporary values. Each ILRef is an index into that array where the source value can be retrieved from. In fact, the only supported operations load an intermediate value from that array and then store back to it. Any value that needs to be operated on needs to be loaded into that array first. Then the final value will be loaded from that array and stored back out to a register or memory. This simplifies the implementation of the emulator as all instructions either operate on intermediate values or move values into or out of the array of intermediates. There’s no need for operations to support operating on both architectural values and intermediate values.

Translating the tree into this flat structure is as simple as a post order traversal of the LLIL instruction. Each operation will then operate on the ILRefs produced by its children and store to the next available intermediate index.

Finally, the two extra instructions allow for instrumenting the emulated program. I didn’t like the idea of having to check a set or hashmap on every instruction to know if a hook or breakpoint was hit so I decided that those instructions should be directly inserted into the program instead. To insert a breakpoint at an address, the first Emil instruction at that address is changed to a breakpoint instruction and then the original instruction is placed into a separate buffer so it can still be emulated. When the breakpoint is hit, the emulator will fetch the instruction, see that it is a breakpoint, and then exit the emulation loop and return control to the user. To continue, the emulator will just fetch the original instruction and then go back to the next instruction from the program. The price of hooks and breakpoints is only paid if you actually use them.

Hooking code is supported in the same way using a Hook instruction that gets inserted. This is a part of Emil that is not implemented as well as I would like. Right now the Hook instruction actually contains a function pointer that will be called when the hook is hit. Part of that function pointers type is the implementation of the backing memory used by the emulator which therefore requires the Emil type to be parameterized by the implementation of virtual memory being used which is not ideal. I know a way to address this problem but have not had the time to implement it yet.

Representing Values

One of the hardest design challenges with Emil was figuring out how to represent arbitrary values that are used in computations. There are very simple architectures like 32 bit Risc-V where all values are 32 bits. There’s only one size of value that needs to be supported and that sized value is also well supported by architectures that will be doing the emulation. However, most architectures will have registers of differt sizes that may range anywhere from 1 to 64 bytes in size. To support all of these different architectures, Emil needs a way to represent values that have an arbitrary number of bytes in them.

I wanted the representation to be able to use native instructions for easily handling standard sized values while still being able to represent arbitrary sized values. This led to the following enum

pub enum ILVal {
    Flag(bool),
    Byte(u8),
    Short(u16),
    Word(u32),
    Quad(u64),
    Big(Big),
}

Native sized values will be tracked that way and the host can use instructions that natively operate on values of that size when possible. For all other values, Emil will fall back on a big integer implementation.

Any time memory or a register is loaded into the intermediate values, it will be translated into an ILVal. All operations will be done on the ILVal before being translated back to bytes or some register to be written back to the architectural state.

Virtual Memory

Firmware and other programs can be loaded into memory at an arbitrary address. An emulator needs to support loading memory at an arbitrary address and then handling reads and writes to that address with appropriate memory permissions. To handle all of these requirements, I created the supporting library softmew that is an optimized software memory management unit.

There’s nothing groundbreaking in this library. It handles mapping an emulated virtual address to a host virtual address and will ensure the memory has the required permissions for the access. I did make the choice to choose performance over space constraints. This leads to the MMU potentially using a lot of memory so it can be as fast as possible. I believed this to be the correct choice when I started working on Emil.

System Interactions

Anything that isn’t an LLIL instruction operating on ILVals requires interaction with some external state that is defined by the user. This includes memory, registers, intrinsics, syscalls, and traps. These are all external resources that Emil won’t necessarily know the full behavior of or the user may want to modify the default behavior of. For memory and registers, Emil provides good default implementations that can be plugged in to the emulator. However, intrinsics and syscalls must be implemented by the end user as there isn’t a good default option for handling those.

All of these interactions are specified in the State trait. A user will implement or create some struct that implements this trait and pass that to the emulator. The emulator will then use that state for all external interactions.

For simple freestanding applications, you can plug in the memory management and register structs that Emil defines and leave syscalls unimplemented. Emil should be able to emulate any freestanding function or program with just those resources as long as no intrinsics are encountered.

Ideally, Emil would be able to have one set of intrinsics implemented for each architecture and not require a user to implement them each time. There’s only one set of intrinsics for each architecture that Binary Ninja supports and they will always have the same semantics so it should be possible to just have one implementation that ships with Emil. There’s a big design decision that prevents this from working in practice and some Rust limitations that prevented me from actually doing that. The big design decision that prevents this is that Emil allows a user to use whatever implementation of registers that they want. Some intrinsics will implicitly modify registers that aren’t explicitly used in the instruction. Right now, the implementor of the intrinsic will know the register implementation and can just directly reference the required registers. If a single definition of the intrinsics were used, it would need to be generic over the register file implementation and would not have a good way to actually access those registers. Additionally, it would be hard to have a single definition of intrinsics and still allow users to extend or modify those definitions. An intrinsic like rdrand is one that users would probably want to be able to modify. Since Rust does not support subclassing, a user would have to copy and paste the full intrinsic implementation and then modify that one intrinsic operation which is not ideal.

One extra method you can see in the State trait is the save_ret_addr method. I will come back to that later when I mention some of the issues with emulating LLIL.

Supporting New Architectures

The user experience for adding new architecture support was not something I gave much thought to when I started development. I started development by supporting emulation of 64-bit Risc-V. That is an exceptionally easy architecture to support as there are 32 registers with no sub-registers that access parts of the full width registers. Defining all of the registers and how they are accessed can be done with some Vim macros and a few minutes.

Due to a limitation with the Risc-V architecture plugin lifting atomic accesses to unknown instructions, I switched to emulating Arm64. Defining all of the registers and possible accesses for Arm64 was a horrible experience. There was about 8,000 lines of mostly hand written code to address all of the different registers and ways they could be accessed. That is not the kind of design that is maintainable and extendable. Additionally, if a user needs to write an architecture plugin for Binary Ninja they will have to write a register description in the architecture plugin and then do the exact same thing for Emil.

Once I had wrapped up all of the work I needed to get done for school, I looked into generating all of the required Rust code via a python script that integrates with Binary Ninja. That way a user can write one register definition for the Binary Ninja architecture and then have all of the required Emil code generated from that.

It’s not the prettiest python code that I’ve ever written but it works. The generated Risc-V and Arm64 registers yield the same results as the hand written registers. Now the workflow for adding a new architecture is run a script, add a couple lines to a few files, and then focus on whatever intrinsics or syscalls that you need to support. The barrier to entry is significantly lower and prevents duplication of work.

Example

For testing Emil, I emulated a couple Arm64 programs that were compiled for Linux against Musl libc and statically linked. Arm64 is one of the simpler architectures without being as simple as something like Risc-V. I thought it would be a good initial architecture to test against. It didn’t require the implementation of too many intrinsics which was ideal. I understand the syscalls of Linux quite well so could implement those and Musl is not as heavily optimized as glibc so didn’t rely on as many intrinsics. Finally, Emil only supports statically linked or free standing programs right now.

Below is a snippet of code that emulates the echo program from busybox.

fn main() {
    let headless_session = Session::new().expect("Failed to create new session");
    let bv = headless_session
        .load("../busybox-musl.bndb")
        .expect("Couldn't load test binary");

    let mut prog = Program::<SimplePage, Arm64State, Little, ArmIntrinsic>::default();
    let entry = bv
        .function_at(bv.default_platform().unwrap().as_ref(), bv.entry_point())
        .unwrap();
    let llil_entry = entry.low_level_il().unwrap();
    prog.add_function(llil_entry.as_ref());

    let mut state = LinuxArm64::new(c"/usr/bin/echo", 0x80000000..0x80100000);
    let mem = state.memory_mut();
    load_sections(mem, &bv).expect("Failed to load a section");
    let stack = mem
        .map_memory(STACK_BASE, STACK_SIZE, Perm::READ | Perm::WRITE)
        .unwrap();

    let mut env = Environment::default();
    env.args
        .push("../../repos/busybox/busybox_unstripped".into());
    /* Add env vars and aux vectors... */
    let sp_val = env
        .encode(stack.as_mut(), (STACK_BASE + STACK_SIZE) as u64)
        .unwrap();

    let _heap = mem
        .map_memory(0x80000000, 0x100000, Perm::READ | Perm::WRITE)
        .unwrap();

    state.regs_mut().sp = sp_val;

    let mut emu = Emulator::new(prog, state);

    let mut stop_reason: Exit;
    emu.set_pc(entry.start());
    loop {
        stop_reason = emu.proceed();
        if let Exit::InstructionFault(addr) = stop_reason {
            let func = bv.function_at(bv.default_platform().unwrap().as_ref(), addr);
            match func {
                Some(f) => {
                    emu.get_prog_mut()
                        .add_function(f.low_level_il().unwrap().as_ref());
                }
                None => {
                    println!("Fault hit address that isn't start of a function");
                    break;
                }
            }
        } else {
            break;
        }
    }
    println!("Stop reason: {:?}", stop_reason);
    println!("Stopped at: {:#x}", emu.curr_pc());

    let stdout: Box<dyn Any> = emu.get_state_mut().take_fd(1).unwrap();
    let mut stdout: Box<VecDeque<u8>> = stdout.downcast().unwrap();
    let message = String::from_utf8(stdout.make_contiguous().to_vec()).unwrap();
    println!("stdout: {message}");
}

There are some pros and cons that I see with this example: it’s kind of a lot of code to get a simple program set up but Emil is quite extensible. One thing to note, this is also eliding a decent amount of code that comes from the Arm64State that was required to get all of the linux syscalls working as required for this example.

Emil assumes very little about how the target architecture and platform behave. This means that Emil will do very little for you but also means that it is extremely extensible. You just implement the state struct exactly how you need your platform to work and Emil will happily emulate it. On the other hand, a user must define and implement all of these behaviors just to begin emulation

Another thing you can see is that the Emil library doesn’t directly consume a binary view for the most part. This way it can potentially be used without having to import and start up the Binary Ninja library in the future. I’d like to eventually split the emulator into parts of the library that require the Binary Ninja library and parts that don’t. Then have a feature flag that will conditionally include or exclude the Binary Ninja dependent parts. Then you could have a Binary Ninja plugin that just exports the Emil intermediate language and consume that in a program that doesn’t rely on Binary Ninja at all. On the other hand, this does require the user to do a bit glue work to get everything together. I think this is an easily solvable problem with some more helper functions wherever this becomes an issue.

Another side effect of the above is that functions need to be explicitly added to the Emil program. I originally just had all functions from the database get imported. That was a relatively slow process requiring on the order of 10 seconds to complete (this might be a little off, I don’t remember exactly). That was really annoying to wait for when 90% or more of all those functions would never even get emulated. I therefore changed default behavior to just import functions that the user explicitly asked for. This makes it a lot faster to load up and start emulating programs. This does push the problem to the user as to what functions actually need to be translated into Emil. This is a little annoying but doesn’t require a lot of code to just add a function whenever a missing one was hit.

I discovered during this process that properly setting up the auxiliary vectors is really annoying and that libc implementations aren’t necessarily strictly spec compliant. I implemented a helper struct to manage adding all of the auxiliary vectors and set up the initial stack. Sadly, it seems like there are a number of technically optional vector values that libc will assume are there and crash or have incorrect behavior if they are not there. All of that setup adds a lot of additional code to get everything exactly right. As far as I know, there isn’t a way around that if you want to actually emulate a linux program from entry that relies on libc.

I think emulating a freestanding program that doesn’t use any syscalls would be a lot simpler. That is something that could potentially be integrated into the Binary Ninja UI to emulate single functions really easily. I’m currently trying to work on turning Emil into a GDB backend that Binary Ninja can attach to for this exact use case. Not sure exactly how well that will work but I’m hopeful that it will integrate well.

Difficulties of Emulating LLIL

LLIL Semantics

Most of the LLIL instructions are easy to understand and their exact semantics are what you expect. There are some instructions that I still don’t fully understand. For example, the call instruction has a stack adjust parameter that is not documented anywhere. Right now I just assert that the value is None and I haven’t run into any problems yet. But I assume that there’s some architecture or program that makes use of that value that Emil won’t be able to emulate.

I’ve talked about this a bit in my previous blog post. You can read that for some gotchas I encountered when trying to emulate instructions and the semantics were left undefined or under-defined.

Return Address

A particularly annoying LLIL instruction is the call instruction. That instruction is exactly what you expect it is, but carries annoyingly little information about how an architectural call is actually executed.

On x86, a call instruction will calculate the address of the next instruction, push that address onto the stack, then jump to the target address. In LLIL, only that last step is done in the the call instruction. The call is perfectly handled by just those semantics because the LLIL can’t push the return address to the stack because that would mess up the frame for the current function and doesn’t need to know about the next instructions address because it will just fall through to the next LLIL instruction. This means that Emil is left to figure out those first two steps with the information that it has.

Emil doesn’t have a lot of information about the architecture when it encounters a call instruction. This has led to a pretty complicated implementation of that instruction. I’m not totally confident that the current implementation is even sound in all cases but has worked so far. This might be solvable with some extra work done in the translation from LLIL to Emil’s IL. Ideally, the call instruction would have some metadata that includes the return address. Then Emil could keep track of that address and use it where needed.

The other problem is that the LLIL doesn’t actually put the return address where the architecture expects it to be. As LLIL is purposefully designed for analysis and not emulation, it doesn’t make sense for it to do that. This is why the user defined state needs to implement a method that saves the return address in the correct spot. There’s no one way this is done across each architecture so Emil has to push that work off to something that knows the architecture that’s being emulated.

Intrinsics

Binary Ninja defines a lot of intrinsics for some architectures. For the x86 architecture, there are some 8,000 intrinsics. Ghidra only defines about 1,800 intrinsics for the x86 architecture based off of a quick count of the number of define pcodeops in the sleigh.

That’s a lot of intrinsics to support and emulate to have complete support for the x86 architecture. On the other hand, it’s probably not necessary to emulate every single one of those intrinsics. Only so many of them will be used in any program and I’m going to guess that a lot of them are some pretty obscure operations that you would only see in very application specific highly optimized functions.

The real question is what to prioritize when lifting a program to LLIL: static analysis or emulation. I have some experience writing sleigh and there was a significant trade off to consider when lifting weird and uncommon instructions. Those will generally look pretty gross when lifted to pcode and make the static analysis and decompilation a lot worse. Lifting to just an intrinsic makes the decompilation a lot nicer as the data dependencies are still represented but the decompilation will just show the actual name of the instruction so that it’s obvious what is happening.

Flags

One thing you’ll notice looking at the lifted LLIL is that most instructions will not actually set any flags. Flag writes are aggressively dead store eliminated very early in the lifting pipeline. This is great at removing extra fluff that has no effect on the program but means that the emulated state will be a little off. If you hit a breakpoint at some random instruction, there’s a good chance that the flags will be different from what you would see if you ran the program on actual hardware.

This doesn’t really matter for standard emulation when you’re just testing out inputs. You’re probably not worried about the flags that don’t affect any branches or other instructions. This could be a problem for someone using Binary Ninja for exploit development. In that case, you might be messing with the control flow and get into a state where the dead store eliminated flags could actually affect execution.

I’m not sure at what point all of the flag elimination occurs, but it might be possible to add some kind of API that allows you to access the LLIL before that optimization happens. That way you can keep all of the flags and emulate a little slower when you really need them but also use the optimized LLIL when you’re not worried about that.

Performance

I took some basic performance measurements of Emil emulating cat and echo from a static build of busybox. I don’t have a good comparison since I didn’t want to spend all the effort to get everything set up in Unicorn.

Program Total Time Time After Initialization Emulation Time Number of Instructions
cat 939 ms 261 ms 388 µs 13873
echo 986 ms 309 ms 467 µs 19620

Total time is the time from main to the emulator indicating that busybox has exited. Time After Initialization starts time after Binary Ninja has finished initializing and stops when busybox has exited. Emulation Time is the time from emulating the first instruction to the last instruction.

Something to note is that Binary Ninja has a significant startup time when emulating small programs. Just that initialization accounts for about two thirds of all the emulation time. While significant in this context, 500-600ms is not that long if you’re emulating a larger or longer running program. It was a little annoying when I was trying to debug and was doing a lot of small changes and then rebuilding and rerunning.

After Binary Ninja has initialized and before emulation starts, the benchmark went through and translated all of the required functions into Emil’s intermediate language. This is definitely a slow process. Before I knew that it was slow, I was translating every function into Emil and it would take on the order of 10-20 seconds before it finally got to emulation. I didn’t do any kind of profiling to determine where all of that time was being spent. I’m guessing accessing LLIL instructions is a little slow and it adds up pretty quick when going through so many.

I’ve since changed the emulation flow to translate functions as needed instead of all at once. That seems like the most realistic use case. This spreads out the time of translating functions over the entire emulation time. I didn’t do that for the performance testing as I wanted to get a good idea of how long each step took on its own. Given the numbers above, it seems like emulation time is purely dominated by translation at least for very linear code without long loops.

Emulation time is strictly the amount of time spent actually emulating Emil instructions. You can see that that is the fastest part of emulation by far. It comes out to about 25ns per Emil instruction between the two programs. That time includes the syscall hooks that were required to get the program to run to completion. I don’t have a good reference for if this would be considered fast or not. I think the time per instruction is at least in the right magnitude being measured in the tens of nanoseconds.

Database I used for testing can be found here.

Conclusion

While I was working on Emil, I talked to a couple Vector35 employees that both said LLIL was not apt for emulation. This is not what I wanted to hear going into the project but it was too late for me to turn back and work on something else.

LLIL ended up being not that hard to emulate. It was certainly a lot of effort and required a lot of difficult design decisions that I’m still trying to work through. Most of the the difficult decisions were around creating an emulator that was nice to use and versatile as opposed to emulating LLIL.

Since working on Emil, I have also gained a lot of experience with Ghidra’s pcode by creating a processor module for a DSP architecture. Pcode is definitely more applicable to emulation as it’s already in a linear form. However, once you’ve linearized LLIL they’re very similar intermediate languages.

In the end, LLIL definitely can be emulated. A good architecture plugin will lift native code with enough detail and precision to LLIL that an emulator can consume those instructions and recreate the original process.