1. 项目概述:一次从加密文档到SM4暴力破解的完整实战复盘
最近在复盘去年全国网络安全行业职业技能大赛的一道典型题目,这道题完美串联了文件分析、加密算法识别、密钥提取与暴力破解等多个核心技能点,非常具有教学和实战价值。题目给了一个看似普通的文档,但其中隐藏着经过SM4算法加密的关键信息,解题者需要像侦探一样,从文档的“边角料”中寻找线索,最终通过暴力破解的方式拿到Flag。整个过程,与其说是在“破解”,不如说是在与出题人进行一场逻辑与耐心的博弈。今天,我就把这道题的完整解题思路(WP, Writeup)和我在实战中踩过的坑、总结的技巧,毫无保留地分享出来。无论你是正在备赛的选手,还是对CTF(Capture The Flag)中密码学、逆向工程感兴趣的安全爱好者,这篇文章都能为你提供一个清晰的、可复现的实战路径。
这道题的核心价值在于,它模拟了一个非常贴近现实的场景:你拿到了一份被处理过的文件(可能是配置备份、日志文件或损坏的文档),你需要判断它是否被加密、使用了何种加密方式、密钥可能藏在哪里,以及如何组织有效的攻击。这不仅仅是调用一个解密工具那么简单,它考验的是你对文件格式、加密算法特征、编程脚本编写和资源利用的综合能力。接下来,我将从文件初步分析开始,一步步拆解到最后的暴力破解成功,并重点讲解SM4算法在CTF中的常见考法以及暴力破解脚本的优化技巧。
2. 赛题环境与初始文件分析
2.1 文件初步检视与格式判断
拿到赛题附件,通常是一个压缩包,解压后得到一个文件。我们假设文件名为secret_data.bin。第一步永远不要急着用十六进制编辑器猛看,先做最基础的检视。
1. 使用file命令识别文件类型:在Linux终端或Git Bash中执行:
file secret_data.bin这个命令会读取文件的魔数(Magic Number)来尝试判断其类型。输出可能有两种情况:
secret_data.bin: data:这意味着file命令无法识别其具体格式,很可能头部信息被修改或确实是纯加密数据。这是一个重要信号,表明文件可能不是标准文档(如PDF、ZIP),而是自定义格式或密文。- 识别出某种文档类型(如PDF、XML等)。但即使识别出来,也可能只是外壳,内部数据仍被加密。
2. 使用hexdump或xxd查看文件头部和尾部:
xxd secret_data.bin | head -20 # 查看头部20行十六进制 xxd secret_data.bin | tail -20 # 查看尾部20行这里我们要寻找任何有规律的、或可读的字符串。例如:
- 文件签名:是否有
PK(ZIP)、%PDF、<?xml等。 - 规律性数据:加密后的数据通常看起来是高度随机的,但如果看到大段重复的字符块(如
00 00 00 00),可能提示使用了ECB模式(不推荐),或者这部分是填充(Padding)。 - 可读字符串:在头部或尾部夹杂的一些英文单词、路径名、注释等,这往往是出题人留下的“提示”或密钥的一部分。例如,可能看到
password hint:、key:或一个网址片段。
3. 检查文件大小和熵值:一个简单但有效的方法是检查文件熵(Entropy)。高熵值(接近8)强烈暗示文件是压缩或加密过的。可以使用工具如ent(一个简单的熵计算程序)或在Python中粗略计算。对于CTF题,如果文件看起来像“乱码”且熵值很高,那么加密的可能性就极大。
实操心得:我个人的习惯是,在打开十六进制编辑器(如010 Editor, HxD)进行深度分析前,先用命令行完成上述快速检查。这能帮你建立对文件的“第一印象”,避免一开始就陷入字节的海洋中迷失方向。如果
file命令返回data,我会立刻警觉,这很可能就是一道密码学或逆向题。
2.2 从“加密文档”中提取潜在线索
在本次赛题中,file命令很可能返回data。用xxd查看时,我们发现文件末尾有一段“脏数据”——即一些看似无效、但以可读ASCII字符形式存在的文本。这非常关键!在CTF中,出题人经常把提示、密钥的一部分、或者加密参数(如IV初始向量)放在文件头或文件尾。
假设我们用xxd secret_data.bin | tail -30看到了类似这样的内容:
0001f0: 2e35 3631 2e20 5468 6520 7061 7373 776f .561. The passwo 000200: 7264 206d 6179 2062 6520 7265 6c61 7465 rd may be relate 000210: 6420 746f 2032 3032 3430 3331 352e 0000 d to 20240315... 000220: 434f 4e54 4143 5420 4d45 2041 5420 6578 CONTACT ME AT ex 000230: 616d 706c 6540 646f 6d61 696e 2e63 6f6d ample@domain.com看!这里有两处“宝石”:
The password may be related to 20240315:一个明确的提示,密码可能与日期“20240315”有关。CONTACT ME AT example@domain.com:一个无关紧要的干扰信息或彩蛋。
我们的任务就是将这些文本片段从文件尾部剥离,得到“纯净”的密文数据块。同时,这个日期20240315必须记录下来,它将是后续暴力破解的关键字典素材。
如何精确剥离?我们需要找到密文数据结束和“脏数据”开始的确切偏移量(Offset)。在010 Editor中,你可以直接搜索字符串The password的十六进制表示(54 68 65 20 70 61 73 73 77 6f 72 64),找到其起始位置。假设这个位置是0x1f0(十进制496)。那么,真正的密文就是文件从0x0到0x1ef(495字节)的部分。
我们可以用dd命令来切割文件:
dd if=secret_data.bin of=ciphertext.bin bs=1 count=496这条命令的意思是:从输入文件secret_data.bin中,以1字节为块大小,读取496块(即前496字节),输出到ciphertext.bin。现在,ciphertext.bin就是我们需要解密的密文了。
注意事项:偏移量的计算一定要精确。一个字节的差错都会导致解密失败。在不确定时,可以多尝试切割几次,或者先用Python脚本读取并打印可疑边界附近的字节确认。另外,注意文件大小是否是加密块大小的整数倍(对于SM4和AES,通常是16字节),如果不是,可能还涉及填充(Padding)的处理,这会在后续解密时体现。
3. 加密算法识别与SM4算法精讲
3.1 如何判断这是SM4加密?
面对一个未知的密文块,如何确定它使用的是SM4算法,而不是AES、DES或其它?在CTF中,通常有以下几种线索:
- 题目描述或文件名暗示:这是最直接的。题目名、文件名可能包含“SM4”、“国密”、“GM/T”等字样。
- 上下文线索:就像我们刚才从文件尾部提取到的提示,如果题目背景与中国的标准、规范相关,SM4的可能性就大增。
- 密钥长度提示:如果题目中给出了密钥(Key)或提示密钥与某个固定长度的字符串有关(如16字节的日期),SM4的密钥固定为128位(16字节),这与AES-128的密钥长度相同,但结合“国密”背景,可以优先考虑SM4。
- 密文长度:SM4是分组密码,分组大小为128位(16字节)。因此,密文的长度几乎总是16字节的整数倍(除非有特殊的流密码模式或填充未包含在文件中)。检查
ciphertext.bin的大小,如果是16的倍数,这是一个支持性证据。
在本赛中,结合“全国网络安全行业职业技能大赛”的国家级背景,以及从尾部提取的日期线索,使用国密算法SM4是非常合理的出题思路。因此,我们接下来的工作假设就是:密文由SM4算法加密,密钥未知,但可能与“20240315”这个日期有关。
3.2 SM4算法核心原理与CTF考点
SM4是一种分组密码算法,用于数据加密/解密。理解其基本概念对解题有帮助,但实战中我们更关心如何调用它。
- 分组与密钥长度:128位(16字节)。这意味着它一次处理16字节的明文,产生16字节的密文,密钥也是16字节。
- 工作模式:常见的有ECB、CBC、CFB、OFB等。在CTF中,ECB和CBC最常见。
- ECB模式:最简单的模式,相同的明文块加密后得到相同的密文块。如果密文中有重复的16字节块,可能暗示ECB模式和使用重复的明文(如全零、全空格)。安全性低,不推荐用于实际,但CTF中常见。
- CBC模式:更安全的模式,需要一个额外的16字节初始化向量(IV)。每个明文块在加密前会与前一个密文块进行异或操作(第一个块与IV异或)。IV有时会直接放在密文开头,有时需要从题目线索中寻找。
- 填充:当明文长度不是16字节的倍数时,需要填充。常用PKCS#7填充。例如,如果缺3字节,就填充三个
0x03。
CTF中的常见考法:
- 已知明文/密文对攻击:给你一段已知的明文和对应的密文(可能就在文件头尾的提示里),让你推导密钥或IV。这需要你理解加密模式。
- 弱密钥或密钥枚举:密钥空间被出题人故意缩小。例如,密钥是一个6位数字PIN码、一个8位数字日期、一个字典里的单词,或者像本题一样,是一个有格式的字符串(如
20240315)的某种变体(如MD5后取前16位)。这就是暴力破解的用武之地。 - 模式识别与攻击:识别出是ECB模式,并利用其“相同输入产生相同输出”的特性,破解加密内容的结构(比如判断一张加密图片的轮廓)。
对于我们这道题,模式很可能是CBC(更常见)或ECB。IV可能全为零(\x00*16),也可能藏在文件的另一个角落,或者与密钥有关。我们需要在暴力破解脚本中考虑这些可能性。
4. 暴力破解实战:策略、脚本与优化
4.1 基于线索生成定制化字典
我们得到的线索是:“密码可能与 20240315 有关”。这绝不仅仅意味着尝试“20240315”这一个字符串作为密钥。出题人通常希望考察选手的思维发散能力。我们需要围绕这个种子,生成一个可能性较高的字典。
字典生成思路:
- 直接变形:
20240315(原始)2024-03-15,2024/03/15,2024.03.15(不同分隔符)15032024(DDMMYYYY格式)240315(YYMMDD)202403150000(加上时间)
- 字符串变换:
20240315的MD5值(32位十六进制)。重点!很多CTF题喜欢用MD5哈希值作为密钥,因为它是16字节(32十六进制字符)的二进制数据,正好符合SM4的128位密钥长度。例如,md5(20240315) = 7a6b2e...,取这个哈希值的16进制表示的二进制形式作为密钥。20240315的SHA-1、SHA-256值,但通常取前16字节。20240315进行Base64编码。
- 组合与拼接:
- 与常见弱密码拼接:
password20240315,admin20240315,12345620240315。 - 前后增加固定字符:
key20240315,20240315key。 - 题目中出现的其他字符串(如邮箱
example)与日期组合。
- 与常见弱密码拼接:
使用工具生成字典:我们可以用Python脚本快速生成一个定制字典列表。
import hashlib import base64 base_seed = "20240315" wordlist = [] # 1. 直接变形 wordlist.append(base_seed) wordlist.append(f"{base_seed[:4]}-{base_seed[4:6]}-{base_seed[6:]}") wordlist.append(f"{base_seed[:4]}/{base_seed[4:6]}/{base_seed[6:]}") wordlist.append(base_seed[6:] + base_seed[4:6] + base_seed[:4]) # DDMMYYYY wordlist.append(base_seed[2:]) # YYMMDD # 2. 哈希变换 md5_hash = hashlib.md5(base_seed.encode()).hexdigest() wordlist.append(md5_hash) # 32字符hex字符串 # 注意:SM4密钥是16字节二进制数据。我们需要的是md5_hash这个字符串对应的二进制,还是这个字符串本身? # 这里容易混淆!通常,如果密钥是“字符串”,则使用字符串的UTF-8或ASCII字节。 # 如果密钥是“哈希值”,则使用哈希值的二进制字节(即 bytes.fromhex(md5_hash))。 # 我们生成字典时,先保留字符串形式,在破解脚本中再决定如何转换为密钥字节。 sha1_hash = hashlib.sha1(base_seed.encode()).hexdigest() wordlist.append(sha1_hash[:32]) # 取前32位hex,模拟16字节 # 3. 简单组合 for prefix in ['', 'key', 'pass', 'flag', 'secret']: for suffix in ['', '!', '@', '#', '123']: wordlist.append(prefix + base_seed + suffix) # 去重并保存到文件 wordlist = list(set(wordlist)) with open('custom_dict.txt', 'w') as f: for word in wordlist: f.write(word + '\n') print(f"Generated {len(wordlist)} candidate passwords.")核心技巧:生成的字典不宜过大,通常几百到几千条足矣。真正的重点是密钥的转换方式。是直接用字符串的字节?还是取其MD5的二进制?这是暴力破解成败的关键,往往需要结合题目其他信息或进行多次尝试。
4.2 编写高效的SM4暴力破解脚本
现在,我们有了密文文件ciphertext.bin和一个自定义字典custom_dict.txt。我们需要编写一个脚本,遍历字典中的每一个候选密码,尝试用SM4解密,并判断解密结果是否“像”有效的明文。
步骤拆解:
- 读取密文。
- 读取字典。
- 确定SM4参数:
- 模式(假设先尝试CBC,再尝试ECB)。
- IV(假设先尝试全零
b'\x00'*16)。如果密文的前16字节看起来不像明文(高熵),且题目没给IV,有时IV就是全零。另一种可能是IV被拼接在密文前面,需要先剥离。 - 填充(假设PKCS#7)。
- 遍历字典,转换密钥:
- 对于每个候选密码
candidate,我们需要将其转换为16字节的密钥key_bytes。这是最容易出错的地方。 - 常见转换方式:
key_bytes = candidate.encode(‘utf-8’).ljust(16, b’\x00’)[:16](UTF-8编码,补零或截断)key_bytes = bytes.fromhex(candidate)(如果candidate是32位十六进制字符串)key_bytes = hashlib.md5(candidate.encode()).digest()(取MD5摘要的16字节二进制)key_bytes = hashlib.sha256(candidate.encode()).digest()[:16](取SHA256的前16字节)
- 对于每个候选密码
- 尝试解密并验证:
- 用
key_bytes、IV、密文调用SM4解密函数。 - 检查解密后的数据:
- 可读性验证:解密结果是否包含大量可打印ASCII字符(
\x20-\x7e)?比例有多高? - 模式验证:结果是否以常见的文件头开始?如
PK(ZIP),%PDF,<?xml,flag{,CTF{。 - 填充验证:解密后,PKCS#7填充是否有效?如果填充字节无效,解密函数可能会直接报错(取决于库的实现),这可以帮助我们快速排除大量错误密钥。
- 可读性验证:解密结果是否包含大量可打印ASCII字符(
- 用
Python脚本示例(使用gmssl库):首先安装库:pip install gmssl
from gmssl import sm4 from gmssl.sm4 import CryptSM4, SM4_DECRYPT, SM4_ENCRYPT import hashlib import os def try_sm4_decrypt(ciphertext, key_bytes, iv=b'\x00'*16, mode='CBC'): """ 尝试用给定的密钥和IV解密SM4密文。 返回解密后的明文字节,如果解密过程出错(如填充错误),返回None。 """ try: crypt_sm4 = CryptSM4() if mode.upper() == 'CBC': crypt_sm4.set_key(key_bytes, SM4_DECRYPT) plaintext = crypt_sm4.crypt_cbc(iv, ciphertext) elif mode.upper() == 'ECB': crypt_sm4.set_key(key_bytes, SM4_DECRYPT) plaintext = crypt_sm4.crypt_ecb(ciphertext) else: raise ValueError("Unsupported mode") # 简单验证:检查解密后的数据是否包含大量可打印字符 # 这是一个启发式方法,不一定100%准确,但能过滤掉大部分垃圾结果。 printable_count = sum(1 for b in plaintext if 32 <= b <= 126 or b in [9,10,13]) # 可打印ASCII+制表、换行、回车 if printable_count / len(plaintext) > 0.8: # 假设80%以上可读 return plaintext else: return None except Exception as e: # 捕获解密过程中的错误,如填充错误 # print(f"Decrypt failed with error: {e}") # 调试时可以打开 return None def main(): # 1. 读取密文 with open('ciphertext.bin', 'rb') as f: ciphertext = f.read() print(f"Ciphertext length: {len(ciphertext)} bytes") # 2. 读取字典 with open('custom_dict.txt', 'r', encoding='utf-8', errors='ignore') as f: candidates = [line.strip() for line in f if line.strip()] # 3. 定义可能的密钥转换函数 def key_transform_md5(candidate): """将候选密码进行MD5哈希,取其16字节摘要作为密钥""" return hashlib.md5(candidate.encode('utf-8')).digest() def key_transform_direct(candidate): """直接将候选密码的UTF-8字节作为密钥,补零或截断到16字节""" key = candidate.encode('utf-8') if len(key) < 16: key = key + b'\x00' * (16 - len(key)) else: key = key[:16] return key def key_transform_hex(candidate): """如果候选密码是32位十六进制字符串,则转换为16字节密钥""" if len(candidate) == 32 and all(c in '0123456789abcdefABCDEF' for c in candidate): try: return bytes.fromhex(candidate) except: return None return None # 将转换函数组合成列表,按优先级尝试 transform_funcs = [('MD5', key_transform_md5), ('Direct', key_transform_direct)] # 4. 暴力破解循环 iv = b'\x00'*16 # 先假设IV全零 mode = 'CBC' # 先尝试CBC模式 found = False for idx, candidate in enumerate(candidates): if idx % 100 == 0: print(f"Trying candidate {idx}/{len(candidates)}...") for transform_name, transform_func in transform_funcs: key_bytes = transform_func(candidate) if key_bytes is None: continue plaintext = try_sm4_decrypt(ciphertext, key_bytes, iv, mode) if plaintext: print(f"\n[SUCCESS] Potential key found!") print(f" Candidate: {candidate}") print(f" Transform: {transform_name}") print(f" Key (hex): {key_bytes.hex()}") print(f" Mode/IV: {mode}/{iv.hex()}") print(f"\nFirst 200 bytes of plaintext:\n{plaintext[:200]}") # 保存解密结果到文件 with open(f'decrypted_{candidate}.bin', 'wb') as f: f.write(plaintext) # 尝试用file命令查看解密后的文件类型 os.system(f'file decrypted_{candidate}.bin') found = True # 如果找到多个可能,可以break,这里我们继续看是否还有更好的 # break # if found: # break if not found: print("\n[INFO] No key found with current dictionary and transforms.") print("Trying ECB mode...") mode = 'ECB' for idx, candidate in enumerate(candidates): for transform_name, transform_func in transform_funcs: key_bytes = transform_func(candidate) if key_bytes is None: continue plaintext = try_sm4_decrypt(ciphertext, key_bytes, iv=None, mode=mode) # ECB模式无IV if plaintext: print(f"\n[SUCCESS] Potential key found with ECB!") print(f" Candidate: {candidate}") print(f" Transform: {transform_name}") print(f" Key (hex): {key_bytes.hex()}") print(f"\nFirst 200 bytes of plaintext:\n{plaintext[:200]}") with open(f'decrypted_ecb_{candidate}.bin', 'wb') as f: f.write(plaintext) os.system(f'file decrypted_ecb_{candidate}.bin') return print("\n[FAILED] Exhausted all attempts. Consider expanding dictionary or trying different IVs/transforms.") if __name__ == '__main__': main()4.3 性能优化与错误排查
如果字典很大,或者需要尝试多种变换和模式,脚本可能会运行较慢。以下是一些优化和排查思路:
优化:
- 尽早失败:在
try_sm4_decrypt函数中,一旦检测到填充错误(如果库抛出异常),就立即返回None,这比解密完整数据后再验证可读性要快得多。 - 多进程/多线程:将字典列表分片,用Python的
multiprocessing或concurrent.futures并行处理。 - 使用更快的库:
gmssl是纯Python实现,可能较慢。可以寻找C语言绑定的SM4库(如pycryptodome如果支持SM4的话),或者用ctypes调用本地库。但在CTF中,字典通常不大,gmssl足够。 - 减少转换尝试:优先尝试最有可能的转换方式。例如,如果题目明确提到“哈希”,就优先尝试
key_transform_md5。
排查:
- 密钥转换错误:这是最常见的错误。务必确认密钥字节的长度是16。打印出前几个候选密钥转换后的十六进制形式检查。
- 模式或IV错误:如果CBC模式不成功,尝试ECB模式。如果IV不是全零,尝试从密文开头提取前16字节作为IV,然后用剩下的部分作为真正密文解密。
- 密文格式错误:确认你切割出的
ciphertext.bin是正确的。再次检查偏移量。可以尝试用xxd查看密文文件的前后32字节,确认没有残留的提示文本。 - 填充模式不匹配:SM4本身不定义填充,填充是在模式中处理的。
gmssl的CBC/ECB操作默认使用PKCS#7填充。如果出题人使用了其他填充(如ZeroPadding),你需要修改解密后的数据,手动去除填充,或者使用更底层的库。
5. 成功解密与Flag提取
假设我们的脚本运行后,当尝试到候选密码20240315并使用key_transform_md5转换(即密钥为md5("20240315")的二进制值),在CBC模式、IV全零的条件下,解密成功。
脚本会输出类似以下信息:
[SUCCESS] Potential key found! Candidate: 20240315 Transform: MD5 Key (hex): 7a6b2e... (md5哈希的hex值) Mode/IV: CBC/00000000000000000000000000000000 First 200 bytes of plaintext: b'PK\x03\x04\x14\x00\x00\x00\x08\x00...Congratulations! The flag is flag{SM4_1s_Fun_And_P0werful!}...'b'PK\x03\x04'是ZIP文件的魔数,说明解密后的内容是一个ZIP压缩包。file命令也会确认这一点。
最终步骤:
- 将解密出的字节保存为
decrypted.zip。 - 解压这个ZIP文件。可能需要密码?但通常Flag就在ZIP内的文本文件中,或者ZIP本身无密码。
- 在解压出的文件中找到Flag:
flag{SM4_1s_Fun_And_P0werful!}。
踩坑记录:我在第一次尝试时,直接使用了
20240315的UTF-8字节(b'20240315')作为密钥,补零到16字节,结果失败了。因为出题人更倾向于考察选手对“密钥是哈希值”这一常见套路的理解。在CTF密码学题中,“密码”不等于“密钥”。用户提供的密码(Password)通常需要经过一个密钥派生函数(KDF)才能成为加密算法使用的密钥(Key)。最简单的KDF就是MD5或SHA1哈希。这是一个必须牢记的经验。
6. 总结与扩展思考
回顾整个解题流程,它清晰地展示了一个典型的CTF密码学挑战的解决路径:信息收集 -> 算法识别 -> 密钥空间界定 -> 暴力破解 -> 结果验证。其中,最精髓的部分在于从看似无用的“加密文档”尾部提取关键提示,并基于此构建一个高效的攻击字典。
扩展思考:
- 如果提示更隐晦怎么办?比如提示是一张图片中的隐写文字,或是一段音频的频谱图。这就需要结合Misc(杂项)技能,用工具如
steghide,binwalk,Audacity等先提取出提示信息。 - 如果密钥空间很大怎么办?真正的暴力破解(穷举所有16字节组合)是不可能的。CTF中的暴力破解永远是基于“弱密钥”假设。如果字典攻击失败,需要重新审视线索,是否遗漏了密钥的生成规则(如:公司名+日期,特定单词的Leet变换
p@ssw0rd)。 - 除了SM4,其他国密算法呢?国密算法还有SM2(非对称加密)、SM3(哈希)。SM2的CTF题可能涉及已知密文和公钥,恢复明文或签名伪造。SM3则常与哈希碰撞、长度扩展攻击相关。
- 工具化:可以将上述分析流程脚本化,形成一个半自动的“加密文档分析工具”,自动识别常见文件尾提示、尝试常见哈希变换等,提高解题效率。
这道题的价值在于,它不仅仅是一个解谜游戏,更是一次对真实世界中数据恢复和密码分析的微型模拟。掌握这种从混沌中寻找秩序、将有限线索转化为有效攻击的能力,是网络安全从业者宝贵的技能。希望这篇详细的Writeup能帮助你建立起清晰的解题框架,在下一次遇到类似挑战时,能够从容不迫,直击要害。