CTF密码学实战:Python AES加解密核心原理与攻击技巧
1. 项目概述为什么CTFer必须掌握Python AES在CTFCapture The Flag竞赛的密码学Crypto赛道上AES高级加密标准的出镜率几乎和Web赛道的SQL注入一样高。你可能会在各种场景下遇到它一道纯粹的加解密题给你密文和密钥让你还原明文一个Web题的登录认证Cookie被AES-CBC模式加密了甚至在一个Misc杂项题里一张看似普通的图片其文件尾部可能藏着一个AES加密过的ZIP压缩包。如果你只会用在线工具点点按钮遇到密钥需要动态生成、或者加密模式比较冷门比如OFB、CFB的情况基本就束手无策了。更别提那些需要你分析加密流程、构造特殊密文进行攻击如Padding Oracle Attack的中高难度题目了。这就是为什么一个合格的CTF选手必须把Python的AES加解密能力变成自己的“肌肉记忆”。Python的cryptography或pycryptodome库就像你的瑞士军刀不仅能帮你快速验证思路更能让你深入理解加密的每一个环节——密钥扩展、字节替换、行移位、列混合、轮密钥加……这些概念不再是书本上的名词而是你代码里可以一步步跟踪和操纵的对象。这份指南的目的就是带你从“会用在线工具”升级到“能用Python脚本随心所欲地操控AES”并附上我打比赛这些年总结的常见题型解题套路和踩坑记录。2. AES核心原理与Python库选型2.1 AES算法简述不只是“黑盒”很多人把AES当做一个输入明文和密钥输出密文的黑盒。但在CTF里你需要打开这个盒子看看。AES是一种分组密码固定处理128位16字节的数据块。密钥长度可以是128、192或256位分别对应AES-128, AES-192, AES-256其主要区别在于加密的轮数10, 12, 14轮。每一轮加密除最后一轮稍有不同都包含四个步骤SubBytes字节替换通过一个固定的S盒进行非线性替换这是AES混淆性的核心。ShiftRows行移位将状态矩阵的每一行循环左移不同的位数。MixColumns列混合将状态矩阵的每一列与一个固定多项式进行矩阵乘法提供扩散性。AddRoundKey轮密钥加将当前状态与当前轮的轮密钥进行异或操作。解密过程则是这些操作的逆序。对于CTF选手你不需要手算每一轮除非是特别底层的题目但必须理解模式Mode和填充Padding因为99%的题目陷阱都藏在这里。模式Mode定义了如何对超过一个分组的明文进行加密。ECB电子密码本最简单的模式每个分组独立加密。致命弱点相同的明文分组对应相同的密文分组。如果加密一张图片你甚至能在密文中看到原图的轮廓。在CTF中ECB模式往往提示着“分组重排”或“字节翻转”攻击。CBC密码分组链接最常用的模式。每个明文分组先与前一个密文分组异或再进行加密。需要一个**初始化向量IV**来启动这个过程。CBC是许多攻击如Padding Oracle的舞台。其他模式CTR计数器、CFB、OFB等也偶有出现它们通常将分组密码转换为流密码理解其原理有助于分析题目。填充Padding由于AES是分组加密明文长度必须是16字节的倍数。不足时需要填充。最常见的是PKCS#7缺n个字节就填充n个值为n的字节。例如如果明文差3字节则填充\x03\x03\x03。解密后需要去除填充。很多题目会在这里设置障碍比如故意给一个错误的填充让你去修复或利用。2.2 库的选择cryptography vs pycryptodomePython里主要有两个库cryptography和pycryptodome。我的建议是CTF场景下无脑选pycryptodome。cryptography更现代API设计优雅默认更安全。但它有意隐藏了一些底层细节比如默认使用GCM等认证模式如果你想单纯使用CBC模式需要多绕一步。对于需要精细控制加密流程比如手动设置IV、操纵中间状态的CTF题来说有时不够直接。pycryptodome它是老牌库pycrypto的维护分支。API虽然略显老旧但功能强大且直接对各种模式、填充的支持非常直观完全贴合CTF题目的常见设定。你可以轻松地实现一个“教科书式”的AES-CBC加密这正是我们需要的。安装命令很简单pip install pycryptodome注意导入时使用Crypto首字母大写from Crypto.Cipher import AES from Crypto.Util.Padding import pad, unpad from Crypto.Random import get_random_bytes注意在一些在线平台或限制严格的靶场环境可能无法安装第三方库。这时你需要备用方案1使用Python内置的hashlib仅部分功能或os.urandom生成随机数2纯手写AES算法仅限极端情况。但绝大多数情况下pycryptodome都是可用的。3. 核心操作从零开始实现加解密3.1 基础加密与解密流程我们从一个最标准的AES-128-CBC加密开始假设密钥和IV都是随机生成的。在真实CTF题中密钥和IV可能是给定的、从某种规律衍生的、或者需要你去破解的。from Crypto.Cipher import AES from Crypto.Util.Padding import pad, unpad from Crypto.Random import get_random_bytes def aes_cbc_encrypt(plaintext, key): 使用AES-128-CBC模式加密明文 :param plaintext: 字节串明文 :param key: 字节串必须是16字节AES-128 :return: (iv, ciphertext) 初始化向量和密文都是字节串 # 生成一个随机的16字节IV iv get_random_bytes(16) # 创建密码器指定模式为CBC cipher AES.new(key, AES.MODE_CBC, iv) # 对明文进行PKCS7填充然后加密 ciphertext cipher.encrypt(pad(plaintext, AES.block_size)) return iv, ciphertext def aes_cbc_decrypt(iv, ciphertext, key): 使用AES-128-CBC模式解密密文 :param iv: 字节串加密时使用的初始化向量 :param ciphertext: 字节串密文 :param key: 字节串必须是16字节AES-128 :return: 字节串解密后的明文已去除填充 cipher AES.new(key, AES.MODE_CBC, iv) # 先解密然后去除填充 plaintext unpad(cipher.decrypt(ciphertext), AES.block_size) return plaintext # 示例用法 key bThisIsASecretKey # 16字节密钥 plaintext bThis is a secret message for CTF! iv, ciphertext aes_cbc_encrypt(plaintext, key) print(fIV (hex): {iv.hex()}) print(fCiphertext (hex): {ciphertext.hex()}) decrypted aes_cbc_decrypt(iv, ciphertext, key) print(fDecrypted: {decrypted.decode()})关键点解析AES.new()这是核心函数参数依次是密钥、模式、IV对于CBC等模式。模式常量如AES.MODE_CBC、AES.MODE_ECB等都在AES模块下。pad()/unpad()来自Crypto.Util.Padding。AES.block_size是16这是分组的固定大小。务必在加密前填充解密后去填充。很多新手会忘记这一步导致报错ValueError: Input data must be padded to 16 byte boundary。IV的作用CBC模式需要IV且IV无需保密但必须不可预测。通常随密文一起传输。在CTF题中IV有时是固定的、全零的、或者与密钥有关这都可能成为漏洞点。3.2 处理不同的输入输出格式CTF题目不会总给你完美的字节串。你需要熟练地在各种格式间转换。import base64 import binascii # 1. 处理Hex十六进制字符串 hex_key 746869736973617365637265746b6579 # thisisasecretkey的hex hex_iv 000102030405060708090a0b0c0d0e0f hex_ciphertext c3b2a1...很长一串 key bytes.fromhex(hex_key) iv bytes.fromhex(hex_iv) ciphertext bytes.fromhex(hex_ciphertext) # 2. 处理Base64字符串 b64_ciphertext w7KyocuVzLTKpMq0yrTKtA ciphertext base64.b64decode(b64_ciphertext) # 3. 处理文件常见于Misc题 def decrypt_file(file_path, key, iv): with open(file_path, rb) as f: ciphertext f.read() cipher AES.new(key, AES.MODE_CBC, iv) # 注意文件可能包含非加密部分如图片头需要找准偏移量 # 假设从文件偏移0x100开始才是真正的密文 # f.seek(0x100) # ciphertext f.read() plaintext unpad(cipher.decrypt(ciphertext), AES.block_size) return plaintext # 4. 输出格式转换 decrypted_data bFLAG{real_flag_here} print(fString: {decrypted_data.decode(utf-8, errorsignore)}) # 转为字符串忽略非法字符 print(fHex: {decrypted_data.hex()}) print(fBase64: {base64.b64encode(decrypted_data).decode()})实操心得遇到编码错误UnicodeDecodeError太常见了。解密出来的可能不是文本而是另一个文件如ZIP、PNG的二进制头。先用hex()或base64输出看看用file命令Linux或观察文件头魔数如PK表示ZIP\x89PNG表示PNG来判断。不要一上来就decode(utf-8)。4. CTF常见题型与Python解法实战4.1 题型一已知密钥与IV的简单解密这是最基础的送分题。通常题目描述会直接给出密钥和IV可能是字符串、Hex或Base64以及一段密文。你的任务就是写脚本解密。解题模板import base64 from Crypto.Cipher import AES from Crypto.Util.Padding import unpad def simple_decrypt(): # 从题目描述中提取 key_str supersecretkey123 # 注意长度补全或截断到16/24/32字节 iv_str initialvector1234 ciphertext_b64 xYrHk8qK7...密文 # 转换为字节并确保长度正确 key key_str.encode().ljust(16, b\0)[:16] # 方法1补零并截断 # 或者 key hashlib.md5(key_str.encode()).digest() # 方法2用MD5固定为16字节 iv iv_str.encode().ljust(16, b\0)[:16] ciphertext base64.b64decode(ciphertext_b64) cipher AES.new(key, AES.MODE_CBC, iv) plaintext unpad(cipher.decrypt(ciphertext), AES.block_size) print(plaintext.decode()) simple_decrypt()常见变种与陷阱密钥/IV不是16字节题目给的密码可能是任意长度。你需要将其转换为16字节。常用方法1直接补零或截断如上2使用其MD5或SHA256哈希值作为密钥hashlib.md5(key_str.encode()).digest()。题目通常会暗示使用哪种方法。模式不是CBC仔细看题目描述可能是ECB、CTR等。只需修改AES.new中的模式即可。对于ECB模式不需要IV参数。填充方式不同虽然PKCS7最常见但也有可能是不填充要求明文本身长度正确、ZeroPadding等。如果unpad报错可以尝试手动处理尾部字节或者搜索Crypto.Util.Padding支持的其他方法。4.2 题型二暴力破解或字典攻击密钥当密钥空间不大时例如密钥是一个4-6位的数字PIN、一个常见单词可以暴力枚举。from Crypto.Cipher import AES from Crypto.Util.Padding import unpad import itertools import string def brute_force_key(ciphertext, iv, plaintext_prefixbFLAG{): 已知密文、IV并且知道明文以FLAG{开头暴力破解密钥 假设密钥是4-6位纯数字 charset string.digits # 数字 # charset string.ascii_lowercase # 小写字母 # charset string.printable # 所有可打印字符 for key_len in range(4, 7): print(fTrying key length: {key_len}) for candidate in itertools.product(charset, repeatkey_len): candidate_key .join(candidate).encode() # 将候选密钥处理为16字节例如用MD5 import hashlib key hashlib.md5(candidate_key).digest() cipher AES.new(key, AES.MODE_CBC, iv) try: decrypted unpad(cipher.decrypt(ciphertext), AES.block_size) if decrypted.startswith(plaintext_prefix): print(f[] FOUND KEY: {candidate_key}) print(f[] Plaintext: {decrypted}) return candidate_key, decrypted except (ValueError, KeyError): # 解密失败或填充错误继续尝试 pass print([-] Key not found.) return None, None # 使用示例需要真实的密文和IV # iv bytes.fromhex(...) # ciphertext bytes.fromhex(...) # brute_force_key(ciphertext, iv)优化技巧多进程加速使用Python的multiprocessing库将密钥空间分割并行计算。已知明文攻击如果知道明文的某一部分如flag格式FLAG{可以在解密后直接检查而无需每次都unpad因为填充错误会抛异常。unpad操作相对耗时先检查前缀可以过滤掉大量错误密钥。利用题目约束密钥可能来自一个单词表rockyou.txt这时用字典攻击比纯暴力更快。4.3 题型三ECB模式与字节翻转攻击ECB模式缺陷利用 由于ECB模式每个分组独立加密如果明文由你部分控制你可以实施“块重排”或“字节替换”攻击。例如一个将用户输入与固定前缀后缀拼接后加密的系统plaintext prefix user_input suffix你可以精心控制user_input的长度使得你想窃取的suffix比如flag单独成为一个分组然后通过重复提交将这个分组替换到其他位置来解密。CBC字节翻转攻击 这是CBC模式的经典攻击。回忆CBC解密公式Plaintext_block[i] Decrypt(Ciphertext_block[i]) XOR Ciphertext_block[i-1]对于第一个分组Ciphertext_block[i-1]是IV。 这意味着修改前一个密文分组或IV可以控制下一个明文分组的解密结果。攻击场景一个Web应用用CBC加密Cookie格式为useradminroleuser你的目标是将其改为useradminroleadmin。即使你不知道密钥也可以通过修改IV或前一个密文块使得解密后的目标字节变成你想要的。def cbc_bit_flipping_attack(): 演示通过修改IV改变第一个明文分组解密结果的攻击。 假设已知原始IV加密后的密文以及我们想将明文第一个分组的第n个字节从‘a’改为‘b’。 # 模拟服务端加密我们不知道密钥 key get_random_bytes(16) iv_original get_random_bytes(16) plaintext_target buseradminroleuser # 我们想改变这个 cipher AES.new(key, AES.MODE_CBC, iv_original) ciphertext cipher.encrypt(pad(plaintext_target, AES.block_size)) # 作为攻击者我们只知道 iv_original 和 ciphertext # 我们想将解密后明文第一个分组的第6个字节从0开始从 a(0x61) 改为 b(0x62) index_to_change 6 old_char ord(a) new_char ord(b) # 计算需要翻转的IV字节 # 公式new_iv_byte old_iv_byte XOR old_char XOR new_char iv_list bytearray(iv_original) iv_list[index_to_change] ^ old_char ^ new_char iv_modified bytes(iv_list) # 使用修改后的IV解密 cipher2 AES.new(key, AES.MODE_CBC, iv_modified) # 注意攻击者不知道key这里仅为演示结果 decrypted_modified unpad(cipher2.decrypt(ciphertext), AES.block_size) print(fOriginal plaintext: {plaintext_target}) print(fModified plaintext: {decrypted_modified}) # 输出Modified plaintext: buserbadminroleuser cbc_bit_flipping_attack()注意事项字节翻转攻击可能会破坏目标字节所在分组的所有其他字节因为异或操作的扩散也可能破坏填充导致解密时unpad失败。在实际CTF题中你需要结合错误回显Padding Oracle或精心计算只影响目标字节。4.4 题型四Padding Oracle攻击这是CBC模式下一个非常著名的攻击。如果服务器在解密后对于填充正确与否返回不同的错误信息例如“解密成功” vs “填充错误”攻击者就可以在不知道密钥的情况下逐字节解密出整个密文对应的明文。攻击原理简述 对于密文的最后一个分组C_n攻击者可以篡改其前一个密文分组C_{n-1}或IV并发送(C_{n-1}‘, C_n)给服务器。通过精心构造C_{n-1}‘的最后一个字节并观察服务器的响应填充正确/错误可以推断出中间值I_n的最后一个字节进而计算出明文P_n的最后一个字节。然后依次向前推算可以解密整个分组。Python实现核心逻辑 由于实现一个完整的Padding Oracle攻击脚本篇幅较长这里给出核心步骤的伪代码和思路设定Oracle你需要一个函数模拟服务器行为输入一个密文或IV密文返回填充是否正确。def padding_oracle(iv, ciphertext): # 将(iv, ciphertext)发送给目标服务器 # 根据服务器返回判断填充是否正确 # 返回 True填充正确 或 False填充错误 pass攻击单个分组def decrypt_block_via_padding_oracle(iv, target_block): 利用Padding Oracle解密一个密文分组target_block。 iv是用于解密target_block的前一个密文分组对于第一个分组就是IV。 block_size 16 decrypted bytearray(block_size) # 存储解密出的中间值I forged_iv bytearray(iv) # 用于篡改的IV # 从最后一个字节开始向前逐个字节解密 for byte_index in range(block_size-1, -1, -1): # 1. 设置填充值 padding_value block_size - byte_index padding_value block_size - byte_index # 2. 调整forged_iv中当前字节之后的所有字节使得服务器预期的填充为padding_value for j in range(byte_index 1, block_size): forged_iv[j] decrypted[j] ^ padding_value # 3. 暴力猜测forged_iv[byte_index]的值使得Oracle返回True for guess in range(256): forged_iv[byte_index] guess if padding_oracle(bytes(forged_iv), target_block): # 4. 计算中间值I[byte_index] guess ^ padding_value decrypted[byte_index] guess ^ padding_value break # 5. 计算明文 P I XOR original_iv plaintext bytes([decrypted[i] ^ iv[i] for i in range(block_size)]) return plaintext串联所有分组对密文的每一个分组调用上述函数前一个分组就是它的“IV”。网上有大量成熟的Padding Oracle攻击脚本如padbuster的Python版。在CTF中你更常需要的是识别出Padding Oracle漏洞然后使用现成工具或修改模板脚本来利用。识别关键点服务器对解密失败返回了两种不同的错误信息。5. 实战调试与排错指南即使理论都懂写脚本时还是会遇到各种妖魔鬼怪。下面是我踩过的坑和解决方法。5.1 常见错误与解决方案错误信息可能原因解决方案ValueError: Data must be padded to 16 byte boundary1. 密文长度不是16的倍数。2. 解密后数据填充格式不正确。1. 检查密文是否完整编码转换是否正确Hex/Base64。2. 尝试不使用unpad先解密查看原始字节手动分析尾部填充。ValueError: Incorrect IV lengthIV长度不是16字节。检查IV的源数据用len(iv)确认。用.ljust(16, b\0)[:16]或哈希方法固定长度。KeyError: pad导入错误或库版本问题。确保导入正确from Crypto.Util.Padding import pad, unpad。检查pycryptodome是否安装成功。解密结果乱码但无错误1. 密钥错误。2. 模式错误。3. IV错误。4. 密文包含非加密部分。1. 确认密钥转换无误。2. 确认加密模式ECB/CBC/CTR。3. 确认IV是否正确对应。4. 用hex()输出解密结果看是否有可识别的文件头或规律。TypeError: Object type class str cannot be passed to C code传入的参数是字符串不是字节串。将所有字符串参数用.encode()或bytes.fromhex()转换为字节串。5.2 调试技巧一步步“看见”数据当你不确定问题出在哪时把每一步的中间数据打印出来。def debug_decryption(hex_iv, hex_ciphertext, hex_key): print([DEBUG] Input:) print(f IV (hex): {hex_iv}) print(f Ciphertext (hex): {hex_ciphertext[:32]}...) # 打印前一部分 print(f Key (hex): {hex_key}) iv bytes.fromhex(hex_iv) ciphertext bytes.fromhex(hex_ciphertext) key bytes.fromhex(hex_key) print(f\n[DEBUG] Byte lengths:) print(f IV: {len(iv)} bytes) print(f Ciphertext: {len(ciphertext)} bytes) print(f Key: {len(key)} bytes) # 尝试不同模式如果未知 for mode_name, mode in [(CBC, AES.MODE_CBC), (ECB, AES.MODE_ECB)]: try: if mode AES.MODE_CBC: cipher AES.new(key, mode, iv) else: cipher AES.new(key, mode) decrypted cipher.decrypt(ciphertext) print(f\n[DEBUG] Try mode {mode_name}, raw decrypted (hex first 64 bytes):) print(f {decrypted[:64].hex()}) # 尝试直接解码为UTF-8可能失败 try: print(f As UTF-8: {decrypted[:64].decode(utf-8, errorsignore)}) except: pass # 尝试去除PKCS7填充 try: unpadded unpad(decrypted, AES.block_size) print(f After unpad (hex): {unpadded.hex()}) print(f After unpad (UTF-8): {unpadded.decode(utf-8)}) except ValueError as e: print(f Unpad failed: {e}) except Exception as e: print(f\n[DEBUG] Mode {mode_name} failed: {e}) # 调用调试函数 # debug_decryption(0*32, your_ciphertext_hex, your_key_hex)5.3 性能优化当暴力破解需要速度如果暴力破解密钥空间很大如部分未知密钥你需要优化脚本。使用Crypto.Cipher.AES的new函数一次创建多次使用在循环内重复创建AES.new对象开销很大。如果只是密钥不同可以预先创建好cipher对象在循环内只更新密钥不pycryptodome的AES.new每次都需要密钥。更好的方法是使用低层函数但这很复杂。避免异常开销unpad在填充错误时会抛异常异常处理有开销。在暴力破解时如果可能先检查解密结果的前几个字节是否符合预期如bFLAG{符合再去unpad。使用PyPy对于纯Python的循环计算PyPy解释器通常比CPython快数倍。关键操作用C扩展或库最根本的优化是使用多进程将搜索空间分割。import multiprocessing from Crypto.Cipher import AES def worker(key_chunk, iv, ciphertext, prefix, result_queue): for candidate_key in key_chunk: # ... 尝试解密 ... if success: result_queue.put(candidate_key) return def parallel_bruteforce(): # 准备参数 all_possible_keys [...] # 一个很大的密钥列表 iv ... ciphertext ... num_processes multiprocessing.cpu_count() chunk_size len(all_possible_keys) // num_processes manager multiprocessing.Manager() result_queue manager.Queue() processes [] for i in range(num_processes): start i * chunk_size end None if i num_processes-1 else (i1) * chunk_size chunk all_possible_keys[start:end] p multiprocessing.Process(targetworker, args(chunk, iv, ciphertext, bFLAG{, result_queue)) processes.append(p) p.start() for p in processes: p.join() if not result_queue.empty(): print(fFound key: {result_queue.get()})6. 进阶技巧与工具链整合6.1 整合其他密码学工具CTF的Crypto题很少孤立考察AES常与RSA、Base64、异或等结合。与RSA结合AES密钥可能由RSA加密传输。你需要先用RSA私钥解密出AES密钥。from Crypto.PublicKey import RSA from Crypto.Cipher import PKCS1_OAEP # 假设你有一个RSA私钥文件 ‘private.pem‘ 和一段RSA加密过的AES密钥 with open(private.pem, r) as f: private_key RSA.import_key(f.read()) cipher_rsa PKCS1_OAEP.new(private_key) encrypted_aes_key bytes.fromhex(...) aes_key cipher_rsa.decrypt(encrypted_aes_key) # 得到AES密钥 # 然后用 aes_key 进行AES解密与异或、编码结合密钥或IV可能被Hex、Base64、Base32甚至自定义编码混淆。熟悉Python的base64、binascii、codecs库并准备好写循环处理自定义编码。6.2 使用pwntools进行交互式攻击当题目是一个网络服务时比如一个在线的加密解密Oracle手动交互效率低下。使用pwntools库可以自动化这个过程。from pwn import * # 安装pip install pwntools def attack_padding_oracle_service(host, port): conn remote(host, port) # 1. 接收初始数据例如加密后的Cookie conn.recvuntil(bCookie: ) encrypted_cookie conn.recvline().strip() # 假设 encrypted_cookie 是 hex 格式的 ciphertext bytes.fromhex(encrypted_cookie.decode()) iv ciphertext[:16] blocks [ciphertext[i:i16] for i in range(16, len(ciphertext), 16)] # 2. 实现Padding Oracle攻击逻辑这里省略具体攻击代码 # ... 使用conn.sendline()发送篡改后的密文conn.recvuntil()接收响应判断 ... conn.close() # 使用 # attack_padding_oracle_service(靶机地址, 端口)6.3 从零手搓AES理解赛题极少数硬核CTF题会要求你实现AES的某个步骤如S盒替换、列混合或者分析一个自定义的、有缺陷的AES实现。这时你需要真正理解算法流程。建议你至少实现一次AES的S盒和行移位理解状态矩阵的表示。pycryptodome的源码也是一个很好的学习资源。掌握Python实现AES加解密对于CTF选手来说从“解题者”变成了“造题者”思维的开始。你能看穿题目作者设下的陷阱能快速验证自己的猜想能在复杂的多步加解密中游刃有余。这份指南里的代码和思路是我从无数场比赛和熬夜调试中积累下来的希望它能成为你密码学赛道上的一把利器。下次遇到AES题别再去搜在线工具了打开你的编辑器开始写脚本吧。