Contents

CLOSEDQUORUM queries four commercial language-model APIs to select a Windows implant's next action. The recovered implementation exposes two JSON-decoding layers, exact-string plurality voting, and dispatch into injection, persistence, or collection routines.

The public distribution build contains significant discrepancies between its advertised capabilities and its execution paths. Selection has no quorum threshold, an empty decision round selects injection, and the main function queries the providers only once. The collection branch produced six browser and wallet staging files, while an ignored LSASS access failure propagated into an invalid-handle failure at the dump API. Both injection branches reached identifiable Windows primitives, but neither demonstrated successful execution of the generated payload.

Cisco Talos first documented and named CLOSEDQUORUM in “The Closed Quorum: Inside the first reported autonomous AI C2 implant”, published on 22 September 2026. This article independently examines the same publicly documented sample. Reconstructed provider responses were used to correlate individual decisions with native control flow and host effects; those executions describe the specimen's capabilities, not commands observed in a confirmed intrusion.

Analyzed sample

The analyzed Windows x64 executable has SHA-256 250d4fa37488af9b025333fa17705573d721467b203765bc360890b4f5a90cd7. It was built with Go 1.24.2, with CGO enabled and DWARF information retained. Original application names such as main.queryLLM, main.interModelDiscussion, and main.earlyBirdInject remain recoverable, making it possible to connect the original abstractions with their compiled implementation.

The provider exchanges described below terminated at a local protocol simulator that returned the provider-specific envelopes required by the sample's parser. The reconstructed bodies are not presented as byte-for-byte replicas of complete public-service responses.

What this analysis adds

Talos reported that the public build contained placeholder API credentials and a dummy webhook, and that complete end-to-end execution was not observed. This analysis reconstructs the provider response contracts and follows the executable from its emitted HTTPS requests through decision decoding, selection, and native command execution.

For steal, that request-to-execution chain is established by decrypted provider conversations and six source-identical browser and wallet file copies. The other experiments establish specific execution boundaries: a Run-value write, two distinct injection attempts, and dispatcher fall-through for move. They do not establish successful LSASS memory acquisition, execution of the malformed payload, or delivery to the invalid webhook. The contribution is the correlation of reconstructed tasking with the executable's actual effects and failure paths.

The application flow

Initialization constructs an orchestrator containing four API keys, a webhook URL, a host-context map, a selected target, and a decision history. Each provider key is the literal dummy_api_key. The webhook is the literal dummy_webhook_url, which is not an absolute HTTP URL.

Those values prevent ordinary authenticated service use, but they do not prevent the executable from constructing requests or entering local behavior. The provider client still makes its requests. The webhook caller ignores its error. Most importantly, failure to obtain any decodable decision leads to an active fallback.

Before the provider round, the program attempts an ETW patch, processes an existing global byte slice, sleeps briefly, and searches a fixed list of running processes. It then asks DeepSeek, Qwen through OpenRouter, Mistral, and Gemini in that order. Successfully decoded responses enter the voting slice. The selected decision reaches the main dispatcher, where only three decision strings have implementations: inject, persist, and steal.

After that single dispatch, the program remains in a sleep loop. There is no back edge from that loop to the provider discussion.

Four sequential provider requests lead to a decoded-decision plurality or an active fallback, one dispatch, and a sleep-only loop.

Figure 1 — One ordered provider round, a selected decision or active fallback, one dispatch, and a sleep-only loop. The empty-round fallback bypasses the decision-report call.

Initialization, task selection, and the trailing loop are separate phases. In this build, continued process lifetime after collection is explained by the sleep loop, not by a return to provider tasking.

What the models receive

The host-context map contains six string values: hostname, operating system, architecture, CPU count, Windows version, and an administrator indicator. The OS and architecture are hardcoded as windows and amd64. The hostname comes from os.Hostname(), and the CPU count comes from the Go runtime.

Two context fields require qualification. The version helper formats the low components returned by GetVersion; it is not an independent operating-system inventory. In the Windows 11 execution requests, it produced 6.2. The administrator indicator is not derived from token elevation or group membership. It begins as false and becomes true if a read-only open of \\.\PHYSICALDRIVE0 succeeds. The resulting value represents that access check rather than a verified elevation state.

The context is formatted with %s into the prompt. It arrives as Go's map representation, rather than as a nested JSON object. A normalized rendering is:

SYSTEM CONTEXT:
map[arch:amd64 cores:2 hostname:<computer_name> is_admin:true os:windows windows_version:6.2]
TARGET: explorer.exe
CAPABILITIES: Process injection, persistence, credential theft, lateral movement

The target selector enumerates processes and searches, in order, for explorer.exe, svchost.exe, winlogon.exe, csrss.exe, and services.exe. It returns the first name found, or explorer.exe if none is present. This value is prompt context. It does not bind injection to that existing process: the injection helpers consume the later target_process field and create a new child with that name below C:\Windows\System32.

Provider protocol: two JSON layers

The four providers use ordinary HTTPS JSON APIs, not a custom binary transport. Every request carries Content-Type: application/json and, in this specimen, Authorization: Bearer dummy_api_key. The native client identifies itself as Go-http-client/1.1. Each provider receives a newly constructed HTTP client with a 30-second timeout.

The endpoints and extraction paths are different enough to matter when reconstructing tasking:

Provider Request path and model Returned text selected by the implant
DeepSeek api.deepseek.com/v1/chat/completions; deepseek-chat choices[0].message.content
Qwen openrouter.ai/api/v1/chat/completions; qwen/qwen-2.5-72b-instruct choices[0].message.content
Mistral api.mistral.ai/v1/chat/completions; mistral-large-latest choices[0].message.content
Gemini generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash-exp:generateContent?key=dummy_api_key candidates[0].content.parts[0].text

DeepSeek receives two messages: a fixed system message and the generated user prompt. Its request also sets temperature to 0.7. Qwen and Mistral receive the user message without that extra system entry or temperature field. Gemini receives the prompt below contents[].parts[].text; it also puts the dummy key in the URL query while retaining the bearer header.

The provider response is only the outer envelope. The extracted content or text must itself contain a JSON document that can be decoded into the implant's LLMDecision structure. For the three chat-completion providers, the structure is conceptually:

HTTP response body
  choices[0].message.content : string
    JSON text decoded as LLMDecision

That inner object carries decision, reasoning, target_process, exploit_type, evasion_method, and payload_config. The program supplies the accepted object's Model from the local provider selector and its Timestamp from the current Unix time. They are bookkeeping fields assigned by the implant, rather than values trusted from the provider.

The prompt advertises four decisions—inject, persist, steal, and move—plus several exploit and evasion choices. These are instructions to the language model, not enforced enum constraints. In particular, the compiler's decision structure does not require that decision be present or match a supported branch.

Permissive parsing changes the failure mode

queryLLM reads the entire HTTP response body and attempts to unmarshal it into a generic map. It does not reject a non-200 status, and it ignores the first unmarshal's error. If the expected text member cannot be extracted, it returns the whole raw response body as the candidate text.

The caller then performs the second unmarshal into LLMDecision. Only that second JSON error excludes an otherwise returned candidate. Missing fields and unknown fields are accepted. An object containing an unrelated provider error therefore can decode successfully while leaving Decision empty. Empty objects and JSON null can have the same effect. Conversely, a provider envelope that contains an expected array with an incompatible first-element type can enter an unchecked type assertion and terminate with a Go runtime panic.

The response parser ignores the outer JSON unmarshal result and looks up the Gemini candidates member using a ten-byte string.

Figure 2 — The outer JSON unmarshal result is ignored. The Gemini branch selects the candidates member with a ten-byte string and checks for the expected slice type.

The pooled string appears as candidatesearly_bird, but the operand's length is 10. Only candidates is used as the map key; the adjacent early_bird text is not part of it. The later unchecked first-element map assertion discussed above is outside this crop.

With the sample's dummy keys, authentication rejection does not necessarily produce an empty voting round. A conventional JSON authentication error may become a counted empty decision. If all four error objects decode that way, the empty string receives four votes and the dispatcher executes no capability. The injection fallback is reserved for a genuinely empty slice: every request must fail to supply content that decodes into the decision structure.

Because the requests are sequential, four full timeout waits could accumulate roughly 120 seconds before selection. That timing follows from the four 30-second client timeouts; it was not measured as a native timeout experiment.

A plurality with no minimum quorum

Selection counts exact Decision strings in a map, then scans the original ordered slice. It replaces the winner only when a candidate's count is strictly greater than the maximum seen so far. The second pass is over the slice, not the count map, so map iteration randomness does not determine the outcome.

A tie is resolved by the first occurrence among the successful providers: DeepSeek, then Qwen, Mistral, and Gemini. If DeepSeek supplied no decodable response, Qwen becomes the earliest possible tie winner. A single surviving decision also wins; there is no minimum participation or majority check.

The voting key is only the decision string. Providers that agree on inject but disagree on target_process or exploit_type are counted together. The winning value is nevertheless one complete provider object, including that object's target, exploit type, and reasoning. The code does not merge parameters, vote on each field, or reconcile conflicting payload configurations. Within the winning action, the first matching candidate supplies those other fields.

A second ordered scan looks up each decision string's count, replaces the winner only on a strict increase, and returns the selected complete object.

Figure 3 — The strict-greater comparison preserves the first maximum in provider order. The selected value is the whole decision object, not a merged set of independently voted parameters.

For a decoded winner, the sample attempts a Discord-style decision report before returning it to the dispatcher. The body contains an embeds array, a title of Model Decision: <Model>, and fields for the decision, target process, exploit type, and reasoning. Reasoning is truncated to 1,024 bytes. An RFC3339 timestamp is included. The POST result is ignored, so the invalid dummy_webhook_url does not stop dispatch. The hardcoded empty-round fallback is returned from its separate branch without this report call.

The fallback is active injection

When no candidate survives the second decode, the code constructs a decision whose Model is consensus and whose Decision is inject. consensus is the attribution field, not the dispatch selector. Its target is explorer.exe, its exploit type is process_hollow, and its evasion method is obfuscate.

The payload map also contains injection_method: early_bird, sleep_time: 0, and encryption: aes-256. Those labels do not control the branch. The dispatcher tests ExploitType, so process_hollow selects the image-base overwrite routine despite the contradictory early_bird entry.

The empty-round experiment used four valid provider envelopes whose text held a fixed non-JSON string. Thus the exchanges succeeded as HTTP, but all four inner decisions failed decoding. The resulting attempt reached CreateProcessW with C:\Windows\System32\explorer.exe and CREATE_SUSPENDED. That call returned FALSE; the recorded native status was STATUS_INVALID_PARAMETER. Remote context and memory operations were not reached. The existing interactive shell at C:\Windows\Explorer.exe was not adopted as a target.

This is a fail-open task-selection design in the examined build: no valid decisions selects an injection attempt, rather than simply ending the round.

One decision, then sleep

The main function contains one provider discussion and one dispatch. Its subsequent loop sleeps for the current delay and assigns a new inclusive random delay between 300 and 900 seconds. The initial delay is 300 seconds. The back edge stays inside that sleep logic.

There is consequently no implementation basis for describing this exact sample as polling its models every five to fifteen minutes. The delays keep the process alive after its chosen action, but they do not return to the request builder or refresh the target. The advertised move value makes this distinction especially visible: it can win selection, miss all three branches, and leave the process sleeping without lateral-movement activity.

Startup telemetry modification and the fixed payload

The startup routine resolves ntdll.dll!EtwEventWrite by name and passes one byte, 0xC3, to a local patch helper. That is a RET instruction. The helper requests PAGE_EXECUTE_READWRITE, writes the byte, then attempts to restore the previous protection.

Neither VirtualProtect result is checked, and the helper does not flush the instruction cache. The recovered code establishes an ETW patch attempt, not a verified change to the export in the examined executions. There is no AMSI patch in this routine.

The patch helper requests writable executable protection, copies the supplied bytes, and attempts to restore the old protection without checking either return.

Figure 4 — The patch helper requests PAGE_EXECUTE_READWRITE, writes the supplied bytes, and attempts to restore the previous protection. Neither protection-change result is checked.

Next, the routine hashes local time formatted to the minute, YYYY-MM-DD HH:MM, with SHA-256 and XORs an existing global shellcode slice using digest bytes modulo 32. The global slice's pointer, length, and capacity are zero-initialized, and the reviewed initialization does not populate them. A loop over a zero-length slice changes nothing. This path should not be described as an encrypted secondary payload without actual populated bytes. Bytes displayed by a disassembler for unbacked storage are not evidence of serialized shellcode.

The prelude finally sleeps for a random 100–500 milliseconds. The random helper reads eight bytes, ignores the random-reader status, reduces the value modulo the inclusive range, and adds the lower bound.

A 32-byte construction, not a working decoder

The injection payload comes from a different function. Despite accepting a payload_config map, that generator ignores it and returns exactly 32 bytes. It selects a one-byte XOR key K from 1–255 and appends a fixed encoded body to a fixed prefix:

00–09  FC 48 83 E4 F0 E8 C0 00 00 00
0A     K
0B     14
0C–1F  20 fixed bytes XOR K

The prefix begins with cld, stack alignment, and a relative call. The call's next-instruction address is offset 0x0A; adding its displacement 0xC0 targets offset 0xCA, beyond the 32-byte region that the sample writes. No reachable XOR decoder precedes that transfer.

Removing the XOR independently yields a body ending with 48 8D 3D 00 00 00, an incomplete x64 lea missing a displacement byte. The key and 0x14 length marker resemble decoder metadata, but metadata does not supply the missing executable decoder. The generated buffer is therefore not coherent, self-contained x64 shellcode.

The payload generator XORs twenty fixed bytes, appends the random key and length marker to a fixed prefix, and returns thirty-two bytes.

Figure 5 — The generator XORs 20 fixed bytes with a key from 1–255, appends the key and length marker, and returns a 32-byte buffer.

The complete thirty-two-byte local source buffer contains the fixed prefix, XOR key 5F, length marker 14, and encoded body.

Figure 6 — The complete 32-byte local source buffer at WriteProcessMemory entry, with XOR key 0x5F and length marker 0x14. This is not a read-back of the remote destination.

inject: APC injection and image-header overwrite

inject requests execution in a newly created child process. Its three operative task fields are the action string, target_process, and exploit_type. The object uses the same provider-specific HTTP envelope as collection: JSON text in message.content or Gemini's text, decoded into the decision structure.

The Early Bird experiment used the following decoded field values, excerpted from the retained response:

{
  "decision": "inject",
  "target_process": "winver.exe",
  "exploit_type": "shellcode"
}

The response also contained reasoning, an evasion label, and a payload configuration map. None supplied replacement machine code. The image-base experiment selected process_hollow with the same winver.exe target; both experiments exercised the embedded generator rather than a response-provided payload.

The dispatcher generates that same buffer for every inject decision. An exact exploit_type of process_hollow chooses the image-base path. Every other exploit-type value chooses the APC path. Consequently, labels such as dll, reflective, or an empty string do not select distinct DLL or reflective-loader implementations here.

The target contract is likewise narrower than a general remote-process selector. Each helper constructs C:\Windows\System32\<target_process> and requests a new suspended child. Neither injects into the existing process that originally appeared in prompt context.

The branch comparison is the only selection between these two implementations. payload_config.injection_method does not override it, and selecting process_hollow does not change the generated bytes. The action, branch selector, and payload generator must therefore be interpreted separately: a provider can choose an injection path without supplying a functional payload for that path.

APC delivery to a suspended child

The Early Bird helper resolves CreateProcessW, allocates zeroed startup and process-information structures, and calls process creation with CREATE_SUSPENDED (0x4). The reviewed code does not initialize the startup structure's required cb member. Only the process-creation return is checked.

It then requests a remote allocation with MEM_COMMIT | MEM_RESERVE (0x3000) and PAGE_EXECUTE_READWRITE (0x40). The allocation length is the generated buffer length, 0x20. That remote address becomes the destination of WriteProcessMemory and then the APC routine address passed to NtQueueApcThread on the primary thread. The three APC arguments are zero. Finally, the helper resumes that thread and returns true.

WriteProcessMemory entry arguments identify the child-process handle, remote allocation, local source buffer, and thirty-two-byte write length.

Figure 7 — WriteProcessMemory entry arguments: process handle 0x480, remote destination 0x28124720000, local source 0xC00000FC20, and length 0x20. The view shows the requested write, not its return or subsequent APC delivery.

At this native boundary, RCX carries the child-process handle, RDX the remote allocation, R8 the local source pointer, and R9 the byte count. The source pointer corresponds to the 32-byte buffer in Figure 6. Combined with the preceding allocation record, these arguments connect the generated bytes to the intended remote destination. They do not establish that the child's thread subsequently executed those bytes.

The helper's return value is not an injection-completion indicator. A true result means that child creation passed the checked condition and the routine issued the remaining calls. Allocation, writing, APC execution, and the payload's effect are not verified by that boolean.

In the paired debugger observations, the sample supplied C:\Windows\System32\winver.exe, a 104-byte all-zero STARTUPINFOW, and CREATE_SUSPENDED. Process creation succeeded. The allocation and successful write both used 32 bytes. NtQueueApcThread returned NTSTATUS 0, and ResumeThread returned one, matching the prior suspend count.

A separate standalone execution recorded a directly parented child named winver.exe, followed by exit status 0xC0000005. It ended before an exact image-path identity could be retained. The debugger sequence demonstrates the intended injection primitive; the parent/name/crash observation does not, by itself, establish that the APC ran or that the malformed buffer caused the crash. Successful payload execution was not established.

process_hollow writes at the image base

The other helper creates a suspended child and requests its initial thread context through NtGetContextThread with ContextFlags = 0x10000B. It treats returned RDX as a PEB pointer, reads 0x2C8 bytes of the remote PEB, and extracts the qword at offset 0x10, PEB.ImageBaseAddress.

The next write is not to a calculated entry point. It attempts to place the 32-byte buffer at the image base itself, over the leading PE image bytes. The helper does not unmap the original image, construct a replacement PE, apply relocations, resolve a new entry point, change the destination page protection, or redirect the thread context. It then resumes the original thread.

The image-base branch builds five WriteProcessMemory arguments using the PEB image-base field as the destination and the generated payload buffer as the source.

Figure 8 — Reserved3[1] represents the PEB's ImageBaseAddress field at offset 0x10. That image base becomes the write destination; it is not a replacement entry point.

The five-element argument array is significant. It contains the child-process handle, the PEB-derived image base, the local payload pointer, the payload length, and a null bytes-written pointer. There is no addition of an entry-point RVA to the destination. The attempted write begins at the mapped image's header, and the thread's original execution context is retained.

This is not completed process hollowing. The narrower description is an image-header overwrite attempt against a suspended child. That distinction is supported by the native return: in the focused debugger run, WriteProcessMemory returned FALSE, and the same thread's immediate error was 998, ERROR_NOACCESS. A read-only inspection still showed the original MZ bytes at the child's image base. The sample ignored the failed write and resumed the thread; ResumeThread returned one.

The recorded WriteProcessMemory return boundary shows zero in RAX and LastError 3E6, ERROR_NOACCESS.

Figure 9 — The recorded write-return boundary shows RAX = 0 and ERROR_NOACCESS (0x3E6). A separate read confirmed that the remote image retained its MZ header.

The standalone child in this path survived the observation interval, consistent with the original image continuing after the ineffective write. This is an execution-specific failed write, not a claim that every Windows system must produce the same return.

Taken together, the two experiments establish different stages of an injection attempt. The APC path reached allocation, a successful 32-byte write, queueing, and resume; it did not establish execution of a coherent payload. The image-base path reached context retrieval, PEB reading, and an unsuccessful write before resuming the original thread. These are useful command-to-API correlations, but they are not interchangeable with successful injection or completed process hollowing.

persist: three attempts, not three established mechanisms

The persistence routine obtains its own executable path and uses it for three separate mechanisms. A provider decision needs only decision: persist; no target-process or payload field supplies a different executable for these operations.

The first operation opens the current user's Software\Microsoft\Windows\CurrentVersion\Run key and writes a WindowsUpdate value containing the sample's path. Native inspection recovered the exact value as REG_SZ after an ordinary-user execution. The stored path was unquoted. That proves a Run-value write; it does not prove a future logon relaunch, particularly for paths whose interpretation depends on spaces or quoting.

The persistence routine opens the current-user Run key, obtains its own executable path, and writes a WindowsUpdate string value.

Figure 10 — The Run-key path obtains the executable's own path and writes it as a REG_SZ value named WindowsUpdate under HKCU.

The task attempt invokes schtasks through Go's argument vector:

/create /tn \Microsoft\Windows\WindowsUpdate /tr <sample_path> /sc minute /mo 5 /f

These are distinct arguments, not a shell-expanded command string. The request specifies a five-minute schedule and overwrite behavior. It does not request /ru SYSTEM or /rl HIGHEST. On the examined Windows 11 system, the requested task path also coincided with a legitimate Windows Update task folder. A folder or a similarly named TaskCache key is therefore not sufficient evidence of a registered, runnable task. No runnable matching task, action, task XML, or task-file identity was established in the executions examined here.

The WMI attempt writes C:\Windows\Temp\wmi.ps1 and invokes:

powershell -ExecutionPolicy Bypass -File C:\Windows\Temp\wmi.ps1

Its filter is named WindowsUpdateFilter. The query watches __InstanceModificationEvent every 60 seconds for a Win32_PerfFormattedData_PerfOS_System target, with root\cimv2 as the event namespace. The intended consumer is named WindowsUpdateConsumer and is supposed to execute the sample's own path.

The retained script contains implementation defects, rather than merely an environmental prerequisite. Its class paths have doubled separators between namespace components. Two assignment lines are consumer.Name = ... and consumer.CommandLineTemplate = ..., without the $ required to reference the consumer variable. The final binding is created without assigning its Filter and Consumer references. The previously saved filter and consumer paths do not reach that binding.

An ordinary-user execution retained the original malformed script and the exact Run value. Matching named WMI filter, consumer, and binding inventories remained unchanged. These results should not be compressed into a single persistence-success label: the Run write is established, while scheduled-task registration, a working permanent WMI subscription, and persisted relaunch are not.

steal: one sequential collection command

The collection branch makes three synchronous calls in a fixed order: LSASS dump attempt, browser-file staging, then wallet-file staging. There is no goroutine launch around those three calls. An asynchronous webhook sender inside the LSASS file helper does not turn the dispatcher into three concurrent collectors.

The command does not use target_process to choose an arbitrary process or payload_config to supply collection paths. The LSASS name and the browser, wallet, and staging locations are embedded in the implementation.

A five-byte steal decision selects three synchronous collection calls, followed by the separate sleep-only loop.

Figure 11 — The steal branch calls the LSASS, browser, and wallet helpers in order. The following loop sleeps and updates the delay without returning to provider tasking.

The packed comparison reads 0x61657473 in little-endian order as stea; the fifth byte, 0x6C, completes steal. It is a compiled string comparison, not a separate numeric command identifier.

Delivering the collection decision on the wire

The collection experiment answered all four provider requests with the same valid six-field inner decision. Empty target and exploit fields are sufficient for steal, because this branch does not consume them:

{
  "decision": "steal",
  "reasoning": "Fixed research profile for the aggregate collection branch.",
  "target_process": "",
  "exploit_type": "",
  "evasion_method": "obfuscate",
  "payload_config": {}
}

Figures 12–15 preserve all four conversations from one provider round. Red text is the request emitted by the sample; blue text is the response returned by the local simulator. Each response uses the provider's non-streaming outer field layout and places the same escaped six-field decision at the path consumed by the executable. Response identifiers, creation values, fingerprints, and token counts are deterministic synthetic metadata; the model fields repeat identifiers recovered from the sample. None of these bodies came from a public model service. Only the simulator's product name is redacted in the figures.

The DeepSeek request uses POST /v1/chat/completions, selects deepseek-chat, and carries both the fixed system message and the generated user prompt, with temperature set to 0.7. Its response contains the chat-completion object and model identifiers, a system fingerprint, detailed usage fields, and one completed assistant choice. The executable reads choices[0].message.content and decodes that string again as the LLMDecision object.

A Follow HTTP stream shows the sample-origin DeepSeek request and deterministic emulated response carrying the steal decision.

Figure 12 — DeepSeek receives the sample's system and user messages. The escaped choices[0].message.content string in the response decodes to steal.

The Qwen request is routed through OpenRouter at POST /api/v1/chat/completions. It selects qwen/qwen-2.5-72b-instruct and carries one user message rather than DeepSeek's system-and-user pair. The response uses OpenRouter's normalized chat-completion layout, including a nullable system fingerprint, compact token accounting, and one assistant choice. The executable uses the same choices[0].message.content extraction path.

A Follow HTTP stream shows the sample-origin Qwen request through OpenRouter and deterministic emulated response carrying the steal decision.

Figure 13 — Qwen is requested through OpenRouter with the recovered model identifier. The normalized response carries the escaped steal object in choices[0].message.content.

Mistral also receives a single-message POST /v1/chat/completions, but the request targets api.mistral.ai and selects mistral-large-latest. Its response contains a Mistral completion identifier, model and object fields, token usage, and one assistant choice. The parser again extracts choices[0].message.content; the provider-specific distinction lies in the surrounding request and response contract rather than in the inner decision schema.

A Follow HTTP stream shows the sample-origin Mistral request and deterministic emulated response carrying the steal decision.

Figure 14 — The Mistral conversation uses its own host and model while retaining the chat-completion extraction path. The assistant content string decodes to steal.

Gemini uses a different request and response grammar. The model appears in POST /v1beta/models/gemini-2.0-flash-exp:generateContent?key=dummy_api_key, and the prompt is stored below contents[0].parts[0].text. The response places the escaped decision below candidates[0].content.parts[0].text, assigns the content role model, records finishReason: STOP, and includes usage, model-version, and response-identifier fields.

A Follow HTTP stream shows the sample-origin Gemini generateContent request and deterministic emulated response carrying the steal decision.

Figure 15 — Gemini carries the prompt and decision through contents and candidates rather than a chat-completion choices array. The escaped text value decodes to steal.

All four returned decision strings decode to the same steal object, giving the action four votes. These conversations establish provider tasking and parser acceptance. They contain neither browser nor wallet bytes, and the capture contains no subsequent webhook exchange. The source/destination comparisons described below independently establish the six local staging copies.

From decrypted tasking to collection results

The matching TLS session keys make the captured HTTPS records available as complete HTTP conversations in Wireshark. Figures 12–15 therefore expose the request and response bodies, including the host context, provider-specific JSON members, and the exact collection decision. No browser or wallet file is embedded in those bodies.

steal is a single aggregate action. There are no separate Chrome, Firefox, Ethereum, Exodus, or MetaMask command identifiers in this dispatcher, and payload_config does not select their source paths. After the response is decoded and selected, the sample executes its embedded LSASS → browser → wallet sequence. The network evidence establishes the delivered task; the native call records and local file comparisons establish the corresponding execution.

Together, the provider conversations, recovered dispatcher, and six confirmed staging files establish the request-to-local-collection chain. They do not establish an exfiltration path. The browser and wallet helpers have no transfer call, and the reviewed LSASS sender uses a webhook value that cannot produce a valid HTTP request in this build.

LSASS: access denial followed by an ignored invalid handle

The LSASS helper attempts to enable SeDebugPrivilege, enumerates processes to find the exact name lsass.exe, and calls OpenProcess with access mask 0xFFFF. It opens C:\Windows\Temp\lsass.dmp with Go flags 0x242, O_RDWR | O_CREATE | O_TRUNC, and passes the resulting file handle to MiniDumpWriteDump with dump type 2, MiniDumpWithFullMemory.

The important implementation issue is that none of the privilege, process-open, file-open, or dump-call outcomes reliably gates the later flow. File creation happens before the dump succeeds. A path named lsass.dmp therefore does not establish memory acquisition.

The LSASS helper uses the unchecked OpenProcess handle in a seven-argument dump call and then passes the fixed dump path to the file sender.

Figure 16 — The process-open result is retained without a null-handle check. It becomes the first of seven dump-call arguments, and the fixed output path is subsequently passed to the file sender regardless of the dump result.

The focused native-call diagnostic found the current LSASS process and captured the exact failure chain. OpenProcess(0xFFFF, FALSE, <lsass_pid>) returned NULL. The immediate same-thread Win32 error was 5, ERROR_ACCESS_DENIED. The sample saved that null handle and continued. CreateFileW then opened the fixed output path successfully.

At the dump boundary, the first argument was still NULL. The second argument was the selected LSASS PID, the third was a valid output-file handle, and the fourth was 2. MiniDumpWriteDump, resolved through DbgHelp to DbgCore, returned FALSE with immediate HRESULT 0x80070006, HRESULT_FROM_WIN32(ERROR_INVALID_HANDLE). This is a separate error from the earlier Win32 5: process access was denied first, and a null process handle was then passed to the dump API.

The paired native calls returned:

OpenProcess(0xFFFF, FALSE, 824)
  return: NULL
  immediate Win32 LastError: 5 (ERROR_ACCESS_DENIED)

MiniDumpWriteDump(NULL, 824, 0x240, 2, NULL, NULL, NULL)
  return: FALSE
  immediate HRESULT: 0x80070006 (ERROR_INVALID_HANDLE)

Windows independently recorded LSASS as a protected process at level 4, and RunAsPPL remained 2. That is consistent with ordinary LSA protection preventing a nonprotected process from reading LSASS. It does not establish PPL as the sole possible cause of every failure, but it provides an independent explanation consistent with the observed denied open. Microsoft's LSA protection documentation describes this protection boundary; the MiniDumpWriteDump reference separately specifies its HRESULT-based failure reporting.

The standalone collection executions produced a zero-byte placeholder. The elevated debugger diagnostic initially observed an empty file but measured a 2,894-byte partial artifact at the failed dump's native return. Metadata alone did not establish a valid dump or its internal format, and the diagnostic stopped before the subsequent file-read and encryption call. The difference from the standalone zero-byte result remains unexplained.

The sample's defect is not that Windows denied protected-process access. It is that the code ignores that denial, passes the null handle onward, and does not use the dump result to decide whether its fixed output path should be processed as captured data.

Browser collection is a raw file-copy operation

The browser helper performs three path-specific copy operations in the order Chrome, Firefox, then Edge. Chrome and Edge are restricted to their Default profiles. Firefox uses the first directory-listing entry whose name contains .default-release. The helper does not parse browser records or decrypt stored passwords.

Chrome's source is %LOCALAPPDATA%\Google\Chrome\User Data\Default\Login Data, copied to C:\Windows\Temp\chrome_logins.db. Edge's source is %LOCALAPPDATA%\Microsoft\Edge\User Data\Default\Login Data, copied to C:\Windows\Temp\edge_logins.db. Firefox is located below %APPDATA%\Mozilla\Firefox\Profiles; the selected profile's logins.json is copied to C:\Windows\Temp\firefox_logins.json.

The browser helper constructs Chrome's fixed Default Login Data path, invokes the copy helper, and begins constructing the Firefox Profiles root.

Figure 17 — Chrome's fixed Default\Login Data path is followed by construction of the Firefox Profiles root. The Edge branch occurs later in the same helper.

The string operands in this figure carry both a pointer and a length. APPDATA has length 7; any adjacent pooled text displayed after those seven bytes is not part of the environment-variable name or the path the sample constructs.

Chrome's path is assembled from os.Getenv("LOCALAPPDATA") and a fixed suffix, then passed directly to the shared copy helper. There is no profile enumeration or selection based on an active browser session. A Profile 1 directory is outside this path, as are Cookies, Web Data, and the browser's Local State file. The destination's .db suffix is simply a staging name; it does not indicate that SQL records were extracted.

Firefox uses ioutil.ReadDir on the Profiles root and searches each entry name for the substring .default-release. It does not consult profiles.ini or installs.ini, and the comparison is not an exact profile-name or suffix check. Go's ReadDir contract returns filename-sorted entries, making the selected entry the first matching name in that ordering. After constructing its logins.json path, the caller invokes the copy helper and breaks even if that copy produces no destination file. It does not try a second matching profile.

Edge is processed after the Firefox scan. It repeats the fixed LOCALAPPDATA-relative Default\Login Data copy with its own destination name. Neither Chromium path collects companion key material or converts the source database into a credential list.

The distinction from usable credential recovery is material. Copying Chromium Login Data does not show that any encrypted password value was interpreted. Copying Firefox logins.json does not show that the companion key material was obtained or that any record was decrypted. No such decryption or provider upload occurs in this reviewed helper.

The shared helper calls os.ReadFile and invokes os.WriteFile only when the read error is nil. The write receives the entire returned byte slice and file-mode argument 0x1A4 (0644). There is no intermediate record parser, archive, or per-record transformation. The helper does not create the destination's parent directory and discards the write error, so its caller receives no reliable indication that staging succeeded. No database-aware snapshot or locked-file recovery mechanism is implemented.

The shared copy helper writes the source bytes only after a successful read and ignores the destination-write result.

Figure 18 — A source ReadFile error gates the copy, but the destination WriteFile result is ignored. The helper does not create parent directories.

Fixed source markers occupied the three expected browser paths. Each staged output matched its corresponding source bytes, establishing path selection and raw copying rather than password recovery.

In a separate focused debugger pass, the debugger stopped at the LSASS helper's entry and restored the caller's saved return context before any instruction in the helper body executed. Execution then continued into browser collection. The pass stopped before the shared os.WriteFile instruction executed. At that boundary, the raw register record identified the Chrome staging path through RAX and RBX; RCX pointed to the bytes returned by os.ReadFile, and RDI held the authoritative byte count. The 96-byte buffer in Figure 19 is a fixed synthetic marker, not a recovered credential record. It makes the copy boundary visible without implying that the helper interpreted the database or transmitted its contents.

At the shared file-write call, x64dbg's dump pane shows the fixed Chrome marker across six sixteen-byte rows.

Figure 19 — A publication crop of x64dbg's dump pane follows RCX at the shared write boundary and retains exactly the fixed 96-byte Chrome marker. The corresponding register record contains RDI = 0x60 and the C:\Windows\Temp\chrome_logins.db destination; these are local-copy arguments, not credential decoding or network transfer.

These file effects follow the steal value visible in Figures 12–15; they are not additional HTTP transactions. The captured responses select the aggregate handler, while the three source-identical destinations demonstrate that its browser-copy calls executed.

Wallet staging flattens paths

The wallet helper runs after browser staging and uses the same read-gated copy routine. Its order is Ethereum, Exodus, then MetaMask. Ethereum material comes from %APPDATA%\Ethereum\keystore; non-directory walk entries are copied under C:\Windows\Temp\crypto using their basenames. Exodus contributes %APPDATA%\Exodus\exodus.wallet, copied to C:\Windows\Temp\crypto\exodus.wallet.

Ethereum uses filepath.Walk with a closure that captures the destination prefix. The callback tests FileInfo.IsDir, skips directory entries, and combines the prefix with FileInfo.Name for every other entry. It does not restrict collection to a particular filename pattern or inspect a keystore's JSON fields. The source path is used to read bytes; only the basename is used to construct the output path.

Exodus is a single fixed-file operation rather than a directory walk. A missing or unreadable exodus.wallet causes the shared helper to return without writing, while the wallet handler proceeds to the next collector. There is no password input, wallet-format parser, or decryption step in this branch.

MetaMask is located in Chrome's fixed Default extension-storage path:

%LOCALAPPDATA%\Google\Chrome\User Data\Default\Local Extension Settings\nkbihfbeogaeaoehlefnkodbefgpgknn

Its non-directory walk entries are copied below C:\Windows\Temp\crypto\metamask, again by basename. The walk is not equivalent to parsing a wallet, recovering a seed phrase, or unlocking its encrypted values. Flattening also discards source-directory structure: the destination name is not sufficient to reconstruct a file's original relative path, and nested files with identical basenames can target the same output name.

The MetaMask extension identifier is embedded in the source suffix; the helper does not discover installations through Chrome extension metadata or search other browser profiles. Its callback copies storage files as opaque bytes. It does not query extension state, interpret database records, or assemble an export format. Because os.WriteFile truncates an existing destination before writing, basename collisions can replace a previously staged file if the later copy succeeds.

A compiled wallet walk callback immediately dereferences FileInfo, joins a captured destination prefix with the entry basename, and invokes the shared copy helper.

Figure 20 — A compiled walk callback dereferences FileInfo immediately, combines the captured destination prefix with the entry's basename, and calls the shared copy helper.

The destination hierarchy must already exist for these writes to work. The sample's shared copy helper does not create crypto or crypto\metamask. With those directories present, one fixed Ethereum marker, one Exodus marker, and one MetaMask storage marker each appeared as a byte-identical staging copy.

There is also an error-path defect in both walk callbacks. They ignore the supplied err and immediately call through the info interface to test whether the entry is a directory. Go's WalkFunc contract specifies that a failed Lstat, including a missing walk root, supplies info = nil. The recovered callbacks therefore expose a nil-interface dereference on that path rather than a clean skip. Since Ethereum is walked first, such a failure could interrupt collection before Exodus and MetaMask are reached. This is a code-derived failure condition, not a reproduced native panic in the executions described here.

The sample continued through all six browser and wallet copies despite the denied LSASS path. That establishes continuation through the synchronous collection sequence after the process-open failure. The three wallet outputs were confirmed through the same source/destination correspondence as the browser outputs. Their bytes are absent from the provider conversations: neither wallet traversal nor browser staging invokes the encrypted file sender.

The focused pass stopped again at the shared write boundary when the Ethereum walk selected UTC--synthetic-wallet. The raw register record shows RAX resolving to the flattened staging path, RCX pointing to the fixed marker, and RDI = 0x60. This stop occurred before that particular write instruction executed; the separate complete collection experiment established the resulting byte-identical wallet files.

At the shared file-write call, x64dbg's dump pane shows the fixed Ethereum marker across six sixteen-byte rows.

Figure 21 — A publication crop of x64dbg's dump pane follows RCX and retains exactly the fixed 96-byte Ethereum marker. The corresponding register record contains RDI = 0x60 and the C:\Windows\Temp\crypto\UTC--synthetic-wallet destination; the buffer is not evidence of a seed phrase, key recovery, or network transfer.

The encrypted webhook path

Only the LSASS helper directly reaches the reviewed file-exfiltration pipeline from steal. After closing its output file, it passes the fixed path to a helper that reads the file, encrypts the returned bytes, and starts a webhook sender asynchronously. File-read and several crypto or network errors are not reliably propagated.

The encryption uses AES-256-GCM, but its key is derived from the host's local calendar date rather than from a secret shared with an operator. The date is formatted as YYYYMMDD and hashed with SHA-256. A fresh GCM nonce comes from crypto/rand. The sealing operation has no additional authenticated data, and the result places the nonce before the ciphertext and 16-byte tag:

key = SHA-256(local YYYYMMDD)
output = nonce || ciphertext || GCM tag
transport = standard Base64(output)

An eight-byte local-date format feeds SHA-256, whose thirty-two-byte digest initializes AES and GCM.

Figure 22 — The eight-byte local-date layout feeds SHA-256; the 32-byte digest initializes AES and GCM. Nonce generation and sealing occur later in the helper.

The date layout uses the same pointer-and-length representation: the eight bytes 20060102 define Go's YYYYMMDD formatting. Adjacent pooled text beyond length 8 is not included in the layout or the derived key.

The key derivation offers no secrecy against someone who knows the relevant date. The random nonce still serves GCM's per-message requirement; it does not make the predictable key secret. The date is local to the host, so an analysis based solely on a UTC capture date must account for the local-date boundary.

The sender Base64-encodes the encrypted bytes and divides the resulting text into 1,900-character segments. These are encoded-text characters, not 1,900 plaintext bytes or fixed-size HTTP packets. Each segment is placed inside a code fence in a JSON content field and POSTed to the webhook. The routine sleeps for one second after each attempt, ignoring the POST error.

The encryption and sender routines are present in the code, but the fixed invalid webhook URL produced no captured request, nonce, or ciphertext. Provider tasking and encrypted file transfer are different call paths with different bodies. The established collection result is local raw staging plus the failed LSASS attempt, not encrypted exfiltration of those six files.

move: advertised lateral movement without a handler

move refers to the lateral-movement capability advertised in the prompt, not to renaming or moving a file. It is accepted by the same decision decoder and plurality counter as inject, persist, and steal, and it can appear in the best-effort decision report. The main dispatcher, however, has no corresponding branch or lateral-movement routine reached through this value.

The experiment returned a valid six-field decision object from each provider with decision: move, empty target and exploit fields, and an empty payload map. Each provider used its normal outer response format. All four inner objects therefore passed decoding and supplied the same voting key; this was not a malformed-response or connection-failure test.

After selection, the four-byte action string matches none of the dispatcher's three implemented comparisons. The remaining decision fields are not passed to another handler, and control continues into the sleep loop. The absence is therefore an implementation boundary, not a lateral-movement operation that was attempted and rejected by Windows.

The standalone study retained 21 liveness observations over approximately 20 seconds after the provider round. The only sampled direct child was the ordinary Windows console host. The inspected Run value, scheduled task, named WMI objects, dump path, and browser-staging paths did not change. A separate experiment with the fixed action unsupported_fixed_value produced the same fall-through boundary, providing a negative control for accepted but unhandled decision values.

The short observation period corroborates dispatcher fall-through; it does not cover the 300–900-second sleep interval or establish absence of every possible host effect. The sleep-only continuation is established by the recovered control flow. Startup behavior also precedes task selection, so selecting move does not suppress the program's initialization or ETW patch attempt.

The protocol can thus carry and select a decision that the executable cannot implement. move has a valid representation in provider tasking but no native command effect established through that selector. The reasoning and payload fields do not supply the missing implementation.

Detection implications

The four provider domains are legitimate shared services. Contact with any one of them is not a standalone malware indicator. More distinctive evidence is the sequence from an unexpected Windows executable: similar host-context requests to several providers, followed by one of the sample's local action patterns.

Where lawful TLS inspection or provider-side telemetry exposes the body, the structured capability prompt and six-field response contract are strong content anchors. Without plaintext, provider identity, process attribution, ordering, and subsequent host behavior still offer useful correlation. The exact execution context matters more than a blanket domain block.

On the host, the collection branch joins access to LSASS with raw reads of browser and wallet paths and writes under C:\Windows\Temp. Denied process opens remain useful evidence; a failed dump does not erase the attempted credential-access behavior. A detection should not require a valid lsass.dmp before recognizing the attempted access, nor infer successful theft merely because that basename exists.

For injection, the APC sequence and the image-base path should be named separately. Suspended child creation, a short RWX allocation, remote write, APC queue, and resume describe the first primitive. Context acquisition, PEB reading, and an image-base write attempt describe the second. Neither warrants a claim of successful payload execution or completed hollowing solely from API presence.

The Windows Update-themed Run value, schtasks request, and generated WMI script provide another correlated cluster. Their common names are not sufficient by themselves; the own-path value, exact command-line shape, raw script defects, and originating process make the attribution stronger. The specimen demonstrates why attempted persistence and runnable persistence must be kept distinct.

Closing observations

CLOSEDQUORUM's distinctive feature is the placement of commercial model responses at the decision boundary of a Windows implant. In this exact build, that boundary is permissive: HTTP errors can become votes, there is no minimum quorum, agreement covers only the action string, and total decode failure selects injection. The resulting action still depends on ordinary compiled code, including its unchecked returns and implementation defects.

The command-level evidence makes those differences concrete. Browser and wallet files were staged as raw bytes. LSASS access was denied, and the null handle reached a failed dump call. The Run value was written while the task and WMI outcomes remained unestablished. The injection routines reached recognizable primitives without establishing successful payload execution. The advertised move decision never reached an implementation.

The protocol and execution evidence establish what this distribution build selects, attempts, and accomplishes, rather than the broader capabilities implied by its prompt.