← 返回博客
语言: English 中文
Encoding 2026-06-06 6 分钟

JSON 转义不只是反斜杠:Unicode、代理项与嵌入故障

JSON 的字符串转义规则从 2017 年起就被冻结了(RFC 8259、ECMA-404 第二版)。Crockford 当年故意把字符串语法定得比 JavaScript 还窄,这个选择带来的副作用,最常出现在跨系统数据传递时——双方对“字符串”的理解只差那么一点点。

JSONJSON 转义RFC 8259Surrogate PairUTF-8

转义表

JSON 字符串只允许少数几个转义序列,RFC 8259 §7 完整列出来了:

转义 含义
\" 双引号
\\ 反斜杠
\/ 正斜杠(可选,下面解释)
\b 退格,U+0008
\f 进纸符,U+000C
\n 换行,U+000A
\r 回车,U+000D
\t 水平制表,U+0009
\uXXXX BMP 内任意 code point

不在这张表里的转义都是非法的。\a\v\x41——严格解析器一律拒绝。JavaScript 字符串字面量支持这些,JSON 故意不支持。

另一条规范严格的地方是:U+0000U+001F 的控制字符在字符串里必须转义。哪怕是字面的换行字节出现在字符串中间也是解析错误(虽然在编辑器里看不见)。这是 JSON 比人想得更挑剔的少数几处之一。

为什么 \/ 存在

正斜杠转义是允许的、但永远不必需。"https://example.com""https:\/\/example.com" 是等价的 JSON。

它存在的原因是:HTML 允许 </ 在字符串字面量里结束 <script> 标签。如果你在服务端渲染时把 JSON 直接嵌入 <script> 块、又没做转义,那么攻击者控制的字符串里夹一个 </script> 就能跳出脚本上下文。把 / 转义成 \/ 的编码器一举堵死这一类注入。PHP 的 json_encode 默认这么做,许多注重 CSP 的库也这么做。

如果你在输出里看到 \/ 觉得奇怪,原因就在这里。

非 ASCII 与 \uXXXX

\uXXXX 让你用四个十六进制位写出 BMP 内任意 code point。"é"é。多数 JSON 编码器对非 ASCII 字符默认就是这种防御性写法——直接嵌 UTF-8 也合法,但 \uXXXX 经得起任何会破坏非 ASCII 字节的传输通道。

对于 BMP 之外的 code point(任何 U+FFFF 之上的字符——大部分 emoji、所有补充平面),JSON 没有原生转义。它借用 UTF-16 的 surrogate pair 约定:"😀"U+1F600)写成两个 \uXXXX,合在一起编码这个补充平面 code point。对 UTF-16 原生语言(JS、Java)这没毛病;对其他语言则是个小麻烦——必须识别这一对、再合成。

Lone surrogate 陷阱

这一段是真正的乐子。

JSON 允许 \uD800 单独出现。

U+D800 是 surrogate code point——本身在 well-formed Unicode 里非法、只能作为 surrogate pair 的一半。但 RFC 8259 没有强制 well-formed Unicode。{"x": "\uD800"} 这样的文档在大多数库里都能正常解析。

问题在后面:这个字符串无法被序列化成 UTF-8。UTF-8 没有 lone surrogate 的表达方式。一次 JSON → 字符串 → UTF-8 → 字符串 → JSON 的往返,要么报错、要么悄悄替换成 U+FFFD(替换字符)——具体是哪种取决于库。

这就是最离奇的 JSON bug 来源。一个文档解析正常、被持久化、几跳之后数据已经变了。特征是文本里某个该有意义字符的地方出现了 lone U+FFFD——通常是某个 emoji 在传输中丢了一半 surrogate。

如果生产端在你手里,永远不要发 lone surrogate。如果消费端在你手里,显式决定是拒绝还是替换,不要让库的默认行为决定。

U+2028 / U+2029 怪事

直到 2019 年,JSON 都不是 JavaScript 的子集

原因是:JSON 允许 U+2028(行分隔符)和 U+2029(段落分隔符)在字符串里不转义。JavaScript 字符串字面量不允许——这两个字符在 JS 字符串语法里非法。所以一个含有这两个字符的 JSON 文档,对 JSON.parse 是合法的,对 eval 不合法。

这事在 JSONP 时代很要命——你给浏览器发一段 JSON-like payload 包在 callback(...) 里、让浏览器当 JS 执行。JSON 文档里夹了一个 U+2028JSON.parse 收下没问题,浏览器当 JS 跑就解析失败。生产事故由此而来。

ES2019 修了这件事,允许 U+2028U+2029 出现在 JS 字符串字面量里。但旧引擎在长尾环境里还存在(嵌入式 webview、博物馆级浏览器),所以面向直接 JS 消费的 JSON 输出,仍然防御性地把这两个 code point 转义成

常见踩坑

  • 手工拼 JSON。用户控制的字符串里出现 "\ 就把整个文档破坏掉。这同时是 JSON 注入向量,类似 SQL 注入。用序列化器
  • 依赖跨工具的 surrogate 一致性。Python 的 json.dumps(ensure_ascii=False) 输出 UTF-8 字面字符,json.loads 又能愉快接受含 \uD800 的文档。两个工具单看都没错,组合起来不兼容。
  • 忘了控制字符必须转义\n 在 JSON 里是字面量的反斜杠 + n;真正的换行字节是解析错误。
  • \/ 当错误。它是可选合法形式。在输入里把它当"格式不规范"剥掉,会吃掉真实数据。
  • 以为 JSON.parse 后再序列化等于原字节。不等于,两个方向都不等于。一次往返可能改变 Unicode 规范化、surrogate 配对、key 顺序。把 JSON 当传输用、不要当忠实回声用。

实用规则

  • 永远用真正的序列化器。手工实现的转义函数能正确处理的边角情况,比你想的少得多。
  • 不可信字符串嵌入 HTML 的 <script> 标签时:要么用 context-aware 序列化器(额外转义 <>&'U+2028U+2029/),要么把 JSON 渲染到 <script type="application/json"> 块里——后者直接绕开了脚本标签 breakout 这一整类攻击。
  • 存储和 API:emit UTF-8 字面字符(非 ASCII 不要 \uXXXX),\uXXXX 留给控制字符。结果更小、更易肉眼检查。
  • 在边界处拒绝 lone surrogate。不要让它进入系统内部传播。
  • 把 JSON 输出当作单向:parse 再 reserialize 不保证字节相等,哪怕语义相等。

主要参考资料

用于核对本文技术细节的标准与官方文档。

任意字符串往返检查

本站 JSON 转义工具会展示一段字符串经过 JSON 序列化后的精确转义序列(包括非 ASCII 字符的 \uXXXX 形式),并能反向解码回来。本地处理,对验证“这串数据能否安全往返”特别有用。

打开 JSON 转义工具

相关文章

继续阅读同一主题领域的实践指南。

查看全部文章

Cookie 同意

我们使用 Cookie 来增强您的体验并展示相关广告。您可以自定义您的偏好。