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
.verifyis 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.- "File Corrupt" is
SAVEGAME_E_CORRUPTED(Bit 2, Value 4) and is set at two locations, both of which are after the.verifycheck 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. - No AES in the image. Neither AES-NI (
AESENC/AESKEYGENASSIST: 0 hits) nor S-Box/T-tables exist. The crypto comes entirely frombcrypt.dll..verifyis 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. - 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 fromloginusers.vdfanduserdata/‹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:
- "
.verifyis an AES ciphertext" — false, it is an HMAC tag. - "AES is in the image" — false, AES comes from
bcrypt.dll. - "
.verifyblocks loading" — false (Section 3). The claim indocs/spielstaende.md, that.verifyis "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.