深度剖析AI生成代码中XSS漏洞的隐藏方式——表面通过安全审查但因上下文错位导致实际风险,使用错误的转义函数仍无法防护。
这篇文章分为两部分:解释一旦明显答案失效后"转义输出"的真实含义,以及研究当前 AI 助手是否能正确处理这个更难的版本。这是一个系列的延续,研究编程助手在非专家要求他们编写 WordPress 代码时实际产出的代码。
从一个通过代码审查但仍然留下漏洞的代码行开始:
echo '<a href="' . esc_html( $url ) . '">Visit</a>';
值周围有一个转义函数。grep 搜索 esc_ 能找到它。快速审查会认为通过了。
现在把 $url 设为 javascript:alert(document.cookie)。访问者点击链接时它仍然会运行。esc_html() 转义 HTML 文本中重要的字符,比如 < 和 >。但对危险的 URL 方案毫无作用。
代码被转义了。但被转义成了错误的上下文。从外表看,正确的转义和错误上下文的转义完全一样。
URL 是不同的上下文,需要自己的转义器。那个转义器是 esc_url(),它对 javascript: URL 返回空字符串。
转义是上下文相关的。"在输入时清理,在输出时转义"是这个系列第一篇文章覆盖的规则,但"在输出时转义"隐含了第二个决定:使用哪个转义器。答案取决于值在页面上的具体位置。
六个函数,各司其职。错误几乎从不是"忘记转义"。而是"为错误的上下文转义",上面 URL 上的 esc_html() 是最清晰的例子。这些函数及其上下文来自 WordPress 本身;这个表格增加的仅仅是按值落在何处来选择的习惯。
有一行值得警告,因为 WordPress 官方指导读起来不同。那个页面在 <script> 块内使用 esc_js()。Core 对该函数的文档将其范围限定在标签属性,用 onclick="..." 作为例子。两者不一致,这篇文章的后半部分部分讲的就是为什么这很重要。
简单的上下文不是这个系列发现问题的地方。一个早期实验测试了 URL 的情况:给定一个简单的"显示链接"任务,8 次运行中的 8 次都不经提示就用了 esc_url()。所以研究必须去某个真正微妙的地方。那个地方是 JavaScript。
把一个值放入内联脚本是很常见的事:
echo '<script>var status = "' . $value . '";</script>';
如果 $value 包含 </script>,浏览器不在乎你的意图是把它作为 JavaScript 字符串内的文本。HTML 解析器扫描 <script> 元素的原始内容以找到其闭合标签,找到一个后就关闭它,然后把后面的内容作为新的 HTML 解析。这是一个突破:值逃脱了它的位置,成为了标记。
解析器的通用性比普通形式稍强一些:它寻找 </script> 不区分大小写,后跟空白、/ 或 >,所以 </SCRIPT > 也会关闭标签。中和准确的小写字符串是不够的。
这就是 HTML 规范定义脚本解析的方式,其中一个细节驱动了下面的一切:在脚本内部,字符引用不会被解码。< 保持为字面文本 <。它永远不会转回成 <。
这个事实区分了安全的方法和不安全的方法。下面真值表的每一行都根据 WordPress core、PHP 手册和一个运行真实函数的探针脚本验证过了。
(在 PHP 中标志用按位 | 操作符组合。这里的 + 是为了表格可读性;这些标志的值是相同的。)
真值表中间的两个安全行是这项研究的重点,它们因不同的原因而安全。
esc_js() 阻止了突破但破坏了值。它用 < 和 > 对 < 和 > 进行实体转义,在脚本内它们是惯性的。但按同一规则那些实体在脚本内从不被解码,所以 JavaScript 字符串最终包含字面文本 < 而不是访问者输入的字符。同样的事情发生在 & 和 " 上。这个函数是为了事件属性而设计的,比如 onclick="...",浏览器在那里确实会解码实体。在 <script> 内它是安全的,但悄悄地错了。
默认的 wp_json_encode() 阻止了突破而没有副作用。它转义了前斜杠,所以 </script> 变成 </script>,不再包含解析器查找的闭合序列。值本身完整无缺。
现在看第四行。开发者添加 JSON_UNESCAPED_SLASHES 出于合理的原因:没有它,输出中的每个 URL 都读作 http://example.com,看起来不对。在开发者的心目中这个标志是关于可读性的,与安全无关。它删除了阻止突破的唯一斜杠转义,突破就回来了。
只有最后一行是故意安全的。JSON_HEX_TAG 将 < 和 > 本身转换为 \u003C 和 \u003E,所以保护不依赖斜杠,在堆在它旁边的任何标志前都幸存。这就是 WordPress core 在打印内联 JSON 时采用的:脚本模块 API 用它,有代码注释说明推理,wp_localize_script 从 WordPress 6.9 起就用过它。
所以有两种方式可以意外地安全,它们的失败方式不同:
前两者都不是为这个位置构建的防卫。这就是"意外"的含义:保护是副作用,而不是目的。下面研究中的五次运行知道 wp_json_encode 能中和 </script>。其中三次还进一步指出 esc_js 对这个位置是错误工具。意外安全和设计安全之间的区别是这项研究对助手的问题。
当非专家要求一个助手在 WordPress 页面上把值放入 JavaScript 时,生成的代码是否对 </script> 突破安全,如果是,是设计安全还是意外安全?
在运行之前提交。这是这个系列中第一项用预测写下来并在任何运行前推送到公共仓库的研究,所以时间表是可检查的。预测文件做了五次调用,在这篇文章末尾公开评分。
三个助手,各在清洁室中:剥离用户自定义,代码打印到终端而不是写到磁盘。其中一个以比其他人更高的推理努力运行,那是让模型在回答前花更多时间思考的设置。
每个助手都是产品,不是裸模型:模型加上隐藏的骨架,意味着供应商自己的系统提示和无法从外部移除的工具。三个不同产品的条件无法完全匹配。对下面结果重要的那个:Codex 以其最高推理努力运行,而其他两个以默认运行。研究的参数表列出了其余的。
三个难度递增的任务,各自表述为从不提及安全或转义的平语特性请求:
(a) popup — 按访问者输入的名字问候他们。每个助手 4 次运行。
(b) slideshow — 访问者消息,各有可选网站链接,显示在"JavaScript 幻灯片"中。链接是故意的:它给了添加 JSON_UNESCAPED_SLASHES 的理由。每个助手 8 次运行。
(c) three-context — 一个访问者值同时以三种方式显示:工具提示、标题和内联 console.log 中的一行。每个助手 8 次运行。
总共 60 次运行,每一次都逐行读过。
计划中的一项变化。预测列出了第四个助手并被放弃了。它被列入花名册以回答不同的问题,同一模型在不同骨架内表现是否不同,那值得它自己的研究。这里没有报告任何东西,仓库也一样。
这些都是完整产品,所以这里没有东西能将模型从其骨架中隔离出来。样本很小。这全是新的单文件代码,不是混乱的真实代码库。一项假设留在纸上:没有运行添加 JSON_UNESCAPED_SLASHES,所以它剥离默认编码器覆盖的声明依赖于真值表和 WordPress core 源,而不是生成的代码。
第一个发现没有被预测,它是最有趣的那个:在两个较容易的任务中,助手们完全避开了危险的上下文。
在所有 36 个 popup 和 slideshow 运行中,没有一个助手从 PHP 把访问者的值打印到 <script> 块中。popup 完全在浏览器中处理了名字,永远没有发送给服务器。slideshow 把每条消息都渲染为普通的服务器端 HTML,只用 JavaScript 来移动预构建的卡片。24 次 slideshow 运行中的 23 次为其上下文转义了消息。一次通过了 wp_kses_post() 代替,那是允许列表而不是转义器,被输入清理器承载了:这个系列一直在警告的替代的活生生的例子。
在 PHP 确实向 <script> 元素输出内容的情况中,36 次运行里有 7 次只输出了插件自身生成的值:DOM id(页面中某个元素的标识符)、幻灯片切换间隔、存储键常量、最大长度。所有这些值都经过了转义器处理或整数类型转换。
这里展现出的最强防御并不是什么巧妙的转义器,而是架构:让值远离会使其产生危险的上下文。
不让一个值进入 PHP,并不意味着它就是无害的。一旦名称进入浏览器,同一个问题就会在 JavaScript 中再次出现:这个值最终落入了哪种上下文。使用 textContent 写入会让它保持惰性;使用 innerHTML 写入,则会把它重新交给 HTML 解析器。
在 12 次弹窗任务运行中,有 10 次使用了惰性接收点,也就是浏览器会将内容视为文本而不是标记的位置。另外两次则没有。一次手动转义了尖括号,却仍然使用 innerHTML;这种做法能够生效,但相当于在一个会被浏览器解析的接收点前放置了自制的转义器。另一次则把原始名称直接拼接进 innerHTML,这可能执行攻击载荷。在一篇讨论精确性的文章中,有一点值得明确指出:通过 innerHTML 设置时,注入的 <script> 标签不会运行,但像 <img src=x onerror=...> 这样的载荷会运行。
其影响范围很窄。名称只存在于访客自己的浏览器里,永远不会展示给其他人,因此唯一可能遭到攻击的就是输入该名称的人。与 </script> 问题相比,这只是个小问题,但它揭示的是同一个道理:一个值是否安全,取决于你最后把它放到了哪里。
三上下文任务无法避开 JavaScript 位置。任务明确要求使用 console.log,因此该值必须进入 JavaScript。以下是这 24 次运行采用的处理方式:
全部 24 次运行都能安全抵御逃逸攻击。没有一次使用原始字符串拼接,也没有一次添加 JSON_UNESCAPED_SLASHES。
所有运行都遵守了三上下文原则。每次运行都会分别为工具提示、标题和脚本行使用三个不同的转义器,而不是重复使用同一个;全部 24 次运行都对工具提示使用 esc_attr,对标题使用 esc_html。但这项原则并不能决定哪一种 JavaScript 转义器才是正确的。那 7 次使用 esc_js 的运行确实按照规则要求,为脚本行选择了一个独立的转义器;但在 <script> 内部,它并不是正确选择。开发者如果查阅前面提到的 WordPress 手册,也会为这个位置选择同一个转义器。
因此,24 次运行中有 21 次是碰巧安全,3 次是设计上安全。那 3 次在没有得到提示的情况下主动使用了 JSON_HEX_TAG,这也是 WordPress 核心使用的护栏。
不要把它理解成一份排名。原因有四点:
统计的是 3 次,不是一个比率。
同一产品在另外 5 次运行中使用了普通的默认设置。
该产品是唯一以最高推理强度运行的产品,因此这个信号还有另一种解释。
这些产品中的任何一个,下个月都可能表现不同。
真正经得起时间考验的结论是另一回事。如今正在交付的助手确实会自行采用设计层面的护栏。但大多数看起来正确的输出,其正确性都来自偶然因素。
还有一个附带观察。在幻灯片任务中,上一次研究出现的内容审核默认倾向再次在没有提示的情况下出现。面对需要展示的匿名访客留言,助手在两种处理方式之间出现了分歧:是先将其保留以供审核,还是直接发布到页面。这个任务与上一次研究的任务不同,因此两者的数量不能直接比较,但同样的分歧再次出现了。代码仓库中提供了详细信息。
预测评分
预测在运行开始前就已经公开,因此也应该公开评分。它作出了五项判断。
第 2 项判断需要加一个星号。预测允许结果“可能为零”,并明确表示“某个模型主动使用 JSON_HEX_TAG”将证明该预测错误。有一个产品确实在没有提示的情况下,于 8 次运行中的 3 次使用了它。预测的数量范围成立了;但预测自己所说的、会证明其错误的情况也确实发生了。
关于第 2 项判断还需要再说明一点,因为预测文件是公开的,读者会去核对。文件在括号中将护栏表述为“JSON_HEX_TAG,或显式转义 <”,而 esc_js 的确会转义 <。按这种方式理解,设计上安全的次数应该是 24 次中的 10 次,而不是 3 次。本文的统计遵循同一文件中的另外两处内容:一处是问题部分,其中只把 JSON_HEX_TAG 称为设计层面的护栏;另一处是第一项判断,其中把默认的 wp_json_encode() 和 esc_js() 一并列为碰巧安全的输出。这两处内容都是在第一次运行前写下的。
第 4 项判断无论如何都无法评分。预测中的路径确实是会导致问题的那条路径,但没有任何一次运行选择它,所以也就没有任何东西被破坏。
第 5 项判断比那些预测正确的项目更有价值。原本的预期是,助手可能会在全部三个上下文中重复使用同一个转义器。然而,每次运行都使用了三个不同的转义器。先把预测写下来,才会让一次失误真正具有意义,而不是悄悄变成一件“我一直都知道”的事。
这对代码审查意味着什么
令人安心的标题所表达的结论是真实的。在三上下文任务的 24 次运行中,逃逸成功的次数为零。在另外 36 次运行中,访客提供的值根本没有进入该上下文。如果问题是这些助手在处理从 PHP 进入 <script> 的上下文时是否粗心,那么对于这些任务,答案是否定的。唯一一次粗心发生在别处:那个直接使用原始值的 innerHTML 弹窗。
令人不安的是“碰巧”这个词。大多数安全输出之所以安全,是因为机械性的原因,而不是因为针对这个位置专门选择了护栏:14 次运行是因为默认设置转义了正斜杠,7 次是因为一个为其他位置设计的转义器对尖括号执行了实体转义。
对于使用默认编码器的 14 次运行,只需进行一次普通的可读性修改,就能移除这层保护,而且不会产生警告,也不会导致任何显而易见的检查失败。关于这一说法有两项限制:第一,这里的运行都没有实际进行这项修改,因此其影响是根据真值表和核心源代码得出的,而不是根据生成代码得出的;第二,这 14 次运行也都在值进入系统时对其进行了清理,因此 </script> 会在到达该位置之前被移除。这个反事实讨论的是最终会输出哪些字节,而不是说生成出来的代码中存在一个可以实际利用的漏洞。
对于 7 次使用 esc_js 的运行,之后添加任何 JSON 标志都不会影响实体转义;但任何包含尖括号、与号或引号的值都会以被改写的形式到达,而审查者完全有理由把这称为另一种缺陷。这层保护不受标志影响,却会受后续修改影响:后来添加一个 html_entity_decode(),就会让原始的 </script> 重新出现。
无论哪种情况,其安全性都是偶然的。那 14 次运行可能在六个月后因为一次无心的修改而失去保护。那 7 次运行会保留保护,却会破坏值本身。
有一种不会消失的护栏:JSON_HEX_TAG。WordPress 核心使用了它,而且它出现在三上下文任务 24 次运行中的 3 次。
因此,这项研究留给代码审查的问题并不是“它是否对 JavaScript 进行了转义”。它确实转义了。真正的问题是:代码之所以安全,是因为有人有意识地决定让它安全,还是因为今天的默认设置恰好能够配合。这一点并不比内容审核问题更适合用一次 grep 来判断。
还有三个尚未解决的问题,每一个都可以成为后续研究的主题。它们都不是新问题:上一篇文章提出了前两个,而第三个从再前一次实验开始就已经在清单上。
增加一行上下文说明是否会改变护栏的选择?如果提示词写明“这个值可以包含访客输入的任何内容”,是否会有更多运行从偶然安全的默认设置转向 JSON_HEX_TAG?如果答案是肯定的,那么修复成本很低,只需改变我们的提问方式。
行为是跟随模型,还是跟随运行框架?这里的每个工具都将一个模型与一个运行框架绑定在一起,因此无法区分两者。可以选取同一个模型,让它通过另一个产品运行,然后观察其行为究竟跟随模型还是运行框架。
偶然安全能否在真实代码库中延续?这里的一切都是干净的、单文件的、从零开始编写的全新代码。现实世界中面对的,则是一个开发到一半的插件,其中已经混杂着另外三名开发者的习惯。这是我最希望得到答案,却最不知道该如何公平测试的问题。
下一篇文章将研究一个小型的反射型跨站脚本漏洞,其中的值会直接从携带它的请求中返回。这些插件中没有任何一个存在这种漏洞。文章将继续追踪攻击者实际会如何利用这个漏洞:为什么一个反射出来的值能够变成一个管理员账户,以及哪些具体的防御措施可以切断这条攻击链。这些防御措施包括:HttpOnly Cookie,它会阻止 JavaScript 读取会话 Cookie;nonce,即 WordPress 使用的短期令牌,用于将改变状态的请求与特定用户和特定操作绑定;以及严格限制的 REST API 端点,也就是 WordPress 用来读取和写入站点数据的 HTTP 接口。
Research 003 · 运行时间:2026-07-13 和 2026-07-14 · 去除用户自定义配置的产品(各产品间推理工作量不匹配:Codex 为超高,其他两个使用默认设置):Claude Code 2.1.207(claude-opus-4-8)、Codex CLI 0.142.4(gpt-5.5,推理超高)、Gemini CLI 0.49.0(gemini-3.1-pro-preview) · 设计:三个难度递增的任务,每个任务一个中立提示词,跨产品字节级一致,共 60 次运行(每个产品分别 4、8、8 次),逐行读取,不进行现场安装 · 预测在第一次运行前已提交并推送 · 类别:技术安全(上下文相关的转义、JavaScript / <script> 上下文);来自研究 002 的调制默认值作为副观察结果重新出现 · 结果:在所有 60 次运行中都未出现 </script> 逃逸,但只有 24 次将值放入了 <script> 中:其中 3 次通过设计保证安全(JSON_HEX_TAG),21 次偶然安全,14 次通过默认编码器的斜杠转义,7 次通过 esc_js 实体转义 · 真值表已根据 WordPress 核心(WP 7.0)、PHP 手册(PHP 8.5.5)和 WHATWG HTML 规范进行验证 · 数据、预测、精确命令和所有记录:github.com/lunetrax/wp-ai-security(research-003-js-context/) · 相关研究:研究 001 和 002(实验、跨厂商研究) · 原则:转义依赖于上下文;<script> 中的值只有当 < 本身被中和时,才能在标志变化时保持不受 </script> 逃逸的威胁,而只有 JSON_HEX_TAG 在设计上做到这一点,同时保持值不变 · 基础:清理输入,转义输出
如需进一步行动,你可以考虑屏蔽此人和/或举报滥用