Repository navigation
Research Report Cfilekey2
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.
-
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. TheresourceExecroute is therefore viable. -
The derivation:
The secret is the string constant
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.rdata:0x026097e8="swapTeam\n", length 0xa including NUL — same construction as"CYBERDEMON"with length 0xb in the game state path. -
The format is larger than previously assumed.
docs/cfile.mdhad 8-byte header and "Salt 8 or Salt 8 + IV 16" as an open alternative. Correct is: 4 bytesdeclen, 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 afterBCryptGenRandomwith the file modification time (0x00aa23dd). -
Measured, not guessed: 67/67 payloads with correct HMAC tag and valid PKCS7; 50/50
strings/*.langdecrypted, Oodle-unpacked, exactly to the expected length and starting withEF BB BF // string table; 67/67 re-encrypted byte-identically; 7/7 counter checks fail.
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 |
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.
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)
- "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.
python tools/probe_cfilekey.py --selbsttest → Exit code 0.
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.
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.
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.
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.
-
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, andbase/wolfsdk_test.cfgall fail at the day. Rebuild with a different name:probe_cfilekey.encrypt(plain, name, salt12, iv16, declen). - 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). - 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.
- "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-stringstried in the wrong combination (without resource name, without NUL, ciphertext start at 8/16 instead of 32). - "
cfilecould depend on a runtime value like the.verifyHMAC" — no. The KDF takes three buffers, all three are offline available. That is the decisive difference fromsaveverify.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.
English Wiki generated from the measured research notes in docs/. Evidence grades and uncertainty are preserved intentionally.
- WolfSDK Loader · DE
- WolfSDK Studio · DE
- FAQ · DE
- Mod format · DE
- Modding Guide
- Modding Surface
- CVar Catalog
- CVar Validation (old scrape)
- Texture Database
- Developer Mode
- Dev Menu
- Custom-Map Status