赛季打到后半程,队里已经没有人愿意反复盯着题目列表刷新了。我们把队伍名改成了“已经没有人类了”,最初只是调侃,后来变成每天真实发生的事:AI Agent负责从附件下载、类型识别、命令构造、结果解析到提交flag的整条流水线,人类只在启动脚本前点一下确认按钮,偶尔处理一下模型跑偏的情况。这篇文章就把这套全自动打CTF的题解流程拆开讲清楚,包括整体拆解、核心逻辑、实际操作记录,以及我踩了一堆坑换来的排查经验。
我先把背景说清楚。这套流程只针对合法、可控的比赛环境,也就是赛题平台直接给出容器地址和附件的那类CTF。所有环节都在本地Docker沙箱里完成,不碰任何真实系统,也不涉及网络环境里的敏感操作。适合的对象是已经会写一点Python、用过Linux命令行、但对AI编程助手理解还停留在“帮我写个脚本”阶段的人。如果你想知道怎么把一个LLM从“问答工具”变成一个“会自己动手解题的解题员”,这篇文章应该能给你一些可以照抄的思路。
1. AI全自动解题的整体设计与思路拆解
1.1 为什么传统自动化脚本不够用,必须引入语言模型
CTF题目最大的特点是异构。Crypto题的附件是Python脚本,Reverse题的附件是ELF文件,Web题给的是URL,Misc题可能是一张PNG或者一段音频。以前我写过很多一次性脚本,每个脚本只能解一类题,遇到新增题型就要从头再写一套。后来我意识到,CTF解题的核心能力根本不是“某种固定的自动化”,而是“面对陌生目标时,能根据反馈不断调整方向”。这正是语言模型擅长的事。
传统自动化的做法是规则引擎加正则匹配:扫到BEGIN RSA就丢给RSA解析脚本,扫到可执行文件就丢给strings和objdump。这套逻辑的缺陷在于,题目变化是无穷的,规则永远滞后于出题人的思路。而LLM可以理解题目描述、阅读代码、根据工具输出自行推断下一步,等于把“出题人想考什么”这个问题交给模型去猜。
实际测试下来,纯靠LLM直接输出答案是完全不行的,模型会在代码里编造不存在的函数和偏移量。真正稳妥的架构是:LLM负责决策,工具负责执行,执行结果回传给LLM继续决策。这个闭环就是所谓Agent循环。整套系统的核心不是模型多强,而是模型和工具之间的接口设计得多干净。
1.2 工作流设计:从题目入口到最终flag提交的闭环
我的标准工作流分七步,payload和执行细节可以各自替换,但骨架基本固定:
- 题目说明书读取:从比赛目录的description文件或HTML页面提取题目名称、类型、提示词、容器地址。
- 附件获取与识别:模型调用
file、ls、unzip等命令,确定附件是脚本、二进制还是压缩包。 - 静态分析:执行
strings、readelf、objdump、python ast之类的分析操作,把有效信息回传。 - 假设生成:模型根据分析结果提出Solve思路,生成代码或payload。
- 工具执行与反馈:在沙箱里运行生成的代码,捕获stdout/stderr和退出码,把结果裁剪后回传。
- 迭代修正:模型根据报错修正方向,最多循环N次,超过次数则换题或降级到人工。
- 提交验证:提取
flag{...}格式字符串,通过比赛平台接口提交,比对返回结果。
整个流程我刻意把“工具调用”和“LLM推理”分开。原因是LLM上下文窗口有限,如果每次工具输出都全量塞回去,几轮之后上下文就被无用的日志灌满,模型会开始胡言乱语。所以我在每个工具执行后都做一次输出压缩:超过3000字符的部分直接截断,只保留尾部、包含关键字flag或error的行。
1.3 关键设置与避坑方向
第一次跑通这套流程时,我犯了个很蠢的错误:给了模型对宿主机文件系统的完全读写权限。模型在解一道Reverse题时,不知道从哪里推理出“修改/etc/hosts可能会让flag隐藏”,虽然它没有真的执行,但这个意图本身就很危险。后来我规定所有操作必须发生在沙箱目录里,能访问的资源只有题目附件、临时目录和允许执行的工具白名单。
另一个坑是模型喜欢同时开很多线程去“试”,导致沙箱资源被耗尽。我给整套流程设定了并行上限,模型每次只能提交一个命令,等输出回来再决定下一步。这个限制看似降低了效率,实际上大幅提升了稳定性,因为LLM并不是真的在并行思考,它只是在并行制造混乱。
2. 核心分类器与各题型自动化解法细节
2.1 题目类型的自动识别策略
自动识别的核心不是靠文件名,而是靠文件特征。一个名为crypto.py的文件可能是Misc题,一个没有扩展名的附件可能是ELF也可能是压缩包。我让模型先跑一组探测命令:file、ls -la、head -c 200,把结果合并成一段结构化文本,再交给分类Agent判断类型。
分类Agent返回的是带置信度的JSON,比如:
{ "type": "reverse", "confidence": 0.92, "reason": "ELF 64-bit executable, stripped, dynamic linked", "initial_plan": ["run strings", "check main symbol", "trace flag format"] }这个JSON的作用是给后续的解题Agent一个起始计划。注意,置信度低的分类不要硬做,直接标记为unknown并把题目放回队列,等人类来看一眼。有一次模型把Crypto题误判成Misc,因为附件里藏了一段Base64,结果它在编码里翻了一小时。后来我加了校验:如果分类置信度低于0.8,强制要求模型先阅读题目描述再重新分类。
2.2 各类题型的Agent分工与工具链
我给每种题型配置了一个独立的Agent Prompt,但底层工具是统一的。Crypto题重点用Python执行环境,Reverse题重点用二进制分析工具,Web题重点用HTTP请求工具,Misc题重点用隐写和编码工具。每个Agent共享同一个沙箱文件系统,但Prompt里规定了各自的“行动纲领”。
Crypto题的基本路径是:先读脚本,判断是否用了不安全随机数、固定种子、小密钥空间、可预测的padding,然后用sympy或z3做约束求解,最后写穷举脚本。这里有个很重要的经验:让模型把脚本里的assert语句当作关键线索。很多Crypto题在结尾会有assert decrypted == plaintext之类的校验,这直接暴露了加密逻辑的边界。
Reverse题的核心策略是“先字符串后逻辑”。让模型第一轮只跑strings和objdump -d,不要一上来就看反编译大图。模型经常能通过字符串Usage:和correct!定位到核心校验分支,然后顺着分支找flag的生成逻辑或直接patch跳过校验。但我不建议让模型自动patch二进制文件,因为一旦patch失败,原始文件被破坏就得重新下载。我把它设计为只生成patch方案,由沙箱里单独的工具执行,失败时自动回滚原始副本。
Web题的自动化解法比较特殊,因为比赛给的是一段容器地址。我的做法是让模型用curl去访问,观察响应里的HTML注释、cookie、隐藏字段。大部分Web题其实是“源码审计+简单参数篡改”,而不是真正的复杂利用,所以让LLM读源码比直接发请求更有效。我把源码文件和工作目录挂载进Agent沙箱,让模型读路由和鉴权逻辑,再构造请求体。
Misc题就靠工具堆:exiftool、binwalk、steghide、zsteg、fcrackzip、qrcode库。模型判断不出该用哪个工具时,会让它逐一执行白名单内的命令,看哪个输出有异常。这个“逐一套用探测器”的思路效率不高,但对Misc题特别管用,因为Misc题本质上就是考“你对工具熟不熟”。
2.3 模型上下文管理与工具输出的裁剪
所有工具输出回传前都必须裁剪,这一点我再强调一遍。我给每个工具设定了输出长度上限,比如strings最多返回前200行和包含flag的所有行,objdump最多返回核心函数附近的20条指令。这样模型每次决策时看到的都是高价值信息,而不是几千行的汇编。
还有一个细节:所有工具输出必须带退出码和错误流的末尾。LLM对报错信息的敏感度非常高,有时候报错本身就藏着解题线索。例如Permission denied可能意味着文件有额外权限,Segmentation fault可能意味着输入长度触发缓冲区覆盖——这些信号人类看得懂,模型也看得懂,但前提是你得把它传到模型眼前。
3. 实操过程记录:一道Reverse题的自动化通关全流程
3.1 从附件下载到第一轮分析的完整命令链
我拿一道典型的本地Reverse题来复盘。题目附件是一个Linux ELF,名叫challenge_2,没有任何说明文档。AI Agent启动后,第一轮自动执行的探测命令如下:
$ file ./challenge_2 $ ls -la ./challenge_2 $ strings ./challenge_2 | head -50第一轮输出回传到模型手里,模型注意到几个关键点:文件是32位ELF、动态链接、带符号表,strings里有Usage: ./challenge_2 <key>和Access granted两行。模型随即判断这是“输入key做什么”的Reverse题,并生成了一轮计划:
{ "step": "find_flag_by_symbol", "commands": [ "nm -C ./challenge_2 | grep -i check", "objdump -d ./challenge_2 | grep -A 20 'check'" ] }这一步运行后,模型在check_key函数里看到一个循环,循环内部调用了0x8049A20地址的函数,且循环次数是0x10。模型假设这是逐字节比较,于是决定用调试器或者动态注入来验证。由于沙箱里没有交互式调试条件,我让模型改用ltrace跟踪库调用,看看比较函数的值。
ltrace ./challenge_2 abcdef的输出显示了strcmp被调用,模型立刻意识到题目只是用strcmp做了字符串比较。于是它读取了二进制里的字符串常量,把s3cr3t_k3y_2024提取出来作为输入,再次运行后拿到了flag{llm_autopwn_reverse}。整个过程只用了5轮工具调用,耗时不足60秒。
3.2 一次失败尝试的完整复盘
上面这个例子很顺,但更多时候是磕磕绊绊的。同样一道题,我换了另一个模型跑,结果是完全不同的故事。模型第一轮没有跑strings,直接去运行了./challenge_2,得到Segmentation fault。然后把段错误当作有效线索,开始猜测“是不是需要输入超长字符串触发溢出”,接着生成了300个字符的payload去撞。
这里出现的典型问题是模型把工具输出过度解读了。段错误只是一个中性信号,可能是输入格式错误,也可能是程序本身有bug,但模型把“异常”默认成“线索”。解决办法是额外加一个“冷静Agent”,专门负责审核模型提出的假设是否过度演绎。冷静Agent的Prompt里规定:如果当前证据无法排除更简单的解释,就返回“建议先收集更多信息”。
加了这层审核后,同样的模型在第二次跑这道题时,段错误之后没有直接上payload,而是先跑了strings,发现了Usage提示,随后按正规流程解出flag。所以,如果你也在做类似的全自动化系统,我认为“审核Agent”比“解题Agent”本身更重要。
3.3 参数循环次数与资源阀值设置
自动化流程里我设置了两个硬性阀值:单题最大工具调用次数50次,单次命令执行时长30秒,总时长20分钟。超过任何一个阀值就自动暂停并通知人类。这个阀值不是为了限制AI,而是防止模型陷入死循环。有一次模型在解Crypto题时反复提交同一条命令,因为输出被截断后它以为自己没见过这个结果。后来我加了命令去重:相同命令和相同退出码的输出如果出现两次以上,就不再回传给模型,而是提示“重复结果,建议更换策略”。
经验来看,65%的简单题在前10次工具调用内可以解出,20%的题在20次内能解出,剩下的纯粹是模型方向跑偏,再给它50次也只是重复错误路径。所以阀值的意义不是“让它多试几次”,而是“在损失可控的前提下尽早止损”。
3.4 沙箱与并发安全配置的实操细节
沙箱我用的是Docker,镜像里只装了解题必需的工具:Python3、gcc、binutils、gdb、curl、file、binwalk、z3、pwntools。镜像设置为只读根文件系统,唯一可写目录是/workspace,题目附件解压进去,所有配置文件也在里面。模型执行命令时,用timeout 30包裹,防止单条命令卡死。
关于模型权限,我使用的是最小权限账号,非root,不具备apt-get能力,不能访问Docker socket。这个限制会让模型不能自己安装依赖,但换来的是绝对可控。缺库的话只能生成静态代码或者调用已有的库,这样反而逼模型写出更通用的解法。
网络层面只允许访问比赛平台地址列表里的目标,其他一律拒绝。这里必须再强调一遍,整套系统只用于合法的CTF比赛环境,不能接触任何未经授权的目标。安全上的洁癖就是这套系统的生命线。
4. 运行中的常见问题与排查技巧实录
4.1 高发问题速查表
下面是我跑了上百道题之后整理的常见问题表,每条都是我实际踩过坑的地方。
| 现象 | 根因 | 处理方式 |
|---|---|---|
| 模型反复提交相同命令 | 输出被截断后模型误判为“新结果” | 增加命令去重,重复输出不再回传 |
| 把运行时崩溃当解题线索 | 过度解读工具输出 | 加一层审核Agent,要求优先排除简单解释 |
| 上下文被脏数据灌满 | 工具输出未裁剪 | 统一裁剪长度,保留尾部+含flag的行 |
| 模型编造不存在的函数名 | 对代码结构不了解 | 要求模型先跑nm/dir()再下结论 |
| 生成python脚本语法错误 | 缺乏lint反馈 | 在沙箱中先跑py_compile再执行 |
| 单次命令执行时间过长 | 模型提交了死循环代码 | 强制timeout 30包裹命令 |
| 分类置信度低导致方向跑偏 | 分类器未参考题目描述 | 置信度低于0.8时重新阅读题目并分类 |
| 提交flag大小写错误 | flag格式不统一 | 提取后统一lower()后提交两版 |
4.2 模型幻觉的终极解法:基于证据的推理约束
模型幻觉是这套系统里最难缠的问题。我发现大部分幻觉并不是模型“胡说八道”,而是它在信息不足时用训练数据里的相似模式去补全现实。比如它看过很多Crypto题都用srand(0)生成密钥,于是遇到一个没有出现srand的脚本时,依然声称密钥可预测。避免幻觉最有效的方法,是给模型设定一条硬性推理纪律:每个结论后面必须附加证据来源,证据只能来自工具输出,否则判定为无效结论。
具体实现上,我让解题Agent的输出格式变成结构化的“结论-证据-下一步动作”。如果某个结论没有对应的证据ID,审核Agent会直接打回。这招极大地减少了模型信口开河的情况。对比测试里,加上这条约束后,单题平均工具调用次数下降了35%左右。
4.3 从日志里定位模型思维断层的办法
当模型卡住时,我不会去修改Prompt,而是直接翻执行日志。日志里每一条记录都包含:工具名、命令、输出摘要、模型当时的“下一步意图”。很多时候,模型卡住是因为它在某一轮看到了一个错误输出后,就把全部注意力放在那个错误上,完全忽略了其他有价值的信息。这是LLM的注意力偏向问题,和人类程序员钻牛角尖一模一样。
我的排查手段是回放日志:把断开的前后5条记录整理成一个简短的摘要,重新作为“额外提示”注入给模型。这相当于在模型卡住的地方“人工喊停”,让它跳出局部视角。这个方法比重新跑整个流程节省时间得多。
4.4 成本、资源与时间控制的实测数据
最后说点实际的数据。整套流程跑一道简单题,算上模型API调用和沙箱运行时间,成本大约是一次普通API请求的好几倍,但换算成人类时间基本可以忽略。重要成本不在API,而在模型上下文占用和沙箱的CPU时间。
我观察到的耗时分布是:探测命令10%,代码生成20%,代码执行40%,结果分析30%。其中代码执行的40%里有一大半浪费在编译和反复运行错误代码上。如果让我再优化,我会在沙箱里加一个结果缓存层:相同参数的相同命令直接读缓存,不再重新执行。这个优化对重复提交payload的常见病态行为特别有效。
5. 自动化的边界与人类保留区
跑了这么多题之后,我越来越清楚哪些环节可以交给AI,哪些环节必须留人。简单重复的体力活——比如识别文件类型、提取字符串、跑字典、批量提交——全自动做,又快又稳。需要全局视角的策略判断,比如“这道题出题人想考什么考点”“为什么这个flag看起来不合理”,目前还是人类更靠谱。
我的实际做法是把系统设计成“AI First, Human Last”。每个自动化Agent都带一个暂停信号,当任务的置信度持续低于阈值时,系统不会硬撑,而是把中间结果打包成一份精简报告推给人类。报告里只包含:题目类型、模型假设、已尝试方向、被否定的原因、建议人工关注的点。这样一来,人类只出现在真正重要的时刻,其他时间“已经没有人类了”这句话就成了事实。
最后分享一个我自己一直沿用的小技巧:让AI Agent在开始解题之前,先把题目描述用一句话复述给审核Agent听。如果它复述的和原题不一致,那后面无论跑多少轮都基本是白费。这个前置检查只要几秒钟,却能过滤掉一大半低质量的自动尝试。做全自动解题,不是让AI一直往前冲,而是让它每一步都走得有依据,该停的时候停得干脆。