Designing a Minimal ORC JIT Plugin System for High‑Performance Servers
A concise guide to building a minimal ORC JIT plugin system for servers, covering design, security, verification, and when to redesign.
24 Jul 2024, 01:00 UTC

Why the ORC JIT Matters in a Server
When a server must load user‑supplied code at runtime—such as a scripting engine, data‑processing pipeline, or machine‑learning model—LLVM’s ORC JIT offers a fast, native‑code path. The key challenge is to keep the design small enough to be maintainable while protecting the host from malformed or malicious modules.
Requirements
- Stable LLVM target machine for the server’s architecture (e.g.,
X86_64on Linux). - Memory‑managed code cache that can mark pages executable only after code generation.
- Export policy: specify which host symbols the plugin can reference (e.g., a limited runtime API).
- Verification hooks: LLVM’s
verifyModule()must run before linking. - Optional sandbox: a minimal runtime API or a separate process if the server runs privileged.
The Smallest Suitable Design
The core of the system is a single orc::JIT instance that owns:
orc::TargetMachine– the LLVM component that knows how to emit code for the host.orc::SimpleCompiler– the default compiler that feeds LLVM IR into the target machine.orc::MemoryManager– a custom implementation that allocates pages, sets execute permissions, and tracks module boundaries.
All modules are loaded through the same JIT, and the memory manager keeps each module’s code isolated. This minimal stack avoids the overhead of a full MCJIT and gives straightforward control over permissions.
Trust & Data Boundaries
- Separate address space per module: The memory manager allocates a distinct region for each plugin and never allows the plugin to write to the host’s data or code pages.
- Symbol validation: Before linking, the JIT checks that every external symbol the plugin references is present in the host’s symbol table and that the host does not expose internal or privileged functions.
- Execution permissions: Code pages are marked
PROT_READ | PROT_EXEConly after the module has been verified; data pages remainPROT_READ | PROT_WRITE.
Operational Checks
- Run
llvm::verifyModuleon the IR. If it fails, reject the plugin. - Use
llvm-objdump -don the generated object to confirm that no illegal relocations exist. - Instrument the memory manager to log allocation boundaries; in debug builds, assert that no write occurs outside the allocated range.
- Optionally, execute the plugin in a sandboxed thread with limited signal handlers to catch illegal instructions.
Failure Modes & Redesign Triggers
- Malformed bytecode: Causes
verifyModuleto fail or crashes during code emission. - Memory corruption: If the plugin writes beyond its allocated pages, the memory manager will detect it only if writes are traced; otherwise, it can corrupt the host.
- Privilege escalation: Untrusted plugins that can resolve host symbols (e.g.,
malloc) may overwrite critical data. - Multi‑architecture support: If the server must run plugins compiled for ARM and x86, the single JIT design must be split or extended to handle multiple target machines.
- Startup latency: A large number of small plugins may benefit from ahead‑of‑time compilation; in this case, replace the on‑demand compiler with a pre‑compiled cache.
- Security policy tightening: If the host runs with elevated privileges, consider moving the JIT into a separate process or container.
Concrete Example
Below is a minimal C++ snippet that demonstrates loading a plugin that implements int add(int, int) and calling it via ORC JIT. The example assumes LLVM 18 and a Linux target.
#include <llvm/ExecutionEngine/Orc/LLJIT.h>
#include <llvm/IR/LLVMContext.h>
#include <llvm/IR/Module.h>
#include <llvm/IR/IRBuilder.h>
#include <llvm/Support/TargetSelect.h>
using namespace llvm;
using namespace llvm::orc;
int main() {
// 1. Initialize LLVM.
InitializeNativeTarget();
InitializeNativeTargetAsmPrinter();
InitializeNativeTargetAsmParser();
// 2. Create a JIT instance.
auto JIT = LLJITBuilder().create();
if (!JIT) return 1; // handle error
// 3. Build a simple module.
LLVMContext Context;
auto M = std::make_unique<Module>("plugin", Context);
FunctionType *FT = FunctionType::get(Type::getInt32Ty(Context),
{Type::getInt32Ty(Context),
Type::getInt32Ty(Context)}, false);
Function *Add = Function::Create(FT, Function::ExternalLinkage, "add", M.get());
auto &B = *llvm::IRBuilder<>::Create(llvm::IRBuilder<>::getInsertBlock(Add));
auto Args = Add->arg_begin();
B.CreateRet(B.CreateAdd(*Args, *(Args+1)));
// 4. Verify the module.
if (verifyModule(*M, &errs())) return 1;
// 5. Add the module to the JIT.
if (auto Err = JIT->addIRModule(ThreadSafeModule(std::move(M), std::make_shared<LLVMContext>())))
return 1;
// 6. Look up the function pointer.
auto Sym = JIT->lookup("add");
if (!Sym) return 1;
using AddFunc = int(*)(int, int);
AddFunc F = (AddFunc)Sym->getAddress();
// 7. Call the JITed function.
int result = F(3, 4); // should be 7
printf("add(3,4) = %d\n", result);
return 0;
}
After compiling this program, run llvm-objdump -d <executable> to verify that the add function resides in an executable section and that no relocations are present.
Practical Validation Checklist
- Build the JIT module and run
llvm-objdump -d– confirmaddis in an.textsection. - Execute the server with a known benign plugin; the function should return correct results.
- Inject a malformed IR (e.g., missing return) and confirm
verifyModulerejects it. - Run a stress test that loads and unloads 1,000 modules; monitor for memory leaks via
valgrindorAddressSanitizer. - If the server runs as root, consider running the JIT in a separate process and using
ptraceto enforce privilege limits.
When to Redesign
Keep the following red flags in mind:
- Need to support multiple CPU families – the JIT must be extended to create separate
TargetMachineinstances. - Real‑time startup constraints – pre‑compile hot plugins or use a lightweight ahead‑of‑time strategy.
- Security policy changes – move the JIT to a sandboxed container or use a hypervisor‑level isolation.
- Memory‑pressure environments – replace the in‑process memory manager with a shared pool or external cache.
Limitations
- LLVM’s
SimpleCompileris deprecated in newer releases; migrate toorc::JITDylibandorc::CompileOnDemandLayeras needed. - The design assumes the host can expose a minimal API; if the plugin requires complex runtime services, the interface may need to be expanded.
- Performance depends heavily on the target machine’s code cache; tune
MemoryManagerfor your workload.
Conclusion
By keeping the ORC JIT architecture minimal—one JIT instance, a dedicated memory manager, and strict symbol validation—you can deliver dynamic plugin support while maintaining control over security and stability. Monitor the outlined failure modes, and be ready to split the JIT or sandbox it when the server’s requirements evolve.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.