Wolfenstein Modloader Buy me a coffeeKaffee spendieren

Wiki / Research reportsForschungsberichte

.verify in Wolfenstein II — Generation, Verification, and Its Role During Loading

Partly solvedTeilweise gelöst Original page in the GitHub wikiOriginalseite im GitHub-Wiki

In plain wordsIn einfachen Worten

This report reads the decoded game code of Wolfenstein II: The New Colossus to see how the 40-byte .verify files are made and checked. Each one is an 8-byte random value plus a 32-byte HMAC-SHA256 signature, not encrypted data as an earlier report assumed. A wrong .verify does not stop a save from loading; it only switches the game into Developer Mode. The real 'File Corrupt' error comes from broken save structure or a wrong 4-byte header. Rebuilding a .verify offline is not possible yet, because one part of the key is a value that exists only while the game runs.

  • .verify = 8-byte random nonce + 32-byte HMAC-SHA256 over the whole save file.
  • A mismatch only sets a flag that leads to 'Loading Dev Mode or corrupted save game.'; loading continues.
  • 'File Corrupt' (SAVEGAME_E_CORRUPTED) is set later, by a short read or a wrong MD5-based header.
  • No AES code is inside the game; all crypto comes from the Windows bcrypt library.
  • 0 of 37 .verify files could be recomputed; one key part is an unknown runtime value of the user.

Dieser Bericht liest den entschlüsselten Programmcode von Wolfenstein II: The New Colossus, um zu sehen, wie die 40-Byte-.verify-Dateien erzeugt und geprüft werden. Jede besteht aus 8 Byte Zufallswert und einer 32-Byte-HMAC-SHA256-Signatur, nicht aus verschlüsselten Daten, wie ein früherer Bericht annahm. Eine falsche .verify verhindert das Laden nicht; sie schaltet das Spiel nur in den Entwicklermodus. Der echte Fehler 'File Corrupt' kommt von einer kaputten Spielstand-Struktur oder einem falschen 4-Byte-Header. Eine .verify offline nachzubauen geht noch nicht, weil ein Teil des Schlüssels ein Wert ist, der nur im laufenden Spiel existiert.

  • .verify = 8 Byte Zufalls-Nonce + 32 Byte HMAC-SHA256 über die ganze Spielstanddatei.
  • Eine Abweichung setzt nur ein Flag, das zu 'Loading Dev Mode or corrupted save game.' führt; das Laden geht weiter.
  • 'File Corrupt' (SAVEGAME_E_CORRUPTED) wird später gesetzt, bei zu kurzem Lesen oder falschem MD5-basierten Header.
  • Im Spiel steckt kein AES-Code; die gesamte Kryptografie kommt aus der Windows-Bibliothek bcrypt.
  • 0 von 37 .verify-Dateien ließen sich nachrechnen; ein Schlüsselteil ist ein unbekannter Laufzeitwert des Benutzers.

Technical detailsTechnische Details

Der technische Teil ist auf Englisch – so, wie er im Wiki steht. Unterstrichene Begriffe haben eine Erklärung auf Deutsch.

Source: re_probes/runtime_87400/module.bin (running, SteamStub-extracted NewColossus_x64vk.exe, "virtual RVA" layout, DateIOffset == RVA). All addresses are RVAs in this image. Function boundaries from .pdata. Tool: tools/probe_saveverify.py. Corpus: re_probes/save_backups (37 file/.verify pairs), read-only.


Summary

  1. .verify is NOT the cause of "File Corrupt". The loading path reads the pair file and verifies it, but a mismatch does not break the load and sets no error code. It only sets a flag that triggers the message "Loading Dev Mode or corrupted save game." at the end.
  2. "File Corrupt" is SAVEGAME_E_CORRUPTED (Bit 2, Value 4) and is set at two locations, both of which are after the .verify check and both are structural: failed Payload-Read and failed 4-Byte MD5 Fold Header. This is the trail from astra-save-v3, not this one.
  3. No AES in the image. Neither AES-NI (AESENC/AESKEYGENASSIST: 0 hits) nor S-Box/T-tables exist. The crypto comes entirely from bcrypt.dll. .verify is not a ciphertext, but an 8-byte Nonce + 32-byte HMAC-SHA256. The statement "40 Bytes = Nonce + AES-Ciphertext" from the previous report is false.
  4. The derivation is fully read from the disassembly, but not reproducible: one of the four key components is a runtime value of the local user object. Offline forgery is currently impossible with this. Details and all exclusions see Section 5.

1. Route into the Module

.rdata strings and their RIP-relative Xrefs in .text:

String RVA Xrefs (Function)
%s.verify 0x26878c0 0x8afe80, 0x8b09c0, 0x8b1ef0, 0x8b60f0
%s.temp.verify 0x2687c20 0x8b1ef0
*.verify 0x26877b8 0x8b09c0
.verify 0x2687ba0 0x8b57f0, 0x8b60f0
CYBERDEMON 0x26878d0 0x8afe80, 0x8b06d0, 0x8b09c0, 0x8b1ef0, 0x8b3f40

Function roles:

RVA Role
0x8afe80 Verifier (VerifyFile): reads <file>.verify, computes HMAC over the data file, compares
0x8b1ef0 Writer: generates Nonce, writes %s.temp.verify, renames
0x8b3f40 Loader path of a slot (Trying to load slot file %s …), calls 0x8afe80
0x8b4da0 idSaveGameThread::Enumerate (Menu list, reads game.details)
0x8b09c0 Migration of old save games
0x8b60f0 Delete (deletes file + .verify)
0x8b3ed0 Outputs "Loading Dev Mode or corrupted save game."

The AESENC/AESKEYGENASSIST scan over .text was empty — the trail leads instead via the bcrypt imports: BCryptOpenAlgorithmProvider (IAT 0x1e63c30), BCryptCreateHash (0x1e63c10), BCryptHashData (0x1e63be0), BCryptFinishHash (0x1e63bd8), BCryptGenRandom (0x1e63be8), plus BCryptEncrypt/BCryptDecrypt for the *.bin.


2. Algorithm (from 0x8afe80 and 0x8b1ef0)

verify = nonce(8) || HMAC_SHA256(key, <entire data file>)

key    = SHA256( nonce(8)
               || <idStr idLocalUser+0xb0>     Runtime value, see 5.
               || "CYBERDEMON\0"               11 Bytes, including NUL
               || basename(datafile) )          without NUL

Evidence in Code

idSecureHMAC::Initialize (0x12250a0) takes a function index < 7 and looks up the algorithm name in the table .rdata:0x25c8918: { -, MD5, SHA1, -, SHA256, SHA384, SHA512 }. Call at 0x8afe80:

008affe4  mov  edx, 4                      ; -> "SHA256"
008affe9  lea  rcx, [rbp + 0xa8]
008afff0  call 0x12250a0                   ; idSecureHMAC::Initialize
...
01225100  mov  r9d, 8                      ; BCRYPT_ALG_HANDLE_HMAC_FLAG
01225108  call [BCryptOpenAlgorithmProvider]
012251a9  mov  eax, [rsp+0x68]             ; HashDigestLength -> obj+0x28 = 32

The 4-element vector for the key is constructed immediately prior; each element is { void* ptr; int64 len; bool owned }:

008b00a9  lea  rax, [rbp+0x120]            ; Array
008b00b0  mov  [rbp+0xd8], rax
008b00b7  mov  qword [rbp+0xe0], 4         ; 4 elements
008b00c9  lea  rax, [rbp+0x141e0]          ; [0] Nonce buffer
008b00d4  mov  qword [rbp+8], 8            ;     Length 8
008b00eb  lea  rdx, [r14+0xb0]             ; [1] idStr of the local user
008b00f6  call 0x1211440                   ;     Copy -> [rbp+0x78]
008b00fc  mov  rax, [rbp+0x80]             ;     .data (idStr+0x08)
008b0107  movsxd rax, dword [rbp+0x88]     ;     .len (idStr+0x10)
008b0121  lea  rax, ["CYBERDEMON"]         ; [2]
008b012c  mov  qword [rbp+0x38], 0xb       ;     Length 11 -> NUL counts with
008b0143  mov  rax, [rbp+0x148]            ; [3] Base name of the data file
008b014e  movsxd rax, dword [rbp+0x150]

The base name is derived from the idFile filename via idStr::StripPath (0x1210060, cuts at / or `), constructed in 0x8afec2–0x8aff04. The same string also determines the sole exception:

008aff58  lea  rdx, ["profile.bin"]        ; Case-insensitive comparison;
008affa4  je   0x8b0676                    ; on equality: return true,
                                           ; profile.bin is never checked

This aligns with the file inventory: profile.bin and migrated.bin have no backup file, while profile2.bin and progression*.bin do.

0x12c69d0 (idSecureHash) chains the four buffers in index order 0,1,2,3 via BCryptHashData and provides the SHA-256 digest with BCryptFinishHash. Its 32 bytes are passed as pbSecret into BCryptCreateHash:

008b01c3  mov  r9d, [rbp+0x68]             ; 32
008b01c7  mov  r8,  r14                    ; Pointer to the digest
008b01d8  call 0x1224a10                   ; -> BCryptCreateHash(..., secret, 32)

The message is the complete data file, including the 4-byte header:

008b033e  call [rsi_vtbl+0x78]             ; Seek(0, FS_SEEK_SET)
008b0350  mov  r8d, 0x10000                ; Loop: Read(64 KiB)
008b0363  call [rsi_vtbl+0x28]
008b0376  call [BCryptHashData]
008b03ad  call [rsi_vtbl+0x78]             ; Restore old position

The comparison at the end is a byte-by-byte full comparison of the 32 bytes from the backup file against the calculated tag (0x8b04b0–0x8b04c3).

The writer (0x8b1ef0) constructs the same 4-element vector (0x8b2826–0x8b2fe3, with CYBERDEMON length 0xb at 0x8b29d0) and reads the nonce at 0x8b36cb from BCryptGenRandom, writes it at 0x8b3736, and chains the tag at 0x8b388a. For the user string, it reads the idStr fields directly: movsxd rdi, dword [r12+0xc0] (length, 0x8b28aa) and mov rcx, [r12+0xb8] (data, 0x8b2979) — so idStr at +0xb0, .data at +0xb8, .len at +0xc0.

Side note: the same key derivation also encrypts the *.bin files (0x8b4490: SHA256 over the same 4-element vector, then 0x12c7040 with BCryptDecrypt). The *.bin files are thus actually encrypted — just via bcrypt, not with code in the image.


3. Does the load path behave similarly? — No (Main Finding)

Load path 0x8b3f40, per file of the processor set:

008b428f  call [FileSystem+0x108]          ; OpenFileRead(fullpath)
008b4295  mov  rbx, rax
008b42a2  jne  0x8b432e                    ; opened?
  ; --- NOT opened ---
008b42da  mov  dword [r15+0x270], 0x100    ; SAVEGAME_E_FILE_NOT_FOUND

008b432e  mov  rdx, r14                    ; local user
008b4331  mov  rcx, rbx                    ; the data file
008b4334  call 0x8afe80                    ; ==> .verify check
008b4339  test al, al
008b433b  jne  0x8b4383                    ; ok -> continue
  ; --- MISMATCH ---
008b433d  mov  byte [rdi+0x188], 1         ; Flag on the processor
008b4344  cmp  byte [rdi+0x189], al
008b434a  jne  0x8b4383
008b434c  cmp  dword [rip+…], 0            ; .data:0x2f41810 (CVar)
008b4353  je   0x8b437c
008b4378  test al, 4
008b437a  jne  0x8b4383
008b437c  mov  byte [rbp+0x41c8], 1        ; local "corrupted" flag
008b4383  ; ... and here it continues normally with the read

The Mismatch branch does not set [r15+0x270], does not modify r12d (the return value), and jumps to the same continuation point as the success case. The only result is at the end of the loop:

008b4925  test sil, sil
008b4928  je   0x8b496b                    ; no flag -> nothing happens
008b492a  lea  rdx, [0x8b3ed0]             ; -> "Loading Dev Mode or corrupted
008b4938  call 0x31c6d0                    ;     save game."

This matches the CVar devMode_skipCorruptedSaves ("Skip entering developer mode when loading corrupted saves", registered at 0x1130f0).

A false .verify therefore leads into the Dev-Mode branch, not a rejection.


4. Where "File Corrupt" really comes from

The error values in [processorSet+0x270] are bitflags; the name table is located at .data:0x2f5b890:

Value Name
1 SAVEGAME_E_CANCELLED
2 SAVEGAME_E_INSUFFICIENT_ROOM
4 SAVEGAME_E_CORRUPTED
0x10 SAVEGAME_E_UNKNOWN
0x80 SAVEGAME_E_FOLDER_NOT_FOUND
0x100 SAVEGAME_E_FILE_NOT_FOUND
0x400 SAVEGAME_E_INVALID_USER

In the load path, value 4 is set at exactly three locations — all after the .verify check and all structurally:

a) Payload-Read too short (unencrypted .dat/game.details):

008b4704  call [rbx_vtbl+0x60]             ; Length()
008b4718  test byte [rdi+0x180], 6         ; Header flags set?
008b4721  cmp  rsi, 4                      ; and at least 4 bytes?
008b4738  call [rbx_vtbl+0x28]             ; Read 4-byte header
008b473c… bytewise rotation 0<->3, 1<->2 ; Big-Endian -> Host
008b4770  sub  rsi, 4                      ; Remove padding length
008b4793  call [rbx_vtbl+0x28]             ; Read payload
008b4796  cmp  rax, rsi
008b47a0  je   0x8b47d5
008b47c4  mov  dword [r15+0x270], 4        ; SAVEGAME_E_CORRUPTED

b) 4-Byte header incorrect — this is the most likely trigger:

008b47d5  test byte [rdi+0x180], 6
008b4802  call 0x12bb890                   ; MD5_BlockChecksum(payload, length)
008b4807  cmp  eax, dword [rbp+0x41d0]     ; against the rotated header
008b480d  je   0x8b4841                    ; match -> ok
008b4831  mov  dword [r15+0x270], 4        ; SAVEGAME_E_CORRUPTED

0x12bb890 is the MD5 folding; the MD5 constants lie directly next to it (0x12baf42 = 0xd76aa478, 0x12bb8a4 = 0x67452301). This is exactly what tools/savehash.py replicates.

c) Decryption failed (only *.bin):

008b4501  call 0x12c7040                   ; Decrypt
008b4506  lea  ecx, [rax-3]
008b450d  cmp  ecx, 1
008b4510  jbe  0x8b4604                    ; Status 3 or 4
008b462d  mov  dword [r15+0x270], 4

For the candidate FC2-WOLF0-c2v1_custom_v2 (only .dat/game.details modified), only (a) and (b) are possible — both within the status range of astra-save-v3. The .verify branch cannot generate "File Corrupt" at all.


5. Reproduction: failed, and why

The condition "recalculate an existing .verify byte exactly" is not met. Reason: the second key component is the idStr at idLocalUser+0xb0 — a runtime value of the user object.

What holds true: idLocalUser (base class of idLocalUserWin, constructor 0xb53c80, object size 0x278) has exactly two idStr members at +0x60 and +0xb0, both with 32-byte inline buffers. Throughout the entire image, there is no discoverable write access to +0xb0 outside of constructor and destructor and no vtable getter that returns it — the value is apparently set via an inline-expanded idStr assignment that could not be located despite pattern matching. It does not appear statically in the image; .data was only 2.2 MB out of 36 MB captured in the capture, and the object lies on the heap anyway.

Checked and excluded candidates (each with and without trailing NUL, against 4 files from SLOT0_before_c2custom):

  • empty string
  • <SteamID> (SteamID64, = folder name), ‹account-id› (AccountID), ‹steam-id›, ‹account-id›
  • SchwitzerOhneTalent (Steam Persona Name from loginusers.vdf and userdata/‹account-id›/config/localconfig.vdf), xymrscroubzxyx (AccountName)
  • the tester, ‹machine›, CYBERDEMON, SLOT0, FC2-WOLF0, savegame.user/<SteamID>, full relative path
  • Brute-force all strings of length 0–3 from the printable ASCII range (839,515 candidates)

Additionally vary, without hits: all 24 permutations of the four key components; CYBERDEMON with length 10 instead of 11; filename with/without NUL, without extension, case-insensitive, as full path; message = entire file vs. file minus first 4 bytes; Key = Digest vs. Hex-string vs. 16-byte vs. raw concatenation; SHA256(key||msg) instead of HMAC.

A derivation that correctly uses the Nonce but depends on the user string is therefore not offline falsifiable. To close it, one needs the runtime value (debugger/memory dump at idLocalUser+0xb8) — this was explicitly excluded here (no process attach, no game start).

Practically, however, this is irrelevant: after Section 3, a false .verify blocks loading anyway.


6. Tools and Counterexamples

tools/probe_saveverify.py (Python 3.11, stdlib only, read-only):

python tools/probe_saveverify.py struktur                 # 37 pairs, all 8+32
python tools/probe_saveverify.py pruefen --benutzer ""    # 0 of 37 -> Exit 1
python tools/probe_saveverify.py suchen --liste worte.txt # Try wordlist
python tools/probe_saveverify.py --selbsttest             # Positive + 5 negative controls

The negative controls in the self-test verify that the routine actually discriminates: flipped data bit, changed length, different user, different filename, different Nonce — each of these cases must yield a different tag. pruefen honestly reports 0 of 37 and exits with code 1; a routine that outputs 40 bytes regardless of input would fail here.

Rejected hypotheses, explicitly:

  • ".verify is an AES ciphertext" — false, it is an HMAC tag.
  • "AES is in the image" — false, AES comes from bcrypt.dll.
  • ".verify blocks loading" — false (Section 3). The claim in docs/spielstaende.md, that .verify is "tamper-proof, blocks loading but not [the game]", turns out to be true in result, but was statically guessed; it is now proven by the code.
  • "The user string is empty / the SteamID / the PersonaName" — all three measured and refuted (Section 5).

7. Implications for astra-save-v3

The two files can remain unchanged. "File Corrupt" occurs at exactly two places: Read(rumpf) != length-4 and MD5_BlockChecksum(rumpf) != be32(kopf) — both only active when [prozessor+0x180] & 6 is set, and both after the .verify check. For *.bin files, the Decrypt status is added; these files should generally leave a candidate build unchanged, since their key is the same unreconstructable user string.