latest event 56m ago · pipeline live
← Blog
Malware Analysis

Suspected BatShadow's batman Go Loader and the Multi-Tenant Panel

Shakil Hossain · 15 Aug 2026

The final payload is a Go loader with the internal module name batman, whose single hardcoded GUID serves as both the API key and both AES keys. Recovering it compromises the protocol in full. Controlled interaction with the C2, and the panel's own error handling, exposed a multi-tenant loader-as-a-service platform.

The first two posts covered the delivery chain and the signed-driver EDR killer it installs. This one covers what that killer decrypts and launches.

plugin_runtime.exe is 6,894,592 bytes, PE32+, Go 1.26.2, the same toolchain as the stage-1 loader, with the same zeroed TimeDateStamp and the same anti-tooling modification applied to its pcHeader.textStart field. It carries no Authenticode signature, and its COFF symbol table is stripped.

We recovered 6,908 function names from the pclntab regardless. The Go module root is batman.

Package Structure

Symbol recovery yielded the full module layout, which is a more durable discriminator than any hash:

batman/configs           NewConfigs, GetInstance, SetID
batman/services          Ping, GetTask, UpdateTask, SyncInfo, DownloadFile
batman/pkg/command       PrepareAdminCommand, PrepareBackgroundCommand,
                         getElevatedUserToken, getInteractiveUserToken
batman/pkg/crypto        EncryptWithAES, DecryptWithAES
batman/pkg/information   GetHWID, GetAntiVirusInfo, GetCPUInfo, GetGPUInfo,
                         GetIPInfo, GetLocalIP, GetPing, GetCurrentUser, CheckAdmin
batman/pkg/folder        GetTempDir, GetTempFile
batman/pkg/logrus

A Go module root is a name the developer chose, and an entire matching subpackage layout is not convergent evolution.

Campaign GUID and Key Derivation

The configuration is three strings built at batman/configs.NewConfigs. We read the lengths out of the instruction stream rather than guessing at a NUL-less string blob: a base URL of hxxps://api.franciebags[.]info (28 bytes), a campaign GUID of b4030a4f-71bb-4795-bb68-a545a05c5746 (36 bytes), and a version string of 1.0.0 (5 bytes).

The GUID is hardcoded into the build, not generated per host. It serves three separate roles.

The three roles served by a single 36-byte string.

The two directions use different keys, an asymmetry that is easily overlooked and costly to diagnose. Requests encrypt under SHA-256("pkk_" + GUID). Server responses decrypt under SHA-256(GUID) with no prefix. We established that empirically after served payloads failed under the request key, then confirmed it across five independent captures. Everything else is identical in both directions: AES-256-CBC, PKCS7 padding, a fresh 16-byte random IV per message, and a wire format of hex(iv) + ":" + hex(ct) in lowercase.

Recovering one 36-byte string therefore compromises the protocol in full, covering authentication and both cryptographic directions. No second key exists.

The path component is a per-bot identifier the panel issues at enrolment, and the GUID lives in the x-api-key header.

C2 Protocol and Timing

Order of operations, read from the call graph.

Two details matter for network detection.

The first network event is not the C2. Before it contacts api.franciebags[.]info, the implant fetches hxxps://geo.myip[.]link/ for the host's public IP and country. A request to that geolocation service immediately followed by a first-time connection to an unfamiliar domain is a more specific signature than either observation alone.

There is no single beacon interval. The task poll runs on a 10-second loop. The keepalive runs on its own ~30-second timer in a separate goroutine, concurrently. On the wire this presents as two interleaved periodicities rather than one rhythm, which matters for any hunt keyed to beacon regularity. The rest of the timing, read straight off the time.Sleep arguments: 15 s retry backoff, 40 s client timeout, 1 s between tasks, five consecutive failures before it abandons the loop and re-enrols.

Tasking Surface and Persistence

The task dispatcher compares Go interface type hashes against exactly three itabs: DownloadAndRun, DownloadAndRunAdmin and DownloadAndRunSystem, the last of which additionally sets CREATE_NO_WINDOW. All three take a log ID and a URL, download to a temp path, and execute.

There is no shell, screenshot, keylog, file-exfiltration or credential verb in the dispatcher. Any capability beyond loading arrives as a follow-on binary, written to disk as {logID}.exe.

Persistence uses four independent mechanisms, all masquerading as Windows Update Service. A scheduled task on a five-minute interval at HIGHEST run level, a Windows service, service failure actions set to auto-restart after termination, and a self-healing loop that periodically re-asserts all three. The real Windows Update service is wuauserv; a service named "Windows Update Service" backed by a binary outside System32 is not Microsoft.

It also re-implements the entire kernel EDR-kill natively in Go, including main.warsawKillPID, a dedicated routine for Warsaw / GbPlugin, the Diebold-Nixdorf online-banking protection agent deployed almost exclusively by Brazilian banks.

The implant logs verbosely to C:\Users\Public\Project\log.txt, tagged by component. For an estate sweep, that file is the fastest confirmation that an endpoint executed the chain. Six of those log strings are Vietnamese written without diacritics, among them [killLoop] Bat dau (50ms/lan) ("starting, every 50 ms") and [loadDriver] Driver load thanh cong ("driver load successful"). That is a developer-language indicator, not attribution.

Controlled C2 Interaction

The infrastructure was live, roughly two and a half weeks after the build date implied by the lure folder. We reimplemented the protocol and enrolled a synthetic bot under these constraints:

  • Synthetic identity only. The host fingerprint was fabricated from a seeded RNG. No real HWID, username, CPU, GPU, MAC or public IP was transmitted, and geo.myip[.]link was never contacted.
  • Nothing executed. Any collected file would have been written 0600 to quarantine and identified by header parsing alone.
  • No access to anyone else. Only the bot we ourselves enrolled was ever addressed. No enumeration of other bot IDs, no attempt to read other tenants' records, no exploitation of the panel.

Enrolment returned HTTP 201 and a bot ID. Five task polls over about 50 seconds all returned an empty list.

No payload was served. That negative is scoped tightly: the campaign was idle in a ~50-second window on 2026-08-14. The C2 answered every request, enrolled the bot and marked it online, so it was idle rather than decommissioned, and no conclusion about what it tasks can be drawn from a fifty-second sample.

Server-side Disclosure via Error Handling

The API returns unhandled exceptions with full context. Successive malformed requests gave us, in order, a class-validator message array, a TypeError naming a server-side variable, and a PrismaClientValidationError echoing an entire database model plus a source path. From that: Linux, deploy user loader, project root /home/loader/api, a NestJS + Prisma + TypeScript stack with module layout src/modules/bot/bot.service.ts, and Cloudflare on the edge.

Invalid this.prisma.bot.upsert() invocation in
/home/loader/api/src/modules/bot/bot.service.ts:98:39

  95     data: { hwid: newHwid },
  96   })
  97 }
→ 98 const bot = await this.prisma.bot.upsert({
       where: {
         hwid: "b5bab1cd-8884-17a5-ecef-e37b9e250d03_07e7216f21a9505195dcc2be8d27530f50d87ff91593ffe3c6f83d69c6b471a9"
       },
       create: {
         hwid: "b5bab1cd-8884-17a5-ecef-e37b9e250d03_07e7216f21a9505195dcc2be8d27530f50d87ff91593ffe3c6f83d69c6b471a9",
         localAddress: "",
         isAdmin: false,
         os: "Windows 10 Pro 19045",
         cpu: "Intel(R) Core(TM) i7-10700 CPU @ 2.90GHz",
         gpu: "Intel(R) UHD Graphics 630",
         bit: "",
         ping: 149,
         antivirus: [],
         version: "",
         status: "online",
         password: "",
         username: "",
         userDevice: "",
         country: "BR",
         company: null,
         userId: "41346459-ad66-4099-b2ae-b1bfea33618c",
         lastOnline: new Date("2026-08-14T03:40:42.468Z"),
         createdAt: new Date("2026-08-14T03:40:42.468Z"),
     +   ipAddress: String
       },
       update: {
         ipAddress: undefined,
         localAddress: "",
         isAdmin: false,
         os: "Windows 10 Pro 19045",
         cpu: "Intel(R) Core(TM) i7-10700 CPU @ 2.90GHz",
         gpu: "Intel(R) UHD Graphics 630",
         bit: "",
         ping: 149,
         antivirus: [],
         version: "",
         status: "online",
         password: "",
         username: "",
         userDevice: "",
         country: "BR",
         company: null,
         userId: "41346459-ad66-4099-b2ae-b1bfea33618c",
         lastOnline: new Date("2026-08-14T03:40:42.468Z"),
         createdAt: new Date("2026-08-14T03:40:42.468Z")
       }
     })

The + ipAddress: String line is Prisma naming the field whose absence caused the throw, and pulling the rest of the model into the response with it. Every host value in that record is our synthetic bot's fabricated fingerprint, country included; none of it describes a real victim. What the panel contributed is the shape of its own database: the column set, the source path, and one field the client never sent.

The Prisma error also echoed a field the implant never sends and cannot know:

userId: "41346459-ad66-4099-b2ae-b1bfea33618c"

That is a server-side tenant identifier, resolved by the panel from the x-api-key. There is a nullable company column sitting next to it.

Bots are owned by a userId. The campaign GUID is a per-customer key. The backend lives at /home/loader/api. The implant's only capability is download-and-run. Taken together, this indicates a loader-as-a-service platform with customer accounts rather than a single group's private tool.

That tenant ID is the most durable indicator recovered in this engagement. It survives domain rotation, GUID rotation and a rebuild of the implant, and it is the identifier a hosting-provider legal request would act on. Any future sample resolving to it is the same customer. It is not a deployable indicator: no defender can observe it from their own network.

Unimplemented Screen-capture Configuration

The beacon response is returned un-encrypted:

{"captureQuality":100,"captureInterval":3,"enableSaveImage":false,"viewedAt":null}

The implant has Go struct fields and JSON tags for captureQuality, captureInterval and viewedAt, eight occurrences of each. It deserialises all three. It has no code that acts on them.

We checked for gdi32, BitBlt, GetDC, CreateCompatibleDC, GetDIBits, SelectObject, StretchBlt, PrintWindow, EnumDisplayMonitors, GetSystemMetrics, image/jpeg and image/png. None were present. Go's name table would list those if they were imported. We then probed for a capture endpoint across twelve candidate routes, including /api/capture/, /api/screenshot/ and /api/image/. All returned the framework's default 404, meaning absent from the router entirely. The panel also sends enableSaveImage, a field this build does not parse.

The conclusion from the binary alone, before any OSINT: this 1.0.0 build is a loader-only member of a larger RAT family, being served a superset configuration by shared multi-tenant infrastructure. Sibling builds capable of performing the capture very likely exist.

Comparison with Published Reporting

Two reported clusters are worth holding this chain against. PXA Stealer, a Python infostealer run by Vietnamese-speaking operators and documented by Cyble and by SentinelOne with Beazley Security, and Vampire Bot, the Go implant attributed to the group tracked as BatShadow or BatShade, reported by Aryaka in October 2025. Everything we say about either is the vendors' reporting, not our observation: we hold no sample from either cluster and reproduced none of it.

Comparison of the campaigns side by side.

The delivery half lines up with PXA Stealer at a level of detail unlikely to result from convergence. Both abuse a renamed, legitimately signed Microsoft Word binary as the side-load host. Both hijack a DLL named msvcr100.dll; SentinelOne records exactly that name for PXA's July 2025 wave. Both inflate the side-loaded DLL to defeat size-threshold scanning, reported at roughly 100 MB there against 157 MB here. Both stage under C:\Users\Public. Both ship a Python interpreter with the campaign rather than relying on one being present. And both hide the actual script behind a .dll filename and execute it through exec(): PXA's update.dll against our vcruntime.dll.

The victimology also matches, and closely. Cyble describes LinkedIn as PXA's primary initial access vector, with recruitment messages sent from compromised accounts to job seekers, and names Bangladesh among the target countries alongside India, the Netherlands, Sweden and the United States. Their lure offers a remote part-time digital-marketing role. Ours offers a paid-media assessment to a marketing professional in Bangladesh, and arrives with a Dropbox link, which is also how Cyble's chain delivers its archive.

The payload half points the other way, at Vampire Bot. Both are Go. Both profile the host along the same axes, and Aryaka's list (usernames, hardware identifiers, OS, privilege level, installed security products) maps field for field onto what our enrolment payload sends. Both use a single-instance mutex. The reported C2 naming habit is the same shape as ours, api3.samsungcareers[.]work against api.franciebags[.]info: an api-prefixed subdomain on a domain themed to the lure. And Vampire Bot captures screenshots at configurable intervals in WEBP, which is precisely the capability our panel configures and our build cannot perform.

None of this establishes an actor. A shared delivery toolkit is a much weaker relationship than a shared operator, and the two halves point at two different published clusters, which is consistent with delivery and payload being sourced separately rather than built by a single group. Two differences are also worth stating. PXA's endgame is a Python infostealer exfiltrating credentials and cookies to Telegram, where ours is a Go loader beaconing to a NestJS panel; and neither reported cluster carries a BYOVD EDR killer at all. The Safetica driver is the one substantial capability in this chain that has no counterpart in either body of reporting.

Confidence Assessments

This is assessed to be a loader service, with this campaign as one customer on it. We hold that with high confidence, because it rests on things the panel volunteered about itself rather than on inference: the tenant ID, the company column sitting beside it, the loader deploy user, and a tasking surface that does nothing but download and execute. It is also the framing that accounts for the remaining observations. If the platform is shared, a capability sitting in the codebase tells you nothing about the customer who happens to be running it.

The implant probably belongs to the BatShadow family, whose Go bot is tracked as Vampire Bot. Medium confidence. Reporting from October 2025 describes a Vietnamese-nexus group aiming a Go implant at job seekers and digital-marketing professionals, with screen capture at configurable intervals in WEBP. Our panel serves exactly that configuration to a build that cannot act on it, and the host-profile fields in that reporting map onto our enrolment payload field for field. Two independently derived lines of evidence agree. What stops us going further is the one detail that would settle it: matching batman-rooted Go symbols in an actual Vampire Bot sample. That claim traces back to a social-media post we could not verify, and neither vendor write-up mentions function naming at all. This is a leading candidate, not an identification.

The build appears to anticipate corporate machines, which is not the victim profile the email describes. Also medium confidence. The appended block of the kill list goes after SIEM forwarders, Sysmon and privileged-access agents, none of which exist outside a monitored estate. The email meanwhile targets one person, by name and profession, at a personal address. Two independent signals point the same way: that block, and a token-manipulation suite built to run commands on the interactive desktop from session 0, sitting behind service persistence. Either the operator expects these victims to open the lure on a work laptop, which makes the marketing professional a way in rather than the target, or the block is inherited configuration from somebody else's campaign. What keeps it below high confidence is that the implant has no lateral movement, no credential dumping and no exfiltration. It has nothing that would exploit a corporate foothold.

The Brazilian-banking indicator likely reflects inherited code rather than targeting intent. Medium confidence. The warsawKillPID routine is deliberate: a special case was written for one Brazilian banking product, and the agent removes its service by name. But the kill list's only Warsaw-related entries sit in the inherited legacy block under a generic vendor tag, not in either of the deliberate appends. Had this operator added Warsaw targeting, it would appear alongside their other additions, and it does not. The reading that fits both facts is that the routine belongs to the shared codebase while the list is recycled, and neither tells you anything about who this campaign was aimed at, which on the only evidence we have is digital-marketing job seekers reached through a fashion-brand recruitment lure.

Analytical limitations

We name no threat actor. Six of the implant's log strings are Vietnamese written without diacritics: [killLoop] Bat dau (50ms/lan) (bắt đầu, "starting"), [loadDriver] Driver load thanh cong (thành công, "successful") and [getOrCreateService] Service da ton tai (đã tồn tại, "already exists") among them. These are debug messages a developer left for themselves. They evidence the language of whoever wrote the code, not the identity of whoever ran it. The tenant ID does not close that gap either: it is a row in someone else's database, and it identifies a customer account rather than a person. Bot developer, platform operator and customer remain three separate questions, and this analysis answers none of them.

The sender domain is the one attribution question the evidence does settle, and it settles it only partly. rebelsmarket[.]de runs on Cloudflare nameservers with MX records at Titan Email and an SPF record authorising Titan to send for it, and it shares no infrastructure with the genuine brand, which sits on Shopify behind Linode DNS with Google Workspace mail. Forging a brand in a From line requires no records on the impersonated domain, so this is attacker-controlled infrastructure rather than external impersonation. Who registered it remains unknown: DENIC publishes no per-domain WHOIS for a .de, and without message headers we cannot confirm the mail authenticated against that SPF record. The lookups date from 2026-08-16, not from the time of sending.

The OSINT was web search only. We retrieved the vendor reporting cited above, but ran no VirusTotal, MalwareBazaar, ThreatFox or passive-DNS query, which is exactly where an uncatalogued sample would surface. Any absence should be read as not found by the searching performed. The detection content is unfinished in the same way. The YARA rules compile and were checked against five malicious artifacts and benign controls; the Sigma rules pass sigma check but have never fired against live telemetry; the Suricata rules have never been validated at all. None of them covers the email, the link or the archive.

ATT&CK mapping

 ATT&CK coverage for the implant.

Techniques evidenced in the loader itself. The delivery chain and the BYOVD stage are mapped in the earlier posts. Screen capture is deliberately absent: the panel configures it, but this build has no implementation to map.

Indicators of Compromise

Defanged. Parts 1 and 2 carry the delivery and driver indicators.

Network

C2 base URL         hxxps://api.franciebags[.]info
C2 domain           api.franciebags[.]info
Campaign GUID       b4030a4f-71bb-4795-bb68-a545a05c5746
                    - sent in the x-api-key header, NOT in the URI path
Enrolment           POST /api/hdrp                (no id in the path)
Beacon / tasking    GET  /api/ping/{botId}
                    GET|POST /api/task/{botId}    botId is panel-issued
Geolocation         hxxps://geo.myip[.]link/
Agent version       1.0.0
Command verbs       download_and_run_admin
                    download_and_run_system
Request key         SHA-256("pkk_" + GUID)
  = 1770e71fc36db72d2c6a62788d89acfe61a8c81070737d643c65eedcba518b7f
Response key        SHA-256(GUID)
  = 9c7b928143db68a5b3d3ed74c33febfd31931973b4e6b35b28dd62c05329c819
Wire format         JSON "payload" field of the form <32-hex>:<hex>
Operator tenant     41346459-ad66-4099-b2ae-b1bfea33618c   (investigative pivot,
                                                            not a deployable IOC)

Files and Host

  • Final payload plugin_runtime.exe: 5f57e2adc6e786d056fffa141537ef1891f11f9c2a5b458a7049360025e77a87 (6,894,592 bytes) · MD5 3723c112ea8b670f119b0bab67bd7e40
C:\Users\Public\Project\log.txt          operator debug log
Windows Update Service                    masquerading service / task name
UpdateService.exe                         deployed agent filename
schtasks.exe /create ... /sc minute /mo 5 ... /rl HIGHEST
WarsawPM                                  service the agent explicitly removes
LastLoggedOnUser                          registry value read for user discovery
{logID}.exe                               downloaded second-stage filename
Service failure actions configured for auto-restart

Suricata: Network Rules

A transport caveat applies to all of these: the C2 is HTTPS, so the http.* rules fire only where TLS is terminated and inspected. Without inspection, only the DNS and SNI rules apply.

# DNS lookup - fires regardless of TLS inspection
alert dns $HOME_NET any -> any any ( \
  msg:"BATMAN RAT C2 domain lookup (api.franciebags.info)"; \
  dns.query; content:"api.franciebags.info"; bsize:20; nocase; \
  classtype:trojan-activity; \
  reference:md5,3723c112ea8b670f119b0bab67bd7e40; \
  sid:1000001; rev:1; )

# TLS SNI - the other rule that works without inspection
alert tls $HOME_NET any -> $EXTERNAL_NET any ( \
  msg:"BATMAN RAT C2 TLS SNI (api.franciebags.info)"; \
  flow:established,to_server; \
  tls.sni; content:"api.franciebags.info"; bsize:20; nocase; \
  classtype:trojan-activity; \
  sid:1000002; rev:1; )

# Highest fidelity: the campaign GUID in the auth header. Needs TLS inspection.
alert http $HOME_NET any -> $EXTERNAL_NET any ( \
  msg:"BATMAN RAT C2 auth header carrying campaign GUID"; \
  flow:established,to_server; \
  http.header; content:"api-key"; nocase; \
  content:"b4030a4f-71bb-4795-bb68-a545a05c5746"; nocase; distance:0; within:64; \
  classtype:trojan-activity; \
  sid:1000003; rev:1; )

# Enrolment - the first thing a fresh infection emits, retried every 15s
alert http $HOME_NET any -> $EXTERNAL_NET any ( \
  msg:"BATMAN RAT C2 registration (/api/hdrp)"; \
  flow:established,to_server; \
  http.method; content:"POST"; \
  http.uri; content:"/api/hdrp"; startswith; \
  classtype:trojan-activity; \
  sid:1000004; rev:1; )

# Beacon and tasking - matched by URI shape so it survives a GUID rotation
alert http $HOME_NET any -> $EXTERNAL_NET any ( \
  msg:"BATMAN RAT C2 beacon or task poll (/api/ping|/api/task)"; \
  flow:established,to_server; \
  http.method; content:"GET"; \
  http.uri; content:"/api/"; startswith; \
  pcre:"/^\/api\/(ping|task)\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/"; \
  classtype:trojan-activity; \
  sid:1000005; rev:1; )

These are untested against live traffic. No Suricata binary was available in this engagement, so they were written from static analysis of the request-construction code and validated by reading, not by running. The User-Agent was never recovered, so we deliberately did not sign on it.

The lowercase x-api-key header is a real anomaly only over HTTP/1.1, where Go's Header.Set would normally canonicalise it to X-Api-Key, and this client bypasses that with a direct map assignment. Over HTTP/2 every header name is lowercase by specification, and Go negotiates h2 via ALPN when the server offers it. The casing is therefore not a reliable signature; the GUID is.

Recommended Actions

  1. Hash-block the driver now; do not wait for the blocklist. The build in this chain is version 11.11.4.0, read from its own version resource. Safetica's advisory states Microsoft adds 11.11.4.0 and older to the Windows vulnerable driver blocklist from 15 October 2026, and 11.26.18.0 from 15 November 2026. Both dates are still ahead of us, so HVCI, WDAC and Smart App Control will not stop this driver today. Block both hashes explicitly, alert on the STProcessMonitor service, and if Safetica is deployed legitimately, move it to 11.34.5.0 or later, the first build in which the attack surface is removed.
  2. Sweep for C:\Users\Public\Project\, the STProcessMonitor service, a task or service named Windows Update Service, and log.txt in that directory.
  3. Deploy the mass-termination correlation described in the previous post. It is the rule that still catches this family after the operator changes drivers.
  4. Hunt for sibling builds that share the pkk_ key derivation, the POST /api/hdrp enrolment, and the hex(iv):hex(ct) wire format, but which do carry GDI or image-encoding imports. Those are the builds that can perform the capture this panel configures.
  5. Tell your users that a job assignment never needs administrator rights. The kernel-mode portion of this chain depends on a user approving the UAC prompt.

The one gap we would most like to close is what this loader actually loads. Five polls over fifty seconds is not a representative sample. A stealer would confirm that the individual job seeker is the target; a beacon or a credential-dumper would confirm the corporate reading. Until a payload is served, we can describe how this campaign gains access and how it disables endpoint defences, but not what it does subsequently.

Sources

The technical detail attributed to PXA Stealer and Vampire Bot is as published by these vendors; we reproduced none of it.

Suspected BatShadow's batman Go Loader and the Multi-Tenant Panel — Threat Intel BD