Next Generation
Endpoint Security

Introducing Marco, a Tool for Inter-Binary Control Flow Mapping

2026-02-25 - Matt Hand

When I am reverse engineering an application or operating system component, I am generally trying to answer two questions:

  1. What does this function do?
  2. How does control flow transition in and out of this function?

Answering the first is the essence of reverse engineering, but answering the second is how we gain an understanding of the system as a whole, whether it be a single application or the entire operating system. While virtually all the dominant disassemblers (IDA, Binary Ninja, Ghidra) provide call trees, they tend only to model control flow within the specific binary under analysis based on call instructions. This proves problematic even when analyzing things as simple as calls to imported functions, let alone modeling complex control flow across the operating system. This led me to develop Marco, a control flow mapping tool specifically designed for modeling how execution flows through multiple binaries.

Graphing Execution

Program execution flow is naturally represented as a graph. In a disassembler, we work with function-level graphs based on basic blocks connected via branching logic. Instead, Marco works with functions as nodes in the graph. Each node contains metadata, including the function name, the module to which it belongs, its symbol (a combination of both previous values), its address within the module, and more.

What is more interesting in this use case is the edges that connect nodes to each other. The primary function of Marco is edge discovery, and this initial public release contains a handful of interesting types.

Windows as a graph

CALL

The most basic edge is based on call instructions. Similar to the call trees in a disassembler, these are incredibly useful for following execution through the binary being analyzed. The problem, however, is in the case of calls to an external function as is the case when an application calls a function exported by one of the DLLs it imports. In the disassembler, we simply see a call to an external function that hints to us that we need to analyze another binary.

Call to the external function `ntdll!RtlValidSid`

When we see this in Marco, we simply add the binary that exports the function to the processing queue and create a placeholder node so that we can draw the edge between the binaries. To make clear when a call transitions between files, each module (i.e., DLL, EXE, or SYS) is color-coded.

Calls between `kernel32!WerpLauchAeDebug`, `kernelbase!VirtualFreeEx`, and `ntdll!NtFreeVirtualMemory`

SYSCALL

A system call (syscall) represents the transition from user mode to kernel mode. On Windows, this is accomplished by moving a system call number into the EAX register and issuing a syscall instruction. The system call numbers correlate to an entry in the System Service Descriptor Table (SSDT), which routes control flow to the appropriate function in the kernel.

A syscall stub in ntdll.dll showing the syscall number 0x105 for NtImpersonateAnonymousToken

To draw a SYSCALL edge between a stub in ntdll.dll and ntoskrnl.exe, we simply need to recreate the translation logic handled by the SSDT. The “right” way to do this is to parse the nt!KiServiceTable manually and do the math to resolve the symbols.

Unresolved `nt!KiServiceTable` entries

The easiest way to do this is to exploit the fact that the function names are shared between user mode and kernel mode (e.g., the syscall stub ntdll!NtAllocateVirtualMemory maps to nt!NtAllocateVirtualMemory). This allows us to draw an edge between a stub function in ntdll.dll and its similarly named counterpart in ntoskrnl.exe, the details of which can then be populated during later analysis steps.

Execution flowing from user mode to kernel mode along the SYSCALL edge

SECURE_CALL

Like traditional system calls, the secure call interface allows the NT kernel (i.e., ntoskrnl.exe) to interact with the secure kernel (i.e., securekernel.exe). This is a deceptively simple system that, unlike traditional system calls, requires a hypercall because the secure kernel effectively runs as a virtual machine, necessitating communication with the hypervisor. Thankfully, Connor McGarr and others have written extensively about this topic, so we can keep the details about its internal workings short in this post.

Constructing this edge involves locating calls to nt!VslpEnterIumSecureMode, which is the function responsible for issuing the vmcall that transitions control to the secure kernel. A “secure system call number” (SSCN) is stored in the RDX register before this call (along with the arguments in R9, but that’s outside of the scope of this post). This SSCN can be referenced in the nt!_SKSERVICE enum to check its validity. Note that, as of build 10.0.26100.4946, an SSCN > 0x113 will result in a bugcheck from the secure kernel (0xC000001C), so there is a true upper bound.

`nt!_SKSERVICE` enum

Rather than dispatching to a dedicated function to handle, securekernel!IumInvokeSecureService internally uses a lookup table and a jump table to calculate the location of an inline function to which the caller should be routed. The logic can be described as:

result_address(input_index) = image_base + jump_table[lookup_table[input_index]]

The inline handler for SSCN 0x5

Since there is no intermediate handler function to map to, we can calculate the address of the inline block and return it, along with the SSCN and enum variant from nt!_SKSERVICE, as properties of the SECURE_CALL edge. This allows us to model control flow from user mode, through the NT kernel, and into the secure kernel.

Control flows from user mode (yellow, purple), into the NT kernel (orange), and then to the secure kernel (blue)

RPC_CLIENT_CALL

One of the most exciting possibilities with Marco is modeling interprocess communication (IPC). This is a notoriously annoying problem in reverse engineering because you must:

  1. Identify clients
  2. Extract relevant information about the target server and procedure from the client
  3. Locate and validate the server binary
  4. Validate the server binary and extract relevant information
  5. Map the two together (oftentimes, only mentally)

This can become cumbersome in the case of remote procedure calls (RPC) where client and server stub information is mostly undocumented and tooling is generally built around identifying the requisite information at runtime rather than statically.

Marco tackles each of these steps individually using the decompiler. If the binary being analyzed is a client (i.e., it calls rpcrt4!NdrClientCall or one of its variants), it will parse the client stub descriptor and MIDL-generated format string to extract the interface ID and procedure number (OpNum) for the call, storing it in an internal data structure. If the binary is a server (i.e., it calls rpcrt4!RpcServerRegisterIf or one of its variants), Marco parses the RPC_SERVER_INTERFACE to extract the interface ID and then walks the dispatch table in the MIDL_SERVER_INFO to identify procedures and OpNums. When analysis is completed, client and server details are reparsed to create the RPC_CLIENT_CALL edge. The logic for this check is:

for (c, s) in ((c, s) for c in clients for s in servers \
               if c.iid == s.iid and c.opnum in s.opnums):
    draw_rpc_client_call_edge(c, s)

The result is a new ability to interact with control flow as it crosses between binaries via RPC.

RPC control flow

Get Started

Marco is available today as an open source project on our GitHub at https://github.com/originsec/marco

To get up and running, you’ll need the following:

  • A valid Binary Ninja license that permits API access*
  • Python 3.10+
  • The uv package manager
  • Neo4j (the free Aura tier is fine for small runs, but local installs work best)
  • The binaries that you plan to analyze

To install Marco, simply run the following command:

uv tool install git+https://github.com/originsec/marco

And to run it:

marco --help

Detailed usage instructions can be found in the main README.

Note: I recognize that the Binja price tag might preclude some people from using Marco. Hex-Rays’ recent release of the IDA Domain API makes adding support for IDA Pro (notably, not the free version) more palatable. Ghidra scripting remains an arcane art due to its integrated interpreter, but John McIntosh (@clearbluejar) is doing some incredible work in this area. If you’re interested in adding support for additional decompilers, please contribute!