Repository navigation
Cfile Encryption
Summary. cfile is AES in block mode via the Windows CNG library bcrypt.dll; the handler is named idEncryptedFileResource. The key is derived per file from a random salt, and the derivation specification exists exclusively within the SteamStub-encrypted .text section. There is no ready-made key in a readable section, and none of the derivations using only public inputs succeed. Localization remains unknown. What was previously "unknown" is now measured as excluded — the numbers are below, the tool is tools/cfile.py.
python tools/cfile.py --survey # Container and the three structural measurements
python tools/cfile.py --probe # Derivations from public inputs
python tools/cfile.py --scan-exe # every window from .rdata/.data as key
python tools/cfile.py --scan-strings # every string constant as KDF secret
python tools/cfile.py --key HEX # a candidate against all cfiles
python tools/cfile.py --selbsttest
20 unique names: 10 .cfg files and all 10 strings/*.lang. Across the five archive generations (gameresources, patch_1 … patch_4), there are 67 unique payloads; each generation provides a new .lang. All lie with compression == 0 in the archive, so uncompressed from the perspective of IDCL — the archive passes through the bytes, and encryption sits underneath.
0x00 u32 Length after unpacking, 0xFFFFFFFF = not packed at all
0x04 u32 Unix timestamp in seconds
ab 0x08 Noise
The timestamp matches entry.timestamp_us of the archive entry (exactly to the day, strings/english.lang in patch_4: 1538736764 in the header against 1538740169566088 µs in the entry). The inflated length is genuinely larger than the file for all 63 packed payloads — for strings/english.lang from patch_4, 2,452,756 bytes on disk versus 478,352 bytes; for those from gameresources, 2,049,240 versus 389,360. Four files carry ffffffff (default_mp.cfg, default_snap.cfg, snapdemo.cfg, snapedit.cfg); their plaintext is immediately .cfg text, and precisely those are therefore the oracle for every key attempt.
In .rdata lies a contiguous string pool revealing the entire class (addresses are RVAs in NewColossus_x64vk.exe, ImageBase 0x140000000):
0x026C9550 "decompress failed"
0x026C9568 "decryption failed(%d)"
0x026C9580 "Failed to derive key"
0x026C9598 "Failed to generate salt"
0x026C95B0 "File is larger than INT_MAX: 0x%llx"
0x026C95D8 "Could not read file"
0x026C9630 "Failed to encrypt (%d)"
0x026C9648 "cfile"
0x026C9650 "idEncryptedFileResource"
0x026C9668 "Failed to allocate buffer"
0x026C9688 "could not derive key"
The type string cfile stands eight bytes before the class name — the same string pool, the same translation unit. The order of error messages describes the flow: generate salt → derive key → encrypt, and on read decrypt → decompress. The key is calculated, not read.
.text is fully SteamStub-encrypted in both EXEs (entropy 8,000, no 64-KiB block under 7,97; see docs/ghidra.md). Therefore only what CNG demands as plaintext was read.
bcrypt.dll, names from the Import Name Table starting at .rdata@0x2E34ED8:
BCryptOpenAlgorithmProvider BCryptCloseAlgorithmProvider
BCryptCreateHash BCryptHashData BCryptFinishHash
BCryptDuplicateHash BCryptDestroyHash
BCryptGenerateSymmetricKey BCryptDuplicateKey BCryptDestroyKey
BCryptEncrypt BCryptDecrypt
BCryptGetProperty BCryptSetProperty BCryptGenRandom
This is missing every key derivation function: no BCryptDeriveKeyPBKDF2, no BCryptKeyDerivation, no BCryptDeriveKey. The strings PBKDF and KeyDerivation do not appear anywhere in the file. Thus, the KDF is hand-built from CreateHash/HashData/FinishHash — it is code, and this code is not readable here. BCryptGenRandom is the counterpart to "Failed to generate salt".
All UTF-16LE strings in the EXE that could be BCRYPT algorithm or mode names:
0x029798A0 SHA512 0x029798F0 SHA256 0x02979900 SHA384
0x029799E8 AES 0x02979DA8 MD5 0x02979DB0 SHA1
0x02979DC0 RNG 0x0297A520 ChainingMode
0x0297A5D0 ChainingModeGCM
AES is the only block cipher. ChainingModeCBC, ChainingModeCFB, ChainingModeECB, ChainingModeCCM do not appear; ChainingMode appears zero times as ASCII and twice as UTF-16 — once as a property name and once as a value. The three UTF-16 hits on DES belong to country code tables (DEL DES ENI ENG) and a palette name, not CNG. The only set mode is GCM, which belongs indisputably to the other class: the error message next to it reads
idSecureAEADCipher: BCryptSetProperty( BCRYPT_CHAINING_MODE ) failed: %x
(0x297A540). idSecureBlockCipher sets no mode, so it runs in CNG-default, which is CBC.
The two classes in the string pool 0x2979500–0x297A600:
| Class | Evidence |
|---|---|
idSecureHash |
MD5/SHA1/SHA256/SHA384/SHA512, HashDigestLength
|
idSecureHMAC |
own Initialize/hmacFunction errors |
idSecureBlockCipher |
AES, BlockLength, Encrypt/Decrypt, EncryptWithOSPadding/DecryptWithOSPadding
|
idSecureAEADCipher |
ChainingModeGCM, AuthTagLength, nonce must be exactly 12 bytes (bcrypt restriction), cipherText too short (%d < %d)
|
67 of 67 payloads are multiples of 16 bytes.
This is not archival alignment. A cross-check via gameresources.resources, 13,915 entries with compression == 0; excluding sound (which is inherently 16-aligned) and cfile itself leaves 8,948 entries:
csize%16: 0:434 1:551 2:1609 3:583 4:360 5:490 6:499 7:668
8:385 9:745 10:517 11:486 12:343 13:363 14:496 15:419
Expected for uniform distribution: 559 per remainder class.
The remainder 0 is at 434, which is below the expectation. The archive does not align to 16; the property is embedded in the cfile format.
AES-GCM is a stream cipher: Ciphertext length = Plaintext length, plus 12-byte Nonce and 12–16-byte Tag. For 67 files with arbitrary plaintext lengths — 63 Oodle streams and 4 .cfg texts — all to fall on multiples of 16, the 67 independent lengths would need to hit the same remainder class mod 16. This is 16⁻⁶⁶ ≈ 10⁻⁸⁰. AEAD/GCM rules out for cfile. What remains is a block mode with padding — for which there is a matching function: idSecureBlockCipher::DecryptWithOSPadding, i.e., BCRYPT_BLOCK_PADDING, i.e., PKCS7. Only with this can the true plaintext length be recovered for the four ffffffff files, because the header does not state it there.
The header is 8 bytes of plaintext; a block mode ciphertext is a multiple of 16; and both together are a multiple of 16. Thus, in between lies a range of length 8 (mod 16). This fits "Failed to generate salt": 8-byte Salt (ciphertext starting at 0x10) or 8-byte Salt plus 16-byte IV (ciphertext starting at 0x20). Which of the two, cannot be decided by the bytes alone — both variants are always checked in tools/cfile.py as CT_STARTS.
Part 1 dumps the same file in plaintext: inside Wolfenstein.The.New.Order\base lies strings/english.lang as a resource type file, 868,273 bytes, uncompressed, UTF-8 with BOM:
efbbbf 2f2f 2073 7472 696e 6720 7461 626c 650d0a "// string table\r\n"
2f2f0d0a 0d0a 7b0d0a 09 22 23 7374 725f ... "//\r\n\r\n{\r\n\t\"#str_…"
The format is "#str_key"<TAB>"Value" per line within a { } block. Part 2 provides the similarly named file at the same location (strings/<language>.lang), but has changed its type from file to cfile, packing and encrypting it. The type change is the entire difference — the underlying format is the same string table format, which is why its header serves as an acceptance criterion.
This makes the three test cases executable and available as code in tools/cfile.py (verify_plain, tested against the actual file from Part 1 via --selbsttest):
-
Readable Text — the plaintext must start with
EF BB BF // string table. -
The stated length is correct — according to Oodle exactly
0x1F44D8= 2,049,240 bytes forenglish.langfromgameresources, and 2,452,756 bytes for the one frompatch_4. Both are listed in the header and are not numbers that come out randomly. -
All 67, not just one.
--keyiterates over all of them.
A candidate that fails any of these three is invalid.
- 1,254,614 16-byte blocks across all 67 payloads. Of these, 0 occur in more than one file. No ECB with a fixed key, no key/nonce repetition.
- The longest common prefix of two payloads starting at offset 8 is 1 byte.
- The sharpest single piece of evidence:
xb_classic.cfg,xb_default.cfg, andxb_mirrored.cfgare each 624 bytes long and declaredeclenas 1261, 1261, and 1262 respectively — three controller configurations that differ by a handful of characters in plaintext. Their common prefix starting at offset 8 is 0 bytes. Nearly identical plaintext, completely different ciphertext: a unique salt per file. This is also what "Failed to generate salt" states.
If the key were calculated solely from what is in the file — Salt, filename, type, timestamp, stated length — the encryption would be pure obfuscation and could be broken here. --probe checks this:
- 31 compositions of
salt8,salt16, header, full name, filename, basename,"cfile",declen,ts - × 5 Hashes (MD5, SHA1, SHA256, SHA384, SHA512), each directly and as HMAC in both directions
- × 3 Key lengths (16/24/32) × 4 Start offsets (8/16/24/32) × ECB and CBC
- = 4,092 triples of (Construction, Start offset, Mode), each measured against all four plaintext oracles — 16,368 measurements
The proportion of printable bytes is evaluated over every 6 blocks. CBC requires no IV: P[i] = D(C[i]) xor C[i-1] applies from the second block onward, so the result depends only on the key.
Random 0.379 (97 printable out of 256 values)
Plaintext 1.000
measured 0.500 best, ecb@32, hmac_msg_sha512(salt8+head)[:32]
The five best values lie between 0.479 and 0.500 and belong to five different, unrelated constructions — this is noise, not a signal. No public derivation succeeds.
--scan-exe --step 1 takes every byte offset in .rdata, .data, and _RDATA as raw AES keys, length 16 and 32, and checks ECB and CBC at all plausible start offsets against snapdemo.cfg:
33 791 358 windows in 694 s, best text ratio 0.656
.rdata+0x2D59E24, 32 bytes, cbc@16
07e00570 04600000 b0c8d401 0037a702 00000000 10fb1c01 ffffffff 02fc1c01
yields: 56 96 b5 db ac 31 5c ac ba 5a 52 0a 81 31 7d 66 …
(33.8 instead of 37.8 million, because windows are skipped over entirely by identical bytes — the null ranges in .data.) The "best" candidate is visibly a chunk of pointer/relocation table, and its result is noise: 84 of 128 printable bytes, where 48.5 would be expected and ~5.7 million draws land roughly there at most. A real key would lie around 1,000.
No finished AES key exists in the readable sections. This aligns with "Failed to derive key" — it is indeed being calculated — but now it is measured rather than hypothesized.
The most natural construction for a build key would be a string literal in binary, hashed against the salt. --scan-strings exhaustively attempts this: 117,703 unique printable strings (4–64 characters) found in .rdata, each raw, as string‖salt and as salt‖string, through all five hash functions the game brings along, sliced to 16 and 32 bytes — 2,824,872 key candidates in 67 seconds.
best text portion 0.609
sha256("g_aiIncomingDamageScale_DIFFICULTY_MEINLEBEN" + salt)[:16], ecb@16
yields: dc 61 34 3e a0 40 e2 bd ba 91 35 6f bc 9b 32 45 c5 7b c5 a3 …
78 of 128 printable bytes instead of the expected 48.5 — this is exactly the maximum that ~1.9 million draws from noise provide, and the bytes above speak for themselves. No EXE string constant is the secret of this KDF — at least not in this combination.
-
The KDF itself. It consists of
BCryptCreateHash/HashData/FinishHashand a secret. The secret could be a byte-array in.rdatathat we do not recognize as such (it only passes through the check with the correct KDF, not as a key), or it stands as a sequence ofmovimmediates in.text. Neither can be decided without reading.text, and.textis SteamStub-encrypted. A decompressor for this is Copy Protection Removal and is not an option. -
CBC is closed, not measured. Measured is: block mode with padding (the lengths), AES as the only block cipher (the strings), no
ChainingMode*value except GCM (the strings), GCM belongs to the AEAD class (the error message beside it). From this follows CBC only via the documented CNG default for AES, ifBCryptSetPropertyis not called. ECB is weakly excluded by the data — with 67 files containing almost exclusively compressed plaintext, repeated blocks would also be rare under ECB.tools/cfile.pytherefore checks both everywhere. - Salt 8 or Salt 8 + IV 16. The length arithmetic allows both. Without a key it is not distinguishable, and with a key it would be resolved in one attempt.
-
Whether a loose
base\strings\english.langis read at all. If the load path prefers loose files (seedocs/ladepfade.md), the question remains whetheridEncryptedFileResourcehas an unencrypted input. This is a runtime question; the game was not started. -
The rest of the
idSecure*family. WhatidSecureAEADCipher(GCM) is used for in the game remains open — it is demonstrably not used bycfile.
docs/moddingflaeche-tnc.md §6 remains correct in principle — localization is not moddable via this path — but one line there is superseded by this measurement: "The content is encrypted, the key unknown". More precisely: AES in block mode via bcrypt.dll, handler idEncryptedFileResource, key per file derived from a random salt, derivation rule only in .text. The numbers are here.
Furthermore, the finding softens further than noted there: default.cfg, default_sp.cfg, and build.cfg additionally lie as loose plaintext files in base\, so CVar changes do not need the archive anyway. Only the game text itself remains encrypted.
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