Skip to content

Cfile Encryption

LopeKinz edited this page Sep 26, 2026 · 1 revision

cfile — the encrypted Resource type of Wolfenstein II

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

1. What it is

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.

Payload Header

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.

The Handler

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.


2. Which Algorithm — proven without a line of code

.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.

2.1 The Import Table lists exactly 15 functions

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".

2.2 There is exactly one algorithm and exactly one chaining mode

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)

2.3 The lengths rule out AEAD/GCM

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.

2.4 Where the ciphertext starts

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.


3. The cross-checks the key must pass

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):

  1. Readable Text — the plaintext must start with EF BB BF // string table.
  2. The stated length is correct — according to Oodle exactly 0x1F44D8 = 2,049,240 bytes for english.lang from gameresources, and 2,452,756 bytes for the one from patch_4. Both are listed in the header and are not numbers that come out randomly.
  3. All 67, not just one. --key iterates over all of them.

A candidate that fails any of these three is invalid.


4. What is excluded, and with what

4.1 No fixed key, no fixed IV

  • 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, and xb_mirrored.cfg are each 624 bytes long and declare declen as 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.

4.2 No derivation from purely public inputs

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.

4.3 No finished key in a readable section

--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.

4.4 No EXE String Constant Is The Secret

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.


5. What Remains Open

  • The KDF itself. It consists of BCryptCreateHash/HashData/FinishHash and a secret. The secret could be a byte-array in .rdata that 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 of mov immediates in .text. Neither can be decided without reading .text, and .text is 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, if BCryptSetProperty is 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.py therefore 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.lang is read at all. If the load path prefers loose files (see docs/ladepfade.md), the question remains whether idEncryptedFileResource has an unencrypted input. This is a runtime question; the game was not started.
  • The rest of the idSecure* family. What idSecureAEADCipher (GCM) is used for in the game remains open — it is demonstrably not used by cfile.

6. Connection to the Rest of the Docs

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.

Clone this wiki locally