2026-06-12 17:03:22 -04:00
2026-06-12 17:03:22 -04:00
2026-06-12 17:03:22 -04:00
2026-06-12 17:03:22 -04:00
2026-06-12 17:03:22 -04:00

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

  1. Verify pre-computed RVAs from the built-in DB (fast path).
  2. Micro-scan any DLL whose pinned RVA doesn't match the running build.
  3. 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.

S
Description
Automated archival mirror of github.com/nbaertsch/pool-proxy-ng
Readme
58 KiB
Languages
Zig 100%