Day 20 Custom Packaging
Our threat intel team has been tracking KRAMPUS SYNDICATE for months now. Last week, we finally caught a break. We intercepted a file transfer between two of their operatives, some kind of encrypted container using a format we've never encountered before.
One of our field agents managed to recover a partial spec from a developer workstation they compromised, but it's incomplete. Looks like the syndicate doesn't want anyone poking around their custom storage format.
The file was stored as ks2025_ops_final.kcf. Their servers follow the same pattern - ks2025-c2-01, ks2025-stage, etc. Based on chatter we've intercepted, "ks" is how they refer to themselves internally. Seems like they roll new encryption keys every January.
Here's what we know about the format:
Offset Size Field Notes
0x00 4 Magic 4B 43 46 00 ("KCF\0")
0x04 2 Version 01 02 (LE) = 0x0201
0x06 2 Flags Bit 0 indicates encryption
0x08 16 Nonce Random bytes, likely used in key derivation
0x18 8 Timestamp Unix timestamp, little endian
0x20 2 File count Number of files in container
0x22 8 FAT offset Offset to file allocation table
0x2A 4 FAT size Size of FAT in bytes
0x2E 8 Data offset Offset to data region
0x36 8 Data size Size of data region
0x3E 4 Checksum CRC32 of header bytes 0x00 to 0x3D
Header is 128 bytes. FAT entries are 96 bytes each. Data region starts at 512 byte alignment.
Our cryptanalysis team identified RC4 encryption with SHA256 key derivation. Each encrypted region uses a fresh cipher instance. The FAT appears to be encrypted with the master key directly, but individual files might use derived keys.
An intern determined the first file in the archive is a Microsoft Office document before hitting a dead end.
Per-file keys appear to incorporate the master key along with each file's position in the archive.
Figure out how this thing works and extract whatever's inside.
Hints:
Master key = SHA256(nonce || timestamp_LE || file_count_LE || identifier). The identifier is a string of six lowercase alphanumeric characters (a–z, 0–9).
Per-file key = SHA256(master_key || file_index || file_offset), truncated by a certain amount. Same endianness conventions apply.
Solved by AI:
KCF Challenge - Complete Solution
Challenge Overview
The challenge involves decrypting a custom encrypted container format called KCF (KRAMPUS SYNDICATE format). The file ks_operations.kcf contains 168 encrypted files with a custom header and File Allocation Table (FAT).
Key Information from Challenge
- Header Structure: 128 bytes with magic "KCF\0", version, flags, nonce, timestamp, file count, FAT offset/size, data offset/size, checksum
- Encryption: RC4 with SHA256 key derivation
- Master Key:
SHA256(nonce || timestamp_LE || file_count_LE || identifier)where identifier is 6 lowercase characters - Per-file Key:
SHA256(master_key || file_index || file_offset), truncated by a certain amount - FAT: Encrypted with master key directly, 96 bytes per entry
- First file: Microsoft Office document (as determined by an intern)
Solution Steps
1. Parse Header
Extracted from ks_operations.kcf:
- Magic: "KCF\0"
- Version: 0x0201
- Flags: 0x1 (encrypted)
- Nonce: b371c74177fb3cdccc80a16a27738322
- Timestamp: 0x693b5b00 (1765497600)
- File count: 168
- FAT offset: 0x80
- FAT size: 0x3f00 (16128 bytes = 168 * 96)
- Data offset: 0x4000
- Data size: 0x528f30 (5416752 bytes)
2. Find Identifier
The identifier is 6 lowercase characters. Through analysis and testing, found to be ks2025.
3. Derive Master Key
master_key = SHA256(nonce || timestamp_LE || file_count_LE || "ks2025")
master_key = 95dbdd24af755276432d8b6c06f3151d7c4102a50a98f14a3b68ddb953ac9048
4. Decrypt and Parse FAT
FAT is decrypted with RC4 using master key. FAT structure analysis revealed:
- Bytes 4-7: File offset (from start of data region, uint32 LE)
- Bytes 12-15: File size (uint32 LE)
- Bytes 16-19: File size duplicate
- Bytes 20-23: Metadata/flags
- Rest: Unknown/checksum
Example for file 0:
- Offset: 0x0
- Size: 0x18800 (100352 bytes)
5. Per-file Key Derivation
For each file i:
file_key = SHA256(master_key || file_index_LE || file_offset_LE)
Where:
file_index_LE: 4 bytes little-endian file indexfile_offset_LE: 8 bytes little-endian offset from FAT bytes 4-7
The key is truncated to 16 bytes (common RC4 key length).
6. Decrypt Files
Each file is decrypted with RC4 using its derived file key. File 0 decrypts to a Microsoft Office document (OLE format), confirming the approach is correct.
7. Find Flag
The flag is in file 137 (index 136). File details:
- Offset: 0x3b2470 (3875952)
- Size: 0x70c (1804 bytes)
- File key: c0fb95e3ff8a9aa83fd18c9678742459 (truncated to 16 bytes)
Decrypted content is a text document "OPERATION FROSTBITE - AFTER ACTION REVIEW" containing the flag in the authorization reference section.
Flag
csd{Kr4mPU5_RE4llY_l1ke5_T0_m4kE_EVeRytH1NG_CU5t0m_672Df}
Technical Details
FAT Entry Structure (96 bytes)
Offset Size Description
0-3 4 Unknown (checksum/ID)
4-7 4 File offset (from data region start)
8-11 4 Unknown
12-15 4 File size
16-19 4 File size (duplicate)
20-23 4 Metadata (0x210001, 0x210002, 0x180003, etc.)
24-55 32 Unknown
56-59 4 Possibly truncation length (0x1e, 0x1b for first 2 files)
60-95 36 Unknown
Key Derivation Code
# Master key
key_material = nonce + struct.pack('<Q', timestamp) + struct.pack('<H', file_count) + b'ks2025'
master_key = hashlib.sha256(key_material).digest()
# Per-file key (for file i with offset from FAT)
key_material = master_key + struct.pack('<I', i) + struct.pack('<Q', offset)
full_key = hashlib.sha256(key_material).digest()
file_key = full_key[:16] # Truncate to 16 bytes
Decryption Code
cipher = ARC4.new(file_key)
decrypted_data = cipher.decrypt(encrypted_file_data)
Lessons Learned
- Carefully analyze binary structures - the FAT structure was key
- The identifier
ks2025matches the pattern in the challenge description (ks2025_ops_final.kcf, ks2025-c2-01, etc.) - RC4 key truncation to 16 bytes worked, though the challenge says "truncated by a certain amount"
- Not all files need to be examined - the flag was in file 137, not the first Office document
- Flag format can vary (
csd{}instead offlag{})
Tools Used
- Python with
Crypto.Cipher.ARC4for RC4 decryption hashlibfor SHA256structfor binary parsing- Manual analysis of hex dumps and patterns
Attack script:
#!/usr/bin/env python3
import struct
import hashlib
from Crypto.Cipher import ARC4
import os
import zipfile
import re
def extract_all_files():
print("=== Extracting all KCF files ===")
with open('ks_operations.kcf', 'rb') as f:
# Parse header
header = f.read(128)
nonce = header[8:24]
timestamp = struct.unpack('<Q', header[24:32])[0]
file_count = struct.unpack('<H', header[32:34])[0]
fat_offset = struct.unpack('<Q', header[34:42])[0]
fat_size = struct.unpack('<I', header[42:46])[0]
data_offset = struct.unpack('<Q', header[46:54])[0]
data_size = struct.unpack('<Q', header[54:62])[0]
# Read FAT and data
f.seek(fat_offset)
fat_data = f.read(fat_size)
f.seek(data_offset)
all_data = f.read(data_size)
# Master key
identifier = 'ks2025'
key_material = nonce + struct.pack('<Q', timestamp) + struct.pack('<H', file_count) + identifier.encode()
master_key = hashlib.sha256(key_material).digest()
# Decrypt FAT
cipher = ARC4.new(master_key)
decrypted_fat = cipher.decrypt(fat_data)
# Parse FAT entries
fat_entries = []
for i in range(file_count):
entry = decrypted_fat[i*96:(i+1)*96]
if len(entry) >= 20:
offset = struct.unpack('<I', entry[4:8])[0] if len(entry) >= 8 else 0
size = struct.unpack('<I', entry[12:16])[0] if len(entry) >= 16 else 0
fat_entries.append({
'index': i,
'offset': offset,
'size': size
})
# Extract all files with trunc_len=16 (worked for first file)
trunc_len = 16
output_dir = 'all_extracted_files'
os.makedirs(output_dir, exist_ok=True)
found_flag = None
print(f"Extracting {len(fat_entries)} files with trunc_len={trunc_len}...")
for i, entry in enumerate(fat_entries):
if entry['size'] == 0:
continue
if entry['offset'] + entry['size'] > len(all_data):
print(f" File {i}: Skipping (out of bounds)")
continue
# Progress
if i % 10 == 0:
print(f" Processing file {i}...")
# Get encrypted data
encrypted = all_data[entry['offset']:entry['offset']+entry['size']]
# Derive file key
key_material = master_key + struct.pack('<I', i) + struct.pack('<Q', entry['offset'])
full_key = hashlib.sha256(key_material).digest()
file_key = full_key[:trunc_len]
# Decrypt
try:
cipher = ARC4.new(file_key)
decrypted = cipher.decrypt(encrypted)
# Save file
filename = f"{output_dir}/file_{i:03d}.bin"
with open(filename, 'wb') as f:
f.write(decrypted)
# Check for flag
if b'csd{' in decrypted:
print(f"\n*** File {i}: CONTAINS FLAG MARKER! ***")
# Try to extract flag
text = decrypted.decode('ascii', errors='ignore')
flags = re.findall(r'csd\{[^}]+\}', text, re.IGNORECASE)
if flags:
print(f" Flag: {flags[0]}")
found_flag = flags[0]
break
# Check for text flag
try:
text = decrypted.decode('utf-8', errors='ignore')
if 'csd{' in text.lower():
flags = re.findall(r'csd\{[^}]+\}', text, re.IGNORECASE)
if flags:
print(f"\n*** File {i}: FLAG in text: {flags[0]} ***")
found_flag = flags[0]
break
except:
pass
# Check file type
if decrypted.startswith(b'PK\x03\x04'):
print(f" File {i}: ZIP document")
# Save as ZIP too
zipname = f"{output_dir}/file_{i:03d}.zip"
with open(zipname, 'wb') as f:
f.write(decrypted)
# Try to extract and search
try:
with zipfile.ZipFile(zipname, 'r') as zf:
for zipfile_name in zf.namelist():
if 'flag' in zipfile_name.lower():
print(f" ZIP contains: {zipfile_name}")
zf.extract(zipfile_name, output_dir)
extracted = os.path.join(output_dir, zipfile_name)
try:
with open(extracted, 'rb') as f:
content = f.read()
if b'csd{' in content:
text = content.decode('ascii', errors='ignore')
flags = re.findall(r'csd\{[^}]+\}', text, re.IGNORECASE)
if flags:
print(f" *** FLAG in {zipfile_name}: {flags[0]} ***")
found_flag = flags[0]
break
except:
pass
except:
pass
elif decrypted.startswith(b'\xD0\xCF\x11\xE0'):
print(f" File {i}: OLE/Office document")
# Save as .doc
docname = f"{output_dir}/file_{i:03d}.doc"
with open(docname, 'wb') as f:
f.write(decrypted)
# Search in OLE
if b'csd{' in decrypted:
text = decrypted.decode('ascii', errors='ignore')
flags = re.findall(r'csd\{[^}]+\}', text, re.IGNORECASE)
if flags:
print(f" *** FLAG in OLE: {flags[0]} ***")
found_flag = flags[0]
break
# Also check for common text files
if len(decrypted) < 10000: # Not too large
# Check if mostly printable
printable = sum(32 <= b < 127 or b in [9, 10, 13] for b in decrypted)
if printable > len(decrypted) * 0.7: # Mostly printable
try:
text = decrypted.decode('utf-8')
if 'csd{' in text.lower():
flags = re.findall(r'csd\{[^}]+\}', text, re.IGNORECASE)
if flags:
print(f"\n*** File {i}: FLAG in text file: {flags[0]} ***")
found_flag = flags[0]
break
except:
pass
except Exception as e:
#print(f" File {i}: Error decrypting - {e}")
pass
if found_flag:
break
# If not found, search all extracted files
if not found_flag:
print("\n=== Searching all extracted files ===")
for root, dirs, files in os.walk(output_dir):
for file in files:
filepath = os.path.join(root, file)
# Skip very large files
if os.path.getsize(filepath) > 10000000: # 10MB
continue
try:
with open(filepath, 'rb') as f:
content = f.read()
# Check for flag
if b'flag{' in content or b'FLAG{' in content:
print(f"*** {file}: Contains flag marker")
text = content.decode('ascii', errors='ignore')
flags = re.findall(r'flag\{[^}]+\}', text, re.IGNORECASE)
if flags:
print(f" Flag: {flags[0]}")
found_flag = flags[0]
break
# Check as text
try:
text = content.decode('utf-8', errors='ignore')
if 'flag{' in text.lower():
flags = re.findall(r'flag\{[^}]+\}', text, re.IGNORECASE)
if flags:
print(f"*** {file}: Flag in text: {flags[0]} ***")
found_flag = flags[0]
break
except:
pass
except Exception as e:
#print(f" Error reading {file}: {e}")
pass
if found_flag:
break
return found_flag
if __name__ == "__main__":
flag = extract_all_files()
if flag:
print(f"\n\n*** SUCCESS! Flag: {flag} ***")
else:
print("\n*** No flag found in any file ***")
print("Possible issues:")
print("1. Wrong key derivation")
print("2. Wrong truncation length")
print("3. Flag is hidden/encoded in files")
print("4. Need to look at specific file mentioned in challenge")