
1. 项目概述一次从加密文档到SM4暴力破解的完整实战复盘最近在复盘去年全国网络安全行业职业技能大赛的一道典型题目这道题完美串联了文件分析、加密算法识别、密钥提取与暴力破解等多个核心技能点非常具有教学和实战价值。题目给了一个看似普通的文档但其中隐藏着经过SM4算法加密的关键信息解题者需要像侦探一样从文档的“边角料”中寻找线索最终通过暴力破解的方式拿到Flag。整个过程与其说是在“破解”不如说是在与出题人进行一场逻辑与耐心的博弈。今天我就把这道题的完整解题思路WP Writeup和我在实战中踩过的坑、总结的技巧毫无保留地分享出来。无论你是正在备赛的选手还是对CTFCapture 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行这里我们要寻找任何有规律的、或可读的字符串。例如文件签名是否有PKZIP、%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 ampledomain.com看这里有两处“宝石”The password may be related to 20240315一个明确的提示密码可能与日期“20240315”有关。CONTACT ME AT exampledomain.com一个无关紧要的干扰信息或彩蛋。我们的任务就是将这些文本片段从文件尾部剥离得到“纯净”的密文数据块。同时这个日期20240315必须记录下来它将是后续暴力破解的关键字典素材。如何精确剥离我们需要找到密文数据结束和“脏数据”开始的确切偏移量Offset。在010 Editor中你可以直接搜索字符串The password的十六进制表示54 68 65 20 70 61 73 73 77 6f 72 64找到其起始位置。假设这个位置是0x1f0十进制496。那么真正的密文就是文件从0x0到0x1ef495字节的部分。我们可以用dd命令来切割文件dd ifsecret_data.bin ofciphertext.bin bs1 count496这条命令的意思是从输入文件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(fGenerated {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填充是否有效如果填充字节无效解密函数可能会直接报错取决于库的实现这可以帮助我们快速排除大量错误密钥。Python脚本示例使用gmssl库首先安装库pip install gmsslfrom gmssl import sm4 from gmssl.sm4 import CryptSM4, SM4_DECRYPT, SM4_ENCRYPT import hashlib import os def try_sm4_decrypt(ciphertext, key_bytes, ivb\x00*16, modeCBC): 尝试用给定的密钥和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(fDecrypt failed with error: {e}) # 调试时可以打开 return None def main(): # 1. 读取密文 with open(ciphertext.bin, rb) as f: ciphertext f.read() print(fCiphertext length: {len(ciphertext)} bytes) # 2. 读取字典 with open(custom_dict.txt, r, encodingutf-8, errorsignore) 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(fTrying 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(fdecrypted_{candidate}.bin, wb) as f: f.write(plaintext) # 尝试用file命令查看解密后的文件类型 os.system(ffile 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, ivNone, modemode) # 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(fdecrypted_ecb_{candidate}.bin, wb) as f: f.write(plaintext) os.system(ffile 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: bPK\x03\x04\x14\x00\x00\x00\x08\x00...Congratulations! The flag is flag{SM4_1s_Fun_And_P0werful!}...bPK\x03\x04是ZIP文件的魔数说明解密后的内容是一个ZIP压缩包。file命令也会确认这一点。最终步骤将解密出的字节保存为decrypted.zip。解压这个ZIP文件。可能需要密码但通常Flag就在ZIP内的文本文件中或者ZIP本身无密码。在解压出的文件中找到Flagflag{SM4_1s_Fun_And_P0werful!}。踩坑记录我在第一次尝试时直接使用了20240315的UTF-8字节b20240315作为密钥补零到16字节结果失败了。因为出题人更倾向于考察选手对“密钥是哈希值”这一常见套路的理解。在CTF密码学题中“密码”不等于“密钥”。用户提供的密码Password通常需要经过一个密钥派生函数KDF才能成为加密算法使用的密钥Key。最简单的KDF就是MD5或SHA1哈希。这是一个必须牢记的经验。6. 总结与扩展思考回顾整个解题流程它清晰地展示了一个典型的CTF密码学挑战的解决路径信息收集 - 算法识别 - 密钥空间界定 - 暴力破解 - 结果验证。其中最精髓的部分在于从看似无用的“加密文档”尾部提取关键提示并基于此构建一个高效的攻击字典。扩展思考如果提示更隐晦怎么办比如提示是一张图片中的隐写文字或是一段音频的频谱图。这就需要结合Misc杂项技能用工具如steghide,binwalk,Audacity等先提取出提示信息。如果密钥空间很大怎么办真正的暴力破解穷举所有16字节组合是不可能的。CTF中的暴力破解永远是基于“弱密钥”假设。如果字典攻击失败需要重新审视线索是否遗漏了密钥的生成规则如公司名日期特定单词的Leet变换pssw0rd。除了SM4其他国密算法呢国密算法还有SM2非对称加密、SM3哈希。SM2的CTF题可能涉及已知密文和公钥恢复明文或签名伪造。SM3则常与哈希碰撞、长度扩展攻击相关。工具化可以将上述分析流程脚本化形成一个半自动的“加密文档分析工具”自动识别常见文件尾提示、尝试常见哈希变换等提高解题效率。这道题的价值在于它不仅仅是一个解谜游戏更是一次对真实世界中数据恢复和密码分析的微型模拟。掌握这种从混沌中寻找秩序、将有限线索转化为有效攻击的能力是网络安全从业者宝贵的技能。希望这篇详细的Writeup能帮助你建立起清晰的解题框架在下一次遇到类似挑战时能够从容不迫直击要害。