Wolfenstein Modloader Buy me a coffeeKaffee spendieren

Wiki / Research reportsForschungsberichte

cfile — Key derivation found, corpus decrypted, round-trip byte-exact

SolvedGelöst Original page in the GitHub wikiOriginalseite im GitHub-Wiki

In plain wordsIn einfachen Worten

Wolfenstein II: The New Colossus stores some files, such as language texts and config scripts, as encrypted "cfile" resources. This report found in the game's code how the key is made: a SHA-256 over a random salt from the file, the fixed text "swapTeam" and the resource name. No value from the running game is needed, so modders can decrypt these files and build new ones offline. A test config file was built, but not yet tried in the game.

  • The container is AES-128-CBC encryption with a 32-byte HMAC tag at the end; the old documentation had the layout partly wrong.
  • 67 of 67 retail cfile payloads decrypt with a valid tag; 50 of 50 language files unpack to the exact expected length.
  • 67 of 67 files are re-encrypted byte for byte identical to the originals.
  • 7 of 7 negative controls (wrong key, flipped bytes, wrong mode) fail as they should.
  • The key is tied to the resource name; a new 96-byte test file that loads a custom map was built but not tested in the game.

Wolfenstein II: The New Colossus speichert manche Dateien, etwa Sprachtexte und Konfigurationsskripte, als verschlüsselte „cfile“-Ressourcen. Dieser Bericht fand im Spielcode, wie der Schlüssel entsteht: ein SHA-256 über ein zufälliges Salt aus der Datei, den festen Text „swapTeam“ und den Ressourcennamen. Kein Wert aus dem laufenden Spiel wird gebraucht. Modder können diese Dateien also offline entschlüsseln und neue bauen. Eine Test-Konfigurationsdatei wurde gebaut, aber noch nicht im Spiel ausprobiert.

  • Der Container nutzt AES-128-CBC-Verschlüsselung mit einem 32-Byte-HMAC-Tag am Ende; die alte Doku hatte den Aufbau teilweise falsch.
  • 67 von 67 Original-cfile-Daten entschlüsseln mit gültigem Tag; 50 von 50 Sprachdateien entpacken exakt auf die erwartete Länge.
  • 67 von 67 Dateien werden byte-genau identisch zum Original neu verschlüsselt.
  • 7 von 7 Gegenproben (falscher Schlüssel, gekippte Bytes, falscher Modus) schlagen wie gewollt fehl.
  • Der Schlüssel hängt am Ressourcennamen; eine neue 96-Byte-Testdatei, die eine eigene Karte lädt, ist gebaut, aber nicht im Spiel getestet.

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-unpacked NewColossus_x64vk.exe, Layout "virtual RVA", FileOffset == RVA). All addresses are RVAs in this image; function boundaries from .pdata. Tool: tools/probe_cfilekey.py (Python 3.11, stdlib + ctypes→bcrypt.dll). Corpus: the 67 unique cfile payloads from base/*.resources, read-only.


Summary

  1. The key depends on NO runtime value. Unlike .verify-HMAC (saveverify.md §5), all inputs are available offline: a salt from the file, a string constant from .rdata, and the resource name. The resourceExec route is therefore viable.
  2. The derivation: digest = SHA256( salt(12) || b"swapTeam\n\x00" || resourceName ) AES-Key = digest[:16] AES-128-CBC, PKCS7 (BCRYPT_BLOCK_PADDING) HMAC-Key = digest HMAC-SHA256 over salt || iv || ciphertext The secret is the string constant .rdata:0x026097e8 = "swapTeam\n", length 0xa including NUL — same construction as "CYBERDEMON" with length 0xb in the game state path.
  3. The format is larger than previously assumed. docs/cfile.md had 8-byte header and "Salt 8 or Salt 8 + IV 16" as an open alternative. Correct is: 4 bytes declen, 12 bytes Salt, 16 bytes IV, Ciphertext, 32-byte HMAC tag at the end. The "Unix timestamp at 0x04" is not a separate field but the first 4 bytes of the salt: the writer overwrites them after BCryptGenRandom with the file modification time (0x00aa23dd).
  4. Measured, not guessed: 67/67 payloads with correct HMAC tag and valid PKCS7; 50/50 strings/*.lang decrypted, Oodle-unpacked, exactly to the expected length and starting with EF BB BF // string table; 67/67 re-encrypted byte-identically; 7/7 counter checks fail.

1. Route into the Module

The Xrefs that docs/cfile.md could not take because .text was still packed there. Searched for RIP-relative lea references to known .rdata addresses:

String .rdata Xref in .text
could not derive key 0x026c9688 0x00aa217e
decryption failed(%d) 0x026c9568 0x00aa21d3
decompress failed 0x026c9550 0x00aa2214
Failed to generate salt 0x026c9598 0x00aa23c2
Failed to derive key 0x026c9580 0x00aa2403
Failed to encrypt (%d) 0x026c9630 0x00aa25d1
idEncryptedFileResource 0x026c9650 0x0013c204

From this, two functions and their helpers via .pdata:

RVA Role
0x00aa20f0 Reader: reads declen + Salt, derives, decrypts, decompresses
0x00aa22f0 Writer: generates salt, derives, packs, writes, encrypts
0x00aa2690 the KDF
0x012c69d0 idSecureHash::Hash(Buffer list, out) — SHA-Index 4 = SHA256
0x012c6910 BCryptGenRandom wrapper
0x012c6b10 Encrypt(file, data, salt, key)
0x012c7040 Decrypt(file, out, salt, key)
0x012c5960 / 0x012c5ec0 Oodle Compress / Decompress

2. The KDF (0x00aa2690)

Signature: bool DeriveKey(const char* name, Buffer* salt, Buffer* keyOut). Constructed is a list of three {void* ptr; int64 len; bool owned} buffers, which idSecureHash::Hash concatenates in index order:

00aa26a6 lea rax, [r11 - 0x48] ; Element array 00aa26ae mov qword [r11 - 0x28], 3 ; 3 elements 00aa26bb mov qword [r11 - 0x48], rdx ; [0] = Caller's Salt buffer 00aa26bf lea rax, [rip + 0x1b67122] ; .rdata:026097e8 "swapTeam\n" 00aa26ca mov qword [r11 - 0x58], 0xa ; Length 10 -> NUL counts with 00aa26db mov qword [r11 - 0x40], rax ; [1] 00aa26e1..00aa26f9 ; strlen(name) 00aa26fb mov qword [rsp + 0x20], r9 ; [2] = name 00aa2703 mov qword [rsp + 0x28], rax ; Length = strlen, WITHOUT NUL 00aa2712 mov qword [rsp + 0x60], rax ; == [r11 - 0x38] 00aa271f call 0x12c69d0 ; SHA256 over [0]||[1]||[2] -> keyOut

The bytes at 0x026097e8 are 73 77 61 70 54 65 61 6d 0a 00 — exactly b"swapTeam\n\x00". This is clearly a reused multiplayer command string (neighbors: apwithlobby\n, Unknown dialog command), not a byte array recognizable as key material. That is precisely why --scan-strings in docs/cfile.md did not find it: there, only hash(string+salt) and hash(salt+string) were attempted, without the name as a third component and without the NUL terminator.

0x12c69d0 is the same helper described in saveverify.md for the .verify key: idSecureHash::Initialize(4) → table .rdata:0x25c8918 → SHA256, then per element BCryptHashData(ptr, (u32)len), finally BCryptFinishHash → 32 bytes.


3. The Container Format (0x00aa20f0 and 0x012c7040)

0x00   u32    declen        uncompressed length; 0xFFFFFFFF = not packed
0x04   12     salt          u32 timestamp (LE) + 8 bytes BCryptGenRandom
0x10   16     iv            BCryptGenRandom, = BlockLength of AES object
0x20   n*16   ciphertext    AES-128-CBC, PKCS7
end-32 32    tag           HMAC-SHA256

Evidence in the reader:

00aa212b  mov  qword [rbp - 9], 0xc       ; Salt buffer, length 12
00aa2146  mov  edi, 4
00aa2155  call [rax + 0x28]               ; Read(&declen, 4)
00aa2166  call [rax + 0x28]               ; Read(salt, 12)
00aa2175  call 0xaa2690                   ; DeriveKey([rsi+8] = Resource name, salt, key)
00aa21bb  call 0x12c7040                  ; Decrypt(file, out, salt, key)
00aa21e7  test edx, edx / jle             ; declen <= 0 (also 0xFFFFFFFF) -> do not unpack
00aa220b  call 0x12c5ec0                  ; else Oodle, [rsp+0x20] = 8

Evidence in the decryptor 0x012c7040(file, out, salt, key):

012c709d  lea  edx, [r15 + 4]             ; idSecureHMAC::Initialize(4) = SHA256
012c70b2  mov  r9d, [rbx + 8]             ; HMAC-Secret = key.len = 32
012c70b6  mov  r8,  [rbx]                 ;               key.ptr
012c70f2  call [BCryptHashData]           ; HMAC << salt (12)
012c7126  mov  edx, edi                   ; idSecureBlockCipher::Initialize(1) = AES
012c7144  lea  r9d, [rdi + 0xf]           ; 0x10  -> GenerateSymmetricKey(key, 16)
012c714b  mov  r8,  [rbx]                 ;            AES-128, not AES-256!
012c71ac  call [rax + 0x28]               ; Read(iv, BlockLength = 16)
012c71e7  call [BCryptHashData]           ; HMAC << iv (16)
012c71fb  call [rax + 0x60]               ; Length()
012c7201  sub  rbx, rsi                   ;   - 32 (Digest length)
012c720a  call [rdx + 0x70]               ;   - Tell()      = Ciphertext length
012c7280  call [BCryptHashData]           ; HMAC << ciphertext
012c73a9  call [BCryptFinishHash]
012c73e0..012c73f8                        ; byte-wise comparison against the last 32 bytes
012c7452  mov  dword [rsp + 0x48], edi    ; dwFlags = 1 = BCRYPT_BLOCK_PADDING
012c7472  mov  rax, [rbp + 0x98]          ; pbIV  = the read IV
012c748b  call [BCryptDecrypt]

BCryptSetProperty(ChainingMode) is never called — so the CNG default CBC applies. The conjecture in docs/cfile.md §2.2 was correct; it has now been measured via a round-trip and not just reverse-engineered.

Evidence in the writer (0x00aa22f0), relevant for building new files:

00aa23a6  mov  qword [rsp + 0x60], 0xc    ; 12 Byte
00aa23b9  call 0x12c6910                  ; BCryptGenRandom -> "Failed to generate salt"
00aa23dd  mov  dword [rax], r14d          ; salt[0:4] := Timestamp
00aa23fa  call 0xaa2690                   ; DeriveKey
00aa24ba  cmp  rdx, 0x200                 ; packing only begins after 513 bytes
00aa24d0  call 0x12c5960                  ; Oodle Compress (Kraken, Level 4)
00aa24f0/00aa2513  Write(&declen, 4)      ; actual length or 0xFFFFFFFF
00aa25b0  call [rax + 0x30]               ; Write(salt, 12)
00aa25c5  call 0x12c6b10                  ; Encrypt: GenRandom(iv), Write(iv),
                                          ; Write(ct), Write(tag)

Corrections to docs/cfile.md

  • "0x04 u32 Unix timestamp, from 0x08 noise" → Salt is 12 bytes starting at 0x04, of which the first 4 are the timestamp. The observation was correct; the interpretation as a separate field was not.
  • "Salt 8 or Salt 8 + IV 16, undecidable" → Salt 12 and IV 16.
  • "AES in block mode" → AES-128-CBC; the SHA256 digest is 32 bytes, but only the first 16 are passed to BCryptGenerateSymmetricKey.
  • The 32-byte HMAC tag at the end was unknown. It also explains why the length arithmetic in §2.4 failed: 4 + 12 + 16 + 32 = 64 ≡ 0 (mod 16).
  • "No EXE string constant is the secret" → yes, "swapTeam\n\0"; only the tested associations without a resource name were excluded.

4. Acceptance Test

python tools/probe_cfilekey.py --selbsttest → Exit code 0.

4.1 Positive Control, Structural

python tools/probe_cfilekey.py entschluesseln --nur strings/
  50/50 ok   (10 languages x 5 archive generations)
python tools/probe_cfilekey.py entschluesseln
  structurally (HMAC tag + PKCS7): 67/67
  plaintext    (verify_plain):     66/67

The 50 strings/*.lang files pass the full chain: HMAC tag correct, PKCS7 valid, Oodle yields exactly the length announced in the header (e.g., strings/english.lang from patch_4: 478 352 → 2 452 756 bytes), and the plaintext begins with EF BB BF // string table as the unencrypted .lang file from Part 1. This is cfile.verify_plain, taken unchanged.

The hard counter remains the HMAC tag: 32 bytes, which a wrong key hits with probability 2⁻²⁵⁶. "Looks like text" no longer applies here.

One deviation, honestly named: default_mp.cfg from gameresources (80 bytes) decrypts to exactly one byte 00. Tag and padding match; the round-trip reproduces the original bytes — the file is simply empty in-game. verify_plain counts it as "no text". This is a finding about the file, not a key problem; therefore the two counters are separate.

4.2 Round-Trip — the strong criterion

python tools/probe_cfilekey.py roundtrip
  67/67 byte-identically reproduced

Decrypt, then re-encrypt with the same salt and the same IV, and compare against the original payload — including padding and HMAC tag. 67 out of 67, byte for byte, from 80 bytes to 478 352 bytes. This confirms not only the key but the entire write direction: mode, padding type, tag order (salt || iv || ct), field order.

4.3 Negative Controls — 7/7 fail as intended

ok  wrong key (Name 'snapdemo2.cfg')        HMAC tag incorrect
ok  wrong secret (SHA256 without 'swapTeam\n\0')   HMAC tag incorrect
ok  flipped salt byte (salt[6] ^= 1)                HMAC tag incorrect
ok  flipped ciphertext byte                         HMAC tag incorrect
ok  flipped tag byte                                HMAC tag incorrect
ok  wrong mode (ECB instead of CBC)                    no PKCS7 padding
ok  AES-256 instead of AES-128 (full digest as key)     no PKCS7 padding

The last two intentionally bypass the day comparison and fail only at the plaintext structure — otherwise, the HMAC alone would block everything, leaving control blind to mode and key length.


5. The New Payload

python tools/probe_cfilekey.py bauen --name wolfsdk_test.cfg

→ re_probes/cfilekey/wolfsdk_test.cfg.cfile, 96 bytes.

Resource-Name wolfsdk_test.cfg
Plaintext map game/dlc/c02/c2v1_custom\n (29 bytes)
declen 0xFFFFFFFF — the writer also cannot pack under 0x200 bytes
Salt 7c42b75bab14bbb219691e48 (ts 1538736764 + 8 random bytes)
IV b59d9d79de83ba976f473c5a56ec75fc

The trailing \n is intentional: resourceExec pushes the text into the command buffer, and a terminator costs nothing. A version without it yields --text "map game/dlc/c02/c2v1_custom".

Not tested at runtime. The game was not started. Only confirmed that the file passes the checks executed by the load path: geometry, HMAC tag, PKCS7, declen.

What the Container Writer Must Know

  1. The resource name goes into the key. The payload is bound exactly to the name under which it appears in the IDCL entry. Counter-test: wolfsdk_test.cfh, WOLFSDK_TEST.CFG, and base/wolfsdk_test.cfg all fail at the day. Rebuild with a different name: probe_cfilekey.encrypt(plain, name, salt12, iv16, declen).
  2. Entry type cfile, compression == 0 — like all 67 retail payloads. The archive passes the bytes through; its own Oodle layer sits under the encryption and is off anyway here (declen = 0xFFFFFFFF).
  3. The reader calculates ciphertext length from Length() - 32 - Tell(). The payload must be exactly as long as written here; appended padding bytes break the tag.

6. Discarded or Corrected Hypotheses

  • "The derivation rule stands exclusively in the SteamStub-encrypted .text" (docs/cfile.md) — true only for the packed EXE. In the runtime capture, it is open; the unpacker was never needed.
  • "No derivation from purely public inputs applies" — the statement remains correct for the 4,096 triples tested there, but is refuted as a generalization: the real derivation uses exclusively public inputs plus a string constant that --scan-strings tried in the wrong combination (without resource name, without NUL, ciphertext start at 8/16 instead of 32).
  • "cfile could depend on a runtime value like the .verify HMAC" — no. The KDF takes three buffers, all three are offline available. That is the decisive difference from saveverify.md §5.
  • "AEAD/GCM is ruled out" (docs/cfile.md §2.3) — correct in result but for the wrong reason. There is indeed an authentication tag here, but it is a separate HMAC-SHA256 and not part of the cipher.