Skip to content
Go back

Field Notes

Internet Dumpster diving OpenDirs

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 VAResolved import
0x401581WinHttpOpen
0x4015afWinHttpConnect
0x4015f5WinHttpOpenRequest
0x40163b / 0x4016ebWinHttpSendRequest / WinHttpReceiveResponse
0x40171b / 0x4017bdWinHttpQueryDataAvailable / WinHttpReadData
0x401855 / 0x401973GetTempPathA / WriteFile
0x401b4cCreateProcessA

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:

C++
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.

Text
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 offsetConstantVerified resolution
0xea0x0726774cKERNEL32.DLL!LoadLibraryA
0x1880xa779563aWININET.DLL!InternetOpenA
0x1bb0xc69f8957WININET.DLL!InternetConnectA
0x2bc0x3b2e55ebWININET.DLL!HttpOpenRequestA
0x2fa0x7b18062dWININET.DLL!HttpSendRequestA
0x35b0xe2899612WININET.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.

ArtifactSHA-256
system_info.exe38e513da03ceaf361da6a57eabb08672b8810194cf77d4749b645e9f12fb06c6
nfo.exe7af8578adde7511d764c7dc1494b9812fdaf5f8178b3413872ee47c9777f50c9
baba.cpp06f817181ff154b60b68afc51d4c892cca5ef83269f2d9a99828370f39589585
recipe.xmld74ec5f34bd0592802d526bcc3e3c6b898390fc39b2bfc7639ff4b3cb95c7ae3
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:

Assembly
; 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 arrayDLL RVADecoded role
w_ho0x52c0Host com-selfhelp[.]page
w_pa0x5280Path /static/agent.dat
w_rt0x5330CLR runtime v4.0.30319
w_ae / w_rn0x5310 / 0x5308Managed type AgentEntry, method Run
s_run_val0x5408Run value WinServiceHost
s_ad_sub0x53f0AppData 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 capabilitySupported ATT&CK mapping
WINMM impersonation and explicit companion-DLL hostT1574.001 DLL, entry and forwarding paths above; legitimate-host loading unobserved
Receive and decode the managed stageT1105 Ingress Tool Transfer and T1140 Decode Files or Information, worker receive/XOR loops
Current-user Run valueT1547.001 Registry Run Keys, source 340โ€“363
Conditional elevation and Defender-exclusion attemptsT1548.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.

ArtifactSHA-256
winmm_v14.dll145d725efc40e35691764ac28e2c693c379a906a48298a4d9da8433a7ab59922
winmm_v14.c8ca6c22df6e91d7d13f5ad15d45a3b4d71f9e1eac607d12f5bddf27bbd8c1478
host_exe.exe710cae02b8e3066c1fefab6893f94a7799683c005111dfde548de5f22a002866
host_v2.exe710cae02b8e3066c1fefab6893f94a7799683c005111dfde548de5f22a002866
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:

Text
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:

C#
// 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:

LayerRaw ASHX handlerASMX pass method
Input representation consumed by codeRaw request bytesURL-decoded string, then base64-decoded bytes
Inner cipherSame key/IV value; CBC with framework-default paddingSame
Assembly/dispatch stateContext.ApplicationContext.Application
Later response representation produced by codeCiphertext bytes16 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:

Assembly
; 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:

YARA
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.

ArtifactSHA-256
gtxy.ashx and system.ashx28554327bac0d34206c5734d4feed94b0c55ae043b9683c03e808748633aa28f
sitemap.asmx854257f27988ac9100f1188f57b2019080b8578c0c43c016762062b2cd6db4fc
sitemap.soap9751cdad0b2a3395f12bca15701ee76e2a1d955f47ac587e6f9868c6c5318e97
dll.zip member dll\iis64.dllf07d869ddd17d4359e26da43574d0d07987b500a390196b72b3c1747a4cbb3bf
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.

ArtifactSHA-256
Agent6.exe81451b4d2c35fa19da790fe1700fd057629bfa6255dc7105742f5aa4f36efe46
c2_server/server/app.py455da6a6e1e4f670a1cfc25022c341f11c4d68ad9e692dc61ea9ee9ada3abc99
c2_server/server/cryptography_utils.pyf993e04cf4414d004054d21a34ee66ce1bafb37a582c617ceb83fbd778dfbebe
fingerprint-proxy-linux-amd64.new69ffa6da40ce2a34c70e8f03577b8780b261fa0696075ecb1b17880ace2af51e

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 payloadHandling
Binary `01
Binary `02
Other binary bytesAttempt encrypted JSON decoding.
Text 42["event",{"d":"โ€ฆ","ts":โ€ฆ}]Extract Base64 ciphertext from d, decrypt, then parse JSON.
Other JSON textParse 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:

Text
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:

Python
# 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):

Python
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

MappingEvidence and limit
T1555.003 โ€” Credentials from Web BrowsersServer processes uploaded browser databases and supplied key material. Endpoint collection was not observed.
T1573.001 โ€” Symmetric CryptographyImplemented GCM and CBC-HMAC command formats; binary relay classes take a different path.
T1090.001 โ€” Internal ProxyPer-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:

Go
// 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:

Go
// 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:

Text
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:

Text
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.

ArtifactBytesSHA-256
core/http_proxy.go82,348a57df09fd6340fcb853c7e2d549e747eba9462d4fd9e0b64712f8c9bd72948fd
core/admin.go3,250096f62d6b491b92cbc9d0663c1e501b2c0f06eb2a9d14e23c2e7dedff13a68d7
evilginx224,660,590ee169ba793bdd6c481ea53fb5ad1babce500b96ffe1ec74b7e90896dc9194b70

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:

Text
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.

ArtifactSHA-256
chisel_aarch64c6f4a347d1b38fd2a52c3f4d508d86f62316006953d607db3f87bbdf1e60656a
3proxy710310cc5d64ebff581283111cb0a2961684a0665c0e9aa972d675b3295d93b6
chisel_server_deploy.sh65d02ab50e1ddb66c8383340c4303e90b563b27a4d4bdbafa49d825cdd330d54
3proxy_deploy.shf52a1758aca20be61f6436bfde28f80908402a1e6636523f0cf2f3ba81f49bff

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:

Text
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:

Shell
# 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:

Shell
# 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:

Shell
# 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

MappingConcrete evidence
T1090.001 โ€” Internal ProxyIntended router-originated access through Chisel's SOCKS handler or native 3proxy. No observed downstream traffic claimed.
T1036.005 โ€” Match Legitimate Resource Name or LocationChisel becomes netconf_helper; 3proxy becomes netconf_svcd under /bootflash/.cache.
T1053.003 โ€” CronMinute, three-minute and @reboot entries are present in different templates.
T1037.004 โ€” RC ScriptsGenerated 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:

Text
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:

StageSource-defined construction
Key agreementEphemeral secp256k1 ECDH; public key encoded as uncompressed SEC1 bytes, then Base64
Session derivationscrypt(shared secret, salt session-salt-2026, N=16,384, r=8, p=1), 32-byte output
Master derivationThe same scrypt parameters with salt c2salt2026
Event encryptionAES-256-GCM with a random 12-byte IV and 16-byte authentication tag
Outer encodingBase64 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:

Rust
// 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:

Text
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:

Rust
// 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 nameHashDispatch target
ping0x165df089Returns pong and timestamp
shell0x11e1fc01shell::execute
persist0xe979f483persist::install
socks5_start0xd014acfeproxy::start
full_steal0x2d455184exfil::full_steal
kill0xc50f4599Returns 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:

Rust
// 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

MappingCode evidence and boundary
T1071.001 โ€” Web protocolsWebSocket/Socket.IO command and result events in transport.rs
T1573.001 โ€” Symmetric cryptographyApplication-layer AES-GCM envelope in kb-crypto/src/lib.rs:50โ€“89
T1547.001 โ€” Run keys / StartupInstaller functions selected by install_all and install_sync; successful host modification was not observed
T1053.005 โ€” Scheduled taskScheduled-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:

Assembly
; 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:

INI
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:

C
/* 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:

Assembly
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:

AddressOperationBuffer or length
0x1544send16 bytes from [rbp-0x1b0]
0x1564derive_keyCopies the configured password into the key buffer
0x15d3send12 bytes from [rbp-0x1c8]
0x15f1recvUp to 12 reply bytes
0x160frecvUp 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:

C
/* 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:

Text
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:

C
/* 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 behaviorMappingEvidence
Write and enable a systemd serviceT1543.002 Systemd Serviceachieve_persistence, source 118โ€“137 and native calls above
Append a reboot cron entryT1053.003 CronSource 139โ€“140, native 0x184c
Adopt a kernel-worker-like thread nameT1036 MasqueradingSource 145, native 0x188b
Gate execution on environment checksT1497.001 System ChecksSource 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.

ArtifactBytesSHA-256
botnet/kronos_agent17,128c4b6e7ebd100beec82f0c045056de656e632b970decf3bff97ada91d78ecc496
botnet/kronos_agent.c5,4468aa6712d340fddb5d8594043a84b9cba7ccbc1c43b4023b6f58f2626c1c70c47
botnet/kronos_c2.py4,046c53b6769ca2c2aa539f3c64838bbdc4f8713746028f47b3aa9e7f06c50005241
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

ArtifactBytesSHA-256
bot_client.c8,22037fa2b5fd71f3533f9277138091fc88e60d0b5b3802383f85d021afa080d28ab
c2_server.c21,599b435189d037bb38563cfaeee9b97cfa60e2bb8c6699ab5831eb54a7e6bba00c0
bot_client_armv7l17,63204ab1b6de2f52ab72d8cadfb531fcf41da768df374530759a7002a204d564e34
bot.arm7485,372c869584f3e17fdde01a26bdf58d92837d8f396aad9944dffb08627c1f7e1323f

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:

DirectionRecord
Client โ†’ controllerHELLO <hostname>\n
Controller โ†’ clientA newline-terminated UDP instruction containing IPv4 address, duration, destination port and payload length
Controller โ†’ active clientSTOP\n
Client โ†’ controllerDONE <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:

C
/* 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:

C
/* 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:

C
/* 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 recv result as a record. It does not reassemble split DONE lines or iterate over multiple coalesced records.
  • accept_loop disconnects 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:

BinaryFile offsetASCII content
bot_client_armv7l13,774/proc/sys/kernel/hostname
bot_client_armv7l13,800Configured controller IPv4 address
bot.arm7379,154/proc/sys/kernel/hostname
bot.arm7379,180Same 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:

Python
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 offsetSizeCaptured reply
04Total message length, including the 16-byte header
44Incoming request ID + 1
84Incoming request ID
124Opcode 2013
164Flags, always zero
201Section kind 0
21VariableBSON 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:

Python
# 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:

Python
# 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:

Text
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:

Python
# 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:

JSON
{
  "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.

FindingEvidenceInterpretation
Query metadata exported through Telegramsend_telegram:17โ€“22, summary at 217โ€“226Static capability consistent with T1567 Exfiltration Over Web Service
Protocol impersonationHand-built replies at 63โ€“79, canned command results at 175โ€“202Operator-side fake service
JSON prototype markerevil_rpc.py:24โ€“27Probe 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.

ArtifactBytesSHA-256
fake_mongo_v3.py10,5454d625a14234743a5ac550226a9e911594203823aaabd5a26aad78a242e2acabf
evil_rpc.py2,5300f8c89c81440b996551a8f1ddb5c29eff02688badbe2f6c414532e5c76d6e8ea