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:
.textis 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% arePAGE_NOACCESSand 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
0x044b5c0ofOverwatch_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:
| protection | share of .text | bytes |
|---|---|---|
R-X, present and executable | 58.69% | 28,852,224 |
--- PAGE_NOACCESS | 41.31% | 20,312,064 |
| type | 100% MEM_MAPPED | not 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 size | repeated plaintext blocks | same ciphertext | differing |
|---|---|---|---|
| 8 bytes | 173,938 | 26 (0.01%) | 99.99% |
| 16 bytes | 48,017 | 11 (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:
| references | subsystem |
|---|---|
| 279 + 115 + 115 + 39 | Protocol Buffers, google::protobuf |
| 65 | BNL_Browser, the Battle.net login browser |
| 64 | Unhandled JamMessage, unable to dispatch - Blizzard’s Jam RPC |
| 62 + 55 | Enlighten SDK, Geomerics global illumination |
| 21 + 66 | 0x811C9DC5, the FNV-1a offset basis |
| 19 | NVIDIA NGX (DLSS) |
| 13 | sqlite_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.