做渗透测试这些年,最烦的事清单里,记命令一定排前三。后来我试着把LLM接进日常工作流,做了一个用自然语言下指令的CLI攻击框架——输入“扫一下子域”,它自己决定调哪个工具、填什么参数、把结果整理成结构化报告。这个项目我已经在授权的红队评估里跑过,不是玩具。这篇文章是完整复盘:从翻译管线设计到模型选型,从函数调用到授权校验,以及踩过的好几个坑。
1. 为什么要把LLM塞进渗透测试CLI流程
1.1 传统命令行工作流到底卡在哪里
最常见的渗透测试流程是:拿到授权目标后,先做子域枚举,再解析IP、探测端口、识别服务,接着上漏洞扫描,最后手工验证关键漏洞。流程本身不难,难的是每一步都有大量参数要记。nmap一个工具就有-sS、-sT、-Pn、-A、-T4、-p-等几十个开关,组合起来更是无数种可能。nuclei用YAML模板,写匹配规则要翻文档。subfinder参数少,但和httpx、nmap、nuclei之间的数据格式转换,每次都要靠jq或者临时脚本。
这不是熟练度的问题。就算你背得很熟,新工具、新参数、新模板层出不穷,老手也经常翻help。更消耗时间的是把这些工具串成一条链路:子域结果要解析成IP,IP列表要按端口去重,存活主机又要喂给漏洞扫描器。每一跳都是一次手工转换。LLM驱动框架解决的不是“不会用工具”,而是组织工具链和参数组合时的心智负担。
我最初的想法很简单:做一个能听懂人话的壳,把“帮我跑一下”这种模糊指令,变成一串准确的CLI调用。实际搭建起来才发现,难点根本不在“听懂”,而在如何让LLM安全、稳定、可复现地调用一堆真实安全工具。
1.2 自然语言层不是替代命令,而是替代“记忆与串联”
我见过不少团队直接把一个聊天机器人套壳做成“输入一句话就执行命令”的脚本,结果模型生成什么命令就执行什么命令,很快翻车。这里的关键认知是:自然语言层只是入口,不是执行层。真正干活的是底层那些CLI工具,LLM的价值在于降低调用它们时的记忆成本和编排成本。
我倾向于把这个框架理解为“翻译管线”:用户用自然语言表达意图,管线把意图翻译成受控的工具调用,再翻译成真正的shell命令,最后把工具输出翻译回人话。每一层职责单一,出了问题也好排查。框架不是替代nmap、subfinder这些工具,而是给它们加了一层调度大脑。
如果你已经明明确确知道要跑哪条命令,直接敲反而更快,多一层LLM只是增加延迟。但如果面对的是一个模糊任务、一个需要五六个工具串联的流程、或者一个不常用工具的调用,自然语言层的优势非常明显。我实际跑下来,单次信息收集流程的时间大概能压缩一半以上,主要省在“想参数”和“转格式”这两件事上。
1.3 这类框架的适用场景与边界
这个方案最适合三类使用场景。一是需要持续跟踪大量资产的红队评估,批量操作多、链路重复,自然语言层能有效减少重复劳动。二是新人培训,用自然语言先熟悉工具调用方式,再慢慢深入CLI细节。三是对工具使用频率不高的防御侧人员,他们偶尔需要跑一次扫描,不必花半小时回忆参数。
它也有一堆不适合的边界。比如GUI类工具,或者需要交互式操作的扫描器,接入成本很高,收益有限。又比如需要精细控制发送速率、规避检测的对抗型测试,模型目前还不适合做那么细微的判断。我的原则是:凡是单条命令能解决的事,永远不要让LLM插手;凡是需要五条以上命令串联的事,才值得让它来编排。
2. 框架的整体设计:自然语言到CLI调用的翻译管线
2.1 四层架构:自然语言入口、意图理解、工具注册、执行反馈
与其叫“攻击框架”,不如叫“工具编排框架”。整体分四层。第一层是入口层,负责接收用户指令,维护会话。第二层是意图理解层,LLM在这里分析指令,判断是否需要调用工具、需要调用哪些工具。第三层是工具注册与参数生成层,它把意图映射到工具注册表里的具体函数,并按JSON Schema生成参数。第四层是执行与反馈层,真正去跑CLI进程,把stdout和退出码解析成结构化结果,再回传给LLM。
这个设计里有一条硬规则:LLM不直接接触shell。它只能从注册表里选择函数,不能像聊天一样自由生成命令。这是踩了两次坑才确定的原则。第一版我让模型直接输出bash命令,它确实能写,但也会写出带--os-shell的危险参数,甚至编造根本不存在的工具命令。后来改成函数调用,模型只能选注册项,参数再离谱也会被schema校验挡住。
执行器这层还要负责把工具产物保存到固定目录、记录执行时间、做并发控制。它本质上是LLM与真实系统之间的隔离带。任何安全相关的强制校验,比如目标白名单、高危操作确认,都放在这一层做,而不是依赖模型自觉。
2.2 工具注册表与JSON Schema设计
工具注册表是整个框架的地基。每个工具注册项包含四部分:函数名、描述、参数Schema、执行器命令模板。其中描述是给LLM看的,直接影响它什么时候选这个工具、怎么填参数。参数Schema是给模型和校验器看的,规定了参数的类型、枚举值和必填项。执行器命令模板才是真正落到shell里的东西。
举个例子,nmap在注册表里长这样:
{ "name": "nmap_scan", "description": "使用nmap对指定目标进行端口扫描。目标必须在授权清单中。仅在用户需要发现开放端口或服务信息时使用。", "parameters": { "type": "object", "properties": { "targets": { "type": "array", "items": { "type": "string" }, "description": "目标IP或域名列表" }, "ports": { "type": "string", "description": "端口范围,如 '80,443,8080-8090'", "default": "top-1000" }, "scan_type": { "type": "string", "enum": ["quick", "full", "service", "vuln"], "default": "quick" }, "timing": { "type": "string", "enum": ["T0", "T1", "T2", "T3", "T4", "T5"], "default": "T3" } }, "required": ["targets"] } }注意两点。第一,description里不仅写了“这个工具干什么”,还写了“目标必须在授权清单中”。我对比过,加这么一句之后,模型在目标选择上的随机性小了很多。第二,把具体的nmap参数抽象成quick、full这种业务语义,让模型做选择题,而不是做阅读理解。执行器里再写一个转换函数,把quick映射为-T4 --top-ports 1000 -F,把service映射为-sV -sC等。模型不需要背参数,只需要理解业务意图。
2.3 会话状态与上下文管理
渗透测试很少是一条指令就完事,通常是“先收集子域,再扫端口,最后看漏洞”。所以框架必须维护会话上下文:当前目标是什么、已经跑过哪些工具、有哪些结果还在后台执行。我在内存里维护一个状态对象,核心字段就几个:
current_targets:当前有效的目标列表scan_history:已经执行过的工具调用和结果摘要pending_confirmations:等待人工确认的高危操作
状态对象本身不直接喂给LLM,而是先做摘要。nmap几百行原始输出如果直接塞回上下文,既费token又会干扰模型判断。我的做法是执行器先把XML解析成“开放端口、服务指纹、操作系统猜测”的摘要表格,再放回上下文。这样模型看到的是干净结论,而不是原始日志。这一步对token成本控制的作用,后面专门展开讲。
3. 模型选型与函数调用的可靠性问题
3.1 本地模型还是API:权衡点
模型跑在哪里,直接决定框架的可用性和合规边界。我的判断标准有三个:数据敏感性、调用延迟、函数调用质量。
如果被测目标的数据足够敏感,客户不允许任何外部请求,那只能选本地模型。本地模型优先考虑支持function calling的开源版本,类似Qwen系列带tool calling的版本,或者Llama 3配合结构化输出。简单工具调用它们已经够用,但复杂参数组合容易翻车。函数调用质量不是笼统的“问答能力”,而是它能不能严格按照schema输出JSON、能不能在描述字段模糊时做出稳定选择。
如果条件允许走API,函数调用稳定性和复杂意图理解通常会好不少。但要注意,渗透测试过程中目标域名、IP、端口信息都会作为prompt发送出去,必须提前和客户确认数据出境是否被允许,并且明确记录在授权书里。这事没有灰色空间,要么合规,要么别用。我自己的默认策略是:客户开放环境且项目级别允许时用API,其他绝大多数项目用本地部署。
3.2 函数调用(function calling)的正确打开方式
很多人以为function calling就是把工具列表塞给模型就完事,实际远没那么简单。我在调优过程中总结出几个非常实用的经验。
工具描述要以“什么时候用”来写,而不是“这个工具是干什么的”。“当用户想要从域名中发现子域名时使用”比“Subfinder是子域名枚举工具”有效得多。前者触发条件明确,后者只是名词解释。参数设计上,尽量用高层业务语义,不要暴露原始命令行细节。端口扫描就让模型选quick、full、service,不要让它去纠结-sS和-sT的区别。对模型来说,日常语言到抽象参数的映射远比日常语言到shell开关的映射容易。
每次工具调用的结果都要以结构化方式回传。我统一用{ success, summary, artifact_path, error }这种结构,LLM据此判断是继续下一步还是换一条路径。如果结果只是原始文本,模型很难从几千行输出里提取出“下一步该干嘛”的信号。
3.3 参数填充错误的兜底:二次校验与人工确认
即使做了schema约束,模型还是有填错参数的时候。最常见的是把扫描目标写成IP格式错误、端口范围不合法,或者更隐蔽的——把历史会话里的旧目标填进新一轮任务。所以我在执行器前面加了一层参数校验器,逻辑很直接:
- 目标必须能匹配授权清单里的IP段或域名模式
- 端口必须是数字或合法区间组合
- 扫描类型枚举值必须存在于schema定义中
- 高危操作必须等待人工确认
所谓高危操作,包括漏洞利用、暴力破解、写入文件、反弹连接这类行为。LLM可以生成执行请求,但必须弹窗找人来点确认。这不是防模型,而是防自己手滑。我见过别人的demo,让LLM全自动执行命令,结果它自动对一个授权目标跑起了sqlmap的--os-shell,那已经不是技术问题了,是流程失控。渗透测试工具加AI,边界意识比模型能力更重要。
4. 提示词设计:把“安全”和“效率”写进系统层
4.1 系统提示词的目标与写法
系统提示词是整个框架的“性格设定”。我用下来发现,提示词不是越多越好,越多越容易让模型发散。我的系统提示词只有三段:
第一段设定角色:你是渗透测试中的工具调度助手,只能使用注册工具,不能自行编造命令。第二段设定操作准则:每次决策先确认目标在授权清单内,不在就明确拒绝;优先复用已有结果,不重复扫描;有歧义时向用户提问,而不是瞎猜。第三段设定输出要求:回复简短,只包含工具调用和下一步建议,不做长篇解释。
最重要的是那句“只能使用注册工具,不能自行编造命令”。没有这句话,模型就会幻觉出很多根本不存在的工具。我见过它一本正经地调用一个名为subscan.py的脚本,而整个项目目录里根本没有这个文件。提示词不是摆设,它决定了模型会不会乱来。
4.2 授权范围校验与目标白名单
白名单校验可以写在提示词里,但不能只写在提示词里。必须在代码层强制校验。框架启动时读取授权文件,里面定义允许扫描的CIDR、域名以及时间窗口。执行任何工具前,解析出的目标必须过一遍白名单匹配函数,不匹配就返回错误。这个校验不依赖LLM判断,是硬性约束,纯代码执行。
有人觉得这会影响效率,但实际经验正好相反,白名单能省掉大量“目标写错”的返工。有一次模型把192.168.10.0/24写成了192.168.100.0/24,被白名单拦下来,只花了不到0.5秒。如果没有这层校验,这次扫描就会打到一个完全错误的网段,轻则浪费几小时,重则造成未授权扫描事故。
校验规则我用CIDR和域名后缀匹配,不做模糊IP匹配。框架里会额外维护一个“目标状态”:已经完成的任务目标记为finished,避免同一个资产被重复扫描。LLM在上下文里能看到这个状态,但真正拦住重复扫描的,还是代码层去重。
4.3 避免LLM“自由发挥”:约束生成与工具选择策略
除了提示词,技术上还可以做约束生成。很多推理框架支持grammar或JSON schema约束,让模型输出必须符合预定义JSON格式,从源头避免结构错误。本地部署时我会开这类选项,效果非常明显——工具调用结果的JSON解析失败率从10%以上降到了接近零。
工具选择策略上,我倾向于多步拆解模式:先让LLM判断意图,给出候选工具列表和初步参数,再由执行器合并去重。比如“扫一下子域”会被拆成subfinder枚举加dnsx解析,不一定需要nmap。如果完全让模型自由决定调用多个工具,它会把整个工具链跑一遍,一个简单需求可能触发五分钟的全家桶扫描。token和时间浪费都很明显。
所以我会给会话设置工具调用上限,默认最多执行5到8次工具调用。超过上限,框架不再自动执行新工具,而是列出“建议下一步”让用户手动确认。这不是限制能力,是防止模型陷入循环。有些迭代里模型会因为扫描结果为空,反复用不同参数重试同一个工具,非常消耗时间。
5. 一次真实授权测试会话复盘
5.1 输入:先收集子域,再看常见服务端口
用一次真实但脱敏的授权测试做例子。目标是一个虚构公司域名acme-test.local,授权范围包括该域名及对应子网的24个IP,时间窗口48小时。操作员的原始指令是:“先收集子域,再看常见服务端口。”
框架接收指令后,意图理解层判断出两个任务:子域枚举和端口扫描。这两个任务都需要工具调用,于是从注册表里选中subfinder和nmap_scan。这里最关键的点是,模型没有直接输出shell命令,而是先通过函数调用生成参数对象。比如对subfinder生成{ "domain": "acme-test.local", "source": "all" },对nmap_scan生成{ "targets": "<上一步解析出的IP列表>", "scan_type": "quick", "timing": "T4" }。
参数对象经过代码层校验后,才会交给执行器。执行器把参数对象翻译成真正的CLI命令并执行。
5.2 框架实际翻译出的命令链与执行顺序
实际执行过程中,执行器按依赖关系排列命令链:
# 1. 子域枚举 subfinder -d acme-test.local -silent -all -o subdomains.txt # 2. 解析域名并过滤存活 dnsx -l subdomains.txt -a -resp-only -o resolved_ips.txt # 3. 常见服务端口扫描 nmap -iL resolved_ips.txt -T4 --top-ports 1000 -open -oX nmap_result.xml # 4. 对开放服务的存活主机跑一次轻量漏洞模板匹配 nuclei -l live_hosts.txt -t /opt/nuclei-templates -severity low,medium,high第一个子域枚举工具执行完成后,输出的是纯文本域名列表。执行器把它保存为subdomains.txt,再把文件路径和统计信息(找到N个域名)返回给LLM。LLM据此决定是否需要继续解析和端口扫描。整个过程是链式的,LLM在每个环节只做“下一步决策”,不碰原始扫描逻辑。
这里有个容易忽略的细节:nmap_result.xml会被执行器解析成JSON摘要,再把摘要返回给上下文。原始XML文件只留存在本地工件目录,供后续人工追溯。这让LLM不需要处理几百行XML,也让我后续写报告时有原始证据可查。
5.3 结果解析与报告生成:从JSON到可读输出
工具输出格式五花八门。subfinder是纯文本,nmap是XML,httpx是JSON,nuclei是JSON或markdown。执行器的核心工作之一是把它们统一成内部事件结构,每个事件包含:
- asset:目标资产
- tool:哪个工具发现的
- severity:严重程度,如果适用
- evidence:关键证据片段
- timestamp:发现时间
然后LLM再根据这批事件生成阶段小结,比如“发现3个高危组件,建议优先验证xx端口”。这一步如果让LLM直接读原始输出,它会被几百行无关信息带偏。先由执行器提取事件,再交给LLM做总结,幻觉率明显下降。
报告生成我采用了模板加LLM混合方式:资产清单、端口列表、漏洞列表全部由模板渲染,保证准确;LLM只负责生成“漏洞解释”和“修复建议”两个自由文本字段。这么做是因为完全让LLM重写报告,它总会忍不住添油加醋,而模板渲染能保证每一个安全发现都能追溯到原始工具输出。
6. 落地过程中踩过的坑与优化细节
6.1 token成本失控:历史消息裁剪与工具结果摘要
第一版框架跑一个简单任务,token消耗量吓人。原因很简单:每次都会把上一次nmap的原始输出重新塞进context。后来我引入两个机制。
第一个是滚动窗口,只保留最近三条用户指令和最近五次工具结果摘要,更早的存到本地状态文件里,不再进入当前上下文。第二个是摘要模板,工具结果在执行器侧先做结构化压缩。nmap的XML先解析成“开放端口列表、服务指纹、操作系统猜测”的Markdown表格,再返回给LLM,而不是原始XML。
优化之后,同类任务token用量降到原来的四分之一,模型反而更准确了。原因是上下文干净,没有噪音。这也是我给所有想复刻这个框架的人的第一条建议:永远不要直接把工具原始输出丢给LLM,先做摘要,再进上下文。
6.2 超时与并发:CLI调用不能无限等
LLM调一个工具函数,底层是真实CLI进程。nmap全端口扫描可能跑十几分钟,如果一个agent等在那里,整个对话就卡死了。我的处理方式是所有工具调用走异步队列,默认超时按工具类型区分。子域枚举120秒,端口扫描600秒,漏洞扫描900秒。超时后进程强制结束,执行器返回部分结果,LLM可以继续追问,后台任务完成后再把结果补进会话。
并发要克制。默认最多并发跑3个工具,否则目标机器和本机CPU都会被打满。有一次我把并发调到8,结果本机多个nmap进程互相争抢资源,有些扫描直接崩了,而且输出文件都写乱了。安全工具追求的是稳定可复现,不是越快越好。
6.3 误报过滤与结果格式化:LLM生成总结时的幻觉问题
LLM做总结时最大的问题是幻觉。它会在结果里补充一条压根不存在的“高危漏洞”。我排查过一两次,原因都是LLM根据训练数据里的旧知识补全了CVSS评分,而不是从工具输出里读到的。这很危险,安全报告里一个捏造的漏洞足以毁掉整次评估的可信度。
解决方案是把“生成总结”和“调用工具”彻底分开。总结时给LLM的事件信息是预先校验过的,且明确告诉它:不要补充事件列表之外的信息,每一个结论必须引用事件ID。实践下来,这个约束有效把“结论都在事件列表里”这句话写进了提示词。只要不让模型自由发挥,幻觉率就能控制在可接受范围。对于安全报告类输出,甚至可以不用LLM生成最终结论,直接用模板渲染事件,LLM只负责解释和补救建议。
6.4 权限最小化:框架进程的沙箱与账户隔离
这个框架本质上会执行很多敏感命令,它自身的安全必须足够严。我的部署方式是用一个独立低权限账户运行框架,不用root;只给框架账户授予特定工具路径的执行权;输出文件写入固定目录,禁止跨目录写。如果某些扫描确实需要root权限,通过sudoers里指定的白名单命令来执行,而不是让它随意拿到一个shell。
还有一条容易被忽略:框架的管理接口只绑本地回环地址,不要把Web界面暴露到外网。实际测试中我用Docker加只读根文件系统,再配合seccomp限制系统调用。后续考虑叠加一个网络层黑名单,确保即使执行器被攻破,也不能访问内网其他资产。安全工具本身如果被打穿,那就成了攻击者的跳板,这个底线必须守住。
如果让我重新做一遍,我会把工具注册表和授权校验从第一天就写进代码,而不是先跑通demo再补。安全工具加AI的这条路,最大的风险从来不是模型不够聪明,而是边界不够清晰。框架的每一层约束都是在回答一个问题:LLM可以做什么,绝不做什么。把这个答案写死在代码里,剩下的才是效率问题。