1. CTF里Python到底在忙什么:五大方向的工具清单
CTF比赛里有个很有意思的现象:不管你主攻的是密码学、逆向、Web、Pwn还是杂项,最后手里都得攥着Python。它不是最炫的武器,却是最万能的工具——有的题靠它算大数分解,有的题靠它拼字节流,有的题干脆就是靠它把人和程序之间的对话自动化。今天这篇,我就围绕自己在CTF里最常用的Python实战场景,把解析、破解和自动化脚本这三块怎么打、有什么坑、怎么写效率最高,一次讲透。适合刚入门CTF但脚本能力比较薄弱的同学,也适合那些思路有但代码写不顺的老手,照着这份思路可以快速搭建自己的解题工具箱。
1.1 五大方向的角色定位
先搞清楚Python在每个方向里到底承担什么职责。很多新手有个误区,以为CTF拼的是对漏洞原理的背诵,其实到了赛场上,真正的瓶颈往往是把一个已经想清楚的思路转化成代码的速度。Python的价值就是把这个转化过程压到最短。
| 方向 | Python的主要工作 | 典型场景 |
|---|---|---|
| 密码学 Crypto | 大整数运算、模逆、分解、约束推导 | RSA参数恢复、AES/Padding Oracle、格密码 |
| 逆向 Reverse | 静态分析辅助、解析文件结构、脚本化处理字节码 | 脱壳、还原算法、Android smali分析 |
| Web | 批量请求、参数构造、正则提取、自动打接口 | SQL注入判断、目录扫描、验证码识别联动 |
| Pwn | 交互与payload构造 | 栈溢出、格式化字符串、堆利用辅助 |
| Misc 杂项 | 数据解析、隐写提取、流量重组 | 图片LSB、压缩包爆破、PCAP分析 |
这样列出来你就能发现一个规律:除了Pwn方向有pwntools这种"专武"之外,其他方向Python干的活其实都很朴素——读数据、算数据、发数据。但恰恰是这几件事,用手工做能累死人,用脚本做就是秒级完成。
1.2 环境初始化与常用库安装
个人强推把CTF环境单独隔离出来,别跟日常开发环境搅在一起。因为pwntools、z3这些库的版本变化比较大,今天升级明天可能就不兼容,污染到工作项目里太难受了。
python3 -m venv ctf_env source ctf_env/bin/activate pip install --upgrade pip pip install pwntools pycryptodome z3-solver requests scapy装完跑一句验证:
python -c "from pwn import *; print('pwntools ok')" python -c "from Crypto.Util.number import inverse; print('crypto ok')" python -c "from z3 import *; print('z3 ok')"这几个库基本覆盖了CTF 90%的脚本需求。scapy不是每场都用,但碰到流量包题目时非常好使。gmpy2在某些大数运算场景比原生pow快很多,密码学刷得多的话建议也装上。
提示:如果你用的是Windows,pwntools的原生支持一直很别扭,哪怕装了也容易在终端处理上报错。最省心的方案是装一个Linux虚拟机或者用Docker开一个固定的CTF镜像,把库一次性装好,之后所有比赛都在里面跑。我在练习阶段吃过这个亏,当时在本机折腾半天,后来直接切到Linux环境,十分钟全部搞定。
1.3 为什么是Python而不是别的
有人会问:Rust性能好,Go并发强,为什么CTF圈偏偏集体用Python?
答案其实是效率。CTF比赛的时间是按小时算的,你花三分钟写完一个爆破脚本,和花三十分钟写一个性能更优的编译型程序,显然后者在这场比赛里是亏的。Python的交互式特性让验证想法变得极其轻量,我有大量脚本其实就是在ipython里一行一行敲出来的,先试一个变量,再试一个函数,确认没问题之后才整理成.py文件。
另一个原因是生态。RSA里要用的pow(c, d, n),Python原生支持大整数,一句搞定;z3约束求解器、pwntools的ROP自动搜索、scapy的协议解析,这些都是CTF社区多年沉淀下来的直接可用的轮子。你不需要自己去实现复杂逻辑,组合这些库就够了。
2. 解析类题目的实战套路:先学会读懂字节再说
CTF里大量题目本质上是"数据藏匿"游戏:flag被藏在某个文件里、某段字符串里、某个网络流量里。这时候Python承担的角色就是字节级解析器,你要么从编码里把明文剥出来,要么从文件结构里把附加数据挖出来。
2.1 拿到一串神秘字符串,先做这三步探测
杂项和密码学题目里最常见的开局,是题目甩给你一串字符,让你想办法解码。新手最容易犯的错就是人肉盯着字符串猜——看到一串大写字母和小写字母混排就以为是base64,看到666c6167开头的就以为是某种奇怪的密文。实际上解析这种题是有固定套路的,写成脚本批量试探比人肉判断快得多。
import base64 import binascii s = '一段题目给的字符串' # 第一步:看是不是hex try: data = bytes.fromhex(s) print('[HEX]', repr(data)) except ValueError: pass # 第二步:看是不是base64 try: data = base64.b64decode(s) if all(32 <= b <= 126 or b in (9, 10, 13) for b in data): print('[B64]', data.decode()) except Exception: pass # 第三步:单字节异或试探,后面破解章节会展开这套三板斧逻辑很简单:先转hex还原显式编码,再尝试base64标准解码,最后检查输出是否是可打印字符。关键点在于不要一次性把所有解码方式都堆上去,而是按"先无损、再有损"的顺序来,这样定位问题会容易很多。
2.2 文件结构与隐写数据的字节级解析
除了字符串,文件类题目也是解析的重灾区。最常见的模式是:题目给一张图片,看起来完全正常,但文件尾部藏着另一段数据。很多纯手工选手会用十六进制编辑器慢慢翻,但数据一多、文件一多,手工就不现实了。
PNG文件的结构是固定的:8字节文件头,然后是一系列的块(chunk),每个块由长度、类型、数据、CRC组成。正常图片以IEND块结束,如果flag被附加在文件末尾,它一定会在IEND之后出现。
with open('pic.png', 'rb') as f: data = f.read() iend = data.find(b'IEND') # IEND块本身占12字节(4长度+4类型+4CRC),所以真正的尾部从iend+12开始 tail = data[iend+12:] print('[TAIL LENGTH]', len(tail)) print('[TAIL]', repr(tail[:500]))跑完这段,尾部藏的东西基本上就现出原形了。如果尾部是base64字符串,直接拿去解码;如果尾部是另一个文件头,比如PK开头说明是个zip,那就把数据切片保存下来再处理。
这个思路可以延伸成批量检查脚本。某次线上赛出现过图片轮发的形式,几百张图里只有一张藏了东西,人肉翻会瞎。我当时写了个循环遍历所有文件,提取每个文件的尾部数据看有没有可打印字符串,直接把可疑文件筛了出来,前后不到五分钟。
2.3 解析脚本调试:先验证小样本再全量跑
解析类脚本的调试有个老生常谈的问题:数据太多、输出太杂。我的习惯是先在一小段数据上验证逻辑,再放开跑全量。比如你怀疑base64解码后是个压缩包,先只解一段,看输出是不是PK开头,确认了再整个解。不要一上来就全量print,终端刷几万行之后你根本找不到重点。
调试时还有个小技巧:用repr()代替直接print。Python打印bytes时会对不可见字符做转义,但repr()能让你清楚看到换行符、空字节这些细节。很多时候你以为脚本解错了,实际上数据是对的,只是输出到终端时被换行符和乱码干扰了判断。先冷静看一眼repr()的输出再下结论。
3. 破解类题目的核心打法:参数泄漏、异或与约束求解
解析解决的是"数据藏在哪"的问题,破解解决的是"数据经过什么变换"的问题。这一类题目的共同点是给你一部分信息,让你推导出完整的明文或密钥,Python在这里扮演计算器和推导助手的角色。
3.1 RSA经典参数泄漏:dp泄漏恢复p
RSA是CTF密码学里出镜率最高的考点,不是因为RSA本身难,而是因为题目总是会在参数生成时埋点小错误。最经典的一种是dp泄漏——dp = d mod (p-1)被直接给了出来,而n、e、c也都给你了。
推导过程不复杂:因为e * dp ≡ 1 mod (p-1),所以存在一个整数k,使得e * dp - 1 = k * (p-1)。k的范围是1 <= k < e,e通常就是65537,枚举k很快就能找到一个让n % p == 0的候选值,这时候p就被恢复了。
from Crypto.Util.number import inverse, long_to_bytes n = 0x... e = 65537 c = 0x... dp = 0x... for k in range(1, e): if (e * dp - 1) % k != 0: continue p = (e * dp - 1) // k + 1 if n % p == 0: q = n // p phi = (p - 1) * (q - 1) d = inverse(e, phi) m = pow(c, d, n) print(long_to_bytes(m)) break拿到flag之后你会觉得这个脚本简单得不像话,但第一次接触时最难的是想清楚"为什么要枚举k"以及"k的范围为什么是1到e"。多写几遍,RSA相关的套路就能形成自己的工具箱了。类似的还有低加密指数、共模攻击、Wiener攻击等等,每个都是"已知什么、枚举什么、推出什么"的固定模板,把模板存成脚本就能复用。
3.2 单字节异或:穷举密钥快速定位
异或在CTF里太常用了,因为它作为"加密算法"极其简单,又经常被用来做混淆。单字节异或的意思是:明文的每个字节都和同一个密钥字节做异或,生成密文。破解思路也直白——密钥只有256种可能,挨个试一遍,看看输出里有没有flag特征或者可读英文。
data = bytes.fromhex('密文hex') for key in range(256): out = bytes([b ^ key for b in data]) if b'flag' in out.lower() or b'FLAG' in out: print(f'KEY={key} (0x{key:02x})') print(out) break这里有一个工程细节:如果密文比较长,先只取前32个字节来跑穷举,速度快得多,确认命中了再把整个密文丢进去。写脚本的时候习惯性地加个切片,能省下不少无谓等待。
多字节异或的破解稍微复杂一点,常见方法是先猜测密钥长度,再对每个分组分别做单字节异或分析。原理不复杂,关键是不管哪种异或题,第一步永远是"把目标输出特征想清楚"——你是要找flag关键字,还是要找英文的高频字符,这个决定了脚本怎么写。
3.3 逆向条件用z3约束求解
有相当一部分逆向题,你其实不需要完整读懂程序的汇编逻辑。程序里往往是一堆条件判断,比如"flag的第5个字符加第9个字符等于多少""第2个字符必须是某个字母""所有字符的ASCII和等于某个值"——这些条件组合起来,就是一个约束系统。手动逆向这些条件费时费力,但z3就是干这个的。
from z3 import * # 假设flag长度为20,全是可打印字符 flag = [BitVec(f'flag_{i}', 8) for i in range(20)] s = Solver() for i in range(20): s.add(flag[i] >= 32, flag[i] <= 126) # 从逆向分析中提取的条件 s.add(flag[0] == ord('f')) s.add(flag[1] == ord('l')) s.add(flag[2] == ord('a')) s.add(flag[3] == ord('g')) s.add(flag[0] + flag[19] == 210) s.add(flag[4] ^ flag[10] == 42) s.add(flag[6] * 3 - flag[2] == 250) if s.check() == sat: m = s.model() ans = bytes([m[flag[i]].as_long() for i in range(20)]) print(ans) else: print('unsat')这个例子的核心价值在于:你不需要知道"为什么这些条件成立",只需要把逆向过程中看到的约束原样抄进脚本里,让求解器去算。我用z3解决过好几道原本可能卡半个小时的逆向题,尤其是那种逻辑分支多但条件清晰的白盒题目,效果出奇地好。
注意:z3里的BitVec是比特向量,字节运算要注意溢出问题,比如
flag[i] * 3可能超过255,如果原始逻辑是在一个字节里溢出后再比较,脚本里也要用Extract或者掩码模拟这个溢出行为,否则算出来的结果对不上。
4. 自动化脚本编写:把手工操作全部交给Python
如果说解析和破解是CTF的"内功",自动化脚本就是"外功"。一场比赛里你会反复遇到同一个需求:跟程序对话、批量提交、处理大量重复数据。手工做又慢又容易出错,脚本化才是正解。
4.1 pwntools解决交互类题目的标准模板
Pwn题的考点是最依赖交互脚本的,因为程序通常运行在一个远程环境里,你需要通过标准输入输出跟它打交道。pwntools把这件事封装得极其顺手,新手先背熟这个模板就行:
from pwn import * context.log_level = 'info' context.arch = 'amd64' # 本地调试用process,打远程时换成remote sh = process('./pwn1') # sh = remote('127.0.0.1', 10001) sh.recvuntil(b'Input:') payload = b'A' * 32 + p64(0x40123a) sh.sendline(payload) sh.interactive()recvuntil是等一个指定字符串出现,sendline是发送一行(自动补换行符),interactive把控制权还给选手方便手工操作。这套模型覆盖了绝大多数Pwn题的交互需求。
还有一类不需要漏洞利用,但非常吃脚本的题:程序让你做一百次数学题,做完才给flag。这种纯属考自动化能力,pwntools依旧好使:
from pwn import * r = remote('127.0.0.1', 10001) r.recvuntil(b'solve this many: ') times = int(r.recvline().strip()) for i in range(times): line = r.recvuntil(b'=', drop=True) expr = line.decode().strip() a, op, b = expr.split() a, b = int(a), int(b) if op == '+': ans = a + b elif op == '-': ans = a - b elif op == '*': ans = a * b r.sendline(str(ans).encode()) r.interactive()这个脚本的价值不在具体题目,而在于它展示了一个通用范式:读提示、解析数据、算答案、发结果、循环执行。凡是"固定的问答流程",都可以套这个模板。唯一需要灵活处理的是每次recvuntil的边界条件,比如这里用drop=True把等号去掉,只留表达式本身。
4.2 Web方向requests批量请求与会话保持
Web题里大量场景需要批量发请求,比如某个页面的参数要从1遍历到10000,比如登录后要在多个接口里反复探测。requests库加上Session对象是标配,因为Session会自动携带cookie,模拟登录后的状态。
import requests import re s = requests.Session() url = 'http://127.0.0.1:8080/check' for i in range(1, 1000): resp = s.post(url, data={'id': str(i)}) if 'flag' in resp.text: print(f'FOUND: {i}') print(resp.text) break if i % 100 == 0: print(f'[*] tested {i}')写这种批量请求脚本时,有几个细节经常被忽略。一是要设置超时,requests.post(url, timeout=5),否则某个请求卡住整个脚本就僵在那里。二是要控制频率,尤其是在本地靶场环境,快速连续请求可能把题目服务打崩,也可以在代码里加一个time.sleep(0.05)或者time.sleep(0.1)。
4.3 工程化配置:超时、重试、结果落盘
比赛中的网络环境再稳,也架不住脚本跑半小时、中途断一下。所以稍微正式一点的自动化脚本,我一定会加三层保险:超时、重试、结果落盘。
import time import requests def fetch_with_retry(url, times=3): for i in range(times): try: resp = requests.get(url, timeout=5) if resp.status_code == 200: return resp except requests.exceptions.RequestException as e: print(f'[!] attempt {i+1} failed: {e}') time.sleep(1) return None result = fetch_with_retry('http://127.0.0.1:8080/')超时防止程序无限卡住,重试应对网络抖动,把抓到的中间结果写入文件。特别是批量爆破那种要跑很久的脚本,我会在循环里定期把当前进度和已找到的结果追加写到一个日志文件里,这样即使脚本中途挂了,也不用从头再跑。很多人不在意这个,真到了比赛尾声网络不稳的时候,就知道这个习惯有多救命。
5. CTF脚本调试实录:高频坑位排查与完整写作流程
最后这部分是我最想分享的。因为真正阻挡新手写出顺手脚本的,往往不是算法思路,而是一堆看起来很蠢的报错。我把自己踩过的高频坑整理成速查表,希望你能跳过这些弯路。
5.1 Python3的bytes与str混用坑
这是Python3选手最常见的翻车点。Python2时代字符串和字节不分家,Python3严格区分str和bytes。你从文件读出来的是bytes,用input()拿到的是str,两者相加直接报TypeError。
| 症状 | 原因 | 解法 |
|---|---|---|
TypeError: can't concat str to bytes | str和bytes混拼 | 统一转bytes,用b'flag'或.encode() |
UnicodeDecodeError | 解码二进制字符串失败 | 用bytes操作,不要随意.decode() |
| 终端输出乱码 | 直接print二进制数据 | 用repr()查看,或先判断是否可打印 |
我自己处理这类问题时的原则是:凡是跟网络、文件、加密有关的操作,一律用bytes;只有到了最后输出flag给人看时才decode。这个原则能规避掉大部分编码异常。
5.2 交互脚本卡死与recv异常排查
脚本执行到recvuntil就停住不动,这大概是最让人抓狂的bug。排查思路按顺序来:先确认服务端到底发了什么。用pwntools调试时把context.log_level设为debug,发送和接收的数据全会打印出来,一目了然。
常见原因有三个。一是你等的字符串不存在,recvuntil永远等不到目标。二是服务端在等你的换行符,但send没带\n,对面一直阻塞;这种用sendline就解决了。三是你的解析逻辑把数据提前吃掉了,比如先用了recvline,后面再recvuntil时数据已经被消费完。记住pwntools的数据缓冲机制:一旦从缓冲区读走,那些字节就没了,后续的接收函数看不到它们。
5.3 库版本与导入方式的兼容问题
CTF脚本里有个经典万年坑:Crypto包的导入问题。老项目用pycrypto,新环境装的是pycryptodome,两者导入名都是Crypto,但接口有一些差异。如果你发现from Crypto.Util.number import inverse报错,先确认自己装的是哪个包,建议统一用pycryptodome。
pip uninstall pycrypto pycryptodome pip install pycryptodome另一个是gmpy2库在部分环境里安装很费劲,但Crypto.Util.number里其实也有inverse和GCD,大部分RSA题够用了。我的建议是:依赖能少则少,能用pwntools和pycryptodome解决的问题,就不要额外引入新库,减少环境不一致带来的麻烦。
5.4 一条完整的实战脚本写作流程
最后用一个标准流程把前面所有内容串起来。假设你拿到一道本地二进制题,需要构造输入触发某个函数然后获得flag。正确流程不是上来就写脚本,而是:
第一步,本地运行程序,手工输入看行为;第二步,用checksec或readelf看保护;第三步,定位目标函数地址;第四步,写一个最简单的payload在本地跑通;第五步,把process换成remote,打远程。
from pwn import * context.log_level = 'info' context.arch = 'amd64' elf = ELF('./pwn1') win_addr = elf.sym['win'] payload = b'A' * 32 + p64(win_addr) sh = process('./pwn1') sh.recvuntil(b'Input:') sh.sendline(payload) print(sh.recvall(timeout=3))看到没有,整个脚本不到10行。真正花时间的可能是前面的逆向分析,而不是写这个传输脚本。这也是我想强调的:Python在CTF里的作用永远是"加速"而不是"替代",你的分析思路才是最核心的东西,脚本只是把思路变成现实的最后一步。把这个流程理顺了,你在赛场上写脚本就不会再慌乱。