深度解析塑造未来的技术文章。

AI安全扫描器正在唤醒我们的技术债

AI安全扫描器正把潜伏多年的bug变成严重漏洞。本文分享如何给漏洞积压做分诊,避免被海量发现淹没。

煤矿隧道中的一只鸟笼,身后岩壁上的裂纹正泛着微光向四周蔓延
PHP2Shell是预警信号:我们多年来忽视的崩溃型bug,正在被证明是结构性裂缝。

2024年,PHP项目的问题追踪器里出现了一份bug报告。它描述的是内存耗尽(out-of-memory)的情况,这类问题会让进程直接崩掉,确实烦人,但按照bug圈子的传统排序,基本属于无关痛痒。报告就这么躺着。后来,在OpenAI资助的一次PHP独立安全审计中,安全公司Calif的一位研究员借助AI安全扫描工具和自己深厚的功底,花了大约两周时间,把这个崩溃问题改造成了远程代码执行。运行存在漏洞版本PHP的服务器,可能被诱导执行攻击者的代码,或者交出敏感数据。受影响的范围包括NASA、维基百科和白宫的网站,因为WordPress和Drupal都跑在PHP上,而PHP支撑着互联网上惊人比例的站点。

好在补丁在恶意攻击者动手之前就发布了,故事有了圆满的结局。但参与这件事的研究人员一直在重复同一个观点:这只是开始,远没有结束。四十年来人类编写的代码里埋着海量的bug,我们集体把它们归类为「会崩溃,但不修」。现在AI工具把找出哪些崩溃其实是上了膛的武器这件事的成本大幅压低了。过去几周,我和一些负责真实系统安全的人聊了很多,讨论这一切在实践中究竟意味着什么。下面的内容不是调研报告,而是我已经亲眼看到团队犯过的几个错误,以及那些做对了的人是怎么做的。

错误一:把崩溃型bug当成「烦人但无害」

PHP这个漏洞本身并没有被藏起来。它被报告了,也被确认了,然后因为「只是」导致内存耗尽错误而被降低了优先级。这是成熟软件组织中最常见的分诊失误,而AI辅助的漏洞利用开发让这个失误的代价变得极其高昂。哥伦比亚大学教授Suman Jana长期从事这类研究,在披露前后的采访中把其中的动态说得很直白:任何大型项目在任何时候都堆着一堆没人认真对待的崩溃报告,因为要把崩溃转化为漏洞利用,需要一长串枯燥、高度特定的推理链条。人类不愿意做这种工作,而模型不会觉得无聊。

从机制上讲,这种区别为什么重要,原因如下。一个用不受控数据破坏堆(heap)的内存bug,通常只会让进程直接终止:分配器发现不一致,于是中止程序,或者进程解引用了垃圾数据而段错误退出。但同一个bug,如果喂入的是经过精心设计大小和结构的输入,就会变成一个写入原语(write primitive):

void handle_request(const char *input, size_t len) {
char *buf = malloc(64);
memcpy(buf, input, len);   /* len never validated */
/* len = 65:            heap metadata trashed -> likely abort, filed as 'crash' */
/* len = attacker-tuned: adjacent object's function pointer overwritten */
process(buf);             /* calls through the corrupted pointer -> their code runs */
free(buf);
}

决定结果的是输入,而不是bug本身。搞清楚要覆盖哪些比特、以什么顺序覆盖,才能让执行流落到指定地址,这件事虽然繁琐,却是机械性的,而「繁琐但机械」恰恰是语言模型最擅长协助的领域。解决这个错误,首先要转变思路,然后才是技术层面的动作:别再把「只是会崩溃」当作内存不安全代码的严重性指标。C或C++中的崩溃(包括PHP这类解释器的C核心部分中的崩溃),就是一个尚未被开发出来的漏洞利用。新的分诊问题不应该是「它会不会崩溃」,而应该是「这次错误的破坏是否受攻击者影响」。如果不可信输入控制了错误写入的大小、偏移或内容,就应该假定它可被利用,并相应地排定优先级,哪怕还没有人演示出可用的利用代码。恰恰因为如此,因为如今演示一个可用的利用只需要几周,而不再是几年。

错误二:把扫描器的输出当成待办清单

一旦团队真的在包含二十年历史代码的系统上跑起现代AI辅助扫描器,第二种失败模式就出现了。工具返回几百条发现,仪表盘一片红,团队照着待办清单的习惯,按严重性评分排序,从最高分开始处理。六个月后,他们修了四十个问题,但数字反而涨了,士气也跌到谷底。我接触过的、处理得比较好的工程师都说了类似的话:AI扫描器的输出不是待办清单,而是原始情报,在提交任何一张工单之前,都需要经过分析师的分诊。

在这种情况下,有效的分诊应该问的是利用路径相关的问题,而不是严重性评分相关的问题:

  • 脆弱代码能否被不可信输入触达?只处理本地配置文件的函数里的堆溢出,和请求处理路径里的同样溢出,完全是两码事。
  • 它现在是否暴露在互联网上?问的不是「应不应该暴露」,而是「实际上是不是」。去查负载均衡器配置和DNS,别只看架构图。
  • 输入是否控制了破坏过程?即错误访问的大小、偏移或内容。这就是上一节讲的崩溃与利用之间的分界线。
  • 利用成功能带来什么?沙箱工作进程中的崩溃,如果会自动重启,和持有数据库凭证的进程中的崩溃,影响完全不同。
  • 是否已有野外利用或公开的分析文章?一旦满足,无论评分多少,都应直接排到队列最前面。

最后一点值得特别强调。CVSS之类的评分体系,是为人类以人类速度发现bug的世界设计的,它们系统性地高估理论上的严重性,而低估可达性。当扫描器能以工业规模生成理论严重性时,你就会被淹没。应对得最好的团队已经颠倒了流程:先看可达性和暴露面,再看严重性;对于构造上就不可达的问题,记录下来然后关闭,而不是去修。因为你发布的每一个修复本身都带有回归风险,而这份风险预算应该留给真正重要的发现。在防御侧,AI代码理解检查也在这里派上用场:同一套能找到bug的工具,往往能在几分钟内回答「列出从网络边界到这个函数的所有调用路径」这样的问题,而过去这需要一位资深工程师花一整天时间处理一个发现。

一个工人在传送带上分拣发光的碎片,只把最亮的几块送往金库
分诊意味着大多数发现会被放进旁边的收纳筐,只有可达且暴露在互联网上的少数会得到金库级别的保护。

错误三:以为打补丁的速度能跟上发现的速度

这里有一个不对称性,应该印成海报贴在每个安全团队的办公室里。找到PHP漏洞的利用路径,一位熟练的研究员借助AI大约花了两周。而开发补丁,四名工程师花了两个月。发现问题是一个搜索问题,你只需要找到一条通路。修复则是证明型问题,你需要关闭每一条路径,还要确保数十亿没有遭受攻击的安装实例继续正常运行。防御者的任务严格来说比攻击者难得多,而AI让攻击者那一侧的效率提升,远远快过防御者这一侧。

无视这种不对称性的团队,通常会下两种糟糕的赌注之一。要么为了赶上披露的压力而仓促发布补丁,结果带来回归问题。PHP补丁涉及的解释器内部代码,被大约四分之三公开其技术栈的网站所使用,所以一个糟糕修复的影响范围大到堪称文明级别。要么干脆陷入绝望,什么都不做,一直等待一个完美的修复。处理这段窗口期做得好的实践者,做了一件没那么英雄主义、却更有效的事:把「立即缓解」和「彻底修复」分开。缓解措施便宜、快速且可逆,比如在反向代理上限制受影响的端点,部署一条拦截该利用输入形态的WAF规则,把有漏洞的代码路径放到功能开关后面,在真正的补丁成熟之前,尽量缩小能够访问该服务的人群。我之前写过打补丁还是先缓解这一决策是真正的权衡,而不是道德上的失败,这次事件则是把这一课放大到了全球规模。

没人愿意听到的推论是:你的平均缓解时间(mean-time-to-mitigate)现在成了最重要的指标,因为从「白帽子发现bug」到「更坏的人发现bug」之间的窗口正在缩短。在PHP的案例中,窗口之所以一直保持开放,只是因为研究人员负责任,而基金会行动迅速。同一时期,Brotli压缩库的故事就没那么好看了:一个类似的bug在代码里潜伏了八年,在git中修复后又过了八个月才发布,结果至少被四位研究人员独立重新发现。当发现的成本低廉且可以并行时,「总有人迟早会找到」就不再是一句令人安心的话,而变成了一条排期上的必然。

错误四:因为是LLM写的修复,就直接相信它

针对以上所有观点,乐观的反方论点是:好吧,AI今天找bug的速度超过了我们修复的速度,但AI也能帮忙修。Anthropic在十月发布的工具正是沿着这个思路:由一个LLM生成补丁,再由第二个模型生成一份可机器检查的证明,证明该补丁保留了预期行为,最后由一个小而笨、但值得信赖的证明检查器来验证这份证明。检查器是唯一必须正确的组件,而它完全可以做到正确,因为它几乎什么都不做。Jana对这个方向非常热情,我也能理解原因:它是让修复几乎和发现一样可以自动化的第一条可信路径。

但我已经看到两个团队采用了「AI生成安全补丁」的工作流,两者犯的是同一个错误:他们把模型的补丁当成了解决方案,而不是候选项。其中一次,补丁通过加入长度检查关闭了被报告的堆溢出,却把溢出转移到了六行之外的第二个分配路径上,而模型根本没有考虑到那里。这个修复顺利通过了审查,因为它看起来显然正确,而这恰恰就是失败模式:LLM生成的补丁在表面上比人类写的更流畅,而这种流畅会压制审查的力度。那些从生成补丁中获得价值的团队,按以下顺序增加了三道关卡:

  1. 包含利用输入的回归测试套件。触发该发现的概念验证(PoC)应当成为永久测试用例。如果扫描器给出了一个会导致崩溃的输入,那它就是你的第一个新测试用例,不花一分钱。
  2. 变体排查。合并之前,要有人(或某个系统)回答:同样的模式还出现在哪里?只修复系统性模式中的某一个实例,正是导致后续CVE「续集」的原因。
  3. 独立验证不变量,而不是验证diff。明确说明现在必须成立的条件,比如「从请求处理到memcpy之间,不存在未经校验长度的路径」,然后用测试、静态分析或形式化工具去检查这个条件。只审查diff,等于是在重读模型的作业。

随着形式化验证逐渐成熟,它会在这里发挥作用,但要注意让它值得信赖的分工方式:创造性的部分允许产生幻觉,因为最终的仲裁者是机械化的、体量很小的程序。无论你用什么工具,都值得借鉴这一设计原则:LLM产生的任何东西,无论是发现、补丁还是证明,最终都应该落到一个不与它共享失败模式的环节上。如果你的验证步骤只是另一个提示词,那你其实什么都没有验证。

一枚机械印章和一块黄铜放大镜放在卷起的蓝图旁边,对比创造与验证
让模型负责生成,让一个小而笨、却值得信赖的东西做最后的检查。

错误五:以为别人已经审计过代码

这里每一个技术层面的教训之下,都藏着一个经济层面的教训。PHP的基础设施地位堪比公共事业,它支撑着WordPress、Drupal,以及互联网上一长串网站,其中包括航天机构和政府核心网站。而它的安全性依赖于PHP基金会这个规模不大的资金支持团体,外加志愿者。开源的舒适神话一直是「眼睛足够多,所有bug都会变浅」。但过去三十年实际发生的事情是,人人都假定那些眼睛属于别人。现在,AI正在不加区分地向攻击者和防御者同时提供这些眼睛,这意味着这个神话终于要成真了,只是是以对抗的方式,而且有了截止日期。

PHP2Shell的披露之所以能成功,是因为一种不寻常的安排:OpenAI资助了外部审计,一家资源充足的公司完成了工作,Wiz则帮忙确认并扩展了发现。这属于慈善行为,而不是一套体系,而且它无法扩展到你依赖树中成千上万个关键但没有资金的项目。如果你的组织生产环境依赖某个开源解释器、框架或库(几乎每个组织都是如此),那么真正重要的修复都是些枯燥的事:资助基金会,赞助审计,投入工程师的工时,而当你自己运行扫描器、在依赖项中发现问题时,应该上报给上游,而不是只给自己的分支打补丁然后翻篇。另一种选择,就是约翰霍普金斯大学Matthew Green预言的那个世界:除非一场大到足以惊扰普通人的事故出现,否则什么结构性的改变都不会发生。他在此期间给出的建议既不华丽,却完全正确:当更新提示弹出时,现在就安装,而不是拖到下周。在上一轮重大PHP披露中,我们也以惨痛的方式学到了同样的教训,当时对于那些无法足够快打补丁的团队来说,直接拔掉电源胜过精巧的修复。

眼睛来了,只是它们并不挑边站。

AI发现漏洞之后的周一早晨行动清单

以上所有内容,可以压缩成一份简短的清单。如果我本季度要负责一个有遗留代码在生产环境中运行的工程组织,我真会去做这些事。而如果你正在读这篇文章,那你的生产环境里就有遗留代码。

  • 积极打补丁,并衡量需要多长时间。你的更新延迟现在是一项安全控制,而它的安全阈值正在不断收紧。在真正需要之前,先搞清楚你的这个数字。
  • 用利用可能性而非严重性评分,重新分诊现有的崩溃型bug积压。问的是:不可信输入是否控制了破坏过程?凡是内存不安全且可达的,都应提升优先级。
  • 在你最老的代码上,自己运行AI辅助扫描。重新发现漏洞的边际成本now接近于零,这意味着你应当假定攻击面中任何可以被找到的东西都会被找到。所以,先由你来找到它。
  • 在事件响应流程中,把缓解与修复分开。提前准备好剧本:代理规则、功能开关、访问限制。争取时间是一种正当策略,假装自己不需要时间则不是。
  • 生成的补丁必须通过利用输入测试和变体排查才能合并,而不是看diff是否干净。
  • 把钱或工时投入到你所依赖的上游项目中。经过赞助的审计发现了这个漏洞。这并非巧合,而是模型在发挥作用,而且它应当不再是一种例外。

关于这个时代究竟是在「偿还技术债」,还是在把我们永远绑在一台与功能无关、只管修复的跑步机上,业界有一场值得探讨的争论。大概两者兼有:每个非平凡的补丁都可能带来下一个微妙bug的风险,而且没有哪个系统是真正完工的。但比起那种因为还没被吞没,就继续假装流沙是基岩的世界,我更愿意生活在一个由审计人员和白帽子来催收技术债的世界里。PHP2Shell最终以一个补丁和一声如释重负的叹息收场。下一个漏洞,此刻可能已经躺在某人的崩溃队列里,被标记为无害。去看看你自己的吧。