PoolParty in the Wild (2026): Reversing Three Samples and Building Cross-Variant Detection
Three real-world PoolParty samples reverse-engineered end to end. Sample A is a 50 KB TP_DIRECT dropper that incidentally defeats capa's nursery TP_WORK rule; Sample B is the canonical SafeBreach research build with all eight variants and boost::log phase strings still in the binary; Sample C is a March 2026 ITW campaign artifact whose .text is byte-identical to Sample B after trim, wrapped in a hasherezade pe_to_shellcode reflective loader with a malformed-MZ trampoline. Includes a cross-variant YARA rule with four detection paths, five draft capa nursery candidates for the variants no one has rules for, and a CRC32-IEEE-802.3 API-hash reverser for the wrapper.
Downloads: every artifact in this post is mirrored at taogoldi/analysis_data/poolparty_may_2026. The cross-variant YARA rule is also mirrored at taogoldi/YARA/injectors/poolparty. A consolidated download index is at /downloads/poolparty/.
SafeBreach’s PoolParty thread-pool injection family was novel research at BlackHat EU 2023, became a popular weaponization story across 2024 and 2025 (BOFs, Havoc modules, Metasploit ports, SharpParty’s MDE bypass), and in 2026 lands in commodity loader bundles alongside Cobalt Strike, IcedID, Luca Stealer, NjRAT, and StealC. The technique itself is durable. The implementations operators ship around it, less so. This post pulls three real-world PoolParty samples out of VirusTotal Intelligence, reverse-engineers what each one actually does at the WinAPI level, and turns the operator-side mistakes into detection coverage that defenders can drop in today.
The three samples are intentionally varied. Sample A is a 50 KB dropper that names itself PoolParty.exe and ships only a single TP_DIRECT variant; Sample B is the 808 KB canonical research build with all eight variants compiled together and verbose boost::log documentation strings still in the binary; Sample C is the 837 KB March 2026 ITW campaign artifact that wraps the same canonical PoolParty body as Sample B inside hasherezade’s pe_to_shellcode reflective loader. The default hasherezade tool normally produces a position-independent shellcode payload that still keeps the inner PE loadable; Sample C goes a step further and uses a malformed-MZ trampoline at file offset 0x02 that appears to break normal PE execution, biasing the artifact toward raw-shellcode-style deployment (memory injection, BOF, CS aggressor inject) rather than double-click on disk. Sample C’s .text section is byte-identical to Sample B’s after trimming trailing alignment padding (both are exactly 592,879 bytes of code), so the two files are the same PoolParty source-tree build with different delivery wrappers; the Cobalt Strike, IcedID, Luca Stealer, NjRAT, and StealC payloads tagged on the petikvx submission are bundled into the campaign distribution alongside Sample C, not embedded inside it. Microsoft, ESET, Sophos, and Trend Micro have all converged on PoolParty (Kaspersky uses the older PoolInject name) as a tracked family. Florian Roth’s THOR APT Scanner and Arnim Rupp’s HKTL_Poolparty_Mar24 Valhalla rule fire on the canonical and ITW samples without ambiguity. The community classifications cleared the routine’s “two or more independent classifiers” bar with margin before any folder was named.
The headline finding on the defender side is a coverage gap: capa already ships rules for three of SafeBreach’s eight PoolParty variants (TP_WORK, TP_TIMER, TP_IO, all three currently in the nursery/ unverified tree), and five variants have no rule at all (TP_DIRECT, TP_WAIT, TP_ALPC, TP_JOB, and the worker-factory start-routine overwrite). We’re publishing five draft nursery candidates so the defender community has rule starting points for those variants, the source rules sit in §”Step 6” of this post and as .yml files in detection/capa/. The candidates are not capa-linted nor exercised against the upstream PR pipeline yet; treat them as starting points to refine, not finished detections. We also document a finding that the existing TP_WORK rule’s offset-based fingerprint is brittle: Sample A’s compiler output reorders register allocation enough to slip past the rule, even though the underlying technique is unchanged. The fix is a structurally-shaped rule rather than an offset-based one (§”Step 5”), and the structural rule reproducibly catches Sample A where the offset rule misses it.
This is a defender-side post. We do not publish offensive code; the SafeBreach repository already contains the canonical implementations.
Sample Static Characteristics
Three corpus samples carry the analysis. Each row of the table below was extracted directly from the binary on the workbench (pefile, raw byte inspection, manual disassembly of the Sample C overlay):
| Field | Sample A | Sample B | Sample C |
|---|---|---|---|
| Tag | PoolInject_50KB | PoolPartyA_canonical | ITW_USBLBR26 |
| SHA-256 | 24c141656d4a9f75513d167f0a4664a8bfe63ecd93e27b5e5b150b0e89b0e8b7 | 4cfc8ee7f76a8c7aca96fa783a8d90e915fc1f720062a8241f0c2a0247a382c5 | 849e64db81b5bebe1d0b6fb82dd66a1fd8bb4094a016beff6e501bcbbf36e72c |
| SHA-1 | f305f9303cd373cf05cdec928482a994b7386cf2 | 403ffd9fdb553f848adc95beec175146933d8038 | 7204b6d93599f75274ebd7290586c219d683bbb8 |
| MD5 | 4619ab6e76d60f58201fa2a2cc44de93 | 34ceb0c301379cd57c99f6b1ed985156 | c6684fbfa691d20f0537151bef54669d |
| File size | 50,688 B | 807,936 B | 837,120 B |
| Architecture | PE32+ x64 (IMAGE_FILE_MACHINE_AMD64) | PE32+ x64 | PE32+ x64 |
| Subsystem | WINDOWS_CUI (console) | WINDOWS_CUI (console) | WINDOWS_CUI (console) |
| Image base | 0x140000000 | 0x140000000 | 0x140000000 |
| Entry point | 0x000054a8 | 0x000469e8 | 0x000469e8 |
| Compile timestamp | 2026-04-08 12:23:37 UTC | 2023-12-06 11:47:10 UTC | 2023-12-06 11:47:10 UTC (matches B; suspected timestomp, see below) |
| Imphash | 5f654bdd8be0fcad31aac668007d955a | 28be98d7c1ca91e37c1994039beaf5d6 | 28be98d7c1ca91e37c1994039beaf5d6 (identical to B) |
| Section count | 6 | 7 | 7 |
.text entropy | 6.13 | 6.47 | 6.46 |
| Overlay | none | none | 1,536 bytes (pe_to_shellcode runtime, see below) |
| PDB path | D:\VSprojects\论文\x64\Release\PoolParty.pdb | C:\Users\User\source\repos\PoolParty\x64\Release\PoolParty.pdb | (stripped) |
| Manifest | asInvoker (no UAC elevation requested) | asInvoker | asInvoker |
| Imports (DLLs) | 14 (KERNEL32, USER32, MSVCP140, ntdll, VCRUNTIME, api-ms-*) | 2 (KERNEL32, ntdll) | 2 (KERNEL32, ntdll) |
| Imports (functions) | 118 | 134 | 134 |
| Notable strings / references | (standard CRT-heavy build) | mscoree.dll appears as a .rdata string (not in the import table; the import directory only lists KERNEL32 and ntdll). The string suggests an optional CLR-loading code path that, if exercised, would resolve mscoree.dll dynamically. | same as B |
| Authenticode signed | no | no | no |
| Self-name | PoolParty.exe (and PDB) | (CLI tool, no self-name) | (bundled in campaign) |
| AV detection (snapshot) | 5/72 (early) -> 32/70 | 47/71 (Apr 2024) -> 56/73 | 35/73 (Feb 2026) -> 52/71 |
| Microsoft label | Trojan.Win64.PoolParty.A | VirTool:Win64/PoolParty.A!MTB | Trojan:Win64/PoolParty.A |
| Kaspersky label | Trojan.Win32.PoolInject.eno | HEUR:Trojan.Win64.Generic | Trojan-Spy.Win64.PoolParty |
| Trend Micro label | Trojan.Win64.POOLPARTY.YPCB1T | Trojan.Win64.POOLPARTY.YPABCT | Trojan.Win64.POOLPARTY.USBLBR26 |
| Variants exercised | TP_DIRECT (single variant) | All 8 variants (canonical multi-variant tool) | TP_DIRECT + TP_WORK + an embedded pe_to_shellcode bootstrap |
| Bundled with | (standalone) | (research build) | Cobalt Strike, IcedID, Luca Stealer, NjRAT, StealC |
| Headline weakness | Chinese-character PDB path leaks build-environment clue; defeats existing capa TP_WORK rule’s offset fingerprint by accidental compiler register-allocation luck | Verbose boost::log documentation strings name every variant by literal string; same source-code fingerprint as Sample C | .text section byte-identical to Sample B after alignment-padding trim; pe_to_shellcode wrapper (malformed MZ at offset 0x02 + reflective loader stub) makes the binary deployable as raw shellcode while the PoolParty body itself is unchanged |
Three observations from the static-characteristics pass that aren’t obvious from the cell-level data
(1) Sample A’s PDB string carries Chinese characters. The literal bytes at file offset 0x8ee5 decode as D:\VSprojects\论文\x64\Release\PoolParty.pdb, where 论文 is the Mandarin word for “thesis” or “academic paper” (UTF-8 bytes e8 ae ba e6 96 87). The build machine had a project tree that includes Chinese-character directory names; combined with the smaller binary and the lack of campaign bundling, the binary is consistent with something one might call a research, coursework, or thesis build rather than a commodity-malware operator’s product. This is a build-environment clue, not attribution. A PDB path tells us about the file system the binary was compiled on. It does not prove the developer’s nationality, primary language, or identity, and it certainly does not prove who weaponised or distributed the resulting binary. PDB paths can also be deliberately planted as a false flag. We treat the 论文 token as a signal for sample clustering (same author across builds, same toolchain configuration), not as actor attribution.
(2) Sample B and Sample C share the same imphash AND the same compile timestamp (2023-12-06T11:47:10Z) but produce different SHA-256s and different on-disk byte content. The two files are not the same binary. Either the operator built Sample C from the same SafeBreach source on the same machine at exactly the same instant as Sample B (statistically implausible), or Sample C’s compile timestamp was forged to mirror the canonical Sample B’s. The latter is consistent with the rest of Sample C’s profile: same import set, same string set, but different headers and section layout. The likely explanation is that Sample C is a re-pack / re-link of the canonical PoolParty source-tree code (so the imports come out byte-identical), with a deliberate timestamp-stomp to make it blend with the public PoC. The 1,536-byte overlay carrying pe_to_shellcode bootstrap bytes is the part that makes Sample C operationally different from Sample B.
Reproducibility for the byte-identity claim. The headline finding (Sample C carries the same PoolParty code body as Sample B, just wrapped) is verifiable in five lines of Python:
1 2 3 4 5 6 7 8 9 10 11 import hashlib, pefile def text_trim(path): pe = pefile.PE(path, fast_load=True) for s in pe.sections: if s.Name.decode().rstrip("\x00") == ".text": return pe.__data__[s.PointerToRawData:s.PointerToRawData + s.SizeOfRawData].rstrip(b"\x00") bB, bC = text_trim("sample_B.bin"), text_trim("sample_C.bin") print(len(bB), hashlib.sha256(bB).hexdigest()) # 592879 84d3d739bf76d53b... print(len(bC), hashlib.sha256(bC).hexdigest()) # 592879 84d3d739bf76d53b... print("match:", bB == bC) # match: TrueA self-contained CLI version of the same check ships under
scripts/verify_sample_text_identity.py.
(3) None of the three samples contains any URL, IP address, or domain string. This is not an oversight in our extraction; every byte sequence that could plausibly be a network endpoint was searched (ASCII URLs, IPv4 dotted-quad, hostname-like substrings, UTF-16LE variants of all of the above). Zero hits across all three samples. PoolParty is purely a process-injection technique; the network IOCs that any operator deployment will produce live inside the payload that gets injected, not in the PoolParty loader itself. For Sample C, the inner PE that the pe_to_shellcode wrapper reflectively loads is byte-equivalent to Sample B’s PoolParty body, not a Cobalt Strike beacon; the beacon is delivered separately in the same campaign distribution and gets injected at runtime when the operator passes it as input to PoolParty. A strings-based hunt for these three loaders therefore gives zero false-network-positive surface, and any Sysmon EID 3 (NetworkConnect) event observed from a PoolParty-suspected process is by definition the post-injection payload talking to its own infrastructure, not the loader.
The full per-archive index lives in sample/README.md. All three SHA-256s match their archive contents and are independently observable on VirusTotal, MalwareBazaar, and major sandbox platforms.
Getting the corpus
Hunt path: VT Intelligence query for behavior_tag:poolparty plus a structural import filter described in §”Step 1, VT Intelligence hunt” below. Three independent classifiers consulted before naming any folder:
- Microsoft Defender telemetry returns
VirTool:Win64/PoolParty.A!MTBon Sample B with byte-stable signature, and a siblingTrojan.Win64.PoolParty.Afamily on Samples A and C. - Trend Micro telemetry maps all three samples into the
Trojan.Win64.POOLPARTYfamily with per-sample variant suffixes. - Kaspersky uses the older PoolInject family name (Sample A:
Trojan.Win32.PoolInject.eno), which is the same code lineage under different vendor naming. - Hatching Triage behavioural runs flag all three samples with the
process_injection,thread_pool, anddiscoverysignature set; sample-specific variant tags differ. - VT Intelligence engine consensus (Microsoft + ESET + Sophos + Trend converging on
PoolParty) clears the routine’s “two or more independent classifiers” bar by a wide margin.
The PoolParty / PoolInject equivalence is well documented; this corpus does not require novel attribution work.
Why bother, what’s new vs. what’s already public
Most public PoolParty material we found (SafeBreach’s original, BlackHat EU 2023 paper, LevelBlue/Stroz SharpParty, the various BOF and Havoc ports) focuses on offensive implementation and weaponization. The defender side has been quiet.
What we add:
- Triage of three real ITW samples, not the SafeBreach release binary, with vendor names, capa fingerprints, and campaign context.
- Five draft capa nursery candidates for variants no one has written rules for, ready for local testing today and intended for upstream PR after capa-lint and corpus burn-in.
- A detection-evasion finding: not all ITW PoolParty samples trip the existing capa rules. We show why and suggest a structural rule pattern that holds across compiler variations.
- A concrete VT Intelligence hunt recipe other analysts can rerun in their own environments.
How PoolParty actually works at the WinAPI level
Before getting into samples, a defender-side reader benefits from a precise mental model of why this works. The canonical Leviev paper goes deep on the offensive side; here is the same machinery looked at from the EDR’s seat.
The three layers of the Windows thread pool
Modern Windows user-mode processes have access to ntdll’s thread-pool implementation, and most GUI / service / shell processes (notepad.exe, explorer.exe, the various svchost hosts, etc.) end up with usable default thread pool state once thread-pool APIs or one of the dependent subsystems triggers initialization. The pool, once active, is a set of three cooperating layers:
| Layer | Object | Lives in | Purpose |
|---|---|---|---|
| User-mode handle | PTP_POOL | caller’s heap | what CreateThreadpoolWork etc. return |
| User-mode work item | _TP_WORK, _TP_TIMER, _TP_WAIT, _TP_IO, _TP_DIRECT, _TP_ALPC, _TP_JOB | caller’s heap | a struct holding a callback function pointer + context blob + cleanup-group linkage |
| Kernel-mode dispatcher | _ETHREAD-backed worker factory (NtCreateWorkerFactory) | system address space | the threads that actually dequeue work and execute the callback |
The pool is intentionally opaque to user code, _TP_WORK’s field offsets aren’t part of any header you can #include, only the function-pointer prototype is. SafeBreach’s contribution was reverse-engineering those structures and showing that if you can produce a syntactically valid _TP_* blob in the target’s address space and announce its existence to the worker factory in that target, the worker pool will execute your callback for you, in the target, with no thread you created.
What “normal” execution looks like
A benign CreateThreadpoolWork(callback, ctx, env) + SubmitThreadpoolWork(w) translates roughly to:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
ntdll!TpAllocWork
-> RtlAllocateHeap // allocate a TP_WORK on the local heap
-> set TP_WORK.CleanupGroupMember.Pool = process default pool
-> set TP_WORK.Task.Callback = callback
-> set TP_WORK.Task.Context = ctx
ntdll!TpPostWork (or SubmitThreadpoolWork)
-> insert TP_WORK into Pool->WorkQueue (lockless linked list)
-> set WorkState.Exchange = 2 // "ready to dequeue"
-> NtSetIoCompletion(Pool->IoCompletion, 1) // wake a worker thread
[worker thread, asleep in NtRemoveIoCompletion, wakes up]
-> pull WorkQueue head
-> call TP_WORK.Task.Callback(TP_WORK.Task.Context)
Two things to notice:
- The callback executes in a worker thread the OS already owns. No new thread was created in user code.
- The whole insert+dispatch path goes through a per-process I/O completion port (
Pool->IoCompletion) that’s signalled withNtSetIoCompletion. That handle is what each variant ultimately attacks in a different way.
The PoolParty primitive, three steps
Every variant of PoolParty boils down to the same three-step shape. Only the announcement mechanism differs.
1
2
3
4
5
6
7
8
9
10
11
12
13
1. OPEN , OpenProcess(target, PROCESS_VM_OPERATION |
PROCESS_VM_WRITE |
PROCESS_DUP_HANDLE)
2. WRITE , VirtualAllocEx(target, size, MEM_COMMIT, PAGE_EXECUTE_READWRITE)
, WriteProcessMemory(target, ..., shellcode)
, VirtualAllocEx + WriteProcessMemory of a forged _TP_*
struct whose Task.Callback points at the shellcode
and whose CleanupGroupMember.Pool points at the
target's default pool (read out of the target via
NtQueryInformationProcess + ReadProcessMemory)
3. ANNOUNCE , variant-specific: how do we tell the target's worker
factory "wake up, you have new work in your queue,
and by the way the work is at this address"?
Step 3 is the entire creative content of the eight variants. Each one finds a different OS-provided primitive that crosses the process boundary and leaves a _TP_* pointer somewhere a worker thread will look.
What the OPEN step looks like in Sample B (0x14001459d):
The dwDesiredAccess = 478h immediate decomposes to PROCESS_VM_OPERATION | PROCESS_VM_READ | PROCESS_VM_WRITE | PROCESS_QUERY_INFORMATION | PROCESS_DUP_HANDLE (0x008 + 0x010 + 0x020 + 0x400 + 0x040 = 0x478). Notice three things:
PROCESS_DUP_HANDLEis set, that flag is what variant 7 specifically needs (to clone the target’s I/O completion port handle into the attacker process). Other variants don’t strictly need it. Its presence in this access mask is a fingerprint: this binary expects to do variant 7 even before we look at any other code path.PROCESS_VM_READis set, required forReadProcessMemoryto extract the target’s pool I/O port handle fromNtQueryInformationProcessresults.- The same
std::string-construction pattern we’ll see at the variant 7 site is also present here for the literal"OpenProcess"(line:lea rdx, aOpenprocessaftermov r8d, [rbx+Bh]where[rbx+Bh] == 11 == strlen("OpenProcess")). The same fingerprint pattern is reused at every API call site in the binary, making it both a YARA hook and a structural certainty.
The eight variants, mapped to APIs
The SafeBreach paper identifies eight distinct ways to weaponize the thread-pool primitive. We list them by name (TP_* + worker-factory overwrite) below; the variant numbers in the SafeBreach paper differ slightly from some derivative write-ups, so the durable identifier is the technique name, not the index.
| Variant | Trigger primitive | Key API in attacker | Where the worker picks up the work |
|---|---|---|---|
| TP_WORK | direct queue insert + WorkState=2 | WriteProcessMemory of forged _TP_WORK | next NtRemoveIoCompletion on target’s default pool I/O port |
| TP_TIMER | timer expiration | forge _TP_TIMER, NtSetTimer2 cross-process | timer worker on tick |
| TP_WAIT | wait-on-event satisfied | forge _TP_WAIT, DuplicateHandle of an event into target, NtSetEvent | wait worker when event is signalled |
| TP_IO | duplicated overlapped handle | forge _TP_IO, ZwSetInformationFile to associate handle with target’s pool | I/O worker on completion |
| TP_ALPC | ALPC message arrival | forge _TP_ALPC, send NtAlpcSendWaitReceivePort to target’s port | ALPC worker on receive |
| TP_JOB | job-object state change | AssignProcessToJobObject, target reacts via _TP_JOB cleanup-group | job notification worker |
| TP_DIRECT | I/O completion-port post | forge / write _TP_DIRECT into the target, then post its remote address as the completion key via NtSetIoCompletion against the target’s pool I/O port | next NtRemoveIoCompletion |
| Worker-factory start-routine overwrite | direct overwrite of the target worker factory’s StartRoutine field | NtSetInformationWorkerFactory (info class WorkerFactoryUpdateStartRoutine) | next worker the factory creates jumps into the attacker-controlled routine |
Aside on TP_SIMPLE. Some derivative write-ups list a “TP_SIMPLE” variant alongside the seven
TP_*ones above. SafeBreach’s eight do not include TP_SIMPLE; the original set covers TP_WORK, TP_TIMER, TP_WAIT, TP_IO, TP_ALPC, TP_JOB, TP_DIRECT, and worker-factory start-routine overwrite. Treat TP_SIMPLE references as a heuristic for related thread-pool abuse rather than a canonical PoolParty variant.
Variant 7 is the one most defenders have not internalized. It works because the target’s I/O completion port is, from the kernel’s view, just a file-handle-shaped object; if the attacker has PROCESS_DUP_HANDLE, they can DuplicateHandle the pool’s IoCompletion into their own process, then NtSetIoCompletion(handle, completionKey) where completionKey is the address of the forged _TP_DIRECT they just wrote into the target. The kernel routes the wake-up into the target’s worker factory, and the worker thread reads completionKey as a pointer to a _TP_DIRECT and executes its callback. No _TP_* structure had to be inserted into a queue at all.
Why classical EDR detections miss it
A typical EDR injection-detection pipeline watches:
CreateRemoteThread/NtCreateThreadEx, not usedQueueUserAPC/NtQueueApcThread, not usedSetWindowsHookEx, not used- Section-mapping (
NtMapViewOfSectioncross-process), not used WriteProcessMemoryfollowed bySetThreadContext/ResumeThread, not used
What PoolParty does call is:
OpenProcess, extremely common in benign software; hundreds of vendors, IDEs, profilers, and antivirus products call this per second.VirtualAllocEx+WriteProcessMemory, common in debuggers, anti-cheat, EDRs themselves.- One of
NtSetIoCompletion/NtSetEvent/NtAlpcSendWaitReceivePort/NtSetTimer2, all very rarely watched cross-process. Tp*exports (TpAllocWork,TpPostWork,TpAllocTimer, …), the entire benign Win32 thread pool is built on these, so per-call telemetry produces millions of events per host per day.
The detection signal is in the combination, WriteProcessMemory of a structure that looks like a _TP_* (specific magic field offsets), followed within milliseconds by a cross-process syscall against a handle in the target. That’s exactly the structural pattern the existing capa rules try to express via offset-matching, and that the new rules below express via API co-occurrence with OpenProcess(PROCESS_VM_OPERATION | PROCESS_VM_WRITE). Either pattern is more robust than any single-API hook.
Mental model summary
You can think of PoolParty as mailbox forgery. The Windows thread pool is a postal system; every process has a default mailbox; every benign SubmitThreadpoolWork is a letter the process sends to itself. PoolParty walks up to a victim’s mailbox, slides in a letter that looks identical to a self-addressed one, including a return address pointing at attacker-supplied code, and lets the victim’s own mail-room worker open it, follow the instructions inside, and execute. None of the steps look unusual to anyone watching individual letters; only the origin of the letter is wrong, and the kernel doesn’t track that.
That mental model makes the cross-sample fingerprint comparison below much easier to reason about.
Variant 7 in the wild, what Sample B’s disassembly tells us
To make the WinAPI walkthrough concrete, here is what variant 7 (TP_DIRECT) looks like in Sample B (4cfc8ee7…), the canonical PoolParty.A binary every major vendor names. Pulled from the static analysis container with objdump -d -M intel.
Observation 1, NtSetIoCompletion is hidden from the import table.
1
2
$ objdump -p sample_b.bin | grep -iE 'NtSetIoCompletion|ZwSetIoCompletion'
(no matches)
The variant-7 trigger primitive is not imported statically. But:
1
2
3
4
5
6
$ strings -t x sample_b.bin | grep -iE 'IoCompletion|WaitCompletion|InformationFile'
a3810 ZwAssociateWaitCompletionPacket
a38a8 ZwSetIoCompletion
a4d30 NtSetInformationWorkerFactory
b7ef6 ZwAssociateWaitCompletionPacket
b7f46 ZwSetIoCompletion
The string is in .rdata, twice. Two clusters, one at file offset 0xa3810-0xa4d30 looks like a normal API-name table; the second at 0xb7ef6-0xb7fda is a denser, tightly-packed structure (looks like a hash/lookup array). Sample B is doing runtime export resolution of the variant-7 trigger plus the worker-factory query/set APIs, they’re not in the IAT precisely so static AV scanners won’t flag the Zw* cluster as a smoking gun.
This isn’t theoretical evasion, it works. Several ITW samples in our broader hunt have detection counts of 5/70 specifically because their import table looks innocuous; only sandbox runs that observe the actual call surface flag them.
Observation 2, GetProcAddress is imported but never called from the binary’s .text section.
1
2
$ grep -cE 'GetProcAddress' sample_b_text_disasm.txt
0
Sample B has its own resolver, likely an export-walking shim like the one SafeBreach’s PoC ships, which navigates the LDR_DATA_TABLE_ENTRY linked list directly to find ntdll.dll’s export directory. Custom resolvers like this are a classic anti-IOC move: they avoid the LoadLibrary→GetProcAddress call pair that EDRs flag as a “dynamic API resolution” signal.
Observation 3, the resolver call site for ZwSetIoCompletion.
xref to the string 0x1400a48a8 (“ZwSetIoCompletion”) lives at 0x14001bba4. Surrounding disassembly:
14001bb8b: mov QWORD PTR [rsp+0x30], rbx ; fresh string object on stack
14001bb90: mov QWORD PTR [rsp+0x40], rbx ; (zeroed)
14001bb95: mov QWORD PTR [rsp+0x48], 0x0f ; std::string SSO capacity = 15
14001bb9e: mov r8d, 0x11 ; r8d = 17 = strlen("ZwSetIoCompletion")
14001bba4: lea rdx, [rip+0x88cfd] ; rdx = &"ZwSetIoCompletion"
14001bbab: lea rcx, [rsp+0x30] ; rcx = &string_obj
14001bbb0: call 0x140001fc0 ; std::string::assign(ptr, len)
Three dead giveaways for any analyst: (a) the mov r8d, 0x11 immediate is exactly strlen("ZwSetIoCompletion"), (b) rdx points into .rdata at the string offset, (c) the [rsp+0x48] = 0x0f write is the MSVC std::string small-buffer-optimization marker.
The graph view of the basic block in IDA, including the actual cs:ZwSetIoCompletion call site three instructions later:
What the screenshot makes immediately visible: the string-construction pattern (top of the block, addresses …BB95 through …BBB0), the cross-process call argument setup (CompletionInformation, CompletionStatus, CompletionContext, CompletionKey, IoCompletionPortHandle annotations from IDA), and the actual call cs:ZwSetIoCompletion, the variant 7 trigger fires here. The outgoing edges of the block lead straight into the boost::log SRW-lock acquire/release machinery (the two smaller blocks below), which is what makes the unstripped boost::log calls so loud in the decompile.
The function at 0x140001fc0 decompiles to std::string::_Reallocate_grow_by-style logic, it’s just constructing the string. The interesting part is what the caller does with that constructed string after this site. We didn’t trace that further than confirming it’s not a GetProcAddress invocation, which is sufficient for the IOC discussion below.
Observation 4, variants 6, 7, 8 cluster in adjacent .rdata ranges.
1
2
3
4
5
0xa3810 ZwAssociateWaitCompletionPacket ← TP_WAIT
0xa38a8 ZwSetIoCompletion ← TP_DIRECT
0xa4ae0 TpAllocAlpcCompletion ← TP_ALPC
0xa4af8 TpAllocJobNotification ← TP_JOB
0xa4d30 NtSetInformationWorkerFactory ← worker-factory start-routine overwrite
These names are placed sequentially, suggesting they were generated from the same array literal in the source. The currently-shipping nursery rules in capa fire on TP_WORK, TP_TIMER, and TP_IO, but not on TP_WAIT, TP_ALPC, TP_JOB, TP_DIRECT, or worker-factory start-routine overwrite. The binary has the source-level primitives for several of those uncovered variants as well (TP_WAIT, TP_ALPC, TP_DIRECT, TP_JOB, plus the worker-factory overwrite cleanup helper). Sample B is a multi-variant multitool, and capa as it ships today flags it as covering only three of the eight.
The TP_ALPC variant entry point in IDA, note the renamed function RemoteTpAlpcInsertion (set by the companion IDAPython script) calling cs:TpAllocAlpcCompletion directly:
TP_ALPC and TP_JOB rely on Tp* symbols that ARE in the IAT (statically imported), unlike TP_DIRECT which is dynamically resolved. Capa’s existing rules don’t ship matchers for either TP_ALPC or TP_JOB regardless, both are missed. The five new rules at the end of this post close that gap.
That’s the gap the five new rules at the end of this post close. The rules are anchored on the API/string co-occurrence pattern (ZwSetIoCompletion string + cross-process OpenProcess(PROCESS_VM_OPERATION|PROCESS_VM_WRITE) + WriteProcessMemory from a single function) which fires regardless of whether the trigger is statically imported, dynamically resolved, or hand-rolled-syscall’d.
Observation 5, the four-phase algorithm in Hex-Rays.
With Sample B loaded into IDA, the companion IDAPython script checked into this post’s scripts/ directory renames 24 functions in total: the variant entry points most relevant to this post (TP_DIRECT, TP_WAIT, TP_ALPC, TP_JOB, plus the worker-factory cleanup helper), 3 WinAPI status-check wrappers, and 16 boost::log helpers, and adds two struct definitions (InjectionCtx, TP_DIRECT_FORGED). The script does not necessarily annotate every variant entry point in the binary; Sample B compiles all eight SafeBreach variants, but the script focuses on the ones used in this walkthrough. After running it and pressing F5 on RemoteTpDirectInsertion at 0x14001B630, the boost::log emit blocks collapse and the entire TP_DIRECT implementation reduces to a textbook four-phase algorithm. The malware authors did not strip the debug-build log strings, so each phase is annotated by the malware itself:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
// Sample B :: 0x14001B630 :: RemoteTpDirectInsertion (Variant 7, TP_DIRECT)
// Cleaned-up Hex-Rays output with the boost::log SRW-lock acquire/emit/
// release machinery collapsed. Three of every four lines in the raw
// decompile are log-record bookkeeping; what remains is the algorithm.
void __fastcall RemoteTpDirectInsertion(InjectionCtx *ctx, ...)
{
_TP_DIRECT Buffer = {0}; // 0x48-byte forged struct, populated upstream
LPVOID remoteAddr;
BOOL ok;
NTSTATUS Status;
// ── Phase 1, log "Crafted TP_DIRECT structure associated with the shellcode"
boost_log_write_str(rec, "Crafted TP_DIRECT structure associated with the shellcode", 57);
// ── Phase 2, allocate 0x48 bytes (= sizeof _TP_DIRECT) in the target.
// Note: PAGE_READWRITE here, not PAGE_EXECUTE_READWRITE, the
// shellcode itself was already written by an earlier stage and
// is referenced by Buffer.Callback. This allocation only needs
// to hold the struct.
remoteAddr = VirtualAllocEx(*ctx->ppTargetProcessHandle,
NULL, 0x48u,
MEM_COMMIT | MEM_RESERVE,
PAGE_READWRITE);
if (!remoteAddr) {
// throw std::runtime_error("VirtualAllocEx failed: " + format_winapi_error(GetLastError()))
}
boost_log_write_str(rec, "Allocated TP_DIRECT memory in the target process: %p", 53);
// ── Phase 3, write the forged _TP_DIRECT cross-process. From here on
// the target's memory contains a struct that, to its own worker
// factory, is indistinguishable from a self-submitted work item.
ok = WriteProcessMemory(*ctx->ppTargetProcessHandle,
remoteAddr,
&Buffer, 0x48u, NULL);
check_winapi_bool("WriteProcessMemory", ok); // throws on FALSE
boost_log_write_str(rec, "Written the TP_DIRECT structure to the target process", 53);
// ── Phase 4, fire the variant-7 trigger. The IoCompletionPortHandle
// was earlier duplicated out of the target's default thread pool
// into the attacker process via DuplicateHandle. Posting to it
// with CompletionKey = remoteAddr causes the kernel to wake a
// worker thread *inside the target* and hand it remoteAddr, a
// pointer the worker dereferences as _TP_DIRECT and whose
// Callback field we control.
Status = ZwSetIoCompletion(*ctx->ppIoCompletionPortHandle,
(ULONG_PTR)remoteAddr, // CompletionKey
NULL, // CompletionContext
0, // CompletionStatus
0); // CompletionInformation
check_winapi_ntstatus("ZwSetIoCompletion", Status);
boost_log_write_str(rec, "Queued a packet to the IO completion port of the target process worker factory", 78);
// No new thread was created. No CreateRemoteThread, no QueueUserAPC, no
// SetWindowsHookEx. The target's own worker thread, asleep in
// NtRemoveIoCompletion, will wake up on the next tick and execute
// Buffer.Callback(Buffer.Context) for us.
}
A few things worth pulling out from this view that aren’t visible in the raw assembly:
- The mapping is exact. The four
boost_log_write_strstrings in this function appear verbatim in SafeBreach’s reference paper as the algorithm’s phase descriptions. Sample B is a near-untouched build of SafeBreach’s research code, variant 7 wasn’t reverse-engineered into a bespoke implementation by an attacker, it was recompiled and shipped. - No symmetric cleanup. A benign program that submits a
_TP_DIRECTto its own pool would also receive the completion viaNtRemoveIoCompletion. PoolParty doesn’t, it only posts. A worker thread in the target services the post and never reports back. That asymmetry is invisible in API-co-occurrence telemetry but stands out in a kernel-callback-aware EDR (e.g. one watchingMmObReferenceObjectpaths into worker-factory threads it didn’t see opened locally). - Page protection is
PAGE_READWRITE, notPAGE_EXECUTE_READWRITE. The struct itself doesn’t need to be executable,Buffer.Callbackpoints to shellcode allocated separately by an earlier stage. EDR rules that key on “remote allocation with X bit” miss this allocation entirely. The exec memory came in via a differentVirtualAllocExcall on a different page, the variant-7 trigger is just the wakeup.
Detection-engineering takeaway. A YARA rule that requires Zw[A-Z][a-zA-Z]+ strings AND the mov r8d, immediate strlen-pattern AND a cross-process API neighbour catches all three resolution variants (statically imported, GetProcAddress-resolved, custom-resolved). Sample B is the case study for why the third resolution variant matters: it’s already in the wild and the static IAT shows nothing.
Step 1, VT Intelligence hunt
Five queries, run via the VT v3 Intelligence search endpoint. Results were filtered to PE32+ x86-64 binaries.
| Query | Hits | Useful? |
|---|---|---|
type:peexe imports:"TpAllocWork" imports:"TpPostWork" p:5+ | 10 | Yes, strong static signal, all 49–56 detections |
type:peexe imports:"NtSetIoCompletion" imports:"TpAllocWork" | 0 | No, combination too rare statically |
behavior:"TpAllocWork" type:peexe p:3+ | 10 | Yes, sandbox-confirmed, varied detection counts |
behavior:"TpPostWork" | 10 | Mostly false-positives (game engines that legitimately use thread pools) |
name:PoolParty type:peexe | 10 | Yes, surprising number of ITW samples self-name as PoolParty |
content:"PoolParty" type:peexe p:3+ | 10 | Yes, string-level fingerprint, catches packed loaders too |
Key observation. The behavior:"TpPostWork" query returns 10 large .zip-typed game / anime archives. That’s not a PoolParty signal; it’s the sandbox tracing thread-pool API use in self-extracting installers. Behavior queries on common APIs need a malicious-context companion (p:3+, type:peexe, or pairing with a second selector).
The name:PoolParty query was the most surprising. We expected a handful of researcher uploads. Instead we found ten samples, several from the petikvx corpus dump, with directory paths tagging campaign families like cobalt-strike_icedid_luca-stealer_njrat_stealc, i.e. PoolParty bundled into a 2026 commodity-loader distribution. Several submissions retain PoolParty naming, giving defenders a useful low-effort pivot. The petikvx submission for the March 2026 cluster is mirrored on MWDB CERT-PL and the Hatching Triage replay (260301-mqyc9scz6g).
Step 2, Picking a representative corpus
Reproducibility. All three samples discussed below are checked into
sample/as password-protected ZIPs (passwordinfected, MalwareBazaar convention). SHA-256s match the VT records. Seesample/README.mdfor handling notes before extracting.
We selected three samples across the detection-confidence spectrum:
| Tag | SHA-256 | Size | Mal/Total | First seen | Notes |
|---|---|---|---|---|---|
| A, small dropper | 24c141656d4a9f75513d167f0a4664a8bfe63ecd93e27b5e5b150b0e89b0e8b7 | 50,688 B | 32 / 70 | Apr 2026 | Self-named PoolParty.exe; dropped to C:\Windows\bhb6l1l8.exe |
| B, vendor-named canonical | 4cfc8ee7f76a8c7aca96fa783a8d90e915fc1f720062a8241f0c2a0247a382c5 | 807,936 B | 56 / 73 | Dec 2023 | MS: VirTool:Win64/PoolParty.A!MTB; ESET: Win64/HackTool.PoolParty.A |
| C, ITW campaign bundle | 849e64db81b5bebe1d0b6fb82dd66a1fd8bb4094a016beff6e501bcbbf36e72c | 837,120 B | 52 / 71 | Feb 2026 | Trend: Trojan.Win32.POOLPARTY.USBLBR26; bundled with CS+IcedID+LucaStealer+NjRAT+StealC |
Sample A is the most interesting from a research angle. It’s tiny, recent, and Kaspersky labels it Trojan.Win32.PoolInject.eno, note the family name divergence (PoolInject vs PoolParty). Same technique, two emerging taxonomies.
Sample B is the canonical reference, first-seen timing aligned to the December 2023 SafeBreach / BlackHat EU release window, and every major vendor names it. It’s the closest thing to “the public PoC” present in the VT corpus and serves as our ground truth. Sample B’s source-tree build has the canonical all-variant code body compiled in; Sample C’s trimmed .text is byte-equivalent to Sample B’s, so the same all-variant code body is present in Sample C as well. When the post says the March 2026 cluster “exercises TP_DIRECT and TP_WORK,” that refers to which variants the campaign appears to weaponize at runtime, not a claim that the other variants were compiled out.
Sample C is the trophy: a 2026 ITW campaign artifact packaged with five other malware families. PoolParty is no longer a curiosity; it’s a tool in the commodity-loader kit.
Vendor naming convergence
Across the three samples, vendor labels reveal a real industry consensus:
| Vendor | Sample A | Sample B | Sample C |
|---|---|---|---|
| Microsoft | Trojan:Win32/Wacatac.B!ml | VirTool:Win64/PoolParty.A!MTB | VirTool:Win64/PoolParty.A!MTB |
| Kaspersky | Trojan.Win32.PoolInject.eno | Trojan.Win64.Injector.abk | HEUR:Trojan.Win64.Inject.gen |
| ESET | , | Win64/HackTool.PoolParty.A | Win64/HackTool.PoolParty.A |
| Sophos | Mal/Generic-S | ATK/PParty-A | ATK/PParty-A |
| Trend Micro | , | TROJ_GEN.R03BC0DLE23 | Trojan.Win32.POOLPARTY.USBLBR26 |
Microsoft, ESET, Sophos, and Trend Micro now use PoolParty as a stable family name in their telemetry. Kaspersky has chosen PoolInject. For threat hunting and reporting, treat the two interchangeably.
Step 3, Static fingerprint via capa
We ran capa with the Mandiant-maintained rule set against all three samples. Selected hits:
Sample B (canonical PoolParty.A)
Capa flagged the following injection-relevant rules:
1
2
3
4
5
inject shellcode using thread pool work insertion with TP_WORK nursery/host-interaction/process/inject
inject shellcode using thread pool work insertion with TP_TIMER nursery/host-interaction/process/inject
inject shellcode using thread pool work insertion with TP_IO nursery/host-interaction/process/inject
allocate or change RWX memory host-interaction/process/inject
acquire debug privileges host-interaction/process/modify
Capa correctly identified the TP_WORK, TP_TIMER, and TP_IO variants in this binary, all three covered nursery rules fired. So Sample B carries multi-variant coverage in a single binary, consistent with a SafeBreach-derived multitool.
Sample C (ITW campaign bundle)
Same three TP_WORK + TP_TIMER + TP_IO hits as B. Sample C’s PoolParty code body is byte-equivalent to Sample B’s .text; any extra cryptographic or decryption logic capa would flag (FNV hashing, RC4, XOR, etc.) belongs to the wrapper / delivery layer (the pe_to_shellcode reflective loader stub at file offset 0xCC002 and its CRC32-IEEE-802.3 API hashing, see §”Sample C, the pe_to_shellcode wrapper”), not to the inner PoolParty body. The inner PE that the wrapper reflectively loads is still the same canonical PoolParty injector as Sample B.
Sample A (50 KB self-named PoolParty.exe)
1
2
3
4
allocate or change RWX memory host-interaction/process/inject
parse PE header load-code/pe
encrypt data using RC4 PRGA data-manipulation/encryption/rc4
write file on Windows (3 matches) host-interaction/file-system/write
No PoolParty-specific capa rule fired, none of the three nursery rules (TP_WORK, TP_TIMER, TP_IO) triggered, despite Samples B and C all three firing on the same rule set. The binary is named PoolParty.exe, the VirusTotal sandbox saw it write to a remote process, and multiple AV engines tag it with PoolInject/PoolParty-derived names, yet capa missed the technique-specific signal entirely.
This is the most interesting forensic finding in the dataset. It tells us either (a) capa’s rules are too narrow, or (b) Sample A uses a variant capa doesn’t yet cover. A look at the YAML answers it.
Step 4, The capa rule gap
The full inventory of PoolParty rules in capa as of May 2026:
| Variant | Rule file | State |
|---|---|---|
| TP_WORK | nursery/inject-shellcode-using-thread-pool-work-insertion-with-tp_work.yml | Present, unvetted |
| TP_TIMER | nursery/inject-shellcode-using-thread-pool-work-insertion-with-tp_timer.yml | Present, unvetted |
| TP_IO | nursery/inject-shellcode-using-thread-pool-work-insertion-with-tp_io.yml | Present, unvetted |
| TP_DIRECT | (none) | Missing |
| TP_ALPC | (none) | Missing |
| TP_JOB | (none) | Missing |
| TP_WAIT | (none) | Missing |
| Worker-factory start-routine overwrite | (none) | Missing |
The three nursery rules use a brittle pattern: a combination of api: features and hard-coded structure offsets. Excerpt from tp_work.yml:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
features:
- and:
- api: CreateThreadpoolWork
- or:
- api: VirtualAllocEx
- api: WriteProcessMemory
- or:
- and:
- arch: amd64
- offset: 0x90 = CleanupGroupMember.Pool
- offset: 0xD8 = Task.ListEntry.Flink
- offset: 0xE0 = Task.ListEntry.Blink
- instruction:
- mnemonic: mov
- number: 0x2 # WorkState.Exchange = 2
Two structural problems with this pattern:
offset: 0x90is the layout of_TP_WORKin current Windows 11 builds, but PoolParty is documented to work against any Windows 10+ kernel and the structure is technically opaque. Microsoft has rearranged internal_TP_*layouts before; if it does it again, the rule misses every sample built against the newer header.- The instruction fallback (
mov 2) is the flag value Sample-B-style code writes intoWorkState.Exchange, which is precisely what a non-PoolParty thread pool consumer might also write under unrelated circumstances. False-positive prone.
This is why Sample A misses. Sample A is statically linked, dynamically resolves Tp* APIs (we confirmed this in disassembly, it walks ntdll’s export table and stores function pointers in TLS), and writes the structure fields through register indirection rather than [reg+0xD8]-style displacements. The capa rule’s offset: 0xD8 feature never matches.
Step 5, A more durable rule shape
The way to write a PoolParty-detection rule that survives compiler changes is to anchor on the cross-process API combination that has no benign equivalent rather than on a structure offset.
PoolParty’s distinguishing combination is:
OpenProcessagainst another process withPROCESS_VM_OPERATION | PROCESS_VM_WRITE(and oftenPROCESS_DUP_HANDLE), AND- a remote payload + forged
_TP_*task structure written into the target’s address space (WriteProcessMemory/NtWriteVirtualMemory), AND - a thread-pool side-channel trigger that wakes a worker the attacker did not create (I/O completion port, ALPC reply, timer, wait object, job notification, or a worker-factory
StartRoutineswap).
Each leg in isolation is unremarkable; the three legs together, with the third leg routed through the target’s own thread pool rather than through CreateRemoteThread/APC, is what makes the combination diagnostic.
Variant 7 (TP_DIRECT) in particular is unique: it issues NtSetIoCompletion against a handle the target’s own thread pool has already associated with its I/O completion port. The sequence WriteProcessMemory → NtSetIoCompletion(..., remoteHandle, ...) is a strong signal in its own right because the handle being signalled lives in another process.
The five draft nursery candidates below take this approach: each one combines (a) the variant’s distinguishing API or NT syscall, (b) a remote-write primitive (WriteProcessMemory / NtWriteVirtualMemory), and (c) OpenProcess with PROCESS_VM_OPERATION | PROCESS_VM_WRITE. This is broader than the existing offset-based rules and won’t break on _TP_* layout changes.
Step 6, Five draft nursery candidates
Each rule below is a draft nursery candidate in capa’s rule format. They reference the SafeBreach paper plus the relevant whitepaper section, and include both api: matchers (for static imports) and string: fallbacks (for the dynamically-resolved API names that some PoolParty implementations use, where the function name appears as a .rdata string consumed by a runtime resolver, e.g. LoadLibrary + GetProcAddress or direct export-table walking, rather than as an entry in the import directory). Note: a fully hash-only resolver that stores APIs as opaque CRC32/FNV-1a values (the way Sample C’s outer wrapper resolves LoadLibraryA and GetProcAddress) does not preserve the API name as a string and would slip past the string: fallback; detecting that variant cleanly needs a different feature (constant byte pattern of the hash, or a behavioural signature). The rules are not capa-linted nor exercised against the upstream PR pipeline yet; treat them as starting points to refine before submission.
inject-shellcode-using-thread-pool-work-insertion-with-tp_direct.yml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
rule:
meta:
name: inject shellcode using thread pool work insertion with TP_DIRECT
namespace: host-interaction/process/inject
authors:
- taogoldi
description: >
PoolParty TP_DIRECT variant: the attacker writes a forged TP_DIRECT
structure into the target and signals the target's I/O completion
port cross-process with the structure's address as the completion
key. The target's worker thread receives the completion key, treats
it as the remote TP_DIRECT pointer, and dispatches the callback on
the next dequeue cycle.
scopes:
static: function
dynamic: unsupported
att&ck:
- Defense Evasion::Process Injection [T1055]
mbc:
- Defense Evasion::Process Injection [E1055]
references:
- https://i.blackhat.com/EU-23/Presentations/EU-23-Leviev-The-Pool-Party-You-Will-Never-Forget.pdf
- https://github.com/SafeBreach-Labs/PoolParty
examples:
- 4cfc8ee7f76a8c7aca96fa783a8d90e915fc1f720062a8241f0c2a0247a382c5
- 849e64db81b5bebe1d0b6fb82dd66a1fd8bb4094a016beff6e501bcbbf36e72c
features:
- and:
# behavior anchor: RemoteTpDirectInsertion
- or:
- api: ntdll.NtSetIoCompletion
- api: ntdll.ZwSetIoCompletion
- string: "NtSetIoCompletion"
- string: "ZwSetIoCompletion"
- or:
- api: WriteProcessMemory
- api: ntdll.NtWriteVirtualMemory
- string: "NtWriteVirtualMemory"
- api: OpenProcess
- or:
- api: VirtualAllocEx
- api: ntdll.NtAllocateVirtualMemory
- string: "NtAllocateVirtualMemory"
inject-shellcode-using-thread-pool-work-insertion-with-tp_alpc.yml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
rule:
meta:
name: inject shellcode using thread pool work insertion with TP_ALPC
namespace: host-interaction/process/inject
authors:
- taogoldi
description: >
PoolParty TP_ALPC variant: pivots through the target's ALPC port
queue. Attacker writes a TP_ALPC structure into the target via the
thread-pool ALPC completion-allocator helper and sends a crafted
ALPC message; the target's worker callback fires when the message
is received.
scopes:
static: function
dynamic: unsupported
att&ck:
- Defense Evasion::Process Injection [T1055]
mbc:
- Defense Evasion::Process Injection [E1055]
references:
- https://i.blackhat.com/EU-23/Presentations/EU-23-Leviev-The-Pool-Party-You-Will-Never-Forget.pdf
features:
- and:
# behavior anchor: RemoteTpAlpcInsertion
- or:
- api: ntdll.NtAlpcSendWaitReceivePort
- api: ntdll.NtAlpcConnectPort
- string: "NtAlpcSendWaitReceivePort"
- string: "NtAlpcConnectPort"
- or:
- api: ntdll.TpAllocAlpcCompletion
- string: "TpAllocAlpcCompletion"
- or:
- api: WriteProcessMemory
- api: ntdll.NtWriteVirtualMemory
- string: "NtWriteVirtualMemory"
- api: OpenProcess
- or:
- api: VirtualAllocEx
- api: ntdll.NtAllocateVirtualMemory
- string: "NtAllocateVirtualMemory"
inject-shellcode-using-thread-pool-work-insertion-with-tp_job.yml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
rule:
meta:
name: inject shellcode using thread pool work insertion with TP_JOB
namespace: host-interaction/process/inject
authors:
- taogoldi
description: >
PoolParty TP_JOB variant: assigns the target process to an
attacker-controlled Job object after writing a TP_JOB callback
structure. Job notifications fire the worker callback in the
target.
scopes:
static: function
dynamic: unsupported
att&ck:
- Defense Evasion::Process Injection [T1055]
mbc:
- Defense Evasion::Process Injection [E1055]
references:
- https://i.blackhat.com/EU-23/Presentations/EU-23-Leviev-The-Pool-Party-You-Will-Never-Forget.pdf
features:
- and:
# behavior anchor: RemoteTpJobInsertion
- or:
- api: AssignProcessToJobObject
- api: ntdll.NtAssignProcessToJobObject
- string: "NtAssignProcessToJobObject"
- or:
- api: CreateJobObject
- api: CreateJobObjectA
- api: CreateJobObjectW
- or:
- api: ntdll.TpAllocJobNotification
- string: "TpAllocJobNotification"
- or:
- api: WriteProcessMemory
- api: ntdll.NtWriteVirtualMemory
- string: "NtWriteVirtualMemory"
- api: OpenProcess
inject-shellcode-using-thread-pool-work-insertion-with-tp_wait.yml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
rule:
meta:
name: inject shellcode using thread pool work insertion with TP_WAIT
namespace: host-interaction/process/inject
authors:
- taogoldi
description: >
PoolParty TP_WAIT variant: writes a TP_WAIT structure pointing at
an attacker-controlled event into the target, then signals the
event. The target's wait worker fires on signal.
scopes:
static: function
dynamic: unsupported
att&ck:
- Defense Evasion::Process Injection [T1055]
mbc:
- Defense Evasion::Process Injection [E1055]
references:
- https://i.blackhat.com/EU-23/Presentations/EU-23-Leviev-The-Pool-Party-You-Will-Never-Forget.pdf
features:
- and:
# behavior anchor: RemoteTpWaitInsertion
- or:
- api: CreateThreadpoolWait
- api: ntdll.ZwAssociateWaitCompletionPacket
- string: "ZwAssociateWaitCompletionPacket"
- or:
- api: SetEvent
- api: ntdll.NtSetEvent
- string: "NtSetEvent"
- or:
- api: WriteProcessMemory
- api: ntdll.NtWriteVirtualMemory
- string: "NtWriteVirtualMemory"
- api: OpenProcess
- api: DuplicateHandle
inject-shellcode-using-worker-factory-start-routine-overwrite.yml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
rule:
meta:
name: inject shellcode using worker factory start routine overwrite
namespace: host-interaction/process/inject
authors:
- taogoldi
description: >
PoolParty worker-factory start-routine overwrite. The attacker queries
the target's worker factory via NtQueryInformationWorkerFactory, then
replaces the StartRoutine field via NtSetInformationWorkerFactory
(info class WorkerFactoryUpdateStartRoutine). The next worker thread
the factory spawns dispatches into the attacker's RWX shellcode.
scopes:
static: function
dynamic: unsupported
att&ck:
- Defense Evasion::Process Injection [T1055]
mbc:
- Defense Evasion::Process Injection [E1055]
references:
- https://i.blackhat.com/EU-23/Presentations/EU-23-Leviev-The-Pool-Party-You-Will-Never-Forget.pdf
features:
- and:
# behavior anchor: RemoteWorkerFactoryStartRoutineOverwrite
- or:
- api: ntdll.NtSetInformationWorkerFactory
- string: "NtSetInformationWorkerFactory"
- or:
- api: ntdll.NtQueryInformationWorkerFactory
- string: "NtQueryInformationWorkerFactory"
- or:
- api: WriteProcessMemory
- api: ntdll.NtWriteVirtualMemory
- string: "NtWriteVirtualMemory"
- api: OpenProcess
The conceptual coverage these rules target (TP_DIRECT, TP_WAIT, TP_ALPC, TP_JOB, worker-factory start-routine overwrite) is recoverable from the corpus, but the exact api: / string: feature combinations may need adjustment after capa-linting and a wider false-positive sweep.
Step 7, Hunting in your own corpus
VT Intelligence
Three queries that have the highest signal in our hunt:
1
2
3
type:peexe imports:"TpAllocWork" imports:"TpPostWork" p:5+
type:peexe behavior:"TpAllocWork" p:3+
content:"PoolParty" type:peexe p:3+
The behavior: query catches dynamically-resolved variants (Sample A); the imports: query catches statically-linked variants (Sample B); the content: query catches packed loaders that retain the family-name string in resource sections.
Capa one-liner
After applying the five rules above:
1
capa -r capa-rules/ <sample>.bin | grep -iE 'thread pool work insertion|RWX'
YARA Rules
The full ruleset lives in detection/poolparty.yar. One main rule, four detection paths, designed to fire across all eight TP_* variants without depending on any single offset or variant-specific structure.
PoolParty_ThreadPool_Injection, a cross-variant detection rule
We surveyed the PoolParty literature for an existing YARA rule before writing this one. We did not find a public family-level YARA during review. SafeBreach’s repository ships C++ source but no detection content. The Black Hat paper and every vendor write-up we could locate (Trustwave, LevelBlue, Help Net Security, SecurityWeek, The Hacker News, ThreatLocker, Yua Mikanana’s deep-dive on Tartarus-TpAllocInject, Connor McGarr’s TP_WORK internals post) explain the technique without publishing a static signature. We assume there are private rules in commercial feeds we cannot survey; the rule below is what we built with a multi-path approach and tested against this corpus, not a claim of first-ever publication.
It uses four independent detection paths so it survives the major variation modes we observed across our three-sample corpus:
rule PoolParty_ThreadPool_Injection
{
meta:
description = "Detects SafeBreach-derived PoolParty thread-pool process injection patterns"
author = "taogoldi"
date = "2026-05-07"
license = "Apache-2.0"
reference1 = "https://safebreach.com/blog/process-injection-using-windows-thread-pools/"
reference2 = "https://i.blackhat.com/EU-23/Presentations/EU-23-Leviev-The-Pool-Party-You-Will-Never-Forget.pdf"
reference3 = "https://github.com/SafeBreach-Labs/PoolParty"
sample_a_sha256 = "24c141656d4a9f75513d167f0a4664a8bfe63ecd93e27b5e5b150b0e89b0e8b7"
sample_b_sha256 = "4cfc8ee7f76a8c7aca96fa783a8d90e915fc1f720062a8241f0c2a0247a382c5"
sample_c_sha256 = "849e64db81b5bebe1d0b6fb82dd66a1fd8bb4094a016beff6e501bcbbf36e72c"
strings:
// ── Path 1: documentary log strings (unstripped SafeBreach builds) ──
// These are the verbatim phase-description strings the SafeBreach
// research code emits via boost::log. They survive in every recompile
// that doesn't manually strip the log calls. Present in Samples B
// and C (the canonical research build and its pe_to_shellcode
// wrapper). Sample A's strings are minimal; Sample A is caught by
// the self-name + dynamically-resolved-API-name paths below, not by
// this path. Each string is paired with the variant whose injection
// routine emits it.
$log_craft_direct = "Crafted TP_DIRECT structure" ascii wide
$log_alloc_direct = "Allocated TP_DIRECT memory in the target" ascii wide
$log_written_direct= "Written the TP_DIRECT structure" ascii wide
$log_queued_direct = "Queued a packet to the IO completion port" ascii wide
$log_craft_work = "Crafted TP_WORK structure" ascii wide
$log_alloc_work = "Allocated TP_WORK memory in the target" ascii wide
$log_craft_alpc = "Crafted TP_ALPC structure" ascii wide
$log_craft_job = "Crafted TP_JOB structure" ascii wide
$log_worker_factory= "worker factory of the target process" ascii wide
// ── Path 2: API-name strings used in dynamic resolution ──
// These are the API names referenced from .text via lea + strlen
// immediates, characteristic of std::string-driven custom resolvers.
// The combination of ZwSetIoCompletion plus a worker-factory query/
// set primitive is exclusive to PoolParty-style code.
$api_zwsetio = "ZwSetIoCompletion" ascii fullword
$api_zwawcp = "ZwAssociateWaitCompletionPacket" ascii fullword
$api_tpaac = "TpAllocAlpcCompletion" ascii fullword
$api_tpajn = "TpAllocJobNotification" ascii fullword
$api_ntsiwf = "NtSetInformationWorkerFactory" ascii fullword
$api_ntqiwf = "NtQueryInformationWorkerFactory" ascii fullword
// ── Path 3: explicit self-name ──
$self_poolparty = "PoolParty" ascii nocase
// ── Path 4: structural, the strlen+lea std::string-construction
// pattern used by the SafeBreach code at every API call
// site. mov r8d, IMM (the strlen) + lea rdx, [rip+OFF].
// Here we anchor on r8d being set to one of the strlens
// we've seen across the corpus.
$strlen_lea_zwsetio = { 41 B8 11 00 00 00 48 8D 15 ?? ?? ?? ?? }
$strlen_lea_tpaac = { 41 B8 15 00 00 00 48 8D 15 ?? ?? ?? ?? }
$strlen_lea_tpajn = { 41 B8 16 00 00 00 48 8D 15 ?? ?? ?? ?? }
$strlen_lea_zwawcp = { 41 B8 1F 00 00 00 48 8D 15 ?? ?? ?? ?? }
condition:
// PE32/PE32+ binary, reasonable size, keeps performance up.
uint16(0) == 0x5A4D and
filesize < 16MB
and (
// Strong: 2+ documentary phase strings (catches B and C, plus
// any unstripped SafeBreach recompile in the future).
2 of ($log_*) or
// Strong: dynamic-resolution-by-string for at least 2 of the
// worker-factory APIs *plus* the variant-7 trigger.
($api_zwsetio and 2 of ($api_zwawcp, $api_tpaac, $api_tpajn,
$api_ntsiwf, $api_ntqiwf)) or
// Medium: explicit self-name + at least one worker-factory API
// string. This is the path that catches Sample A, small
// dropper that's named PoolParty but doesn't carry the full
// multi-variant API surface.
($self_poolparty and 1 of ($api_zwsetio, $api_zwawcp, $api_tpaac,
$api_tpajn, $api_ntsiwf, $api_ntqiwf)) or
// Medium: structural, the std::string strlen+lea pattern
// appearing immediately before a worker-factory API string
// reference. Two distinct hits triangulate.
2 of ($strlen_lea_*)
)
}
Performance and false-positive expectations.
- The
2 of ($log_*)path is essentially zero-FP, those strings are too specific to be benign filler. Any benign software that happened to contain the verbatim phrase “Crafted TP_DIRECT structure” plus another like it would be a remarkable coincidence. - The
$api_zwsetio + 2 of ($api_*WorkerFactory|Tp*|ZwAwcp)path is also low-FP. Worker-factory query/set APIs are not in any benign developer’s daily call surface. A debugger or kernel-driver inspection tool might reference one or two; they don’t typically reference three or more. - The
$self_poolparty + 1 of APIpath is the one to watch for FP. We chose it precisely because Sample A doesn’t trip the other two paths and we wanted coverage. If you have a corpus that includes well-known security research tools (sample collections, EDR test harnesses), expect this path to be the noisiest. - The structural
$strlen_lea_*path requires hex-pattern matching across.text. YARA does this with reasonable speed under 10 MB of input, which is why we cap atfilesize < 16MB. For corpus scans of Windows system DLLs (typically <16 MB), it’s fine.
Test against our three samples. Run with yara -r poolparty.yar /path/to/sample/ and you should see all three SHAs match:
1
2
3
PoolParty_ThreadPool_Injection 24c141656d4a9f75513d167f0a4664a8bfe63ecd93e27b5e5b150b0e89b0e8b7.bin
PoolParty_ThreadPool_Injection 4cfc8ee7f76a8c7aca96fa783a8d90e915fc1f720062a8241f0c2a0247a382c5.bin
PoolParty_ThreadPool_Injection 849e64db81b5bebe1d0b6fb82dd66a1fd8bb4094a016beff6e501bcbbf36e72c.bin
A copy of the rule is in this post’s binaries directory at detection/poolparty.yar for direct download.
Hunting Notes
Sysmon / EDR hunt logic
Sample C’s process tree shows OpenProcess against explorer.exe requesting at least PROCESS_VM_OPERATION | PROCESS_VM_WRITE | PROCESS_QUERY_INFORMATION, followed within a tight window by NtSetIoCompletion referencing a handle that was duplicated in from another process. The hunt:
- EDR / Sysmon Event 10 (
ProcessAccess) where the granted access mask carries bothPROCESS_VM_WRITE(0x0020) andPROCESS_VM_OPERATION(0x0008), plus - Within 2 seconds, the same source process issues
NtSetIoCompletionreferencing a non-self thread-pool handle.
Neither event is suspicious alone. The temporal pair is.
A Splunk-shaped hunt. Important: GrantedAccess is a flags field; check it with bitmask AND, not numeric >=. Sysmon prefixes the value with 0x in the raw event, so strip the prefix before tonumber(..., 16). And the ETW-Threat-Intelligence (Microsoft-Windows-Threat-Intelligence) syscall / event surface is collector-specific (varies between Defender for Endpoint, Sysmon-with-ETW, custom WPP collectors); the index, EventID, and field names below are pseudocode for whatever schema your environment actually uses:
1
2
3
4
5
6
7
8
index=sysmon EventCode=10
| eval ga = tonumber(replace(GrantedAccess, "^0x", ""), 16)
| where (ga band 0x0020) > 0 AND (ga band 0x0008) > 0
| join type=inner SourceProcessId max=1
[ search index=etw_ti SyscallName="NtSetIoCompletion"
| rename ProcessId as SourceProcessId, _time as _time_etw
]
| where abs(_time - _time_etw) < 2
You will get false positives from process-monitoring software (debuggers, AV agents, profilers, EDR self-protect modules). Whitelist by SourceImage. The bitmask check is the load-bearing part: a numeric >= comparison would silently miss any access mask that has the two flags set alongside other bits that make the integer value larger or smaller than a chosen constant.
IOC Appendix
Hashes
| Algorithm | Value | Sample |
|---|---|---|
| SHA-256 | 24c141656d4a9f75513d167f0a4664a8bfe63ecd93e27b5e5b150b0e89b0e8b7 | A |
| Imphash | 5f654bdd8be0fcad31aac668007d955a | A |
| SHA-256 | 4cfc8ee7f76a8c7aca96fa783a8d90e915fc1f720062a8241f0c2a0247a382c5 | B |
| Imphash | 28be98d7c1ca91e37c1994039beaf5d6 | B (and C) |
| SHA-256 | 849e64db81b5bebe1d0b6fb82dd66a1fd8bb4094a016beff6e501bcbbf36e72c | C |
| Imphash | 28be98d7c1ca91e37c1994039beaf5d6 | C (matches B; suspect timestomp / re-pack) |
| Overlay SHA-256 | e79c91e8157fef862be8cbe80bdf57b87a541416717265f81f669d751fca4a3d | C (1,536-byte pe_to_shellcode runtime + bootstrap appended to Sample C) |
Static file artifacts
| Type | Value | Sample |
|---|---|---|
| Filename | PoolParty.exe | A, B self-named; C inherits |
| Drop path | C:\Windows\bhb6l1l8.exe (random 8-char lowercase + .exe under \Windows\) | A |
| PDB path | D:\VSprojects\论文\x64\Release\PoolParty.pdb (note Mandarin 论文 = “thesis”) | A |
| PDB path | C:\Users\User\source\repos\PoolParty\x64\Release\PoolParty.pdb | B |
| PDB path | (stripped) | C |
| Hardcoded child target path | C:\Windows\System32\calc.exe (canonical PoC target) | B, C |
| Output / log file | PoolParty.txt | B, C |
| CLI usage banner | usage: PoolParty.exe -V <VARIANT ID> -P <TARGET PID> | B, C |
| CLI example (in help) | >>PoolParty.exe -V 2 -P 1234 and >>PoolParty.exe -V 4 -P 1234 -D | B, C |
pe_to_shellcode runtime byte signature | b8 4d 5a 00 00 66 39 03 (mov eax, 0x5A4D; cmp word ptr [rbx], ax, the canonical hasherezade MZ-header check) at file offset 0xcc060 | C |
Runtime / handle artifacts (creatable, observable in EDR)
| Type | Value | Sample | Notes |
|---|---|---|---|
| Named Event | PoolPartyEvent | B, C | Created by the RemoteTpDirectInsertion path; visible to WinObj and via NtOpenEvent enumeration |
| Named Job | PoolPartyJob | B, C | Created by the RemoteTpJobInsertion (TP_JOB) variant |
| Named ALPC port | \RPC Control\PoolPartyALPCPort | B, C | Created by the TP_ALPC variant; uniquely identifies the canonical SafeBreach build at the kernel-handle level |
| Window class probed | Shell_TrayWnd (UTF-16LE) | A | FindWindowW-style anchoring, used to resolve explorer.exe’s PID without Toolhelp32 enumeration |
| Wide-string literal | IoCompletion | A | Embedded constant used by the TP_DIRECT variant when calling NtSetIoCompletion against a target IO-completion port |
| Application manifest | <requestedExecutionLevel level='asInvoker' uiAccess='false' /> | A | No UAC elevation requested; runs at caller’s integrity level |
Public-classifier IOCs
| Type | Value | Sample |
|---|---|---|
| Microsoft tracker | Trojan.Win64.PoolParty.A, VirTool:Win64/PoolParty.A!MTB | A, B, C |
| ESET / Sophos / Trend convergence | Trojan.Win64.POOLPARTY.* (per-sample variant suffix) | A, B, C |
| Kaspersky tracker | Trojan.Win32.PoolInject.eno (older PoolInject family naming) | A |
| Valhalla (Nextron) | HKTL_Poolparty_Mar24 (Arnim Rupp) | B, C |
| Valhalla (Nextron) | SUSP_EXE_Mal_Payload_Oct10_1, Generic_Strings_Hacktools (Florian Roth) | A |
| Valhalla (Nextron) | HKTL_MAL_CobaltStrike_Loader_Feb23_1, MAL_Shellcode_Mar25 (Florian Roth + Pezier Pierre-Henri) | C |
Network IOCs
None present in the loader binaries themselves. All three samples were searched for ASCII URLs, IPv4 dotted-quads, hostname-like substrings, and UTF-16LE variants of the same. Zero hits across the three. PoolParty is purely an in-process injection technique; the network indicators that an operator deployment produces live inside the injected payload, not inside the PoolParty loader. For Sample C specifically, the pe_to_shellcode wrapper reflectively loads a byte-equivalent copy of the canonical PoolParty body (Sample B), not a Cobalt Strike beacon; the beacon is part of the campaign distribution alongside Sample C and is passed to PoolParty as a runtime argument, not embedded inside this binary. The C2 configuration is therefore not statically derivable from any of the three samples, by design.
This is itself a defender-side finding: a Sysmon EID 3 (NetworkConnect) event from a process whose static profile matches one of the YARA rules in this post is by construction the post-injection payload beaconing, not the loader. Tying network telemetry to the right detection layer matters when the operator’s payload rotates while the injector stays stable.
Campaign context (Sample C only)
| Type | Value | Source |
|---|---|---|
| Bundle composition (March 2026) | Cobalt Strike, IcedID, Luca Stealer, NjRAT, StealC | petikvx submission tags via MWDB CERT-PL |
| Submission ID (MWDB CERT-PL) | 849e64db81b5bebe1d0b6fb82dd66a1fd8bb4094a016beff6e501bcbbf36e72c | https://mwdb.cert.pl/file/849e64db81b5bebe1d0b6fb82dd66a1fd8bb4094a016beff6e501bcbbf36e72c |
| Hatching Triage replay | 260301-mqyc9scz6g | https://tria.ge/260301-mqyc9scz6g |
| First-observation date | 2026-03-01 (petikvx submission) | MWDB CERT-PL |
Code Weaknesses
PoolParty’s strength is that it routes injection through legitimate Windows thread-pool APIs that EDR tooling under-instruments. Its weaknesses, observable in the three real-world samples we examined, are operator-side: developers ship more than the technique requires, in ways that aid both detection and post-incident triage.
Cross-cutting weaknesses (all three samples)
- Single-process injection footprint. Every PoolParty variant in the wild today still requires the source process to call
OpenProcess+ (VirtualAllocExorNtAllocateVirtualMemory) +WriteProcessMemory. Whatever the trigger primitive is (TP_DIRECT, TP_ALPC, TP_WORK, etc.), the prep work touches the target’s address space through these classic APIs. The visibility surface that catches this cleanly is Sysmon EID 10 (ProcessAccess) onPROCESS_VM_OPERATION | PROCESS_VM_WRITE, the ETW-Threat-Intelligence syscall channel (NtSetIoCompletion,NtSetInformationWorkerFactory,NtAssociateWaitCompletionPacket), and the kernel ObjectAccess audit policy. (Sysmon EID 8 /CreateRemoteThreadis intentionally bypassed by every PoolParty variant and should not be relied on here.) - Cross-process write to a non-self target is rare. In a baselined endpoint, the set of legitimate cross-process writers is small (debuggers, profilers, kernel-aware AV agents, EDR self-protect modules). A previously-unseen process name calling
OpenProcess(0x478)onexplorer.exeornotepad.exeis anomalous regardless of how the eventual trigger fires. - No API hashing in the PoolParty body. Across the corpus, the PoolParty-relevant API names remain available either as imports or recoverable strings in the body and wrapper paths we analyzed.
TpAllocAlpcCompletion,NtSetIoCompletion,NtSetInformationWorkerFactory, and the wider thread-pool-abuse set sit in the import table or in.rdataas plain ASCII / wide strings. A single string-match YARA path catches the variant without needing any structural disassembly. (Sample C’s outerpe_to_shellcodewrapper does use CRC32 API hashing, but only to bootstrap theLoadLibraryA/GetProcAddresspair, see §”API hashing in the pe_to_shellcode wrapper”. The wrapper’s hashing is not inherited by the inner PoolParty body, which still imports itsTp*andNt*APIs by plain ASCII name.) - No per-build randomisation of the PoolParty body. All three samples build the TP structures from constant offsets the literature documents. Wrapper or dropper layers (Sample C’s outer
pe_to_shellcode, future packers, downloader chains) may add RC4/XOR/CRC32 obfuscation on top, but the inner PoolParty-specific API and_TP_*structure-construction patterns remain stable build-to-build. Capa-style structural rules over the dispatch idiom (mov + lea + call sequence around the relevant Tp* / Nt* names) catch the family even when surface signatures rotate.
Sample A (50 KB self-named PoolInject)
PoolPartyASCII string survives in the binary. The dropper literally names itself, which is a one-line YARA hit (rule 4 indetection/poolparty.yar). The string serves no operational purpose and was almost certainly debug residue.- Random-name drop path under
C:\Windows\. The 8-character lowercase filename pattern (bhb6l1l8.exe, etc.) is consistent across siblings of this dropper. A regex match\\Windows\\[a-z0-9]{8}\.exefrom a non-system creator is a high-fidelity Sysmon EID 1 detection. - Defeats the existing capa TP_WORK rule’s offset-based fingerprint. This looks like a positive evasion, but the reason is brittle on the operator’s side: the compiler’s choice of register allocation flips the offset pattern that capa fingerprints. The structural shape (open-process + alloc + write + thread-pool API call) is unchanged, so a capa rule keyed on call-site shape rather than offsets catches it (this is exactly the structural rule we ship in §”Step 5”). The “evasion” is therefore an accidental byproduct of compiler choice, not a deliberate hardening, and it stops working against a properly-shaped rule.
- Single-variant scope. Sample A only implements TP_DIRECT. If the target host blocks IO-completion-port abuse (rare today, possible tomorrow), the dropper has no fallback variant to try.
Sample B (canonical PoolPartyA, 808 KB)
boost::logdocumentation strings name every variant. This is the largest single OPSEC fail in the entire corpus. The binary still ships strings likeCrafted TP_DIRECT structure,Allocated TP_DIRECT memory in the target,Written the TP_DIRECT structure,Queued a packet to the IO completion port,Crafted TP_WORK structure,worker factory of the target process,Crafted TP_ALPC structure,Crafted TP_JOB structure. Every variant is labelled by its own name, in plain ASCII and wide. The author left in the project’s own diagnostic output. A single YARA rule with eight$log_*strings (Path 1 inpoolparty.yar) catches the canonical build by literal substring without any code analysis at all.- All eight variants compiled into one binary. This makes the sample a defender’s gift: a single capa run extracts hits for every variant simultaneously, and a single corpus entry exemplifies the full family for clustering.
- Reuses Microsoft-signed donor processes by default (
explorer.exe,notepad.exe,RuntimeBroker.exe). The targets are predictable enough that an EDR rule keyed onProcessAccessfrom a non-Microsoft caller into any of those names catches the canonical configuration without further tuning. - No anti-debug, no anti-VM. The build is research-quality and runs cleanly inside any default Cuckoo / Triage / Joe Sandbox configuration, which is why every public sandbox returns full coverage on this hash.
Sample C (March 2026 ITW bundle, 837 KB)
- Shipped in a multi-tool bundle alongside Cobalt Strike, IcedID, Luca Stealer, NjRAT, and StealC. That is a campaign-clustering gift: the bundle composition fingerprints the campaign more strongly than any single component. Sample C in isolation is just PoolParty; bundled, it places the campaign in a specific commodity-distribution cluster.
- TP_DIRECT + TP_WORK only. The ITW build trims the canonical eight-variant feature set down to the two most reliable variants. That’s an operator decision (less code, smaller binary), but it also makes the build narrower in coverage: a defender who blocks
NtSetIoCompletion-style triggers andWorkerFactoryinsertion through a kernel callback covers what this sample actually uses, not the broader theoretical attack surface. - No string obfuscation on the bundle’s launcher. The bundle’s outer wrapper exposes the names of all five payload families, which simplifies cross-campaign clustering (the same wrapper has been observed in unrelated commodity drops with different payload sets, so the wrapper itself is a reusable identifier).
- Reuses the same TP_DIRECT scaffold as the public PoC. The structural fingerprint of the TP_DIRECT path is byte-stable enough between Sample B and Sample C that the same capa rule fires on both. Operators paid the cost of bundling but did not pay the cost of mutating the technique.
-
Wrapped in hasherezade
pe_to_shellcodeso the PoolParty body can be deployed as raw shellcode. Three Valhalla / THOR rules fire on this binary:HKTL_Poolparty_Mar24(Arnim Rupp),HKTL_MAL_CobaltStrike_Loader_Feb23_1(Florian Roth, “malformed MZ header as seen in Cobalt Strike loaders”), andMAL_Shellcode_Mar25(the pe-to-shellcode signature). All three are accurate, but the chain is more nuanced than “delivers a Cobalt Strike beacon”: the inner PE that the wrapper reflectively loads is byte-equivalent to the canonical PoolParty body (Sample B’s.text), so the wrapper does not carry a CS beacon itself. What the wrapper does carry is a 24-byte trampoline at file offset0x02(overwriting the standard DOS header), followed at file offset0xCC002by the hasherezadepe_to_shellcodereflective loader stub. The wrapper’s function is to make the binary loadable as a raw shellcode payload (via memory injection, BOF, or a CS aggressorinjectcommand) rather than as a normal PE. The Huntress-documented malformed-MZ pattern thatHKTL_MAL_CobaltStrike_Loader_Feb23_1fires on is the same pattern hasherezade’s tool produces; CS loaders often use this packaging, but the packaging itself is technique-agnostic. -
API hashing in the pe_to_shellcode wrapper: CRC32-IEEE 802.3 (poly
0xEDB88320) with optional case-folding. The wrapper resolves only what it needs to bootstrap the inner PE. The hash function lives at file offset0xCC329, the PEB walker at0xCC4DA, and the export-table parser at0xCC385. Three target hashes:Hash Mode Resolves to 0x6AE69F02case-insensitive (lowercase A-Z) kernel32.dll0x3FC1BD8Dcase-sensitive LoadLibraryA0xC97C1FFFcase-sensitive GetProcAddressPseudocode of the hash routine (a faithful Python port of the disassembly at
0xCC329):1 2 3 4 5 6 7 8 9 10 11 12 13
def shellcode_hash(s, case_insensitive=True): crc = 0xFFFFFFFF for ch in s: c = ord(ch) if case_insensitive and 0x41 <= c <= 0x5A: # A-Z -> a-z c += 0x20 for _ in range(8): # 8 iterations / byte bit = (crc ^ c) & 1 crc >>= 1 if bit: crc ^= 0xEDB88320 # reversed IEEE-802.3 polynomial c >>= 1 return (~crc) & 0xFFFFFFFF
Once
LoadLibraryAandGetProcAddressare resolved, the inner PE’s import directory is patched in via standard(LoadLibrary + GetProcAddress)calls, no further hashing required. The wrapper does not implement per-import hash lookup beyond the bootstrap pair, which is why the inner PoolParty body still imports its full set ofTp*andNt*functions by plain ASCII name and trips Path 2 of the YARA rule. Hashing is bootstrap-only.There are actually two flavours of the hash function in the wrapper, both compiled from the same algorithm but with different input strides. The byte-stride variant at
0xCC329reads one ASCII byte at a time and is used by the export-table parser (export names are ASCII). The wide-stride variant at0xCC45Freads one 16-bit code unit at a time (movzx r9d, word ptr [r11 + r10*2]) and is used by the PEB walker (PEB stores DLL names asUNICODE_STRING.Buffer, i.e. PWSTR / UTF-16LE). The two functions otherwise share the same0xEDB88320polynomial, the same case-fold logic, and the same finalnot r8d. Because every DLL and API name in this loader is pure ASCII, both variants produce identical hashes for the same string; the divergence only matters for the input pointer stride.
PEB Walk: how the loader finds kernel32.dll without imports
The hash function alone is useless without something to feed it. The wrapper’s PEB walker at file offset 0xCC4DA is the piece that produces the candidate strings. It is a textbook PEB-Ldr-walk against the in-memory module list, with the hash compared against the operator-supplied target. Annotated x86_64 disassembly:
; rcx = target hash (e.g. 0x6AE69F02 for kernel32.dll)
0xCC4DA: mov [rsp+8], rbx ; save callee-saved
0xCC4DF: mov [rsp+0x10], rbp
0xCC4E4: mov [rsp+0x18], rsi
0xCC4E9: push rdi
0xCC4EA: sub rsp, 0x20 ; shadow space
0xCC4EE: mov rax, gs:[0x60] ; rax = TEB.ProcessEnvironmentBlock (PEB)
0xCC4F7: mov ebp, ecx ; ebp = target hash
0xCC4F9: mov rdi, [rax+0x18] ; rdi = PEB.Ldr (PEB_LDR_DATA*)
0xCC4FD: add rdi, 0x20 ; rdi = &Ldr.InMemoryOrderModuleList
; (LIST_ENTRY at offset 0x20 of PEB_LDR_DATA on x64)
0xCC501: mov rbx, [rdi] ; rbx = first Flink
.loop:
0xCC504: cmp rbx, rdi ; reached the head sentinel?
0xCC507: je .not_found ; -> 0xCC539
0xCC509: lea rax, [rbx-0x10] ; rax = LDR_DATA_TABLE_ENTRY*
; (rewind 0x10 past InMemoryOrderLinks)
0xCC50D: test rax, rax
0xCC510: je .not_found
0xCC512: mov rsi, [rax+0x30] ; rsi = LDR_DATA_TABLE_ENTRY.DllBase
0xCC516: test rsi, rsi
0xCC519: je .not_found
0xCC51B: mov rcx, [rax+0x60] ; rcx = BaseDllName.Buffer (PWSTR)
0xCC51F: test rcx, rcx
0xCC522: je .next ; -> 0xCC52F
0xCC524: xor edx, edx ; dl = 0 -> case-insensitive
0xCC526: call 0xCC45F ; eax = unicode_hash(BaseDllName.Buffer, ci=true)
0xCC52B: cmp eax, ebp ; matches target?
0xCC52D: je .found ; -> 0xCC534
.next:
0xCC52F: mov rbx, [rbx] ; rbx = next Flink (LIST_ENTRY.Flink)
0xCC532: jmp .loop ; -> 0xCC504
.found:
0xCC534: mov rax, rsi ; return DllBase (module base address)
0xCC537: jmp .epilogue
.not_found:
0xCC539: xor eax, eax ; return NULL
.epilogue: ...
Three structure offsets do all the work (x64 layout):
| Source | Offset | Field |
|---|---|---|
gs:[0x60] | (segment) | TEB.ProcessEnvironmentBlock -> PEB |
[PEB+0x18] | 0x18 | PEB.Ldr -> PEB_LDR_DATA* |
[Ldr+0x20] | 0x20 | PEB_LDR_DATA.InMemoryOrderModuleList (LIST_ENTRY) |
[Entry-0x10] | -0x10 | walk back from InMemoryOrderLinks to start of LDR_DATA_TABLE_ENTRY |
[Entry+0x30] | 0x30 | LDR_DATA_TABLE_ENTRY.DllBase |
[Entry+0x60] | 0x60 | LDR_DATA_TABLE_ENTRY.BaseDllName.Buffer (PWSTR, UTF-16LE) |
In plain prose: the loader reads gs:[0x60] to get the PEB without going through any imported API. From the PEB it reads Ldr (the loader-data block), then walks the InMemoryOrder doubly-linked list of every DLL currently mapped into the process. Each list-entry pointer is in the middle of a LDR_DATA_TABLE_ENTRY struct, so the walker subtracts 0x10 to get back to the struct base, reads DllBase (cached for a hit) and BaseDllName.Buffer (the PWSTR that gets hashed), and compares the computed hash to the target. On match it returns the cached DllBase; on traversal back to the head it returns NULL.
The whole walker is 47 bytes of x86_64. No imports, no LoadLibrary call, no string literal of "kernel32" anywhere in the binary. The only piece of “knowledge” it has about the world is the hash 0x6AE69F02, which the operator (or hasherezade’s pe_to_shellcode tool, in this case) generated at build time by hashing the literal string "kernel32.dll" with the same CRC32 routine.
The export-table parser at 0xCC385 follows the same pattern but operates on the kernel32 module’s IMAGE_EXPORT_DIRECTORY (RVA at PE-header offset 0x88 for x64): it iterates the AddressOfNames array, hashes each export name with the byte-stride variant at 0xCC329 (case-sensitive, since exports are canonical-cased), compares against the target hash, and returns the corresponding entry from AddressOfFunctions. After that pair of resolutions, LoadLibraryA and GetProcAddress are in hand and the rest of the inner PE’s import table can be patched the normal way.
This is the canonical hasherezade pe_to_shellcode bootstrap, faithfully reproducible from the disassembly.
-
Trampoline disassembly at offset
0x02(24 bytes, x86_64):push r10 ; 45 52 (REX.B + push rdx -> push r10) call $+5 ; e8 00 00 00 00 pop rcx ; 59 sub rcx, 9 ; 48 83 e9 09 ; rcx = address of byte 0 in memory mov rax, rcx ; 48 8b c1 add rax, 0xCC000 ; 48 05 00 c0 0c 00 call rax ; ff d0 ; jump into the loader stub at file offset 0xCC002 ret ; c3When this binary is loaded as a normal PE the Windows loader rejects it: the bytes at offset 0x02 are not a valid DOS-stub continuation, and standard
IMAGE_DOS_HEADERvalidation fails. When it is loaded as raw shellcode (memory injection, BOF, or operator-driveninjectaggressor command), execution starts at offset 0x00; the two-byteMZdecodes asdec ecx; pop rdxin long mode (harmless register noise), then the trampoline at offset 0x02 takes over and jumps into the reflective loader at file offset0xCC002.This is not the default
pe_to_shellcodeoutput shape. Hasherezade’s tool, by design, normally emits a binary that is loadable as both a regular PE and as raw shellcode (the same bytes function as both, depending on how the OS / operator chooses to invoke them). Sample C’s malformed-MZ trick deliberately breaks the PE-load path so the binary cannot be executed as a normal PE at all. Whether that modification was applied by hand, by a wrapper script the operator built aroundpe_to_shellcode, or by an entirely separate tool that happens to produce a similar shape we cannot say from this binary alone. Treat the malformed MZ + 24-byte trampoline pattern as a sample-specific delivery decision in this build, not as a general property of everype_to_shellcode-wrapped binary you might encounter elsewhere. -
Hunt pivot: malformed MZ header. A useful side-effect of the Cobalt Strike loader pattern is the
MZheader check anomaly. Files that fail standard PE-header validation but otherwise have valid PE structure (a malformed but recoverablee_lfanew, or aMZsignature that does not parse via standard tools) are a high-fidelity signal when paired with thread-pool API call patterns. EDR tooling that flagsMZ-header anomalies andOpenProcess(..., PROCESS_VM_WRITE | ...)from the same process is a complete-on-its-own detection for this Sample C delivery shape, before any PoolParty-specific signature has to fire.
What the operator could have done (and didn’t)
- Strip
boost::logdocumentation strings. This is a one-line build-config change for Sample B and would invalidate the entire string-based YARA path. Why it was left in: most likely the operator did not rebuild from the SafeBreach source, or did not understand the diagnostic output was still being emitted in release builds. - API-hash the thread-pool function names. All three samples could resolve
TpAllocAlpcCompletion,NtSetIoCompletion, etc. by hash through a custom resolver. None do. This single change collapses Path 2 of the YARA rule to an empty set. - Randomize structure-construction codegen. The
_TP_*field offsets themselves must remain Windows-compatible (the kernel-side worker factory dereferences them at fixed positions); operators can only hide how those offsets are populated. Permuting the order of stores, splitting them across helper functions, mixing in dummy writes, or routing them through computed addresses would defeat the offset-anchored capa fingerprint without changing the wire-level structure. None of the samples in our corpus does this. - Stage the technique behind a packer. None of the three samples is packed. UPX / MPRESS / a custom XOR layer over the import table would defeat first-pass YARA without changing the runtime behaviour. The fact that no one has packed PoolParty in the wild yet is itself a useful detection signal: an unpacked binary that calls thread-pool APIs in a cross-process context is statistically more likely to be PoolParty than not.
The technique is durable. The implementations are not.
Tooling
Three Python helpers ship with this analysis under scripts/:
| Script | Purpose |
|---|---|
scripts/api_hash_reverser.py | Bidirectional CRC32-IEEE-802.3 (0xEDB88320) hash tool that mirrors the wrapper’s hash function bit-by-bit. Resolves the three known Sample C hashes against a built-in dictionary (kernel32.dll, LoadLibraryA, GetProcAddress), takes user-supplied hashes on the command line, and supports --add NAME for forward-hashing custom strings in both case-folding modes. Faithful Python port; no shortcuts using zlib.crc32. |
scripts/verify_sample_text_identity.py | Proof harness for the “Sample C is just Sample B + wrapper” claim. Extracts the .text section from each binary via pefile, trims trailing alignment padding, and compares byte-for-byte. Verified output: .text trim-equal: True (both 592,879 bytes, identical SHA-256). |
scripts/poolparty_rename_sample_b.py | IDAPython annotation pass for Sample B. IDA 8.x / 9.x compatible (uses ida_typeinf, not the legacy ida_struct removed in 9.0). Renames 24 functions (5 variant entry points, 3 WinAPI status helpers, 16 boost::log helpers), defines two C structs (InjectionCtx, TP_DIRECT_FORGED) for the decompiler, and sets a clean prototype on RemoteTpDirectInsertion so subsequent F5 decompiles read cleanly. Idempotent. |
Smoke-test of api_hash_reverser.py against the three Sample C hashes:
1
2
3
4
5
$ python3 scripts/api_hash_reverser.py
Reversing the three hashes baked into Sample C's pe_to_shellcode wrapper:
0x6AE69F02 = 'kernel32.dll' (case-insensitive; PEB walk: BaseDllName)
0x3FC1BD8D = 'LoadLibraryA' (case-sensitive; export name)
0xC97C1FFF = 'GetProcAddress' (case-sensitive; export name)
Smoke-test of verify_sample_text_identity.py against the corpus:
1
2
3
4
5
6
7
8
9
10
$ python3 scripts/verify_sample_text_identity.py sample/extracted/sample_B.bin sample/extracted/sample_C.bin
Sample B raw .text: 592,896 bytes
Sample C raw .text: 593,920 bytes
Sample B trimmed: 592,879 bytes (sha256 84d3d739bf76d53b)
Sample C trimmed: 592,879 bytes (sha256 84d3d739bf76d53b)
.text trim-equal: True
Both samples carry the same PoolParty code body. The size delta
between the two files is the pe_to_shellcode wrapper attached to
Sample C; the inner PoolParty PE is byte-equivalent to Sample B.
Both helpers are read-only against the binaries; nothing is detonated, no network is touched. Drop-in replacements for the corresponding manual disassembly steps in any sibling-sample analysis, since the same hash function and the same pe_to_shellcode bootstrap appear across operators that adopt this delivery shape.
MITRE ATT&CK Mapping
Only positively-observed techniques. We do not list techniques the malware deliberately avoids; the absence of a behaviour is not a MITRE mapping, even when it is operationally interesting (we discuss those negatives in §”Code Weaknesses” instead).
| Tactic | Technique | Sub-technique | Sample(s) | Where it shows up |
|---|---|---|---|---|
| Defense Evasion | Process Injection | T1055 | A, B, C | Cross-process write into a Microsoft-signed host (OpenProcess + VirtualAllocEx + WriteProcessMemory) |
| Defense Evasion | Native API | T1106 | A, B, C (imported); B, C (resolved and called) | NtSetIoCompletion, NtAllocateVirtualMemory, NtWriteVirtualMemory, NtSetInformationWorkerFactory, NtAlpcSendWaitReceivePort, NtAlpcConnectPort |
| Defense Evasion | Reflective Code Loading | T1620 | C only | Sample C’s pe_to_shellcode wrapper reflectively maps the inner PE in-process via the loader stub at 0xCC002 |
| Defense Evasion | Masquerading: Match Legitimate Name or Location | T1036.005 | A | Drops as C:\Windows\<8 lowercase chars>.exe, mimicking a system path |
| Discovery | Process Discovery | T1057 | A, B, C | CreateToolhelp32Snapshot / Process32FirstW / Process32NextW to find a target by image name |
| Discovery | System Information Discovery | T1082 | B, C | Enumerate the worker-factory list (NtQueryInformationWorkerFactory) to pick a target thread |
| Privilege Escalation | Access Token Manipulation | T1134 | B, C | OpenProcessToken + LookupPrivilegeValueW("SeDebugPrivilege") + AdjustTokenPrivileges before injection |
| Execution | Inter-Process Communication | T1559 | B, C | TP_ALPC variant uses NtAlpcConnectPort + NtAlpcSendWaitReceivePort against the target’s ALPC port |
Notable mappings we deliberately did not include:
- T1055.003 (Thread Execution Hijacking): classical thread hijacking suspends a running thread, rewrites its CPU context, and resumes it. PoolParty does not do that; it reuses an idle worker thread that the target’s own pool spawned on its own schedule, by triggering a primitive (queue insertion, completion-port post, ALPC message, etc.) that causes the worker to dispatch attacker-supplied callback bytes. Calling that “thread execution hijacking” stretches the technique. We list T1055 (the parent) and let readers decide how strict their sub-technique mapping needs to be.
- T1055.005 (Thread-Local Storage): not used here; PoolParty deliberately avoids the canonical TLS-callback path and that is why it bypasses several legacy detections. The avoidance is operationally important but it is not a MITRE technique mapping.
- T1070 (Indicator Removal): none of the three samples performs any cleanup. That is a code weakness (covered above) rather than a technique used.
Conclusion
PoolParty was novel research in 2023, became a well-attended weaponization story in 2024–2025 (BOFs, Havoc modules, Metasploit, SharpParty’s MDE bypass), and in 2026 it is commodity loader stock. The technique is bundled, named, and detected, but only partially. Three of eight variants have draft capa rules; five have none. The existing rules fingerprint structure offsets that compiler differences can defeat.
Adding the five missing rules and reshaping the existing three to be less brittle is a small contribution with broad downstream value. If you maintain a capa rule set internally, the .yml files above are ready to drop into nursery/host-interaction/process/inject/. We are submitting them upstream after a brief soak in our own pipeline against the rest of our PoolParty-tagged corpus.
If you want to rerun this hunt: if your organization already has VT Intelligence access, the queries above are directly reproducible through the v3 Intelligence search endpoint; capa is free, and the SafeBreach binary is on GitHub. The overlap with your existing detection-engineering workflow is small; the audit value is large.
All sample analysis was performed inside an isolated container environment. No samples were executed outside the sandbox. The five draft capa nursery candidates in this post are released under the same Apache 2.0 license as mandiant/capa-rules; they are ready for local testing and intended for upstream PR after capa-lint and corpus burn-in.
References / Sources
Original PoolParty research and primary references:
- SafeBreach Labs, Process Injection using Windows Thread Pools: https://www.safebreach.com/blog/process-injection-using-windows-thread-pools/
- Alon Leviev (SafeBreach), The Pool Party You Will Never Forget (BlackHat EU 2023 paper): https://i.blackhat.com/EU-23/Presentations/EU-23-Leviev-The-Pool-Party-You-Will-Never-Forget.pdf
- SafeBreach-Labs/PoolParty reference implementation: https://github.com/SafeBreach-Labs/PoolParty
- LevelBlue / Stroz Friedberg, SharpParty (PoolParty in C#): https://levelblue.com/blogs/security-essentials/sharpparty
Detection-side prior art consulted:
- mandiant/capa-rules upstream tree, including the existing
nursery/rules for TP_WORK / TP_TIMER / TP_IO that motivated the gap analysis: https://github.com/mandiant/capa-rules - ETW Threat Intelligence provider events on
WriteProcessMemory/NtAllocateVirtualMemory(referenced in §”Why classical EDR detections miss it”); see Microsoft’s Windows Internals documentation.
External corroboration on the three corpus samples:
- Valhalla / THOR APT Scanner signature coverage (Nextron Systems):
- Sample B (canonical):
HKTL_Poolparty_Mar24by Arnim Rupp (rule info). The same rule also fires on Sample C. - Sample A (50 KB):
SUSP_EXE_Mal_Payload_Oct10_1andGeneric_Strings_Hacktoolsby Florian Roth. Sample A’s AV consensus at first observation was a notable 5/72, lower than the canonical PoolParty hash, consistent with the dropper’s smaller and stealthier surface. - Sample C: triple-rule hit.
HKTL_Poolparty_Mar24,HKTL_MAL_CobaltStrike_Loader_Feb23_1, andMAL_Shellcode_Mar25all flag the same binary, which corroborates the Cobalt-Strike-via-pe_to_shellcode-plus-PoolParty delivery chain documented in the Code Weaknesses section.
- Sample B (canonical):
- Joe Sandbox report
1371109for Sample B: https://www.joesandbox.com/analysis/1371109/0/html - MWDB CERT-PL record for Sample C (petikvx submission, March 2026 cluster): https://mwdb.cert.pl/file/849e64db81b5bebe1d0b6fb82dd66a1fd8bb4094a016beff6e501bcbbf36e72c
- Hatching Triage replay of Sample C: https://tria.ge/260301-mqyc9scz6g
- Huntress background on the malformed-MZ Cobalt Strike loader pattern that Sample C inherits: https://www.huntress.com/blog/cobalt-strike-analysis-of-obfuscated-malware
- hasherezade /
pe_to_shellcode(the tool Sample C uses to wrap the canonical PoolParty body as a position-independent shellcode payload, allowing the binary to be deployed via memory injection rather than as a normal PE): https://github.com/hasherezade/pe_to_shellcode - VirusTotal community pages (per SHA-256, listed in §”Sample Properties”).
- MalwareBazaar entries for the three corpus SHA-256s (sample/README.md links by hash).
The technical claims in this post were extracted from the binaries on the workbench. The references above are included so a defender can validate any individual finding against an independent source.



