Ten captures recovered from exposed directory listings. Expand a section to read the full analysis, code excerpts, artifact evidence, and stated limits.
A System Information Tool Hands Downloaded XML to MSBuild
Native call sites, a misleading success flag and the decoded x64 WinINet downloader.
A System Information Tool Hands Downloaded XML to MSBuild
The captured system_info.exe presents a system-information screen, downloads recipe.xml, and asks MSBuild to run it. Its success message is misleading: the downloader returns an HTTP-operation flag, rather than the result of writing the XML, creating MSBuild, or running its embedded task. The adjacent C++ source makes the mistake explicit, and the native binary corroborates the download and process-launch path.
I found the files under /root/data/15.222.235.229. This is an IP-named saved directory, but I found no matching acquisition command in the retained root Bash history. Its endpoint provenance is therefore weaker than the recursive mirrors with matching history entries. The source and decoded payload independently reference 15[.]222[.]235[.]229; that establishes a configured endpoint, not an acquisition timestamp or a currently operating service.
A native launcher with a managed second layer
Both system_info.exe and nfo.exe are native x86 PE32 console programs, image base 0x400000, entry RVA 0x13e0, with zero CLR-directory RVAs. A .NET decompiler would miss their downloader logic. I parsed the imports with pefile and reviewed x86 instructions with Capstone on the VPS.
For system_info.exe, the CRT path calls the application main routine at VA 0x4ae5c0. That routine calls the system-information display at 0x4ae600, then the downloader at 0x4ae61e. The downloader begins at 0x401550. The following native anchors tie the behavior to this executable's hash:
| Native call VA | Resolved import |
|---|---|
0x401581 | WinHttpOpen |
0x4015af | WinHttpConnect |
0x4015f5 | WinHttpOpenRequest |
0x40163b / 0x4016eb | WinHttpSendRequest / WinHttpReceiveResponse |
0x40171b / 0x4017bd | WinHttpQueryDataAvailable / WinHttpReadData |
0x401855 / 0x401973 | GetTempPathA / WriteFile |
0x401b4c | CreateProcessA |
The adjacent baba.cpp describes that same path: connect to port 80, request /recipe.xml, save it under the temporary directory, and try three MSBuild locations. These are the Framework64 path, the Framework path and finally MSBuild.exe resolved through the process environment. The application does not fetch an executable named โupdate.โ The fetched object is a build project that contains executable task code.
The first read is also the last read
The source calls WinHttpQueryDataAvailable once and allocates dwSize + 1 bytes. It performs one WinHttpReadData, then writes the returned byte count. It has no response-body loop. Native calls at 0x40171b and 0x4017bd corroborate that sequence; the reviewed success path proceeds toward the file write rather than looping to query the next chunk.
That is a consequential dependency. A response can be available in pieces, so this logic does not establish that the saved XML is complete. It also does not validate an HTTP status code, content type, digest or project signature before handing the file to MSBuild.
The source's result propagation is even weaker. These are selected original statements from baba.cpp:38โ47, 93โ104, 134, with comments added by me:
BOOL bResults = WinHttpSendRequest(/* arguments omitted */);
if (bResults) {
bResults = WinHttpReceiveResponse(hRequest, NULL);
}
// Much later, process creation and a 15-second wait occur.
// Neither the process result nor its exit code replaces bResults.
return bResults;
The main routine chooses its โSystem update completed successfullyโ message using that return value (baba.cpp:168โ171). Successful response handling can therefore produce the update-success message even when later work fails. The native main tests the downloader's return at 0x4ae623โ0x4ae625, consistent with this design.
The process wait is 0x3a98, 15,000 milliseconds, at 0x402259โ0x402264. The program reads and prints the process exit code afterward, but the source sets executed = TRUE and breaks as soon as process creation succeeded. It does not require a successful MSBuild exit code. A timeout does not terminate the child.
The XML decodes APIs and starts raw machine code
recipe.xml declares a MicrosoftUpdate target that invokes TrustedComponent, an inline C# task supplied through CodeTaskFactory and Microsoft.Build.Tasks.v4.0.dll (recipe.xml:1โ7). The task's Execute method is the actual second-layer entry point.
Three base64 strings decode to VirtualAlloc, CreateThread and WaitForSingleObject. I decoded them as data on Linux and checked the names against their use in the task. Lines 35โ48 load the system library, resolve each address and construct delegates. Lines 131โ139 XOR the embedded byte array, allocate executable memory, copy the result and start it as a thread.
Analyst reconstruction of recipe.xml:131โ139:
decoded[i] = encrypted[i] XOR the embedded single-byte key
memory = VirtualAlloc(NULL, decoded.length, 0x1000, 0x40)
copy decoded bytes to memory
thread = CreateThread(..., memory, ...)
WaitForSingleObject(thread, 0xffffffff)
The decoded array is 904 bytes, SHA-256 963dd1d5fcc26f63111fb6adc461203fb7e964be5b983a1acf1016eab435c78e. It begins with plausible x64 setup instructions and a PEB-walking API resolver, rather than an MZ header. I kept the decoded bytes on the VPS and did not execute or export them.
The inner downloader uses a different HTTP stack
The decoded resolver rotates a 32-bit accumulator right by 13 and adds each byte. Its module-name loop uppercases ASCII lowercase characters, while the export-name loop includes the terminating zero. I independently recalculated the combined module/export hashes. Among the matching values are:
| Decoded offset | Constant | Verified resolution |
|---|---|---|
0xea | 0x0726774c | KERNEL32.DLL!LoadLibraryA |
0x188 | 0xa779563a | WININET.DLL!InternetOpenA |
0x1bb | 0xc69f8957 | WININET.DLL!InternetConnectA |
0x2bc | 0x3b2e55eb | WININET.DLL!HttpOpenRequestA |
0x2fa | 0x7b18062d | WININET.DLL!HttpSendRequestA |
0x35b | 0xe2899612 | WININET.DLL!InternetReadFile |
These offsets are relative to the decoded 904-byte array. Inline strings interrupt the instruction stream, so I disassembled separate code blocks around those strings rather than accepting a linear sweep through them as code.
The inner downloader loads wininet, embeds an iPad Safari user agent at 0xfa, and embeds the configured host at 0x199. It supplies port 0x1bb, 443, at 0x1ac. After a failed send, it loads 0x1388, 5,000 milliseconds, and resolves Sleep; a counter initialized to ten controls retries (0x2c8โ0x31f). The later read loop requests 0x2000, 8,192 bytes, per call (0x34bโ0x377). Its allocation sequence requests four MiB with executable memory protection. This download layer is distinct from the outer launcher's WinHTTP request for XML.
The x64 code also limits the advertised launcher fallback. The first source MSBuild path is Framework64; the second is the 32-bit Framework location. Starting a 32-bit task host would not make these x64 instructions into a compatible payload. The native launcher's own x86 format does not resolve that dependency.
nfo.exe is a separate build, not a hash duplicate. Its process-creation call is at 0x4018da, and its wait at 0x40195d uses 10,000 milliseconds rather than 15,000. I do not assign all of baba.cpp's details to that executable merely because it sits nearby. Likewise, bro.c is actually a PowerShell command line that downloads XML and invokes MSBuild, not compilable C source.
Detection and evidence limits
I would correlate an unexpected system-information executable writing %TEMP%\recipe.xml, starting framework MSBuild, and a subsequent WinINet connection using an iPad Safari user agent. The filename alone is weak. MSBuild tasks that decode API names, allocate executable memory and start a thread provide a more specific content lead; legitimate build tasks can use native interop, so the sequence matters.
The outer download supports T1105 Ingress Tool Transfer. The inline task supports T1127.001 MSBuild, and its base64/XOR processing supports T1140 Deobfuscate/Decode Files or Information. These are static capabilities. I did not compile the task, launch either PE, contact either configured service, recover the next download, or establish full source-to-binary equivalence. The hashes and native paths support the staged-loader finding; they do not establish a successful infection or family attribution.
| Artifact | SHA-256 |
|---|---|
system_info.exe | 38e513da03ceaf361da6a57eabb08672b8810194cf77d4749b645e9f12fb06c6 |
nfo.exe | 7af8578adde7511d764c7dc1494b9812fdaf5f8178b3413872ee47c9777f50c9 |
baba.cpp | 06f817181ff154b60b68afc51d4c892cca5ef83269f2d9a99828370f39589585 |
recipe.xml | d74ec5f34bd0592802d526bcc3e3c6b898390fc39b2bfc7639ff4b3cb95c7ae3 |
A WINMM Proxy Delays Its Exports Before Loading CLR Code
The forwarding gap, companion host, WinHTTP options and managed assembly invocation path.
A WINMM Proxy Delays Its Exports Before Loading CLR Code
winmm_v14.dll exposes 89 multimedia exports, but its process-attach routine starts a worker that sleeps for three seconds before resolving their destinations. The export trampolines do not wait. An application calling one of those exports during that interval can therefore encounter an uninitialized destination. This is a materially different design from the VERSION loader I reviewed earlier: this DLL implements forwarding, but initializes it late.
I traced the x64 DLL and adjacent C source in /root/cryptol/45.154.98.13:9998. The matching acquisition command is recorded in the batch's evidence manifest where available; the directory name alone does not establish an acquisition date or infrastructure role. This post covers the v14 loader and two host-program builds, rather than treating every neighboring version as equivalent.
The native entry point establishes the delay
The DLL is native PE32+, image base 0x28e050000, with no CLR directory. Its PE entry RVA is 0x1340, the runtime wrapper. The application DllMain is at 0x3880; process attachment reaches these instructions:
; winmm_v14.dll RVAs; analyst annotations. 0x3898 call [DisableThreadLibraryCalls] 0x38ae lea r8, [rip-0x2105] ; worker RVA 0x17b0 0x38bd call [CreateThread]
; Worker begins separately. 0x17c3 mov ecx, 0xbb8 ; 3,000 milliseconds 0x17c8 call [Sleep]
Those calls agree with winmm_v14.c:368โ379,452โ457. After the delay, the worker decodes strings, resolves additional APIs, attempts exclusions and installation, and only then calls resolveFwd.
The source's forwarding resolver constructs the genuine system-directory winmm.dll path, loads it, and fills function pointers with GetProcAddress (235โ281). timeGetTime, for example, is exported at RVA 0x38dc as an indirect jump through a global pointer. Its export has no PE forwarder string. The initially zero-filled destination belongs to the DLL's BSS, and DllMain does not populate it before starting the delayed worker.
This establishes an initialization hazard, not an observed crash. A host that merely loads the DLL and waits avoids immediately exercising those exports; a host that expects working multimedia APIs has a stronger dependency on initialization order.
Three seconds is the initial sleep, not a complete upper bound on this interval. The exclusion helper can wait up to 15 seconds for a child process, and installation can invoke that helper again before forwarding is resolved. The native export path contains no corresponding readiness wait.
The host source does not describe both executables
host_exe.c says it forces loading through a static timeGetTime import, then sleeps for ten minutes. The retained host_exe.exe does not import WINMM. Its imports include LoadLibraryA and Sleep from KERNEL32 instead. I do not equate that binary with the neighboring source based on its filename.
The separate host_v2.c explicitly builds a path to winmm.dll beside its own executable, calls LoadLibraryA, and loops over 60-second sleeps. The host_v2.exe instructions corroborate that path: GetModuleFileNameA at RVA 0x14beโ0x14c5, string append at 0x153bโ0x1542, LoadLibraryA at 0x154bโ0x1552, then Sleep(60000) at 0x1554โ0x1562. It does not need to call a multimedia export to keep the loader thread alive.
This is a purpose-built loader host. The evidence does not establish a successful load by an unrelated, legitimate application.
Fifty-one encoded arrays survive into the DLL
I parsed the C byte-array initializers as data and found all 51 encoded arrays in the retained DLL. That is a stronger source/binary connection than matching a few plaintext strings, although it is not a reproducible-build claim.
The first decoding layer XORs string bytes with 0x3b. Narrow strings include the encoded terminator; wide strings are processed for their full array length. The worker's native loop begins at 0x17e5, with byte XORs at 0x17ee and 0x180c. Recovered anchors include:
| Encoded array | DLL RVA | Decoded role |
|---|---|---|
w_ho | 0x52c0 | Host com-selfhelp[.]page |
w_pa | 0x5280 | Path /static/agent.dat |
w_rt | 0x5330 | CLR runtime v4.0.30319 |
w_ae / w_rn | 0x5310 / 0x5308 | Managed type AgentEntry, method Run |
s_run_val | 0x5408 | Run value WinServiceHost |
s_ad_sub | 0x53f0 | AppData suffix \Microsoft\WinServices |
The source comment claims that the import table shows โonly kernel32.โ The actual DLL imports KERNEL32, msvcrt and USER32. Its WinHTTP, COM-automation and registry functions are dynamically resolved, but the broader comment is false.
Download first, then host the CLR
The worker connects to the recovered host on port 8443 and requests the recovered path with WinHTTP's secure-request flag. Native RVA 0x30c1 supplies 0x20fb, 8443; 0x30e5 supplies request flag 0x00800000. At 0x313d, the code supplies 0x3300 to security option 31.
I checked those constants against Microsoft's WinHTTP header. Option 31 is WINHTTP_OPTION_SECURITY_FLAGS; 0x3300 combines ignore-unknown-CA (0x100), ignore-wrong-usage (0x200), ignore-common-name (0x1000) and ignore-date (0x2000) flags. The helper's return value is not checked. The implementation attempts to relax those certificate checks; successful option application or a completed TLS connection was not observed.
The receive path starts with a 65,536-byte heap buffer. It queries available bytes, doubles capacity to twice the required total when necessary, and reads into the accumulated offset (winmm_v14.c:392โ400; native 0x3193โ0x3250). The loop ignores the query/read return values. In particular, the code does not establish forward progress when a read reports zero bytes while availability remains nonzero.
The downloaded bytes receive a second XOR transform using a separate 12-byte repeating key. I verified the modulo-indexed native loop at 0x348cโ0x34b6; I have omitted the key value. No agent.dat file was found in the two retained Cryptol roots, so there is no recovered next-stage plaintext or successful decode claim here.
The loader then resolves CLRCreateInstance, obtains runtime/domain interfaces, creates a byte SAFEARRAY, and loads the downloaded assembly. Source lines 414โ445 culminate in resolving AgentEntry.Run and invoking it. The native instructions include COM calls through vtable offsets 0x168 at 0x367e, 0x88 at 0x36da, another 0x168 at 0x3732, and 0x128 at 0x37a2. Unlike the earlier VERSION capture's minimal CLR test, this path proceeds beyond SAFEARRAY scaffolding toward assembly loading and method invocation.
Installation and defense changes are attempts
The source copies a host EXE and WINMM DLL into the AppData subdirectory and writes a current-user Run value (340โ363). Copy failures abort that installation path. The Run-key update uses an open operation rather than creating a missing key, another condition on success.
The exclusion helper constructs a PowerShell Defender-exclusion command. With an elevated token it launches directly; otherwise it uses a per-user ms-settings association and fodhelper.exe, then removes the association (318โ337). I have not reproduced the command or registry-write sequence as a deployment recipe. Static review establishes the attempt and its conditions, not elevation or a successful protection change on any Windows build.
| Static capability | Supported ATT&CK mapping |
|---|---|
| WINMM impersonation and explicit companion-DLL host | T1574.001 DLL, entry and forwarding paths above; legitimate-host loading unobserved |
| Receive and decode the managed stage | T1105 Ingress Tool Transfer and T1140 Decode Files or Information, worker receive/XOR loops |
| Current-user Run value | T1547.001 Registry Run Keys, source 340โ363 |
| Conditional elevation and Defender-exclusion attempts | T1548.002 Bypass UAC and T1685 Disable or Modify Tools, source 318โ337 |
For hunting, correlate the WinServiceHost value, paired host/WINMM files under AppData, %TEMP%\winsvc.log, and a process loading CLR and making the configured HTTPS request. The multimedia filename or a CLR load alone is weak. The three-second export gap is useful in forensic reconstruction, but is not a network signature.
The reviewed scope is v14 and the named host artifacts. Other versions, an adjacent Agent6.exe, actual stage contents, successful persistence and runtime export behavior remain unresolved. Captured programs and scripts were not executed, and configured endpoints were not contacted.
| Artifact | SHA-256 |
|---|---|
winmm_v14.dll | 145d725efc40e35691764ac28e2c693c379a906a48298a4d9da8433a7ab59922 |
winmm_v14.c | 8ca6c22df6e91d7d13f5ad15d45a3b4d71f9e1eac607d12f5bddf27bbd8c1478 |
host_exe.exe | 710cae02b8e3066c1fefab6893f94a7799683c005111dfde548de5f22a002866 |
host_v2.exe | 710cae02b8e3066c1fefab6893f94a7799683c005111dfde548de5f22a002866 |
ASP.NET Wrappers Cache Assemblies Across Requests
Request-handler review exposes a two-phase encrypted assembly protocol, application-wide state and response markers that do not authenticate the payload.
ASP.NET Wrappers Cache Assemblies Across Requests
The ASP.NET handlers in this capture do not contain a conventional command switch. Their first successful request decrypts and loads a managed assembly; later requests create its LY class and trigger it through overridden object methods. Three files use the same application-wide cache entry, whose name is still the literal {payloadStoreName}. A fourth uses a static hashtable named session, which is not ASP.NET per-user session state.
I reviewed these wrappers in the 146[.]88[.]129[.]138:8888 mirror, along with the metadata and registration entry of adjacent native IIS modules. The root also contains security tools and scanning material. Those neighboring filenames are not evidence that every file is malware or that the wrappers were deployed together.
Start at the actual request handlers
gtxy.ashx and system.ashx are byte-identical, not merely similar. Each declares an IHttpHandler implementing IRequiresSessionState, with ProcessRequest(HttpContext) as the request entry point. Their only substantive operation is on source line 7.
The handler reads the request body through BinaryRead(Request.TotalBytes) and passes the resulting bytes, starting at offset zero for Request.ContentLength, to RijndaelManaged.CreateDecryptor(...).TransformFinalBlock(...). The supplied key and IV are the same 16-byte ASCII value. I compared the source literals across all four wrappers without publishing their values.
No mode or padding override appears. The framework's documented defaults are CBC mode and PKCS#7 padding. With the supplied 16-byte key and default 128-bit block size, the implemented wrapper is AES-128-CBC. This is a static reconstruction of the code, not a successful decryption of a captured request.
The wrapper itself does not inspect an authentication token, require a particular HTTP method, or check the caller's application role. Successful decryption and the surrounding IIS/ASP.NET configuration determine whether execution can proceed. I did not establish that a real server exposed these handlers without additional authentication.
First request: store an assembly
After decryption, the null-cache branch invokes the byte-array overload of Assembly.Load through reflection:
Analyst reconstruction of gtxy.ashx:7 and sitemap.asmx:9:
decoded = decrypt(request_bytes)
if application_cache["{payloadStoreName}"] is null:
application_cache["{payloadStoreName}"] = Assembly.Load(decoded)
else:
dispatch_to_cached_assembly(decoded)
The body of the first successful request is therefore assembly material. It is not automatically a shell command. The loader does not write a DLL to disk before loading it on this path, and the wrapper does not explicitly call an LY method in that first branch.
That distinction also affects response interpretation. The raw handler writes encrypted output only in its later branch. A successful first load can consequently produce an empty response. The ASMX variant similarly leaves its response builder empty during initial loading. An empty result can mean initial staging, failed decryption, failed loading, or another exception swallowed by the broad catch; it is not a reliable success indicator.
The same cache key appears in both raw handlers and sitemap.asmx. If those files run inside the same ASP.NET application, they address the same Application collection entry. The placeholder was not replaced with distinct per-handler names. Initializing through one endpoint can therefore change the branch taken by another. The check and assignment are not protected by an explicit lock in these snippets, so simultaneous first requests also have a check-then-set race.
This is application state, not victim-specific state. Implementing IRequiresSessionState does not make Context.Application private to a session. Likewise, the EnableSession = true attribute on the service method does not change which collection its implementation actually uses. The raw handler's IsReusable getter returns false (11โ16); constructing another handler instance still does not clear the application collection.
Later requests: an object-method dispatch convention
The cached assembly is asked to create an instance whose name is exactly LY. The following selected original calls appear in the later branch; comments are mine:
// gtxy.ashx:7; selected statements, not a complete handler.
o.Equals(outStream); // Supplies a writable MemoryStream to the loaded object.
o.Equals(data); // Supplies this request's decrypted input.
o.ToString(); // Triggers the object's implementation.
byte[] r = outStream.ToArray();
There is no reason to expect the ordinary Object.Equals or Object.ToString semantics here. The convention depends on implementations in the loaded assembly. The wrapper's job is to provide the stream and bytes, invoke the trigger, then encrypt whatever the object wrote.
The retained wrapper source establishes that dispatch mechanism. It does not establish a command list, file-browser operation, process-execution primitive, or internal protocol for LY. I did not recover and bind a managed LY assembly to these handlers. Inventing its operations from the loader's familiar shape would erase the most important boundary in this capture.
The ASMX wire format adds constant markers
sitemap.asmx exposes pass(string pass) with a WebMethod attribute (5โ10). It URL-decodes the supplied string, base64-decodes it, and decrypts those bytes. Its later response uses a different outer representation from the raw handler:
| Layer | Raw ASHX handler | ASMX pass method |
|---|---|---|
| Input representation consumed by code | Raw request bytes | URL-decoded string, then base64-decoded bytes |
| Inner cipher | Same key/IV value; CBC with framework-default padding | Same |
| Assembly/dispatch state | Context.Application | Context.Application |
| Later response representation produced by code | Ciphertext bytes | 16 uppercase hexadecimal characters + base64 ciphertext + 16 uppercase hexadecimal characters |
The two hexadecimal pieces come from an uppercase MD5 representation of a fixed four-character label concatenated with the key. The full digest is 32 hexadecimal characters, split at character 16. It is constant for that label/key pair and is not a digest of the returned data. It therefore does not authenticate each response body.
There is no per-message IV field in either wrapper. For a later response containing L output bytes, the documented padding produces C = 16 ร (floor(L / 16) + 1) ciphertext bytes, including an extra block when L is already block-aligned. The ASMX inner response string then occupies 32 + 4 ร ceil(C / 3) characters: 32 marker characters plus base64. These are derived framing sizes, not measurements of a captured session.
The returned ASMX string may also sit inside framework-managed service serialization, depending on the service protocol and handler mapping. The code's inner string framing should not be confused with a captured HTTP response format; no such response was validated here.
sitemap.soap changes the storage implementation. It declares private static Hashtable session at line 8 and uses that table in line 19. It still caches under the same literal placeholder and uses the same class/method convention. Its variable name is misleading: a static table is shared class state. Also, the .soap filename alone does not establish a working IIS mapping equivalent to .asmx.
The adjacent DLLs use a separate native IIS entry point
I inspected dll.zip without installing its contents. The x86 and x64 iis DLL members have zero CLR-directory RVAs and export RegisterModule; they are native IIS modules, not recovered managed LY payloads.
The x64 member's RegisterModule begins at RVA 0xeef0. It allocates an eight-byte factory object, installs a vtable pointer, sets the notification-mask argument to 0x20000001 and the post-notification argument to zero, then tail-calls the registration object's virtual slot at +0x10:
; dll.zip!dll\iis64.dll, selected RVAs; analyst annotations.
0xef0b lea rax, [rip+0x2df16] ; factory vtable
0xef12 mov [rdx], rax
0xef19 mov rax, [rbx] ; registration object's vtable
0xef1c xor r9d, r9d ; zero post-notification mask
0xef1f mov r8d, 0x20000001 ; notification mask supplied by module
0xef2d jmp qword ptr [rax+0x10] ; registration virtual call
This is concrete registration behavior. It does not prove equivalence with the ASP.NET wrappers' command channel, and I have not assigned command capabilities to the native callback bodies. The archive's installer and neighboring privilege-escalation utility likewise remain outside this scoped handler finding.
A narrow source rule, tested on the retained variants
I used the loader/dispatch combination rather than a generic RijndaelManaged string:
rule Captured_ASPNET_LY_Assembly_Wrapper {
strings:
$cache = "{payloadStoreName}"
$cipher = "RijndaelManaged"
$load = "GetMethod(\"Load\""
$type = "CreateInstance(\"LY\")"
$stream = "o.Equals(outStream)"
condition:
filesize < 8KB and uint16(0) == 0x253c and all of them
}
The rule matched all four retained wrapper files, representing three unique hashes. It did not match the adjacent Python scanner or either inspected native IIS DLL. That validates a narrow source-family pattern, not a comprehensive web-shell detector. Copied examples, laboratory fixtures and analysis repositories can legitimately contain the same source and produce alerts.
The wrappers support T1505.003 Web Shell, T1620 Reflective Code Loading through Assembly.Load(byte[]), and T1573.001 Symmetric Cryptography for their encrypted request/response channel. These describe implemented static mechanisms. They do not establish successful staging or observed operator commands.
Useful hunting combinations are the literal cache placeholder, an LY instance created from an application-cached assembly, the stream/data/object-method sequence, and the two alternative response representations. A .soap extension, a cryptography namespace or a native IIS module name alone is weak evidence.
The capture does not establish handler deployment, effective server authentication, the missing managed stage, or native IIS callback behavior. No handler, DLL, installer or scanner was executed; no keys, request bodies or collected records are published.
| Artifact | SHA-256 |
|---|---|
gtxy.ashx and system.ashx | 28554327bac0d34206c5734d4feed94b0c55ae043b9683c03e808748633aa28f |
sitemap.asmx | 854257f27988ac9100f1188f57b2019080b8578c0c43c016762062b2cd6db4fc |
sitemap.soap | 9751cdad0b2a3395f12bca15701ee76e2a1d955f47ac587e6f9868c6c5318e97 |
dll.zip member dll\iis64.dll | f07d869ddd17d4359e26da43574d0d07987b500a390196b72b3c1747a4cbb3bf |
BrowserGrabV2 Splits Control Across Two WebSocket Transports
A captured browser-stealer control server exposes mixed WebSocket framing, encryption asymmetry, and unfinished SOCKS and authentication boundaries.
BrowserGrabV2 Splits Control Across Two WebSocket Transports
BrowserGrabV2 splits agent control between raw WebSockets and Socket.IO, but its SOCKS relay still sends through only one transport. The preserved Python server at 45[.]154[.]98[.]13:9999 also processes browser-data uploads and relays desktop-frame messages. A Windows Agent6.exe and a separate Go fingerprinting proxy workspace sit alongside it.
I followed those paths statically on the Linux VPS. No captured programs were executed, no original endpoints were contacted, and session databases, private keys and victim records were excluded. Capabilities below are source-backed; they are not claims that this exact Windows executable exercised every feature.
The executable and the source are different evidence
Agent6.exe is a 36,519,630-byte AMD64 PE with entry RVA 0x5ead20, nine sections, and a zero CLR data-directory entry. UTF-16 strings include hostfxr.dll at file offset 0x714c58 and hostfxr_main_bundle_startupinfo nearby. That is evidence of a native .NET hosting component, not a directly exposed managed assembly that can be identified from a CLR header.
I have not established which embedded application, if any, exactly matches the Python server's C# compatibility comments. The server source can be analyzed independently without pretending a filename establishes a build relationship.
| Artifact | SHA-256 |
|---|---|
Agent6.exe | 81451b4d2c35fa19da790fe1700fd057629bfa6255dc7105742f5aa4f36efe46 |
c2_server/server/app.py | 455da6a6e1e4f670a1cfc25022c341f11c4d68ad9e692dc61ea9ee9ada3abc99 |
c2_server/server/cryptography_utils.py | f993e04cf4414d004054d21a34ee66ce1bafb37a582c617ceb83fbd778dfbebe |
fingerprint-proxy-linux-amd64.new | 69ffa6da40ce2a34c70e8f03577b8780b261fa0696075ecb1b17880ace2af51e |
Static Go build metadata identifies the latter as fingerprint-proxy/cmd/proxy, built with Go 1.24.13 from revision 1420a90e7179ab9cfabd381f46d6f42a88edccfc, with local modifications. Its dependencies include uTLS 1.8.2. Adjacent YAML selects chrome_120, enables PROXY protocol and configures a loopback campaign service. This corroborates a separate fingerprinting/proxy component; it does not prove that it transports BrowserGrabV2 traffic.
One server, two WebSocket transports, three payload classes
app.py:815โ825 routes an upgraded raw WebSocket at /ws to _handle_raw_ws. Other requests go through Flask-SocketIO middleware, including its /socket.io/ transport and /ws namespace. A namespace named /ws and a raw socket at path /ws are separate mechanisms.
The raw and Socket.IO receive paths share this first-byte dispatch (app.py:163โ183,563โ584):
| Application payload | Handling |
|---|---|
| Binary `01 | |
| Binary `02 | |
| Other binary bytes | Attempt encrypted JSON decoding. |
Text 42["event",{"d":"โฆ","ts":โฆ}] | Extract Base64 ciphertext from d, decrypt, then parse JSON. |
| Other JSON text | Parse directly in the explicitly labelled development path. |
The 42[...] string is also used inside an emitted Socket.IO message event (app.py:209โ217,748โ761). It is application-level compatibility wrapping. The Socket.IO protocol adds namespace connections and event packets above Engine.IO; embedding a string that resembles an encoded event does not make a raw ClientWebSocket a Socket.IO client.
A less obvious consequence follows from putting untagged ciphertext beside byte-tagged relay messages. GCM ciphertext packets start with a random IV. If a raw binary encrypted packet's first IV byte is 01 or 02, dispatch consumes it as relay traffic before decryption. Under uniform random IV generation, those two values occur with probability 2/256. That is a static inference about the raw-binary path; the Base64 text wrapper avoids this ambiguity.
โv2 supportedโ does not mean โv2 transmittedโ
The crypto module supports these layouts:
v1: nonce[12] || AES-256-GCM ciphertext[N] || tag[16]
v2: IV[16] || AES-256-CBC ciphertext[N] || HMAC-SHA256[32]
For v2, the AES key is SHA-256 of the configured string; the MAC key is SHA-256 of "hmac-" + configured_string. CBC uses PKCS#7 padding. The MAC covers IV || ciphertext and is checked with compare_digest before decryption (cryptography_utils.py:16โ25,40โ59,73โ92). The configured secret is omitted here.
The public wrappers are asymmetric:
# cryptography_utils.py:148โ165, abbreviated def encrypt_message(plaintext, key_string=None): return encrypt_v1(plaintext, key_string) # outgoing is always GCM
def decrypt_message(data, key_string=None): try: return decrypt_v2(data, key_string) except Exception: pass return decrypt_v1(data, key_string)
Inbound format detection is trial decryption, not a version field. Supporting CBC-HMAC on receive does not select it for outgoing commands.
The 01 desktop and 02 SOCKS payloads bypass these application crypto functions entirely. TLS may still protect their transport: app.py:827โ838 enables it only when certificate and key environment variables are both present. Port 8443 alone does not establish TLS.
Commands, saved tasks and the wire can disagree
The authenticated REST command route first stores command and params, then constructs the delivered dictionary (api.py:80โ100):
msg = {"cmd": command, "task_id": str(task_id), **params}
The final unpack can overwrite both reserved fields. Pending-task replay repeats the same ordering using cmd_msg.update(...) (app.py:255โ262,652โ660). A stored command and its delivered command can therefore differ. This is an operator-side integrity problem, not an authentication bypass.
The REST response also calls a command โsentโ based on the online-agent map, without checking send_command_to_agent's Boolean result. Transport acceptance and successful execution are separate states that this response collapses.
There is a second boundary mismatch in authentication. HTTP panel routes and REST commands use require_auth. The Socket.IO panel connect handler instead contains the literal comment โAuth check could go hereโ (app.py:407โ411). The inspected hvnc_watch and hvnc_input handlers do not call that decorator or perform a local session/token check; they register a viewer or forward input (:421โ458). Thus HTTP login protection is not enforced by these application handlers. External access controls were not established from the capture.
SOCKS forwarding shows an unfinished transport migration
The server-side SOCKS listener binds 0.0.0.0, defaults to 1080 and accepts without SOCKS authentication (socks_proxy.py:45โ53,85โ92). Its comment says authentication is handled by panel login, but that login is not applied to the separate TCP socket. It even sends method 00 without verifying the client offered no-auth.
Only CONNECT is accepted. IPv4, domain and IPv6 addresses become a socks_connect command; a channel ID is the first eight hex characters of a UUID. Subsequent data uses 02 || channel[8] || payload. The handshake uses single recv(256) calls, with no exact-length buffering: TCP fragmentation or coalesced greeting/request bytes can break parsing.
General commands use the raw-WS-aware send_command_to_agent. SOCKS commands and bytes instead call _socketio.emit directly (socks_proxy.py:148โ149,234โ236). Raw-WS registration stores its own UUID as the agent SID, not a Socket.IO session. The inspected SOCKS sender has no bridge to that raw socket. This predicts a delivery gap for raw-only agents; end-to-end runtime interoperability was not tested.
Browser handling has a similar divergence. The Socket.IO raw_browser_data branch passes both master_key and optional app_bound_key; the raw-WS branch passes only the former (app.py:296โ320,686โ698). Both store results as steal logs. browser_decrypt.py:75โ108 confirms actual login/cookie SQL extraction rather than relying on a feature name.
Code-backed TTPs
| Mapping | Evidence and limit |
|---|---|
| T1555.003 โ Credentials from Web Browsers | Server processes uploaded browser databases and supplied key material. Endpoint collection was not observed. |
| T1573.001 โ Symmetric Cryptography | Implemented GCM and CBC-HMAC command formats; binary relay classes take a different path. |
| T1090.001 โ Internal Proxy | Per-agent SOCKS CONNECT and channel relay implementation, with the raw-WS gap described above. |
The distinctive fingerprint is the combination of 42 text wrapping, 01/02 binary classes, eight-character channels, asymmetric crypto selection and two competing transport paths. Those details are more useful for recognizing this server than its self-assigned malware name.
Georgeginx Exports Cookies After a Count Stability Check
Inside a captured Evilginx fork: authentication-cookie harvesting, delayed Telegram delivery, a query-password admin API, and the limits of its anti-bot code.
Georgeginx Exports Cookies After a Count Stability Check
The captured Georgeginx source collects authentication cookies in its HTTPS proxy, waits for their count to stabilize, then sends them to Telegram as a JSON attachment. I traced that path in the Go tree retained in the 136[.]0[.]7[.]68:8181 capture, alongside an evilginx2 ELF, phishlets and templates.
From proxy response to stolen session
main.go:101โ114 opens configuration and the BuntDB session store, then starts NewAdmin. Lines 158โ159 construct and start the HTTPS proxy. Within that constructor, core/http_proxy.go:243โ246 installs the request hooks; the response path later processes upstream cookies.
Credential extraction supports JSON and form bodies. In the JSON branch, the phishlet's regex searches the body and passes capture group 1 to the in-memory session and database (http_proxy.go:693โ714). The form branch matches configured field-name regexes, then extracts from v[0] (786โ805). Collection depends on the phishlet's rules; it is not a generic grab of every submitted field.
The response handler reads resp.Cookies(), removes the original Set-Cookie headers, normalizes the original cookie domain, and checks pl.isAuthToken(c_domain, ck.Name) (1033โ1077). The following excerpt preserves the original calls; comments are mine:
// core/http_proxy.go:1073โ1085, excerpt
if pl.isAuthToken(c_domain, ck.Name) {
s, ok := p.sessions[ps.SessionId]
if ok && (s.IsAuthUrl || !s.IsDone) {
// Nonempty, unexpired cookies pass an additional guard here.
is_auth = s.AddAuthToken(c_domain, ck.Name, ck.Value,
ck.Path, ck.HttpOnly, auth_tokens)
if len(pl.authUrls) > 0 {
is_auth = false // Completion is deferred to an auth URL.
}
// The original persists tokens and marks IsDone if is_auth.
}
}
Session.AddAuthToken stores values by domain and cookie name. It builds a copy of required token rules, excludes optional rules, removes each satisfied literal or regex rule and returns whether the remaining domain map is empty (core/session.go:56โ105). That is the completion test.
There is a small serialization defect: updating an existing token assigns tk.Path = path at line 63, but the new-token initializer at lines 66โ70 omits Path. A cookie seen only once can therefore reach the later export with an empty path.
The proxy subsequently rewrites the cookie domain toward the phishing host and re-adds Set-Cookie (http_proxy.go:1097โ1098). This is session interception in the proxy response path, including HttpOnly cookies; it does not depend on JavaScript reading document.cookie. The behavior maps to Steal Web Session Cookie, T1539 within an adversary-in-the-middle flow, T1557.
Wait for the cookie count, then upload
For phishlets with authentication URLs, the later handler requires an existing session with s.IsDone, matches the response path against pl.authUrls, and saves tokens (1296โ1306). If a username and tokens exist and the notification has not been sent, it starts monitorCookieStability in a goroutine (1314โ1321).
The monitor's timing is explicit:
// core/http_proxy.go:2130โ2134, 2167โ2176, selected lines initialCookieCount := 0 stableCount := 0 maxWaitTime := 15 * time.Second checkInterval := 500 * time.Millisecond stabilityThreshold := 4
currentCount := p.countSessionCookies(s) if currentCount == initialCookieCount && currentCount > 0 { stableCount++ if stableCount >= stabilityThreshold { p.sendUniversalNotification(sessionId, index, phishletName, req) return } }
Four unchanged counts mean roughly two seconds of stability. A changed count resets the counter; after 15 seconds it sends anyway (2144โ2149, 2180โ2184). This monitors count, not cookie values. Replacing an existing token value leaves the count unchanged.
The sender sets s.WebhookSent = true before GeoIP lookup and before attempting delivery (2200โ2219). It then calls sendTelegramNotification without handling its returned error (2251). A failed upload can leave the session marked as sent, suppressing a later retry.
The Telegram function waits another two seconds, refreshes the session pointer and serializes browser-shaped cookie objects. Its schema is:
name, value, domain, path, httpOnly, expirationDate
The exporter strips a leading domain dot and assigns the same literal expirationDate: 1801365946 to every object (2284โ2303). It does not preserve original expiry, Secure or SameSite in that exported object. The attachment name derives from the username after replacing @ and .; the multipart body contains chat_id, caption, parse_mode and a document part (2308โ2331).
The destination is the Telegram Bot API sendDocument endpoint (2334โ2335). No configured bot token, chat identifier or captured session value is needed to establish this path. The HTTP code handles transport errors but does not validate the returned status or Telegram's JSON success field (2335โ2347).
An admin API with a URL password
core/admin.go:52โ69 registers /listSessions and /listAll, then calls http.ListenAndServe(":1337", nil). That binds plain HTTP on all interfaces, subject to host/network controls.
/listAll compares the pwd query parameter with pwd_str; a mismatch returns without an explicit denial status. An accepted request receives Access-Control-Allow-Origin: *, JSON session data and HTTP 201 (71โ87). The export includes username, password, serialized tokens, remote address, user agent and update time (103โ125).
The password comes from cfg.adminpage_path, with an eight-character generated fallback. Source proves the listener and authorization design; it does not establish external reachability or the configured secret.
Read the anti-bot implementation literally
The request hook actually calls checkBot (http_proxy.go:354). It runs the basic detector, then MLDetector.PredictBot, rejecting the latter only above BOT_THRESHOLD (150โ163).
The โMLโ branch is a weighted heuristic followed by a sigmoid, with fixed weights summing to 1 (ml_detector.go:32โ67). Each implemented feature is bounded by 1, so its score cannot exceed:
weighted sum <= 1
sigmoid(1) = 0.731058...
BOT_THRESHOLD = 0.90
With these defaults, that branch cannot reject a request. A source-wide search found the UpdateWeights definition but no caller. The earlier basic detector and explicit user-agent/IP gates remain separate, reachable checks.
Likewise, the JA3 detector is initialized, but I found no calls through p.ja3Detector. The polymorphic-script call is commented out (1211โ1214). These filenames overstate the active behavior.
Microsoft-related HTML does receive anti-debug/automation scripts. The proxy copies an existing script nonce when available and inserts code into the head (1231โ1252). The so-called anti-VM script checks WebDriver, headless and automation globals; its comment says environmental VM checks were removed (anti_vm.go:24โ49). It renders a fake 403 overlay. The anti-debug script checks window dimensions and a debugger timing delay, then redirects to example.com (anti_debug.go:11โ57). Those are observable response artifacts, not evidence of reliable VM detection.
Huntable artifacts
On an acquired host, the :1337 listener with /listAll and query-password access is a precise lead. Correlate the proxy process's Telegram sendDocument traffic with multipart cookie-JSON attachments. In retained HTML, the combined automation checks, copied nonce, fake 403 overlay and title replacement with - (1285โ1289) provide a useful cluster; any one string alone is weak.
| Artifact | Bytes | SHA-256 |
|---|---|---|
core/http_proxy.go | 82,348 | a57df09fd6340fcb853c7e2d549e747eba9462d4fd9e0b64712f8c9bd72948fd |
core/admin.go | 3,250 | 096f62d6b491b92cbc9d0663c1e501b2c0f06eb2a9d14e23c2e7dedff13a68d7 |
evilginx2 | 24,660,590 | ee169ba793bdd6c481ea53fb5ad1babce500b96ffe1ec74b7e90896dc9194b70 |
Analysis stayed on the Linux VPS using static source inspection and hashing. I did not read victim sessions, execute the ELF or contact the captured service. Deployment state, successful delivery and source-to-binary equivalence remain unverified.
IOS XE Proxy Installers Masquerade as NETCONF Services
Chisel and 3proxy deployment templates, fragile watchdogs, and the difference between a tunnel listener and a SOCKS listener.
IOS XE Proxy Installers Masquerade as NETCONF Services
Proxy installers in the 64[.]177[.]66[.]160:8888 capture rename Chisel to netconf_helper and 3proxy to netconf_svcd, then wrap them in watchdogs and boot hooks. The IOS-XE-oriented workspace contains ARM64 binaries, competing shell installers and two WSMA command helpers. Its scripts repeatedly remove one proxy deployment before installing another.
This is static analysis of the preserved files on a Linux VPS. I did not run the installers or binaries. Their โproven workingโ comments establish author intent, not successful exploitation or a deployment timeline.
Binary identity before interpreting the scripts
chisel_aarch64 is a 9,044,120-byte AArch64 ELF. Reading its Go build metadata with go version -m gives:
path github.com/jpillora/chisel
mod github.com/jpillora/chisel (devel)
go go1.23.1
GOOS / GOARCH linux / arm64
CGO_ENABLED 0
vcs.revision 3208da347e4f7d38aa5d9e3def45c4b032dd0b86
vcs.modified false
The dependency list includes github.com/armon/go-socks5 and github.com/gorilla/websocket. These are recorded build properties, not a claim that this file is byte-identical to an official release. The nearby versioned filename alone would have been weaker evidence.
The 1,066,880-byte 3proxy is also AArch64 ELF. At file offset 0xc80d0 (819,408), its ASCII string identifies 3proxy-0.9.6 (); 0xc8070 identifies the 0.9.6 string table. Chisel and 3proxy are legitimate networking tools. The camouflage, installation paths and surrounding control scripts explain their role in this capture.
| Artifact | SHA-256 |
|---|---|
chisel_aarch64 | c6f4a347d1b38fd2a52c3f4d508d86f62316006953d607db3f87bbdf1e60656a |
3proxy | 710310cc5d64ebff581283111cb0a2961684a0665c0e9aa972d675b3295d93b6 |
chisel_server_deploy.sh | 65d02ab50e1ddb66c8383340c4303e90b563b27a4d4bdbafa49d825cdd330d54 |
3proxy_deploy.sh | f52a1758aca20be61f6436bfde28f80908402a1e6636523f0cf2f3ba81f49bff |
Port 5002 is two different protocols
The Chisel templates run the router-side binary in server mode with --socks5, --auth, port 5002 and a 25-second keepalive (chisel_server_deploy.sh:101โ106). The printed operator example later uses a Chisel client with the socks remote (:318โ322).
That creates this direction:
operator SOCKS client
-> local SOCKS endpoint provided by Chisel client
-> Chisel tunnel to router TCP/5002
-> server-side SOCKS handler
-> destination reached from the router
TCP/5002 is the Chisel tunnel listener, despite the script printing โPort: 5002 (SOCKS5).โ It is not a raw SOCKS5 socket to which an ordinary SOCKS client can connect directly. Nor is this template a router making a reverse connection to the VPS: the router is the listening server. These distinctions follow the actual arguments and Chisel's documented server/client SOCKS roles.
3proxy_deploy.sh deliberately changes that design. Its opening comment says it replaces Chisel with native SOCKS5 โfor low latency.โ The generated configuration (:38โ47) uses a username record, auth strong cache 300, a user allow rule and socks -p5002. The separately captured 3proxy.cfg:5โ10 uses auth strong, explicitly sets -i0.0.0.0 -e0.0.0.0, daemonizes and writes a PID file. The separately captured file describes a directly accessible, authenticated SOCKS service under 3proxy's configuration semantics; the Chisel transport layer is gone.
The generated configuration has an additional failure: auth strong cache 300 treats 300 as an authentication-method name, not a cache lifetime. In 3proxy 0.9.6\'s ZZZHTMLSLOT2ZZZ parser, every token must resolve to a registered method; an unknown method returns an error. Cache policy belongs to the separate authcache directive. Against that parser, this template is expected to fail configuration loading before reaching its SOCKS service declaration. The separately captured 3proxy.cfg uses auth strong without the invalid numeric token. I did not repair or execute either captured configuration.
These configurations are alternatives, not one multi-hop chain. Shared port numbers and credentials do not make their wire protocols interchangeable.
Persistence is built from several independently fragile checks
chisel_server_deploy.sh downloads into /bootflash/.cache/netconf_helper, creates netconf_monitor.sh, writes /etc/init.d/S99chisel_socks, and conditionally adds an @reboot cron entry. The binary and watchdog use setsid with detached standard streams. This handles shell-session lifetime; it does not by itself prove reboot survival on an IOS-XE filesystem.
The 30-second watchdog distinguishes โdead,โ โhungโ and โnot listening.โ Its health timestamp is written by the watchdog, not by Chisel:
# chisel_server_deploy.sh:179โ183
else
# All good - update health
date +%s > "$HEALTHFILE"
fi
sleep "$CHECK_INTERVAL"
A timestamp therefore confirms that previous local checks passed. It says nothing about the tunnel servicing requests. A live but unresponsive server can continue receiving fresh health stamps.
The socket test is weaker still:
# chisel_server_deploy.sh:94โ98
grep -q ":138A " /proc/net/tcp 2>/dev/null && return 0
grep -q ":138A " /proc/net/tcp6 2>/dev/null && return 0
0x138a is 5002, but the expression does not require TCP state 0A (LISTEN), constrain the local-address column, or link the socket inode to the recorded PID. A matching remote endpoint or another process's socket can satisfy it. Combined with kill -0, the test proves only that a PID exists and a matching text fragment occurs in a system-wide socket table.
There are materially different persistence variants. deploy_chisel_v2.sh:44โ48 kills every discovered cron/crond process, then writes a minute-based cron watchdog at :200โ206. Writing that file does not restart the daemon it just killed. The same variant can have a background monitor and independent cron checks racing to restart a service; there is no shared lock around those decisions.
The 3proxy replacement has a smaller but concrete path defect:
# 3proxy_deploy.sh:23 โ existence test [ -x "/bootflash/.cache/3proxyexec" ]:27 and :55 โ downloaded and executed file
/bootflash/.cache/3proxy_exec
A correct 3proxy_exec does not satisfy that initial test, so a rerun unnecessarily takes the download branch. If only the differently named tested file exists, that branch is skipped while the later watchdog still references the underscore path. This is a deployment logic error, not evidence of an exploit.
The WSMA helpers provide context, not a verified compromise
cisco_soap_exploit.py:28โ79 constructs a SOAP configApply request with CLI text inside a CDATA block, posts to a doubly percent-encoded WSMA endpoint, and disables TLS certificate verification. Its user and privileged modes prepend two or one exit lines respectively. ios_cmd.py:12โ13,48โ61 instead targets loopback HTTPS and tries plain, singly encoded and doubly encoded endpoint forms.
The author labels these helpers with CVE-2023-20198. The files demonstrate attempted IOS CLI access and a container-local control path. They do not establish a vulnerable firmware version, a successful response, or the initial means of reaching a Linux shell. I have omitted the configured target addresses and shared authentication values.
TTPs and useful pivots
| Mapping | Concrete evidence |
|---|---|
| T1090.001 โ Internal Proxy | Intended router-originated access through Chisel's SOCKS handler or native 3proxy. No observed downstream traffic claimed. |
| T1036.005 โ Match Legitimate Resource Name or Location | Chisel becomes netconf_helper; 3proxy becomes netconf_svcd under /bootflash/.cache. |
| T1053.003 โ Cron | Minute, three-minute and @reboot entries are present in different templates. |
| T1037.004 โ RC Scripts | Generated S99chisel_socks, S99socks_proxy and S99proxy init scripts. Successful boot invocation is unverified. |
For triage, correlate netconf_ paths, the specific init/cron names, port 5002, PID files and executable hashes. A Chisel filename or a listening port alone loses the distinctive part of this capture: several installers competing to preserve proxy access while disguising it as network management.
KB Rust Agent: One Code Image, Two Windows Identities
Source reconstruction of KB's Socket.IO crypto, command dispatch and persistence, paired with two near-identical Windows builds that change their version-resource identity.
KB Rust Agent: One Code Image, Two Windows Identities
The captured KB Rust agent wraps Socket.IO events in AES-GCM, dispatches commands through FNV-1a constants, and reports persistence as installed even when individual installation methods fail.
I anchored this pass to kb-botnet-v1.0.2-src.tar.gz from the archived 185.102.115.84:8443 tree. It contains 118 members and hashes to:
967734a8dc30e1772976b705fb39161868228371ced8f44d8cd54d8aac1f8190
Everything below refers to members of that archive. Adjacent loose patches and compiled agents are separate evidence. I inspected the archive on the Linux analysis VPS without extracting or executing its code.
Before the first command
crates/kb-agent/src/lib.rs:58โ146 gives the startup order. The agent acquires its singleton mutex, then release builds hide the console and wait 12โ29.999 seconds. An anti-analysis decision can terminate the process before a second 3โ7.999-second delay. Only then does it apply its stealth routines, change process presentation, copy itself, install persistence, and start watchdog tasks.
The delay is therefore ahead of both registration and ordinary command handling. A short observation window could finish before network activity begins. These are source-defined gates; I did not establish their effectiveness against any sandbox.
The advertised capability JSON is also broader evidence than it first appears. Fields including shell, mining, exfiltration, P2P and lateral movement are hard-coded true. A capability flag is a registration claim, not the result of a successful feature test.
What is actually on the wire
The transport implements Engine.IO/Socket.IO framing over WebSocket. On the Engine.IO open message it sends 40; a Socket.IO connection leads to a key_exchange event. The peer's public key feeds session-key derivation before encrypted registration. The relevant state transitions are in transport.rs:250โ322.
crates/kb-crypto/src/lib.rs:18โ23,105โ120 fixes the cryptographic parameters:
| Stage | Source-defined construction |
|---|---|
| Key agreement | Ephemeral secp256k1 ECDH; public key encoded as uncompressed SEC1 bytes, then Base64 |
| Session derivation | scrypt(shared secret, salt session-salt-2026, N=16,384, r=8, p=1), 32-byte output |
| Master derivation | The same scrypt parameters with salt c2salt2026 |
| Event encryption | AES-256-GCM with a random 12-byte IV and 16-byte authentication tag |
| Outer encoding | Base64 of IV, tag, ciphertext |
That byte order matters. The AES library returns ciphertext followed by the tag; the wrapper moves the tag ahead of the ciphertext:
// lib.rs:56โ62; comments added
let enc_len = ciphertext_with_tag.len() - TAG_LEN;
let encrypted = &ciphertext_with_tag[..enc_len];
let tag = &ciphertext_with_tag[enc_len..];
result.extend_from_slice(&iv); // bytes 0..12
result.extend_from_slice(tag); // bytes 12..28
result.extend_from_slice(encrypted); // bytes 28..
A command-result event consequently has this structure:
42["command_result","BASE64(IV[12] | TAG[16] | CIPHERTEXT[n])"]
The two leading characters belong to Socket.IO framing; they are outside the encrypted JSON. Event labels and the exchange sequence remain useful network pivots even when the command contents are opaque.
The signature comment is ahead of the implementation
The command handler's own comment names five fields, but its actual reads are narrower:
// transport.rs:335โ338; original comment
// C2 sends: { command, params, ts, sig, _cmdId }
let command_id = decrypted["_cmdId"].as_str().unwrap_or("").to_string();
let cmd_type = decrypted["command"].as_str().unwrap_or("").to_string();
let params = decrypted["params"].clone();
The handler spawns dispatch at lines 349โ351 without checking ts or sig. A search across the archive's Rust/JS implementation found HMAC signing and verification only in the crypto helper and its unit tests. I found no application-level signature verification or timestamp/replay check on this command path. _cmdId is copied into the reply for correlation; this handler does not use it as a replay cache.
This does not remove GCM's authentication tag. The distinction is between authenticated encryption and the additional signature/freshness checks suggested by the comment.
There is another compatibility choice in transport.rs:366โ379: decryption first tries the current session key and then falls back to the master key, including after a session-key attempt fails. Session establishment therefore does not make master-key event acceptance disappear. That is a source-level acceptance rule, not evidence that captured traffic exploited it.
Recovering the command vocabulary
commands/mod.rs:35โ46 hashes incoming command strings using FNV-1a: offset basis 0x811c9dc5, XOR each byte, multiply by 0x01000193 with 32-bit wrapping.
I recalculated hashes of literal identifiers found in the archived project on Linux and matched 24 names to the switch. Selected results:
| Input name | Hash | Dispatch target |
|---|---|---|
ping | 0x165df089 | Returns pong and timestamp |
shell | 0x11e1fc01 | shell::execute |
persist | 0xe979f483 | persist::install |
socks5_start | 0xd014acfe | proxy::start |
full_steal | 0x2d455184 | exfil::full_steal |
kill | 0xc50f4599 | Returns successful JSON containing message: ok |
That last row is easy to misread. In this version, the kill branch at line 77 acknowledges the request; it does not terminate the process. Naming a command or a source file is weaker evidence than following its selected branch.
Two persistence paths, one optimistic result
Automatic startup calls persist::install_all(). Its five calls cover HKCU Run, HKLM Run, scheduled task, Startup folder, and watchdog task. It does not call the service installer.
The explicit persistence command instead reaches install_sync() at commands/persist.rs:68โ78. That path additionally attempts Defender exclusions and service installation. Its reply separates method booleans from an unconditional outer claim:
// persist.rs:70โ78; middle entries omitted
let results = serde_json::json!({
// ... each installer returns its own boolean ...
"watchdog": install_watchdog_task(),
});
CommandResult::ok(json!({"installed": true, "methods": results}))
Read the individual booleans. Neither success: true nor installed: true proves that a Run value, task or service exists.
The install-path filename decodes to SgrmBroker.exe using obf.rs's fixed XOR byte 0xa7. The destination is under %APPDATA%\Microsoft\Windows\SystemUpdate; the HKCU value label decodes to RuntimeBrokerSvc. Task constants include MicrosoftEdgeUpdateService and MicrosoftEdgeUpdateServiceCore; the service is WinDefCore.
Those labels, the unusual destination, and the executable referenced by each mechanism are stronger host pivots together than a Microsoft-like name alone.
TTPs supported by this archive
| Mapping | Code evidence and boundary |
|---|---|
| T1071.001 โ Web protocols | WebSocket/Socket.IO command and result events in transport.rs |
| T1573.001 โ Symmetric cryptography | Application-layer AES-GCM envelope in kb-crypto/src/lib.rs:50โ89 |
| T1547.001 โ Run keys / Startup | Installer functions selected by install_all and install_sync; successful host modification was not observed |
| T1053.005 โ Scheduled task | Scheduled-task and watchdog installers; task names are constants in persist.rs:7โ9 |
The source establishes intended behavior and several implementation gaps. It does not establish a victim count, active C2 availability, or that any nearby PE was built from this exact archive.
Kronos Installs Persistence Before Its Broken Handshake
Native disassembly confirms persistence and file deletion before a placeholder key derivation and an incomplete C2 exchange.
Kronos Installs Persistence Before Its Broken Handshake
The captured kronos_agent ELF reaches systemd persistence, a reboot cron entry and file deletion before its first C2 exchange. The advertised AES-GCM heartbeat is unfinished in both the adjacent source and the compiled function: the binary calls strcpy for key derivation, attempts to send two uninitialized stack buffers, then waits for a reply without transmitting the heartbeat.
This writeup covers the botnet/kronos_agent.c, botnet/kronos_agent and botnet/kronos_c2.py trio in the 193[.]233[.]88[.]251:8000 capture. Other installers and expert-labelled variants in that directory are separate programs. โKronosโ is the local filename; it does not establish a relationship to another malware family using that name.
The compiled entry path
The 17,128-byte ELF is x86-64 PIE and retains function symbols. These are ELF virtual addresses relative to the PIE image, not file offsets:
; main at 0x1854. Selected calls; analyst annotations.
188b: call prctl@plt ; PR_SET_NAME, "[kworker/u2:3]"
189a: call getenv@plt ; LD_PRELOAD
18b3: call access@plt ; /proc/vz
; Either environment check can branch into the sleep loop.
18d1: call sleep@plt ; 3600 seconds, repeated
; Normal path:
18dd: call achieve_persistence
18e7: call exterminate_competitors
18f1: call communicate_with_c2
18fb: call sleep@plt ; 300 seconds after communication returns
The environment condition is an OR: any defined LD_PRELOAD value or successful access("/proc/vz", F_OK) sends execution into the hour-long sleep loop. That includes benign environments using either feature. The ordinary path performs host modifications before attempting the network connection.
PR_SET_NAME changes the calling thread's name, as described by the Linux manual. It does not turn the executable into a kernel worker or remove its executable mapping.
Persistence points into shared memory
achieve_persistence at 0x17bb writes /etc/systemd/system/netfilter-helper.service. The decisive unit fields are:
Description=Netfilter Helper
ExecStart=/dev/shm/.agent
Restart=always
WantedBy=multi-user.target
A successful fopen leads to the write and then two system calls: systemctl daemon-reload at 0x181f and service enablement at 0x182e. This function does not start the unit immediately or copy the running executable to its ExecStart path. Its success message does not check either command's exit status.
The reboot cron append at 0x184c is outside the successful-file-open branch. Failure to create the systemd unit therefore does not prevent the cron attempt. Repeated successful appends can create repeated reboot entries; this routine has no deduplication check.
Both mechanisms refer to /dev/shm/.agent. On a conventional Linux tmpfs mount, those file contents do not survive reboot, as the tmpfs manual explains. A persistent unit or cron line can remain while its executable disappears. This function contains no restaging mechanism to bridge that gap.
Key derivation is a copy operation
The source calls its helper a simplified PBKDF2 implementation:
/* kronos_agent.c:28โ31; original implementation */
void derive_key(const char* password,
const unsigned char* salt, unsigned char* key) {
strcpy((char*)key, password); // Placeholder!
}
The compiled helper at 0x1449 confirms the operation:
1461: mov rdx,QWORD PTR [rbp-0x8] ; password argument
1465: mov rax,QWORD PTR [rbp-0x18] ; destination key argument
1469: mov rsi,rdx
146c: mov rdi,rax
146f: call strcpy@plt
The salt argument is saved on entry but never contributes to the copied key. There is no iteration, hash or key expansion in this function. The adjacent Python C2 implements actual PBKDF2-HMAC-SHA256 with 100,000 iterations and a 32-byte output. The two sides therefore disagree before any encrypted packet is considered.
The ELF's only DT_NEEDED entry is libc.so.6. That dependency list alone would not rule out embedded cryptography; the disassembled helper and complete communication path provide the stronger evidence here.
The missing ciphertext is visible in native code
After connecting to the configured IPv4 endpoint on TCP/443, communicate_with_c2 makes these calls:
| Address | Operation | Buffer or length |
|---|---|---|
0x1544 | send | 16 bytes from [rbp-0x1b0] |
0x1564 | derive_key | Copies the configured password into the key buffer |
0x15d3 | send | 12 bytes from [rbp-0x1c8] |
0x15f1 | recv | Up to 12 reply bytes |
0x160f | recv | Up to 256 command bytes |
There are no writes initializing either outgoing buffer before its send call. The source explains the omission: random generation of the salt and nonce is commented out. On a successful connection, the binary attempts to send 28 bytes of existing stack contents. It ignores the send return values, so 28 attempted bytes are not proof of 28 delivered bytes.
The heartbeat buffer is built between the two sends: a type byte 0x01 followed by a 16-byte agent identifier. It is never passed to send. The source's encryption call and ciphertext send are comments:
/* kronos_agent.c:67โ75; selected original lines */
// encrypt_gcm(session_key, nonce, packet, packet_len, encrypted_packet);
send(sock, nonce, 12, 0);
// send(sock, encrypted_packet, packet_len + 16, 0);
The Python peer expects a different exchange:
Agent โ C2: salt[16] | nonce[12] | ciphertext[17] | GCM tag[16]
C2 โ Agent: reply nonce[12] | encrypted command | GCM tag[16]
The 17-byte plaintext heartbeat would require 33 bytes of authenticated ciphertext. Those bytes never leave this compiled client.
In the straightforward case where the peer receives the full salt and nonce, it waits in recv(1024) for the missing ciphertext while the client waits in recv(12) for a reply. Neither side sets a socket timeout. The five-minute sleep in main is reached only after communication returns; it is not a guarantee of a fresh beacon every five minutes.
The Python server also assumes that one recv(16) and one recv(12) obtain full fields. TCP fragmentation can violate that assumption independently of the absent ciphertext. Port 443 supplies no HTTPS semantics: these functions use plain sockets with no TLS handshake.
The mining branch cannot run
The C source ends command handling with:
/* kronos_agent.c:85; condition reduced without the comment */
if (0) {
/* START_MINING message and a commented module-loader call */
}
The compiled network routine contains no reachable miner dispatch. The Python response is also internally inconsistent: its comment says START_MINING, but the constructed plaintext starts with 0x02 and the literal SCAN, followed by placeholder pool and wallet strings.
Those labels establish intended features, not working resource hijacking in this variant.
The competitor routine does delete files
exterminate_competitors has an unused array of familiar bot names and a commented-out process-killing example. Its reachable behavior is narrower and concrete: enumerate /dev/shm, match filenames containing dvrHelper or .mc, construct their paths and call unlink.
The compiled routine corroborates opendir at 0x16b3, the two substring tests at 0x16ea and 0x170c, and unlink at 0x176c. It does not establish process termination, successful deletion or malware-family identity.
Hunting and TTPs
The strongest host pivot is the combination of netfilter-helper.service, an ExecStart under /dev/shm, a matching reboot cron entry and a user-space executable presenting the thread name [kworker/u2:3]. A service name alone is weaker than its executable path and originating process.
| Implemented behavior | Mapping | Evidence |
|---|---|---|
| Write and enable a systemd service | T1543.002 Systemd Service | achieve_persistence, source 118โ137 and native calls above |
| Append a reboot cron entry | T1053.003 Cron | Source 139โ140, native 0x184c |
| Adopt a kernel-worker-like thread name | T1036 Masquerading | Source 145, native 0x188b |
| Gate execution on environment checks | T1497.001 System Checks | Source 148โ150, native 0x189aโ0x18d6 |
These are static capabilities. The compiled path supports persistence attempts and file deletion even though its network protocol is incomplete. All source inspection, hashing and disassembly took place on the Linux VPS; the samples were not executed.
| Artifact | Bytes | SHA-256 |
|---|---|---|
botnet/kronos_agent | 17,128 | c4b6e7ebd100beec82f0c045056de656e632b970decf3bff97ada91d78ecc496 |
botnet/kronos_agent.c | 5,446 | 8aa6712d340fddb5d8594043a84b9cba7ccbc1c43b4023b6f58f2626c1c70c47 |
botnet/kronos_c2.py | 4,046 | c53b6769ca2c2aa539f3c64838bbdc4f8713746028f47b3aa9e7f06c50005241 |
The ARM UDP Bot Loses STOP Commands and Overcounts Traffic
Reconstructing a captured C bot and controller, then checking protocol edge cases and ARM build provenance.
The ARM UDP Bot Loses STOP Commands and Overcounts Traffic
The ARM client can consume a fragmented STOP command without stopping. Its packet count includes failed send attempts, and the adjacent stress utility can credit an extra 100,000-attempt batch. Those failures are visible in the C source captured from 185.117.91.6:9999, alongside its controller and ARM binaries.
I read and hashed the artifacts on the Linux analysis VPS. The conclusions below come from source review and static ELF inspection; no captured program or compiler was executed.
Four artifacts to keep separate
| Artifact | Bytes | SHA-256 |
|---|---|---|
bot_client.c | 8,220 | 37fa2b5fd71f3533f9277138091fc88e60d0b5b3802383f85d021afa080d28ab |
c2_server.c | 21,599 | b435189d037bb38563cfaeee9b97cfa60e2bb8c6699ab5831eb54a7e6bba00c0 |
bot_client_armv7l | 17,632 | 04ab1b6de2f52ab72d8cadfb531fcf41da768df374530759a7002a204d564e34 |
bot.arm7 | 485,372 | c869584f3e17fdde01a26bdf58d92837d8f396aad9944dffb08627c1f7e1323f |
The source comment calls this an ARMv7l bot for Android TV / Linux ARM. That is a target label supplied by the author. The actual evidence is an ARM client implementation plus ARM ELF files; it does not establish an Android infection campaign or a malware-family attribution.
Client lifecycle and protocol
bot_client.c:263โ305 performs a single fork. The parent exits, the child calls setsid(), and its standard streams are redirected to /dev/null. A failed fork leaves it in the foreground. There is no double-fork requirement or daemon-management dependency in this routine.
The bot reads /proc/sys/kernel/hostname, replacing whitespace with underscores, and constructs its greeting manually. Its configured default endpoint is 185[.]117[.]91[.]6:4444, distinct from the archived HTTP listing port. Source-defined traffic is plaintext TCP:
| Direction | Record |
|---|---|
| Client โ controller | HELLO <hostname>\n |
| Controller โ client | A newline-terminated UDP instruction containing IPv4 address, duration, destination port and payload length |
| Controller โ active client | STOP\n |
| Client โ controller | DONE <counter>\n |
The greeting is identification, not authentication. The controller's bot_handler reads the first line, optionally extracts a hostname after HELLO , and continues. A nonmatching first line simply leaves the name as unknown (c2_server.c:413โ434).
The client sets send/receive timeouts to 20 seconds while connecting, then clears the receive timeout before waiting for commands (bot_client.c:361โ387). Reconnection jitter is 3โ11 seconds: the modulo expression excludes the named maximum of 12.
Dispatch is a small parser, not a shell
The normal receive path reads one byte at a time, stops at newline, and strips carriage returns. Its only command branch in this file recognizes the UDP prefix. A manual parser advances through the four fields; s_atoi accepts optional leading spaces and a minus sign, then stops at the first nondigit.
The client replaces out-of-range parameters rather than rejecting the whole request:
/* bot_client.c:427โ431 */
if (secs < 1 || secs > 3600) secs = 10;
if (port < 1 || port > 65535) port = 80;
if (len < 1 || len > PKT_MAX) len = 1024;
It then requires inet_pton(AF_INET, ...) == 1 before entering the sender. There is no hostname resolution on that target path. The reviewed client has no shell-command dispatch, downloader, scanner or infection routine. Its demonstrated capability is controlled UDP transmission.
The sender uses a normal SOCK_DGRAM socket. It fills one static payload buffer from an XOR-shift PRNG once per requested run, then reuses those bytes. This is direct traffic generation, not a reflection/amplification implementation; there is no raw-packet source-address construction in this function.
STOP consumes a stream as if it were a message
While sending, the bot stops using its line reader. After each group of 4,096 send attempts, it performs a nonblocking receive into a 16-byte temporary buffer:
/* bot_client.c:137โ147; surrounding send loop omitted */
char tmp[16];
ssize_t n = recv(c2fd, tmp, sizeof(tmp)-1, MSG_DONTWAIT);
if (n > 0) {
tmp[n] = '\0';
if (tmp[0]=='S' && tmp[1]=='T' && tmp[2]=='O' && tmp[3]=='P')
break;
}
TCP does not preserve these command boundaries. If STOP arrives as two reads containing ST and OP\n, neither begins with the full prefix. Both fragments are consumed without a reassembly buffer.
The opposite case is lossy too. Bytes belonging to another command can be consumed by this temporary receive and discarded. A STOP prefix followed by additional bytes terminates the sender but does not preserve those bytes for the normal parser. Control responsiveness also depends on reaching the next 4,096-attempt boundary.
These are deductions from the receive logic, not results from sending commands to a live controller.
The numbers do not mean delivered packets
The client increments its packet counter after every sendto call, without checking the return value (bot_client.c:125โ131). Its DONE value counts attempted sends, including calls that could fail. It cannot establish reception or target impact.
The adjacent stress_max.c is a separate multithreaded command-line generator. Its accounting has an additional bug:
/* stress_max.c:99โ104; inner loop omitted */
__sync_fetch_and_add(&total_packets, update_interval);
/* ... */
__sync_fetch_and_add(&total_packets, local_packets % update_interval);
The inner loop can end early when time expires or the running flag changes, but the first addition still credits the entire 100,000-attempt batch. The final addition then credits the partial remainder again. A final partial batch can therefore add one whole extra batch to the total. Even a full batch measures attempts, because this utility also ignores the sender's return value.
The controller cannot correlate overlapping work reliably
DONE contains no request identifier. The controller parses its number, then walks attack slots from the highest index downward and credits the first running entry (c2_server.c:443โ450). It does not match the reply to the originating target or dispatch. Once the ring wraps, highest slot index is not necessarily newest request.
There are two more sources of misleading state:
- The controller's reply loop treats each
recvresult as a record. It does not reassemble split DONE lines or iterate over multiple coalesced records. accept_loopdisconnects an existing bot with the same peer IP before registering the new connection (c2_server.c:477โ484). Multiple devices behind one public address would replace each other in this implementation.
A bot count or an attack total printed by this controller needs those limitations attached.
Hunting this bundle
For this bundle, correlate the plaintext HELLO <hostname> / DONE <counter> record sequence with connections to the configured 185[.]117[.]91[.]6:4444 endpoint. On the host, the client combines a read of /proc/sys/kernel/hostname, a fork/setsid transition, and standard streams redirected to /dev/null. Each element occurs in legitimate software; their sequence with this controller endpoint and protocol is the useful pivot. This pattern describes the captured client, not UDP generators generally.
What the ELF files corroborate
bot_client_armv7l is ELF32 little-endian ARM, EABI5 hard-float, type ET_DYN, entry 0x948. It has PT_DYNAMIC but no PT_INTERP segment and no DT_NEEDED entries, consistent with a self-contained static PIE. Its symbol tables provide no named functions.
Two concrete binary pivots survive stripping:
| Binary | File offset | ASCII content |
|---|---|---|
bot_client_armv7l | 13,774 | /proc/sys/kernel/hostname |
bot_client_armv7l | 13,800 | Configured controller IPv4 address |
bot.arm7 | 379,154 | /proc/sys/kernel/hostname |
bot.arm7 | 379,180 | Same controller IPv4 address |
bot.arm7 is instead ET_EXEC, EABI5 soft-float, entry 0x10ad4, with no interpreter or needed-library entries. Its compiler comment names Ubuntu GCC 11.4.0; the smaller binary names GNU GCC 11.2.1.
The Makefile specifies static, non-PIE, hard-float compilation. Neither binary matches all those properties. Matching strings support a relationship to the client, but do not prove either was produced from the adjacent source by that Makefile. I did not run the captured cross-compiler to manufacture that proof.
The code supports T1095 โ Non-Application Layer Protocol for raw TCP control and T1498.001 โ Direct Network Flood for the UDP sender. Those are implemented capabilities; the capture supplies no independently verified victim traffic, delivered packet volume, or campaign scale.
The Fake MongoDB Server Drops Its Tenth Query
A fake MongoDB service records query metadata, exports summaries through Telegram, and breaks its own protocol camouflage.
The Fake MongoDB Server Drops Its Tenth Query
The capture from 193[.]149[.]185[.]135:8888 contains a 239-line Python MongoDB impersonator. It accepts database connections on TCP/27017, fabricates successful replies and reports query metadata through Telegram. Its ten-query limit has a revealing edge: the tenth counted query is recorded, then the connection closes before its reply is built.
The same directory contains a fake Ethereum JSON-RPC responder. Both services expose the same operating idea: make an attacker-controlled endpoint look useful enough for a client to send data. Their comments promise more than their implementations establish.
Building a MongoDB reply by hand
fake_mongo_v3.py:63โ69 constructs OP_MSG without a MongoDB server or BSON dependency:
bson_data = bson_encode(doc)
flags = struct.pack('<I', 0)
section = b'\x00' + bson_data
body = flags + section
header = struct.pack('<iiii',
16 + len(body), request_id + 1, request_id, 2013)
return header + body
All four header integers are little-endian. The response copies the incoming request ID into responseTo, making the reply correlate with the request. Its own request ID is simply the incoming value plus one.
| Byte offset | Size | Captured reply |
|---|---|---|
| 0 | 4 | Total message length, including the 16-byte header |
| 4 | 4 | Incoming request ID + 1 |
| 8 | 4 | Incoming request ID |
| 12 | 4 | Opcode 2013 |
| 16 | 4 | Flags, always zero |
| 20 | 1 | Section kind 0 |
| 21 | Variable | BSON document, starting with its own four-byte length |
This matches the basic header and single-body-section layout in the MongoDB wire protocol. The local encoder supports Boolean, integer, double, string, array and embedded-document values. Integers within the signed 32-bit range become BSON 0x10; larger integers become 0x12. It does not retain explicit BSON types: even the canned cursor ID 0 passes through the small-integer branch.
The receive loop handles fragmented TCP headers and bodies correctly: it accumulates all 16 header bytes, then reads messageLength - 16 bytes. However, it applies no maximum length check and sets no per-connection socket timeout. The advertised maxMessageSizeBytes: 48000000 is only a handshake field, not an enforced receive bound. Each accepted connection gets a daemon thread.
A deliberately shallow command parser
The parser assumes that every opcode-2013 body begins with four flag bytes and a kind-0 section byte:
# fake_mongo_v3.py:86โ100; omitted lines removed.
if op_code == 2013:
bson_start = 5
bson_len = struct.unpack('<i', body[bson_start:bson_start+4])[0]
raw_bson = body[bson_start:bson_start+bson_len]
# First BSON element name becomes the command.
It does not inspect the section-kind byte, validate flags or implement compressed messages. MongoDB's OP_MSG specification allows additional document-sequence sections; this parser only walks its assumed first document.
The walk skips a subset of BSON value types to reach later keys such as $db. An unsupported type breaks the walk. BSON binary, type 0x05, has no skip branch. A SASL request containing a binary payload before $db can therefore leave the database name at its default admin. The command name may already have been recovered, so the resulting log can look plausible while losing subsequent fields.
Legacy opcode 2004 gets an even looser treatment: string searches identify ismaster and buildinfo; everything else becomes legacy:<collection>. Unknown opcodes fall through to the default command and receive a legacy-style reply rather than a protocol-specific error.
Successful authentication exists only in the reply dictionary
The authentication branch is three commands sharing one fixed answer:
# fake_mongo_v3.py:184โ185
elif command in ("saslStart", "saslContinue", "authenticate"):
reply_doc = {
"conversationId": 1, "done": True, "payload": "", "ok": 1.0
}
There is no SCRAM challenge, nonce exchange, password verification or server proof in this branch. The MongoDB authentication specification defines an authentication conversation; returning ok: 1 does not complete that conversation on behalf of a conforming client.
The encoder introduces another concrete mismatch. Its empty Python string becomes this BSON element:
02 70 61 79 6c 6f 61 64 00 01 00 00 00 00
โ โโโโโโโโโ "payload" โโโโโโโโ โ length=1 โ NUL
โ BSON UTF-8 string
BSON distinguishes a string (0x02) from binary data (0x05), as specified in the BSON grammar. The local encoder has no binary-value branch. Together, the missing SASL exchange and improvised types explain why โfake server returned successโ cannot establish that a real authenticated driver accepted it.
The ordinary query responses are fabricated too. find and aggregate return one fixed document, a zero cursor and a namespace ending in .collection. getMore returns an empty batch. There is no database engine behind these responses.
Ten records and nine replies
The limit is enforced between logging and response construction:
# fake_mongo_v3.py:164โ175. Analyst comments added. if command not in HANDSHAKE_CMDS: query_count += 1 captured_queries.append(f"{command} (db={db_name})") log(f"QUERY #{query_count}: {command} db={db_name} raw={raw_info[:100]}")if query_count >= MAX_QUERIES: break # The tenth counted request never reaches reply construction.
Build response follows this check.
An independent counter model run on the Linux analysis server reproduced the boundary: ten ordinary requests enter the counter, nine reach the response path, and the tenth closes the connection. This model used the parsed constant MAX_QUERIES = 10; it did not run the captured server.
The limit is per connection. query_count starts at zero inside handle_client. Fourteen command names are exempt, including listDatabases, listCollections, dbStats and authentication commands. Those requests can continue without consuming the budget.
The capture is also narrower than the โquery detailsโ comment suggests. parse_command initially hex-encodes the first 200 body bytes, but the log slices that string to 100 hexadecimal characters: at most 50 original bytes. Telegram receives the peer IP, count, command names and database names, not that raw prefix or complete query documents.
The module docstring says the disconnect causes a reconnect to the real MongoDB. The implementation closes its accepted socket; it neither forwards requests nor opens an upstream database connection. An AST check found no connect, connect_ex or create_connection calls. Any later connection to a genuine database would depend on client configuration or external routing.
A second service plants a JSON marker
Adjacent evil_rpc.py listens on port 8443 using plain HTTPServer, logs incoming headers and POST bodies, then fabricates Ethereum JSON-RPC results. For eth_call, the response includes:
{
"jsonrpc": "2.0",
"result": "0x0000000000000000000000000000000000000000000000000000000000000000",
"__proto__": {"pwned": "FROM_RPC_RESPONSE"}
}
This is an excerpt; the real response also echoes the JSON-RPC ID. The source comment calls the marker a prototype-pollution payload. A JSON property named __proto__ does not by itself modify a JavaScript prototype. A vulnerable merge or property-assignment operation in a consuming application would be needed, as MDN's prototype-pollution reference explains. The responder establishes an attempted probe, not a demonstrated consumer vulnerability.
Detection and supported behavior
The useful service-level combination is a Python listener on 27017, deterministic MongoDB replies, a disconnect on the tenth non-exempt request and outbound Telegram requests after disconnect. Individual ports or Telegram use are weak indicators; correlate the process, reply contents and connection sequence.
| Finding | Evidence | Interpretation |
|---|---|---|
| Query metadata exported through Telegram | send_telegram:17โ22, summary at 217โ226 | Static capability consistent with T1567 Exfiltration Over Web Service |
| Protocol impersonation | Hand-built replies at 63โ79, canned command results at 175โ202 | Operator-side fake service |
| JSON prototype marker | evil_rpc.py:24โ27 | Probe requiring a susceptible client-side consumer |
The service source does not establish how clients were redirected to it, so it does not support an adversary-in-the-middle claim. Analysis remained static on Linux; no captured service, client or original endpoint was contacted. Existing raw logs and credentials were excluded.
Artifacts
Source references above are tied to these exact retained files. The date is the writeup date; acquisition time is not established.
| Artifact | Bytes | SHA-256 |
|---|---|---|
fake_mongo_v3.py | 10,545 | 4d625a14234743a5ac550226a9e911594203823aaabd5a26aad78a242e2acabf |
evil_rpc.py | 2,530 | 0f8c89c81440b996551a8f1ddb5c29eff02688badbe2f6c414532e5c76d6e8ea |