pwn.dog

16 Kilobytes in the Clear: Decrypting Overwatch 2 Without Running It

contents

Overwatch 2’s 47 MB .text ships encrypted at entropy 8.000, decrypted lazily one page at a time. The pad that decrypts it is 16 KB of plaintext bytes in the DLL sitting next to it. This is how that was found, what it means, and the four times I convinced myself of something wrong before getting there.


Open Overwatch.exe in any PE tool and the import table is almost insultingly short. One DLL. One function. Not even by name - by ordinal.

Overwatch_loader.dll
   ordinal 1

Ordinal 1 is eidolon_run at RVA 0x386E50, and its entire body is:

c3 cc cc cc cc cc cc cc cc cc cc cc      ; ret, then padding

It does nothing. The import exists to force the Windows loader to map and initialise Overwatch_loader.dll before the exe’s entry point runs. Everything happens during that DLL’s initialisation.

And it has to happen, because the game’s code is not there. .text is 49,161,612 bytes at entropy 8.000 - the theoretical maximum for a byte alphabet. AddressOfEntryPoint points into it. All three TLS callbacks point into it. Every byte of it is ciphertext.

TL;DR:

  • .text is decrypted lazily, one 4 KB page at a time, on access. At a fresh menu, 58.69% of pages are present and executable; the other 41.31% are PAGE_NOACCESS and still ciphertext.
  • The cipher is XOR against a 16,384-byte pad, read circularly. Every page uses a 4096 byte window into it, modulo 16 KB.
  • That pad ships in plaintext, at file offset 0x044b5c0 of Overwatch_loader.dll, in the same directory as the executable it decrypts.
  • So recovering the game’s code needs no key, no debugger, no memory dump and no running process. Two files as they sit on disk. 99.84% of .text, 46.8 MB, verified byte-exact.
  • Separately, Eidolon guts 22 of the loader’s 1,174 functions: prologue frame setup removed, a few hundred contiguous bytes blanked, and 22-68% of every remaining byte replaced with an indexed INT3. Those functions never exist in memory, not even at runtime.

Measured against the Steam build, Overwatch.exe 65,208,016 bytes, build 2.24.0.3.


The protector has a name, and a factory

The loader is not shy about itself. Two strings, adjacent in the export name table:

eidolon_run
g_warden_aegis_crash_callback_export

and a build path that names the machine that produced it:

D:\Jenkins\workspace\eidolon-generator_main\pro_retail_workspace\__build\vs2022\RelWithDebInfo\loader...

eidolon-generator_main. Eidolon is not a static library linked into the loader; it is a generator that emits a fresh loader per build. That matters for everything downstream: the addresses in this post are true for 2.24.0.3 and will not survive a patch. The mechanisms will.

The second export is more interesting than the first. g_warden_aegis_crash_callback_export is exported by the loader and imported by nothing. The exe’s entire static import table is ordinal 1. So export two exists for a caller that arrives later and resolves it by name. I will come back to that.


Lazy decryption, and a dumper that lied to me

If .text is ciphertext on disk and code at runtime, the obvious move is to read it out of a live process. I did that through a kernel driver, using MmCopyVirtualMemory, in 1 MB chunks.

The result:

read ok 25,927,680    unreadable 49,283,072

The 49.2 MB that failed is, to within a rounding error, exactly .text. Meanwhile .rdata and .data came back fine. So the read path worked and .text specifically refused.

My analysis tool then reported .text as DECRYPTED, because the dump differed from disk almost everywhere. That was wrong, and the evidence was in its own output:

.text   49,164,288 bytes   nonzero  0  (0.00%)     H dump: -0.000

Entropy zero. The section was all zeros. My dumper zero-fills whatever it cannot read, and my analyser measured “differs from disk” without checking “is it anything at all”.

The real reason is a property of the protection, and it is the first useful finding. Walking the target’s address space:

protectionshare of .textbytes
R-X, present and executable58.69%28,852,224
--- PAGE_NOACCESS41.31%20,312,064
type100% MEM_MAPPEDnot MEM_IMAGE

.text is carved into 2,124 alternating regions. Pages the game has executed are decrypted and readable. Pages it has not touched are PAGE_NOACCESS and still hold ciphertext. And the whole section is a MEM_MAPPED view, not the image mapping - Eidolon replaced the loader’s mapping with its own pagefile-backed section.

That is what the KiUserExceptionDispatcher hook is for. The loader writes loader+0x1d4570 into ntdll+0x181230, the slot the dispatcher calls first. Touch a NOACCESS page, fault, Eidolon decrypts it and marks it R-X.

Which explains the dump failure exactly. MmCopyVirtualMemory fails the entire copy if any page in range is unreadable, and at 1 MB granularity all 47 chunks of .text contain at least one NOACCESS page. Every chunk failed. Reading page by page instead:

read ok 55,107,584    unreadable 20,103,168

27.5 MB of plaintext that had been sitting there the whole time, discarded by my own read granularity.


Matched plaintext, and the death of ECB

Now both sides are in hand: ciphertext on disk, plaintext in the dump, at the same offsets. That turns every question about the cipher into an experiment.

First, quality. Is the recovered material actually code?

.text entropy    8.000 on disk  ->  5.980 recovered
REX.W 0x48       7.850%
ret              7,653 per MB
sub rsp,x        63,763

And the entry point, encrypted on disk, disassembles:

sub  rsp, 0x28
call 0x1426e936c
add  rsp, 0x28
jmp  0x1426e88dc

A CRT entry stub. TLS callback 0 is a textbook cmp edx, 2 / mov rax, gs:[0x58] DLL_THREAD_ATTACH check. This is real, correct, previously-encrypted code.

Second, the cipher. Earlier static work had pointed at ECB - duplicate ciphertext blocks appeared at matching offsets-in-function far more often than chance. With plaintext, that becomes decidable. Restricting to plaintext blocks that genuinely repeat somewhere in the section:

block sizerepeated plaintext blockssame ciphertextdiffering
8 bytes173,93826 (0.01%)99.99%
16 bytes48,01711 (0.02%)99.98%

Identical plaintext encrypts differently at different positions, essentially always. It is not ECB. It is position dependent.

The subtlety worth flagging: my first version of that test counted blocks appearing once as “maps to one ciphertext”, which is trivially true and carries no information at all. It reported a comfortable-looking 96.9%. Restricting to genuinely repeated blocks flipped the answer.


The keystream is reused, and then: where it lives

If it is position dependent and XOR-shaped, the difference C XOR P is the keystream for that page, and I have it for every page the game executed. 7,094 live pages produced only 3,368 distinct keystreams. Pages share them.

That alone is a break: a keystream lifted from an executed page decrypts a never-executed page that happens to share it. But the better question is why they repeat at all.

The keystreams are not random. Byte frequencies deviate strongly from uniform, and the index of coincidence sits far above 1/256. So I searched for them in the files I already had.

24 of 25 sampled keystream probes appeared verbatim in Overwatch_loader.dll.

Full 4096-byte keystreams, byte for byte, in the loader’s .rdata. Not a statistical match - an exact one. Eidolon’s pad ships in the DLL next to the ciphertext.


The pad is 16 KB, and it wraps

3,593 of 3,603 recovered keystreams located verbatim in the loader. The remaining 10 did not, and neither did 1,755 of the live pages. Those turned out to be the interesting ones.

Every one of them had both a matching prefix and a matching suffix in the loader. None were unmatched. So they were stitched, not different. Decomposing them greedily into longest matching segments gave exactly two pieces each:

page 57   key[   0: 540] @ 0x044f3a4     key[ 540:4096] @ 0x044b5c0
page 71   key[   0:1428] @ 0x044f02c     key[1428:4096] @ 0x044b5c0
page 77   key[   0:1388] @ 0x044f054     key[1388:4096] @ 0x044b5c0
page 87   key[   0:1664] @ 0x044ef40     key[1664:4096] @ 0x044b5c0
page 90   key[   0:2036] @ 0x044edcc     key[2036:4096] @ 0x044b5c0

Every first segment ends at exactly 0x044f5c0. Every second segment begins at 0x044b5c0. The difference is 0x4000.

keystream table   Overwatch_loader.dll file offset 0x044b5c0
size              0x4000 = 16,384 bytes
keystream(page)   table[off : off + 4096]   taken MODULO 16384

A 16 KB pad, read circularly, covering a 47 MB section. The entire keystream space is 16,384 windows, every one of them generatable from the shipped DLL.

Validation, byte-exact rather than scored: take 300 live pages at random, compute C XOR P, and search all 16,384 circular windows of a table built from the file on disk.

exactly reproduced: 300 / 300  (100.00%)

Result

Applying the table to every page, accepting a window only when the decrypted result scores as x64 code well above what the best of 16,384 wrong guesses can produce:

live pages, ground truth from the dump             6,967
recovered, code score, circular table              4,868
recovered, low-entropy criterion, same table         145
recovered, combined criterion, aligned windows         3
----------------------------------------------------------
.text in plaintext          11,983 / 12,002   99.84%   46.8 MB

Hold-out against pages whose plaintext is known: 118 accepted, 118 exact, 0 wrong. 100% precision.

The low-entropy pass exists because a code-likeness score structurally cannot find pages whose plaintext is not code. Scoring 0x00/0xFF/0xCC density instead scored 120/120 on known pages and recovered 145 data pages the code score had rejected.

One further refinement: every observed offset is a multiple of 4, so the real candidate set is 4,096 dword-aligned windows rather than 16,384. Of those, 3,887 are used by at least one page and 209 are never used at all - almost exactly what uniform random selection over 4,096 slots predicts for 11,983 draws.

19 pages resist every criterion. All are genuinely encrypted at entropy ~7.95, and no window produces output that scores as either code or structured data - several are exact ties. That is not a tuning failure: if one of the 4,096 windows were correct, the result would be the true plaintext, and if that plaintext is itself high entropy then nothing content-based can identify it. Three of their immediate neighbours recovered cleanly at good margins, so a tidy “it is all one data blob” explanation does not hold. Why these particular 19 resist is unresolved.

Splice the plaintext back over the ciphertext and you get a normal PE that Ghidra opens at 0x140000000 with no special handling. The reconstruction agrees with independent prior work: a component-decryption routine at RVA 0x5A77E0, derived months earlier from a runtime dump, disassembles from the reconstruction and contains exactly the documented mov r8, [rdi + r9*8 + 0x110] component lookup.


What the game turns out to be

With 46.8 MB of code readable, the string cross-references say what it is built from:

referencessubsystem
279 + 115 + 115 + 39Protocol Buffers, google::protobuf
65BNL_Browser, the Battle.net login browser
64Unhandled JamMessage, unable to dispatch - Blizzard’s Jam RPC
62 + 55Enlighten SDK, Geomerics global illumination
21 + 660x811C9DC5, the FNV-1a offset basis
19NVIDIA NGX (DLSS)
13sqlite_master, embedded SQLite

Build paths leak the layout: D:\Madness\production\Worker\2_24_0_3\3rdparty\....

And a negative result worth stating: searching every section of the decrypted image for warden, aegis, eidolon, anticheat returns zero hits. The three integrity-flavoured strings that do appear are false positives - integrity_check and IntegrityCk are SQLite PRAGMA and VDBE opcode names, and HypervisorHost is a GPU device-type enum sitting between Passthrough and Virtual.

After all that encryption, the protected payload is just the game. Every protection mechanism found in this work lives in the loader. The exe is what is being protected, not a participant in protecting itself.

(Absence of strings is not absence of code. Anti-cheat logic that never formats a message leaves no trace in that search, and Warden modules are expected to stream at runtime. What it rules out is a named, string-carrying anti-cheat subsystem compiled into Overwatch.exe.)


The 22 functions that are not there

The other half of Eidolon is aimed at its own code, and it is far more aggressive than the game’s encryption.

x64 unwind metadata records, for each prologue instruction, the offset of the first byte after it. So consecutive unwind codes at offsets (prev, cur) mean an instruction occupies [prev, cur). If the byte at prev is 0xCC, that instruction was replaced with a trap - and the metadata still describes what it was.

For fn[0x2b77f0]:

ver=1 SizeOfProlog=17 CountOfCodes=8
FrameRegister=5 (RBP)  FrameOffset=64 (0x40)
codes:  11 03  <- SET_FPREG at offset 0x11
        0c 82  <- ALLOC_SMALL at offset 0x0c

SET_FPREG at 17 with the previous op at 12 means a 5-byte instruction occupies 12..17, and FrameReg=RBP, FrameOffset=0x40 names it exactly: lea rbp,[rsp+0x40] = 48 8d 6c 24 40.

The file has 0xCC at offset 12.

Sweeping all 1,174 functions for that pattern finds 22, and every single one is the same instruction type - SET_FPREG, lea rbp,[rsp+N] with N in {0x20, 0x30, 0x40, 0x80}. That is a surgical choice: remove the frame pointer setup and every subsequent rbp-relative access in the function - locals, spilled parameters, the entire frame - resolves against garbage. One instruction destroys the decompilation of a whole function.

They are never restored. Pulling the loader’s own .text out of a live process and comparing:

disk     55 41 57 41 56 56 57 53 48 83 ec 48 cc d6 b3 93 21
runtime  55 41 57 41 56 56 57 53 48 83 ec 48 cc d6 b3 93 21

Byte identical, 0xCC still at offset 12, 0 of 22 restored. The trap is permanent. Every execution takes an exception, and the handler applies the instruction’s effect to the CONTEXT - setting Rbp = Rsp + 0x40 - then resumes past it via ntdll!ZwContinue. The code is never whole in memory, so no dump ever contains it. Only the unwind metadata betrays what was removed.

And the prologue theft is the small part. The loader’s .eid section carries a table of 10,780 .text addresses, every one pointing at an 0xCC. Classified against the function ranges:

inside function BODY            10,758   99.80%
inside a PROLOGUE                   22    0.20%
inter-function padding               0    0.00%

Zero in padding, so all 10,780 are real traps. And every one of them lands inside the same 22 functions, out of 1,174:

distinct functions containing any table entry : 22 of 1,174  (1.9%)
density inside them                           : 22% to 68% of every byte

Each of those 22 also has one solid blanked block of 199 to 394 bytes, all 0xCC.

So those functions are not protected, they are hollowed out: frame setup gone, a few hundred contiguous bytes blanked, a third of the remainder replaced with traps. What ships on disk is a husk. The real code is not in .text at all, and every trap drives control into the dispatcher. Virtualization by exception, applied to 1.9% of functions rather than the whole image - which is why the other 1,152 decompile perfectly normally and these 22 are noise.

The 22 are recoverable only by accident of the ABI. Unwind data describes prologues and nothing else, so the 0.20% that happen to sit in a prologue can be reconstructed and the 99.80% in function bodies cannot, at any amount of effort.


Warden

The loader is built to host a streamed module and cannot fetch one. Its 162 imports - themselves encrypted, resolved by JIT-generated thunks keyed on the runtime PEB address - contain no networking at all. No ws2_32, no wininet, no winhttp, no sockets.

What it does have is a module-loading and verification toolkit:

LoadLibraryW / LoadLibraryExW / GetProcAddress
VirtualAlloc / VirtualProtect
CreateFileMappingW / MapViewOfFile / MapViewOfFileEx / UnmapViewOfFile
bcrypt: OpenAlgorithmProvider / CreateHash / HashData / FinishHash
sfc.dll
WTHelperGetProvSignerFromChain     (string only, resolved dynamically)

Hashing but no BCryptDecrypt. Authenticode verification, pulled in by name at runtime. Map a blob, make it executable, hash it, check its signature chain, resolve its exports - and no way to obtain the blob.

Combine that with an export named g_warden_aegis_crash_callback_export that nothing imports, and the shape is legible: delivery rides the game’s own Battle.net connection, where all the networking and the protobuf/Jam RPC machinery already lives, and Eidolon is the host. Nothing touches disk, which is why neither binary contains any anti-cheat naming.

That is inference from an import list, an export name and an absence, not a demonstration. The experiment that would settle it is hooking that export and logging who calls it.


Corrections

Four things I asserted during this work that turned out to be wrong, each caught by a test I should have run first.

“The keystream is reused across pages, and that is the weakness.” The reuse is real, but it is a consequence, not the flaw. A 16 KB pad covering 47 MB has to repeat. The actual weakness is that the pad ships in the clear beside the ciphertext.

“About 25% of pages use a second keystream mechanism.” There is no second mechanism. Those pages wrap around the end of the table. I had measured the offset range from the non-wrapping subset only, which also produced a bogus “265 KB region” and a bogus “4-byte aligned” claim - the alignment was an artifact of deltas between sorted offsets.

“100% of .text recovered.” Briefly true-looking and false. A search over 2.3 million candidate windows reported the last 190 pages recovered. Hold-out against known plaintext put that search at 67.5% precision on a prefix score and 80.0% on a full page, with correct and wrong score ranges overlapping almost entirely. Taking a maximum over millions of candidates destroys the statistic that the score is supposed to provide. The honest figure at that point was 98.60%, and 99.82% only came later, from the correct 16,384-candidate set where precision measured 100%.

“Real x64 code runs REX.W at 8 to 14%.” Quoted from memory, used to argue that a blob was not code, and never checked against the target. Measured, the loader’s own .text runs 2.151%. I then over-corrected and claimed the textbook figure was simply wrong; the game’s code measures 7.68%. Both are right - the loader is low because it is MBA-obfuscated, and I had picked the one binary in the folder that was a bad reference.

The through-line is the same each time: a score taken as a maximum over a large candidate set stops being evidence as the set grows, and the only defence is a hold-out against data whose answer you already know, at the same candidate-set size the real run will use.


What this does not cover

The keystream selector. Which of the 16,384 offsets a given page uses is computed, not stored - no lookup table exists in the loader under any encoding I tried, collision differences have a GCD of 1, and offsets show no modular structure. It does not need solving to decrypt the section, since 16,384 candidates is a trivial search, but it is unreversed.

What the 22 gutted functions do. That is the heart of Eidolon and it is still opaque; recovering it means reversing the dispatcher’s per-site semantics rather than the code, because the code is not there.

Whether Eidolon’s design is bad. The INT3 virtualization is genuinely strong - permanent traps that mean the protected code never exists in memory is a better property than lazy patching, and it defeated every emulation attempt I made. The pad placement is the flaw, and it is a deployment decision, not a design one. Encrypting a payload and shipping the one-time pad in the same folder is the kind of mistake that survives review precisely because everything around it looks sophisticated.

Nothing here needed the game to be running. The dumps were how the pad was discovered, by putting plaintext on one side of an XOR. Applying it needs only the two files, and they are already on disk.