Orbit C++ → Rust · Post 21

The First Few Bytes

One function returned the right answer but produced three scopes for one call. Another accepted its hook and crashed. What these small failures tell us about Orbit's native Frida integration—and how it compares with hijk.

A tiny loop was enough to fool the profiler. C called it once. It returned zero, just as it did before instrumentation. Gum reported three entries and three exits. The program's answer was right; the profiler's interpretation of a call was wrong.

The loop jumped back to its own entry. Once that entry contained an instrumentation jump, each iteration visited the hook again. A second loop jumped two bytes past the entry instead. Gum accepted that hook too, and the process crashed. It had jumped into the middle of the new jump instruction.

These are small, runnable examples, not reasons to abandon dynamic instrumentation. They establish its contract. Selecting a function changes executable code. Preserving what that code does—and deciding what its visits mean—is the hard part. Orbit now delegates the machine-code work to Gum and sends its entry/exit events through the same API as manual scopes.

Frida without the scripting runtime

The runtime package contains the service, orbit-frida-helper, and liborbit_frida_agent.so or .dylib. The helper is Rust linked to Frida Core's native injector. The target agent is Rust linked to Gum, with a small C adapter for Gum's listener and module APIs. There is no Python environment, JavaScript agent, CModule compiler, or per-event message relay in Orbit's instrumentation path.

The native helper injects and controls; target callbacks write through the shared Orbit API COLLECTORTARGET PROCESS Rust helper + Frida Coreinject the library; relay control messages Rust agent + Gum Interceptorinstall hooks; resolve loaded function addresses Entry / exit → shared Orbit APIsame producer as manual scopes Rust service → timelinedrain the shared ring; reconstruct nesting
Control messages cross a local socket. Scope events do not. Both Frida libraries are statically linked into their respective native components.

Core and Gum solve different problems. Core gets our library into an already-running process. Gum changes functions inside that process. Linking Gum into the service alone would not instrument another process. Keeping Core in a helper also lets the service retain its static musl build while injection uses a native host binary. The helper is not another interpreter; it is a small native controller. See Frida's native API overview and Orbit's controller.

The first integration used Python and JavaScript for setup, with native callbacks for each call. That worked, but shipped two languages we did not need. The native replacement removes that packaging dependency. It does not rewrite Gum's relocators in Rust, and it does not turn entry/exit instrumentation into Stalker-style basic-block tracing.

What the entry patch actually replaces

Let the function start at F. Gum decodes a prefix, moves its instructions into an executable trampoline, and writes a redirect over the original bytes. The redirect runs the entry listener. The trampoline then performs the displaced work and jumps to the first untouched instruction, F + N.

On x86-64, the common nearby redirect is E9 rel32: five bytes. Instructions have variable lengths, so the relocated prefix can be longer. A seven-byte first instruction must move as a whole; the original entry becomes a five-byte jump and two padding bytes. Leaving the last two bytes behind as executable code would be nonsense.

A five-byte redirect displaces a complete seven-byte instruction, which runs in a trampoline before the untouched body ORIGINAL FUNCTIONGENERATED CODE F: jump to entry trampoline5-byte jump + 2 padding bytes Preserve state; run entry listenerarrange the return detour Relocated original instructionsame operation, corrected address operands F + 7: untouched function bodycontinues toward its normal return jump back
A seven-byte first instruction is an illustrative case. The backend decides the required redirect size and the decoder decides the whole-instruction boundary.

Nearby allocation matters because that relative jump reaches roughly two GiB in either direction. Gum's x86 backend reserves a sixteen-byte redirect budget when it cannot use its near strategy. The selected strategy and complete instruction boundaries determine how much code moves. A valid function address does not imply enough relocatable code exists there.

Copying those instructions is not enough. Consider the seven-byte load mov rax, [rip + 0x1ff9] at 0x1000. RIP-relative addressing uses the address after the instruction:

Original:    0x1007 + 0x1ff9 = 0x3000   intended data
Naive copy:  0x2007 + 0x1ff9 = 0x4000   wrong data
Relocated:   0x2007 + 0x0ff9 = 0x3000   same data

The trampoline in this illustrative example is at 0x2000. The corrected displacement preserves the referenced address. If the new displacement cannot fit, the relocator needs an instruction-specific expansion, sometimes involving scratch registers. Relative calls and branches need their own rewrites. Expanding one branch can change the positions of the instructions after it. The x86 relocator implements those cases; some internal conditional branches become labels in the relocated block. It does not follow that every branch form is remapped identically.

AArch64 simplifies instruction boundaries, not address semantics. Instructions are four bytes, but ADR, ADRP, literal loads, and relative branches still depend on where they execute. A relocated ADRP/ADD address calculation must still reach the original data. Gum has separate ARM64 rewrites. Its interceptor can use a sixteen-byte redirect, an eight-byte alternative, or a four-byte branch to nearby code, depending on the safe prefix, placement, and scratch-register availability. The four-byte branch reaches roughly ±128 MiB.

Two loops, two different failures

A relocator sees the instructions it moves. It does not automatically see every instruction elsewhere that might jump into them. Our failing x86 loop has three instructions in its six-byte prefix and an untouched conditional branch immediately afterward:

An untouched loop branch jumps into the displacement bytes of the new entry jump BEFOREAFTER ATTACHMENT F + 0 xor eax, eaxF + 2 inc eaxF + 4 cmp eax, edi F + 0 E9 displacement…F + 2 [inside jump bytes]F + 5 padding F + 6 jl F + 2 F + 6 jl F + 2 The branch survives. Its destination no longer contains the loop instruction.
The actual interior-backedge probe below moves six bytes for a five-byte redirect. The original branch at F + 6 remains unchanged and enters the redirect at F + 2.

The loop originally increments eax until it reaches the argument. After attachment, the branch at F + 6 still jumps to F + 2. That address now holds part of the redirect's displacement, not inc eax. Moving the old inc to a trampoline does not repair an incoming edge whose source was never moved.

The native reproducer compiles three assembly functions and runs each in a disposable child with Gum C listeners. Here is the recorded Linux x86-64 result for 17.17.0:

FunctionBefore hookingAfter hooking
RIP-relative load4242; one entry, one exit
Loop to F + 23Hook accepted; SIGSEGV
Loop to F00; three entries, three exits

The exact-entry case is subtler. A jump to F reaches the redirect again, but unlike a recursive call it does not push a new return address. This particular Gum build reports three callback pairs for the one C call. Counting visits to patched code is not always counting source-level invocations.

Gum does try to reject unsafe prefixes. Under its default checked policy, the ARM64 check inspects direct branches and can shrink the relocatable prefix when a destination lands inside it. A forward reachability scan starts with a 1,024-byte decode budget. It is not proof about every indirect jump or secondary entry. The x86 checks differ and accepted this example.

These results concern these bytes, this backend, and this version. They do not establish that every loop fails, or that ARM64 rejects every dangerous loop. JIT rewriting, hand-written secondary entries, overlapping code, and jump tables introduce the same larger question: does all control flow still reach meaningful instructions after the patch?

Catching exits without finding every return

Gum does not normally patch every ret. At entry it saves the caller's real continuation in invocation bookkeeping and substitutes its leave trampoline. A normal function return reaches that trampoline; the exit listener runs; execution resumes at the saved caller address. Early returns can all share that mechanism.

Gum saves the real caller continuation and substitutes a leave trampoline, on the x86 stack or in the ARM64 link register x86-64AArch64 CALL puts caller continuation on stackGum substitutes leave trampoline BL puts caller continuation in LR / x30Gum substitutes leave trampoline Normal return → leave listener → orbit_stopContinue at the saved caller address
On AArch64, the function's prologue may later spill the substituted LR onto its stack. “It changes the return address” applies to both architectures; “it writes the stack slot at entry” describes the x86 case.

On x86-64 the continuation starts in a stack slot. On AArch64 it starts in LR, register x30; the function may later spill the substituted value onto its stack. The substitution is explicit in Gum. Preserving the ABI also requires handling alignment, flags, argument and return registers, and control-flow protections. Gum's x86 backend has CET shadow-stack support; ARM64 has special cases for some LR-reading instructions. Those are concrete engineering provisions, not a guarantee for every mitigation or return-address-sensitive routine.

A tail jump may delay the logical exit until the tail callee returns. Exceptions and longjmp may bypass a normal return entirely. Orbit does not infer unwinding from a missing callback. Open spans are clipped at capture end, and missing stops can affect subsequent nesting. Likewise, installing a patch is a concurrency operation: executable permissions, instruction-cache coherence, other threads, and calls already past the entry all matter. Orbit delegates publication and retired-trampoline lifetime to Gum; our own guards protect capture state.

The part Orbit must own

Each native entry listener calls orbit_start_dynamic and stores the returned handle in Gum's per-invocation data. The exit listener calls orbit_stop with that handle. These functions belong to the same API instance the application's manual scopes use. They share thread identity, TLS handle allocation, and the event ring. The service derives one synchronous nesting hierarchy from the combined stream; async scopes stay outside it. There is no producer-side depth counter to reconcile.

Calling a second statically linked copy of the SDK would not accomplish that. Instead, orbit_init publishes a versioned function-pointer descriptor in reserved shared-header bytes. The injected agent discovers it even when the SDK's symbols are stripped. A producer-held lease prevents reuse of stale descriptor addresses after exit or exec. If the program has no SDK, the agent initializes its bundled API. If it has its own SDK, initialize the updated SDK before attachment; initializing a second instance afterward is unsupported.

Dynamic starts use a bit in the existing event flags byte. The live stream and capture file preserve that provenance; an older capture without flags reads as manual. Batches carrying metadata use the existing raw format because the original packed encoding omitted that byte. The event size stays unchanged, with a bandwidth tradeoff for those batches. Linux's retained uprobes are tagged too, but still reconstruct nesting independently of manual scopes.

Teardown has two lifetimes. Capture state may disappear while a function is still running. Gum's listener references keep the native callback data alive, and the injected library remains resident. An old invocation's generation prevents its eventual exit from writing into a new capture. We test stopping with a function blocked inside the hook, starting another capture, then releasing the old return.

Controller death is a different path: socket EOF must detach the hooks without killing the application. That test caught a native-injection detail easily missed by a Rust executable test. C programs can retain the default SIGPIPE disposition; writing a final reply to a dead controller could terminate the target. The agent now suppresses SIGPIPE on its own socket writes, without changing the application's global signal policy.

The same idea in hijk

hijk is a useful comparison because its public API is close to what a profiler wants: create a hook with entry and exit callbacks, then enable, disable, or queue changes. It is a focused Windows x64 library. I reviewed revision e924387; this is a source comparison, not a Windows benchmark.

hijk delegates the entry patch and displaced-instruction trampoline to its vendored MinHook. It replaces MinHook's relay with assembly prologue/epilogue stubs that preserve register state and invoke user callbacks. The prologue saves the original return address in a thread-local vector and substitutes the epilogue stub; the epilogue restores the saved continuation. That is the same broad return-detour idea as Gum, in a smaller implementation. See the callback and relay code and the assembly stubs.

hijk, reviewed revisionOrbit with native Frida
Machine-code engineVendored MinHook + hijk x64 stubsGum Interceptor and its architecture backends
Return bookkeepingThread-local vector of saved continuationsGum invocation state; Orbit handle and capture generation
Platform scopeWindows x64Orbit targets Linux/macOS; Gum also supplies Windows backends
Remote injectionNot supplied by hijk's hook APINative Frida Core injector
Profiler event transportApplication-supplied callbacksShared manual/dynamic Orbit API and ring

The vendored MinHook relocator rewrites RIP-relative operands and relative control transfers. It tracks certain internal branches and rejects instruction-length changes that would invalidate them. Its patching code suspends enumerated threads and adjusts instruction pointers using the old/new instruction mapping. Those are substantive mechanisms, not just an unchecked byte copy. They still do not amount to discovering every incoming edge from arbitrary code.

For a Windows x64 application that already controls loading its instrumentation DLL, hijk has an appealingly narrow interface. For Orbit's Linux and two Mac architectures, adopting it would require new platform machinery and ARM64 stubs/relocation, plus our capture-lifetime integration. Neither its TLS return stack nor Gum's invocation stack automatically solves exceptions, custom continuations, or external unwinding. Gum is the better fit for our present coverage requirement; that is not a measured claim that it is faster.

What changes across Linux, macOS, and Windows

The instruction set chooses the relocator; the OS determines how we obtain access and find loaded code. An Intel Mac shares much of the x86-64 instruction problem with Linux or Windows, while differing in ABI and process policy. Apple Silicon needs the ARM64 backend.

PlatformOrbit's pathBoundary
LinuxNative Core injection, Gum hooks, ELF/debug-file indexptrace/Yama, dumpability, namespaces and target file access
macOSNative injection, Gum hooks, loaded Mach-O symbols and segment offsetsTask ports, SIP, signing, library validation, executable-memory policy
WindowsGum provides an engine; this change does not port Orbit's serviceStill needs Orbit process, symbol, transport and packaging work

Linux's ptrace restrictions still apply. The old uprobe backend remains an explicit option. In the classic x86 uprobe path, a breakpoint transfers control into the kernel, which handles the event and the displaced instruction, often out of line. A uretprobe also redirects the return continuation. This avoids Gum's multi-byte entry redirect but not instruction handling or return-address effects. eBPF changes what can run on the kernel event path; it does not remove the probe. See the Linux 6.12 x86 implementation.

There is a native-loading detail behind the Mac support. Core's Darwin injector uses a custom mapper. Our agent also needs ordinary dyld registration for Rust's native thread-local storage across application threads. The helper embeds a tiny C bootstrap that calls dlopen on the agent, then enters its Rust control function. This keeps the runtime native and lets the system loader own the agent's dependencies and TLS. Testing only against a target with its own SDK would miss this: that target's scope TLS already belongs to a normally loaded image. The no-SDK fixture exercises the injected copy.

On macOS, function discovery translates loaded Mach-O addresses back to executable segment file offsets, accounting for the slide and selected architecture slice. Public symbols take precedence over local assembler aliases at one address. Some shared-cache images still lack usable symbols. Native hosted tests use development targets and run as root to avoid authorization dialogs; that does not validate arbitrary unprivileged attachment. SIP restricts protected processes even for root, including DTrace access. DTrace is not an unrestricted alternative injector.

Gum supports Windows, but our Unix control socket and shared-ring integration are not a Windows port. Windows x64 also has a different calling convention, including shadow space, and its own control-flow policies. Library support is useful groundwork, not evidence that the complete application works there.

What the rectangles cannot prove

Linux sampling can run alongside these scopes. A sample might land in the original body or a generated trampoline. The external unwinder may lack rules for the generated code; even a sample well inside the original body can encounter a substituted caller continuation. Frame pointers and DWARF do not universally repair that. Gum has an in-process return-address translation helper, but an out-of-process perf unwinder does not automatically consult it. Orbit has no trampoline-aware stack repair yet.

A scope duration is wall time between our instrumentation events, including preemption and blocking, with instrumentation overhead mixed in. Native callbacks still dispatch, take synchronization, read clocks, and write the ring. Tiny functions can be dominated by the measurement. Inlining, folded functions, and tail calls further weaken the one-symbol/one-source-call assumption. The loop at the beginning makes that mismatch visible without a complicated application.

The native smoke fixture checks 270 dynamic spans per capture across three threads, with seven alternating manual/dynamic nesting levels, async isolation, provenance, containment, fork isolation, and restart. Other cases cover targets without an SDK, stripped API symbols, missing runtime components, outstanding returns, and controller death. Native platform jobs live in the CI workflow. ARM Linux and Rosetta runtime validation, exception handling, sampled-stack fidelity, and overhead benchmarking remain separate work.

To use it, run ./tools/frida/build.sh on Linux or ./build-service-macos.sh on Mac; the latter supports --universal. The runtime notes describe native component paths and development builds. To reproduce the three instruction experiments on Linux x86-64, run ./docs/blog/experiments/frida-prefix.sh. It uses the same pinned Gum devkit and needs no Python or JavaScript.

The reason to reuse Gum is the amount of instruction and platform work already represented in its backends. The reason to keep testing outside Gum is that Orbit promises something more specific: useful scopes, at the right nesting level, without confusing a visit to patched bytes with work the application never did.