pool-proxy-ng
Next-gen rewrite of PoolProxy in Zig. Same core idea — proxy Win32 API calls through the thread pool so your implant never shows up on the call stack — but with multi-gadget support, automatic gadget discovery, and side-effect DLL loading. See the PoolProxy README for a detailed walkthrough of the technique, the sentinel trick, and how mov [rbx], rax gadgets work.
What's different from PoolProxy
PoolProxy proved the concept with a single hardcoded gadget (ntdll!RtlPcToFileHeader, rbx, 0x40 cleanup, 8 args max). This version:
- Supports 9+ gadgets across multiple DLLs and registers (rbx, rdi, rsi, r14)
- Handles gadgets with arbitrary pop chains before/after the target register via a packed nibble map (
popMap) - Ships a pre-computed gadget database with comptime verification — init takes microseconds
- Falls back to a PE section scanner when RVAs shift between OS builds
- Side-effect loads DLLs (COM activation, crypto APIs, device enumeration, Winsock) instead of calling
LoadLibrary - Covers up to 15 arguments (every documented user-mode Windows API)
How it works
ntdll!TppWorkpExecuteCallback ← TP dispatcher
→ trampoline (.text) ← saves regs, builds pop slots, loads args
→ target API ← your proxied call
→ gadget (signed DLL) ← mov [reg],rax; add rsp,N; pops; ret
→ ntdll!TppWorkpExecuteCallback ← clean return to TP
The trampoline saves callee-saved registers into a scratch area on the proxy struct, builds the gadget's expected pop layout on the stack from the popMap, loads arguments, then does push gadget_addr; jmp target_func. The target's ret lands in the gadget, which stores rax, cleans up the stack, and returns straight to the TP dispatcher. No implant addresses touch the stack at any point.
Gadgets
20 entries in the DB across 10 DLLs, 4 trampoline registers. Here are a few highlights:
| DLL | Reg | Cleanup | Max args | Pops |
|---|---|---|---|---|
| Chakra.dll | rbx | 0x78 | 15 | 4/1 |
| Chakra.dll | rdi | 0x70 | 14 | 4/2 |
| ntdll.dll | rbx | 0x40 | 8 | 0/0 |
| rpcrt4.dll | rbx | 0x30 | 6 | 0/0 |
| kernelbase.dll | rbx | 0x20 | 4 | 0/0 |
The ntdll gadget is the same RtlPcToFileHeader epilogue from the original PoolProxy — always loaded, zero extra pops, simple. The Chakra gadgets extend coverage to 15 args with more complex pop chains.
All gadgets are execution-verified at their full arg capacity via XOR checksums.
Gadget sourcing
- Verify pre-computed RVAs from the built-in DB (fast path).
- Micro-scan any DLL whose pinned RVA doesn't match the running build.
- Side-effect load new DLLs when the stable can't satisfy a call (see below).
Side-effect loading
Instead of calling LoadLibrary directly, we invoke legitimate APIs whose dependency chains pull in the DLLs we need. LdrLoadDll still gets called internally (kernel image-load callbacks and ETW events still fire — the DLL load is not invisible), but the call stack at load time shows system code (combase, advapi32, etc.) rather than implant addresses. This keeps the "no implant on the stack" invariant consistent across init, not just proxied calls. It also keeps loader imports and DLL name strings out of the binary.
| Method | API called | Target DLL | Prerequisite |
|---|---|---|---|
com_chakra |
CoCreateInstance | Chakra.dll | combase.dll |
com_mshtml |
CoCreateInstance | mshtml.dll | combase.dll |
com_cdp |
CoCreateInstance | cdp.dll | combase.dll |
com_jscript9 |
CoCreateInstance | jscript9.dll | combase.dll |
com_oleaut32 |
CoCreateInstance | oleaut32.dll | combase.dll |
com_shell32 |
CoCreateInstance | shell32.dll | combase.dll |
crypt_context |
CryptAcquireContextW | cryptsp/crypt32 | advapi32.dll |
cm_device_list |
CM_Get_Device_ID_List_SizeW | setupapi.dll | cfgmgr32.dll |
winsock_startup |
WSAStartup | mswsock.dll | ws2_32.dll |
Each DLL is only attempted once per stable lifetime. After the side-effect call, we verify the target appeared in the PEB before trusting it.
CET / shadow stack
This technique does not survive CET (Control-flow Enforcement Technology). The trampoline does push gadget_addr; jmp target_func — when the target's ret fires, the hardware shadow stack comparison fails because gadget_addr was never pushed by a call. #CP fault, process dies. IBT (indirect branch tracking) is fine — API entry points have ENDBR64, and the gadget is reached via ret not an indirect branch.
CET user-mode enforcement requires the process PE to set IMAGE_DLLCHARACTERISTICS_EX_CET_COMPAT, a CET-capable CPU (Intel 11th gen+ / AMD Zen 3+), and HVCI enabled. Most processes don't have all three yet, but hardened targets are heading there. For CET-enabled targets, NtContinue-based dispatch (which legitimately manipulates the shadow stack) is the path forward.
Thread safety
proxyCall() works from any thread. init() uses a mutex with double-checked locking. Auto-load grabs the mutex before touching the stable. Fast path (gadget already resolved) is lock-free.
Build
zig build test # unit tests
zig build -Doptimize=ReleaseFast # release binary
.\zig-out\bin\pool-proxy-ng.exe # test harness (loads Chakra, runs per-gadget validation)
Zig 0.15+, x86_64-windows.
Source layout
src/
pool_proxy.zig — proxyCall(), init(), config, thread-safe dispatch
trampolines.zig — naked x64 trampolines with popMap register restoration
gadget_db.zig — pre-computed gadget database, comptime pop_map decode
gadget_scanner.zig — multi-register PE scanner (fallback for unknown builds)
gadget_stable.zig — gadget collection + selection (random or deterministic)
dll_sideload.zig — side-effect DLL loading (COM, crypto, device, Winsock)
dm_bindings.zig — JSON serialization for introspection
types.zig — GadgetInfo, GadgetRegister, PopMap, config types
config.zig — PoolProxyConfig (fallback, auto-load, selection policy)
main.zig — test harness
platform/
ntypes.zig — NT type definitions
peb.zig — PEB walk + hash-based export resolution
Tested on Windows 11 build 10.0.26200.8457.