C2Looper: Reconstructing a GitHub C2 Backdoor, Command by Command
Key Findings
C2Looper is a Rust backdoor packaged as a Windows proxy DLL. Loading the DLL is enough to start the implant: its DllMain loads the legitimate wtsapi32.dll from System32, resolves CreateThread, and starts a long-running worker. A forwarded WTS export does not need to be called first.
The sample uses the GitHub Contents API as a mailbox. It derives a bot ID as <computer_name>_<username> and maintains three repository objects beneath it:
beacon.jsonfor liveness and timestamp updates;cmd.jsonfor a Base64-wrapped command object; andresult.jsonfor Base64-wrapped command output.
uploadis named from the operator's perspective. On the host it is an inbound download: the sample fetches<bot>/upload/<arg>and writes the bytes beneath%TEMP%.injectis local-process execution, not remote-process injection. The preferred path overloadswinspool.drv; the observed fallback allocated private memory, copied a 329-byte benign payload, changed the page from RW to RX, and started a local thread.- The GitHub success parser contains a reproducible bounds bug. A short, valid-looking success response causes a Rust panic because the sample blindly requests a 400-byte slice after finding
"content":{. - HTTP 200 only confirms result publication. A handler can publish an
Error:string and still receive HTTP 200 for the result PUT. After a successfulrundeleted the target, a later command against the same path returnedError: ShellExecute=2. - The sample does not use API hashing in the analyzed paths. It reconstructs selected names with XOR and resolves plaintext names through ordinary
LoadLibraryWandGetProcAddresscalls. No malware-defined direct or indirect syscall path was found.
Analyzed Sample
Zscaler ThreatLabz published C2Looper: A New Backdoor Likely Tied To Ransomware With GitHub C2 on August 17, 2026.
| Field | Value |
|---|---|
| SHA-256 | |
| File type | PE32+ x86-64 DLL, 240,640 bytes, Rust |
| Proxy role | wtsapi32.dll, forwarding 41 WTS exports |
| C2 transport | GitHub Contents API over HTTPS |
Proxy DLL Execution
The DLL presents itself as wtsapi32.dll. On DLL_PROCESS_ATTACH, DllMain:
- constructs the path to the legitimate
C:\Windows\System32\wtsapi32.dll; - loads it and resolves the forwarded WTS functions; and
- resolves
CreateThreadand starts the worker thread.
The worker starts during DLL load, so C2 activity can begin before the host calls any forwarded WTS export.
Configuration and API Resolution
Selected strings use a repeating eight-byte XOR key:
DB AA 78 17 C3 17 BC F7
The corresponding little-endian constant is 0xF7BC17C31778AADB. The decoder at 0x1800269E0 selects a key byte with input_index & 7 and XORs it with each input byte.
At runtime, the same transform reconstructed VirtualAlloc; the result was passed in RDX to GetProcAddress.
Encrypted configuration contains a 93-byte GitHub PAT-formatted credential. For revocation and hunting, this report retains only its SHA-256:
a17c799967ad4dab2900bcdd63b7d537f34d345b29936d88ab6556d28e8e3bbc
The owner of this embedded credential should treat it as exposed and revoke it.
No custom API hashing
mw_resolve_kernel32_api at 0x180026920 and mw_resolve_winhttp_api at 0x180027120 rebuild their DLL names, then use imported LoadLibraryW and GetProcAddress. Most callers pass plaintext API names. The injection block XOR-deobfuscates a few names before the same ordinary resolution flow.
Calls through the resulting pointers are normal indirect user-mode API calls. No export-name hash loop, syscall-number extraction, clean-ntdll stub lookup, or custom direct/indirect syscall mechanism was identified.
GitHub Contents as a C2 Mailbox
| Field | Value |
|---|---|
| Host | api.github.com:443 |
| Owner/repository | adioziaete/memio |
| Branch | master |
| User-Agent | OneDrive/24.170.0825.0001 |
Contents Accept |
application/vnd.github.v3+json |
| Bot ID | <computer_name>_<username> |
| Object prefix | /repos/adioziaete/memio/contents/<bot_id>/ |
Beacon format
mw_github_update_beacon at 0x1800251F0 constructs compact JSON:
{"id":"<computer_name>_<username>","ts":1787161278}
The timestamp calculation converts FILETIME to Unix seconds. The JSON is Base64-encoded once and written to <bot_id>/beacon.json. A missing object produces a create PUT; later updates include the cached content SHA.
Command format and replay gate
Commands are compact JSON wrapped once in the Contents response's Base64 content field:
{"seq":4,"cmd":"recon","arg":""}
The dispatcher extracts the three fields at these sites:
| Field | Address |
|---|---|
seq |
0x1800032FD |
cmd |
0x180003842 |
arg |
0x180003B46 |
The gate at 0x180003CB3–0x180003CB8 requires new_seq > last_seq; commands execute only when their sequence exceeds the last accepted value.
Result format
Every handler returns a string. The worker serializes:
{"seq":4,"output":"<handler output>"}
The compact bytes are Base64-encoded at 0x180004D91 and passed to mw_github_put_content at 0x180004DE4 with commit message r.
Content SHA and retry behavior
Create PUTs omit sha; updates include the last returned content.sha. On HTTP 409, the helper fetches the current object SHA and returns RETRY:<sha> to the caller, which caches it for a later loop. The SHA cache accepts 64 bytes and copies at most 63 characters plus the terminator.
The protocol tracks two independent state values:
last_seqprevents command replay; and- the content SHA coordinates GitHub object updates.
A reproducible 400-byte parser bug
The success-response parser contains a fixed-window bounds error.
After finding "content":{, the code adds 0x190 (400) to the match offset and asks mw_checked_utf8_slice_ptr to validate that full window. A minimal 62-byte success body therefore reaches Rust's bounds panic:
thread '<unnamed>' panicked at src\lib.rs:341:22:
end byte index 401 is out of bounds for string of length 62
The parser unconditionally validates match_offset..match_offset+400 before searching that window for sha; only then does it fall back to the full response. The 62-byte body panicked, whereas the 512-byte padded response satisfied the bounds check. This requirement comes from the parser bug, not TLS.
Decrypted HTTP Protocol
The captured TLS 1.2 connections used TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384. Each decrypted exchange pairs a result PUT with its HTTP/1.1 200 response. The figures retain the public repository path and protocol framing while masking sensitive identifiers; normalized output strips are clearly separated editorial annotations.
The request body is not double-Base64. The layering is:
handler output
→ compact {seq,output} JSON
→ Base64 once
→ GitHub Contents PUT JSON
→ TLS
Command-by-Command Analysis
All eight command checks are inlined in the worker.
| Command | Entry | Behavior |
|---|---|---|
ping |
0x180004035 |
Returns a bot-specific pongv2 string |
ls |
0x180003ECE |
Enumerates one directory level |
drives |
0x180003F9D |
Enumerates logical drives |
recon |
0x18000407E |
Aggregates six host and domain discovery commands |
upload |
0x180004235 |
Downloads a repository object into %TEMP% |
shell |
0x1800042D5 |
Executes a command and captures stdout/stderr |
run |
0x1800040AA |
Opens a path, waits 2.5 seconds, then deletes it |
inject |
0x1800045F1 |
Executes decoded bytes locally through module overloading or private RW→RX memory |
ping — bot-specific liveness
The inline dispatch branch at 0x180004035 does not require arg. It builds the return buffer directly from the constant prefix !!! v2 !!! pongv2 from and the bot ID derived earlier from GetComputerNameW and GetUserNameW; no secondary command helper is involved.
It returned:
!!! v2 !!! pongv2 from <computer_name>_<username>
The pongv2 prefix is a useful plaintext pivot in process memory or decrypted traffic.
ls — directory enumeration
The handler implementation at 0x180003ECE matches the two-byte, little-endian value ls and treats arg as a directory path. It trims trailing backslashes, appends \*, converts the search path to UTF-16, and enumerates one level with FindFirstFileW, FindNextFileW, and FindClose. The loop skips . and .., tests FILE_ATTRIBUTE_DIRECTORY, and combines nFileSizeHigh and nFileSizeLow for regular-file sizes.
If FindFirstFileW fails, the handler returns Error: cannot open <original_arg> plus LF. Once enumeration begins, any false FindNextFileW ends the listing without a GetLastError check. Output is formatted as:
[FILE] <name> (<size> bytes)
[DIR] <name>\
The implementation returns an empty result for an empty directory.
The branch maps to ATT&CK T1083 (File and Directory Discovery); its endpoint signature is the FindFirstFileW/FindNextFileW/FindClose sequence over <arg>\*.
drives — logical-drive enumeration
The dispatch entry at 0x180003F9D resolves GetLogicalDriveStringsW at 0x180003FDB and calls it at 0x18000400A with a fixed 0x200-WCHAR buffer. The handler does not consume arg. It walks the returned double-NUL-terminated drive list one root at a time, converts each UTF-16 root to UTF-8, and appends LF at 0x180004538.
It returned:
C:\
D:\
The endpoint signature is GetLogicalDriveStringsW in the side-loaded process followed by a GitHub Contents result PUT.
recon — six discovery commands
The branch at 0x18000407E calls mw_collect_recon at 0x180027D90, which runs six fixed command lines in order and ignores arg. It adds a banner before each command and passes it to mw_execute_shell_capture at 0x180026B00.
The shell helper prepends cmd.exe /c chcp 65001 >nul && , creates an anonymous pipe, clears inheritance on its read handle, assigns the shared inheritable write handle to stdout and stderr, launches the command with CreateProcessW, drains output with ReadFile, waits for completion, and closes the process, thread, and pipe handles. Command errors remain in the aggregate output:
ipconfig /all
whoami /all
nltest /dclist:
net group /domain "domain computers"
net group /domain "domain admins"
wmic product get name, version
Each block receives a banner in the aggregate output. After Base64 decoding, the captured result showed host network configuration; user, group, and privilege data; failed domain lookups on the workgroup host; and an installed-software inventory.
Sanitized excerpt:
========== ipconfig /all ==========
Windows IP Configuration
Host Name . . . . . . . . . . . : <computer_name>
IPv4 Address. . . . . . . . . . : <private_ip>
========== whoami /all ==========
<computer_name>\<username> <user_sid>
========== nltest /dclist: ==========
Cannot find DC to get DC list from. Status = 1355 ERROR_NO_SUCH_DOMAIN
========== wmic product get name, version ==========
<installed-software inventory omitted>
The aggregate was approximately 20 KB and was byte-identical across two executions; only seq changed in the encoded result object.
Correlating all six commands is more specific than matching any one command.
upload — actually an inbound download
The branch treats arg as a repository object beneath <bot_id>/upload/; on the host, upload is a download. mw_github_download_content first looks for inline content, JSON-unescapes it, and Base64-decodes it once. If inline content is absent, it extracts download_url and performs a raw GET. The observed 115-byte transfer used this fallback.
The handler writes the response beneath %TEMP% with CreateFileW and WriteFile, then returns either OK: saved <size> bytes to <path> or an Error: string from the failing stage.
For the 115-byte lab_probe.txt fixture, it returned:
OK: saved 115 bytes to C:\Users\<username>\AppData\Local\Temp\lab_probe.txt
The inert 115-byte source and destination both had SHA-256 399ef0ea04014a39810d18a97ea806bf8f66875dd1024e06d622097a6fff513e, confirming byte-for-byte inbound delivery.
The defining sequence is a Contents metadata GET, a raw-object GET, and a write beneath %TEMP%.
shell — captured command output
The handler at 0x1800042D5 passes the complete arg value to mw_execute_shell_capture at 0x180026B00. The helper prepends cmd.exe /c chcp 65001 >nul && , creates an anonymous pipe, prevents the read handle from being inherited, and assigns the shared inheritable write handle to stdout and stderr. It launches the child with CreateProcessW, collects output through ReadFile, waits, closes the process, thread, and pipe handles, and returns either the captured bytes or an explicit error.
The executed command was:
echo C2LOOPER_SHELL_PROFILE_OK
The returned marker includes one trailing space and CRLF:
C2LOOPER_SHELL_PROFILE_OK \r\n
The branch accepts an arbitrary command line; echo exercised its pipe and process-capture path.
Correlate a side-loaded process spawning a command shell with redirected output through a shared anonymous pipe and a subsequent GitHub Contents result PUT.
run — launch, delay, and delete
The branch at 0x1800040AA treats arg as a launch path and expands environment variables with ExpandEnvironmentStringsW. It calls ShellExecuteW(NULL, L"open", expanded_path, NULL, NULL, SW_SHOWNORMAL) and treats values of 33 or greater as success.
On success, the continuation at 0x180005ABB sleeps for 2.5 seconds, calls DeleteFileW on the same path without checking its return value, and returns OK: launched <path>. Lower ShellExecuteW values become Error: ShellExecute=<code>.
For the staged text target, the handler returned:
OK: launched C:\Users\<username>\AppData\Local\Temp\\c2looper_run_probe.txt
The doubled separator comes from concatenating a %TEMP% value ending in \ with an argument beginning with \.
A later command against the deleted path returned:
Error: ShellExecute=2
GitHub accepted that error result with HTTP 200.
The endpoint sequence is ShellExecuteW, a 2.5-second delay, and DeleteFileW against the same temporary path.
inject — module overloading with a private-memory fallback
The branch at 0x1800045F1 trims and Base64-decodes arg, then resolves LoadLibraryExW, VirtualAlloc, VirtualProtect, CreateThread, CloseHandle, and Sleep. It first tries to place the decoded buffer in the executable section of a locally loaded winspool.drv. The observed fallback instead allocates private PAGE_READWRITE memory, copies the buffer, changes it to PAGE_EXECUTE_READ, and starts a thread at that address.
Both paths remain inside the backdoor process. Their status strings identify the selected path; only the fallback also reports the decoded size.
winspool.drv module overloading
The module-overloading path:
- calls
LoadLibraryExW("C:\\Windows\\System32\\winspool.drv", NULL, DONT_RESOLVE_DLL_REFERENCES)at0x18000663D; - locates the PE
.textsection; - changes it to writable at
0x180006880; - copies decoded bytes at
0x1800068AB; - sets the section to execute/read at
0x1800068BE; and - starts a thread inside the modified section at
0x18000691C.
Private RW → RX allocation
The fallback at 0x180006743 allocates MEM_COMMIT|MEM_RESERVE read/write memory, copies the bytes, changes the page to PAGE_EXECUTE_READ at 0x1800067C8, and calls CreateThread at 0x1800069D5.
The payload properties were:
| Property | Value |
|---|---|
| Decoded size | 329 bytes (0x149) |
| SHA-256 | 16ca5af0dcf8fccac1875c26143e11f4e4d9df9e5ca21e70596dac215e55c2ba |
| Effect | Load user32.dll, show a fixed MessageBox, return |
At runtime, the executable-memory bytes matched the same hash. The thread reached LoadLibraryA("user32.dll"), displayed the fixed MessageBox test dialog, and returned. The handler reported:
[VirtualAlloc] Injected OK (size=329)
The result buffer records the published status; debugger state separately establishes the RW→RX transition.
At the return from VirtualAlloc (0x18000674B), RAX held a new committed 0x1000-byte PAGE_READWRITE region. Figure 29 shows that return state and the zeroed page.
At the VirtualProtect call, RCX still pointed to the allocation, RDX was 0x1000, and R8 was 0x20 (PAGE_EXECUTE_READ). On return at 0x1800067CF, EAX=1, the saved prior protection was 0x04, and the region reported PAGE_EXECUTE_READ.
CreateThread then received the allocation in R8 as lpStartAddress and returned a nonzero handle, completing the private RW→RX→thread sequence.
Detection and Hunting Opportunities
Network detections
A high-confidence decrypted-traffic signature combines:
Host: api.github.com
User-Agent: OneDrive/24.170.0825.0001
URI prefix: /repos/adioziaete/memio/contents/
Object suffix: /beacon.json, /cmd.json, or /result.json
Additional pivots:
- frequent GET/PUT traffic beneath a path shaped as
<computer_name>_<username>; Accept: application/vnd.github.v3+jsonon Contents GET requests;- compact Base64 content decoding to
{seq,cmd,arg}or{seq,output}; - commit message
ron results; and - raw-object retrieval immediately after a Contents response containing
download_url.
Without TLS inspection, correlate api.github.com SNI or destination telemetry with the loading context and endpoint sequences below. GitHub alone is not a useful indicator.
Endpoint detections
| Behavior | High-value sequence |
|---|---|
| Proxy loading | Non-system wtsapi32.dll → legitimate System32 copy → worker thread from DLL load |
| Recon | ipconfig /all → whoami /all → nltest /dclist: → domain group queries → wmic product |
| Shell capture | CreatePipe → inheritable handles → CreateProcessW → repeated ReadFile |
| Run-and-delete | ExpandEnvironmentStringsW → ShellExecuteW → ~2.5 s sleep → DeleteFileW same path |
| Module overloading | LoadLibraryExW(winspool.drv, flag 1) → writable .text → thread in module section |
| Fallback execution | private RW allocation → copy → RX protection → thread start at private page |
Static pivots
The following strings are useful in combination:
adioziaete/memio
OneDrive/24.170.0825.0001
!!! v2 !!! pongv2 from
[ModuleOverloading] Injected into winspool.drv
[VirtualAlloc] Injected OK (size=
Indicators
| Indicator | Type | Context |
|---|---|---|
f96ff2f3abbff7f382ace509b90e54853b4b61c402ecde27d82f1c17b414867b |
SHA-256 | Analyzed sample |
api.github.com |
Domain | C2 transport; low-confidence alone |
adioziaete/memio |
Repository | C2 namespace |
/repos/adioziaete/memio/contents/ |
URI prefix | Contents API traffic |
OneDrive/24.170.0825.0001 |
User-Agent | Masquerading client identifier |
<computer_name>_<username>/{beacon,cmd,result}.json |
Path pattern | Per-host mailbox |
<computer_name>_<username>/upload/<arg> |
Path pattern | Inbound file delivery |
wtsapi32.dll |
DLL name | Proxy DLL used by the analyzed sample |
winspool.drv |
Module | Preferred module-overloading target |
a17c799967ad4dab2900bcdd63b7d537f34d345b29936d88ab6556d28e8e3bbc |
SHA-256 | Embedded PAT-formatted credential hash |
The normalized host identity, private addresses, private CA and leaf certificate, lab_probe.txt, c2looper_run_probe.txt, fixed echo marker, and benign MessageBox strings are analysis artifacts, not malware IOCs.
Conclusion
C2Looper starts its worker from DllMain, uses GitHub Contents objects as beacon, command, and result mailboxes, and tracks state with both seq and the current content SHA. Its eight handlers are inlined in the worker.
In the observed execution, inject selected the private RW→RX fallback and executed its benign 329-byte test payload inside the backdoor process.
Detection should correlate proxy-DLL loading, the repository/User-Agent pair, the six-command reconnaissance burst, and the private RW→RX→CreateThread sequence.