Rust in Everything
基于 serde_json 1.0.154

recursion limit exceeded

主线位置:反序列化主线上的一道闸。每进一层 [ / {,serde_json 的 Deserializer 都要经过 Visitor 再递归一次,栈跟着长;remaining_depth 数到 0 就停。机制见 反序列化一侧 的「递归深度:最多 127 层」。

flowchart TB
  open["读到数组或对象"] --> dec["remaining_depth 减一"]
  dec --> zero{"等于 0 吗"}
  zero -->|否| rec["递归读里面的值"]
  zero -->|是| err["报递归超限"]
  ign["IgnoredAny、未知字段"] --> iloop["循环跳过,不计数"]
  off["disable_recursion_limit"] --> stack["只剩栈大小"]

check_recursion! 怎么加减、为什么从 128 起数却只能进 127 层、ignore_value 为什么不用栈、unbounded_depth 是哪个版本加的,都在 反序列化一侧 里跑过。这一页看它在真实类型上什么时候触发、怎么改。

本页实验在 M2 Max、macOS、rustc 1.99.0 上运行,debug 构建,主线程栈 8 MB(ulimit -s 是 8176)。依赖 serde = "=1.0.229"(derive)、serde_json = "=1.0.154";关掉限制的实验在另一个 crate 里,serde_json 开了 unbounded_depth。

现象

127 层不是「127 层结构体」。src/bin/rl_repro.rs:

use serde::de::IgnoredAny;
use serde::{Deserialize, Serialize};
use serde_json::Value;

fn arrays(depth: usize) -> String {
  format!("{}{}", "[".repeat(depth), "]".repeat(depth))
}

// A tree: every level is one JSON object plus one JSON array.
#[derive(Serialize, Deserialize, Debug)]
struct Node {
  children: Vec<Node>,
}

fn chain(levels: usize) -> Node {
  let mut node = Node { children: vec![] };
  for _ in 1..levels {
    node = Node { children: vec![node] };
  }
  node
}

fn main() {
  for depth in [127, 128] {
    let r = serde_json::from_str::<Value>(&arrays(depth)).map(|_| ()).map_err(|e| e.to_string());
    println!("Value, {depth} arrays: {r:?}");
  }
  for levels in [63, 64] {
    // serialization has no depth check
    let text = serde_json::to_string(&chain(levels)).unwrap();
    let r = serde_json::from_str::<Node>(&text).map(|_| ()).map_err(|e| e.to_string());
    println!("Node, {levels} levels, {} brackets: to_string Ok, from_str {r:?}", text.matches(['[', '{']).count());
  }
  let e = serde_json::from_str::<Value>(&arrays(128)).unwrap_err();
  println!("classify: {:?}", e.classify());
  let r = serde_json::from_str::<IgnoredAny>(&arrays(100_000));
  println!("IgnoredAny, 100000 arrays: {r:?}");
}
Value, 127 arrays: Ok(())
Value, 128 arrays: Err("recursion limit exceeded at line 1 column 128")
Node, 63 levels, 126 brackets: to_string Ok, from_str Ok(())
Node, 64 levels, 128 brackets: to_string Ok, from_str Err("recursion limit exceeded at line 1 column 832")
classify: Syntax
IgnoredAny, 100000 arrays: Ok(IgnoredAny)
  • 计的是括号,不是 Rust 类型的层数。Node 每一层是一个对象 {"children": 加一个数组 [,占两层,64 层的树就是 128 个括号,超限。一棵评论树、一个目录树、一个 AST,几十层就会碰到。
  • to_string 两次都成功:序列化一侧不查深度,自己写出的 JSON 自己读不回来。
  • 错误的分类是 Category::Syntax,和「不是合法 JSON」归在一起。axum 的 Json 按 classify() 分流,Syntax 走 JsonSyntaxError,所以客户端看到的是 400 而不是 422(Json 提取器)。
  • 不关心内容的部分(IgnoredAny、结构体里的未知字段)再深都能跳过。

不经过文本的路径也不查深度。src/bin/rl_value.rs 在内存里搭一个 200 层的 Value:

use serde_json::Value;

fn main() {
  let mut v = Value::Null;
  for _ in 0..200 {
    v = Value::Array(vec![v]);
  }
  let text = serde_json::to_string(&v).unwrap();
  println!("from_str:   {:?}", serde_json::from_str::<Value>(&text).map(|_| ()).map_err(|e| e.to_string()));
  println!("from_value: {:?}", serde_json::from_value::<Value>(v).map(|_| ()).map_err(|e| e.to_string()));
}
from_str:   Err("recursion limit exceeded at line 1 column 128")
from_value: Ok(())

所以「单元测试用 to_value / from_value 往返都过,上线后从请求体读就失败」是可能的。

出处

计数器是 Deserializer 的 remaining_depth: u8,Deserializer::new 把它设成 128。check_recursion! 套在每个 [ / { 分支外面:

        if_checking_recursion_limit! {
            $this.remaining_depth -= 1;
            if $this.remaining_depth == 0 {
                return Err($this.peek_error(ErrorCode::RecursionLimitExceeded));
            }
        }

ErrorCode::RecursionLimitExceeded 在 classify 里归为 Category::Syntax。整个 ser.rs 和 value/ 目录里没有任何深度计数,这就是上面「写得出、from_value 读得回、from_str 读不回」的原因。跳过未知内容的 ignore_value 是一个循环,不经过 check_recursion!。

📝NOTE
没有蠢问题

问: 能不能把上限调到 256,而不是整个关掉?

答: 不能。1.0.154 里没有设置深度的 API:128 是 Deserializer::new 里写死的常数,字段类型是 u8,255 就是它能表示的上限。唯一的开关是 unbounded_depth feature 后面的 disable_recursion_limit,它把计数整个跳过(if_checking_recursion_limit! 里的代码不执行)。要「放宽但仍有上限」,得在 serde_json 外面做:限制请求体大小,或者在解析前自己数一遍括号。

修法

先看能不能让数据变浅。 深度来自数据结构本身时,最稳的办法是换一种表示:树写成扁平的节点列表加父节点编号,链表写成数组。这样 JSON 的深度是常数,任何格式、任何解析器都没有这个问题。

数据确实深、来源可信时,关掉限制,并给足栈。 Cargo.toml 里 serde_json = { version = "=1.0.154", features = ["unbounded_depth"] }。关掉之后递归下降就只受栈大小约束,disable_recursion_limit 的文档自己建议配合 serde_stacker 的动态增长栈(本机 registry 里没有这个 crate,这里不演示)。不加依赖的做法是在一个栈更大的线程里解析,src/bin/rl_unbounded.rs:

use serde::Deserialize;
use serde_json::Value;

fn arrays(depth: usize) -> String {
  format!("{}{}", "[".repeat(depth), "]".repeat(depth))
}

// Drops a deep Value without recursing (Value's own Drop recurses).
fn drop_flat(value: Value) {
  let mut stack = vec![value];
  while let Some(value) = stack.pop() {
    if let Value::Array(array) = value {
      stack.extend(array);
    }
  }
}

fn parse_unbounded(json: &str) -> bool {
  let mut de = serde_json::Deserializer::from_str(json);
  de.disable_recursion_limit();
  let value = Value::deserialize(&mut de);
  let ok = value.is_ok();
  if let Ok(v) = value {
    drop_flat(v);
  }
  ok
}

fn main() {
  let depth: usize = std::env::args().nth(1).unwrap().parse().unwrap();
  let stack_mb: usize = std::env::args().nth(2).map(|s| s.parse().unwrap()).unwrap_or(0);
  let json = arrays(depth);
  if stack_mb == 0 {
    println!("depth {depth}, main thread: parsed = {}", parse_unbounded(&json));
  } else {
    let handle = std::thread::Builder::new()
      .stack_size(stack_mb << 20)
      .spawn(move || parse_unbounded(&json))
      .unwrap();
    println!("depth {depth}, thread with {stack_mb} MiB stack: parsed = {}", handle.join().unwrap());
  }
}

用 shell 循环依次传入 10000、10000 256、100000 256、1000000 256(== 和 exit= 两行是循环打印的):

== 10000

thread 'main' (2838196) has overflowed its stack
fatal runtime error: stack overflow, aborting
exit=134
== 10000 256
depth 10000, thread with 256 MiB stack: parsed = true
exit=0
== 100000 256
depth 100000, thread with 256 MiB stack: parsed = true
exit=0
== 1000000 256

thread '<unknown>' (2838272) has overflowed its stack
fatal runtime error: stack overflow, aborting
exit=134
  • 8 MB 的主线程栈,一万层就栈溢出,进程以 SIGABRT(134)退出,连 Result 都拿不到。这正是限制要防的情况。
  • 256 MiB 的线程栈能读十万层,一百万层照样溢出。栈大只是把上限往后挪;输入不可信时,关掉限制就必须同时限制输入大小。
  • 解析出来的 Value,它的 Drop、Debug、Display 也都是递归的(disable_recursion_limit 的文档点名了这三个),所以程序里用 drop_flat 拆掉结果。序列化同样递归:src/bin/rl_ser_deep.rs 在内存里循环搭一个 N 层的 Value 再 to_string,1000 层输出 to_string ok, 2004 bytes,100000 层在主线程上 has overflowed its stack、exit=134。

来源不可信时,保留限制。 默认的 127 层就是为这种情况准备的:攻击者送来十万个 [,得到的是一个普通的 Err。真正用得上这么深结构的接口很少,把它当成 400 返回即可。

怎么确认没中招

  • 数一数你的递归类型(树、AST、嵌套评论、Box<Self> 链)在 JSON 里每一层占几个括号:struct 是 {,Vec / 元组 / 序列是 [,外部标签的 enum 变体再多一个 {。层数乘以括号数不能到 128。
  • 往返测试要经过文本:to_string 再 from_str,用一个和线上一样深的样本。to_value / from_value 不查深度,测不出来。
  • 日志里看到 400 加 recursion limit exceeded,先确认是攻击流量还是合法数据变深了,再决定改表示还是关限制。

小结

  • serde_json 读文本时最多嵌套 127 层,[ 和 { 各算一层:每层是「对象 + 数组」的递归 struct,64 层就超限。
  • 序列化、to_value、from_value 都不查深度:自己写出的 JSON 可能读不回来。
  • IgnoredAny 和未知字段走 ignore_value 循环,不受限制。错误属于 Category::Syntax,axum 返回 400。
  • 上限不可调,只能用 unbounded_depth 的 disable_recursion_limit 整个关掉;关掉后深度只受栈约束(8 MB 主线程一万层就溢出),要配大栈线程或 serde_stacker,并限制输入大小。
  • 首选的修法是让数据变浅:树写成节点列表加父编号。

💬 关联讨论

发起讨论
加载中...