1808 words
9 minutes
CVE-2025-55182 분석
2026-05-05

유행 지난 CVE-2025-55182 (React2Shell)을 분석해 볼 것이다.

사실 블로그 쓸 게 없어서 분석하는 것이기도 하지만 Next Master라는 문제를
React2Shell 딸깍으로 푼 게 살짝 마음에 걸려서 분석해 보는 것이기도 하다.

우선 보안에 관심을 가져본 사람이라면 최근은 아니지만 React2Shell이라는 걸 들어본 적이 있을 것이다.

React Server Components를 쓸 때 HTTP 요청 하나로 RCE가 가능한 취약점이고 CVSS가 10이나 될 만큼 굉장히 큰 취약점이라 각종 뉴스에도 나온 나름 유명한 취약점이다.

아무튼 본격적으로 분석을 시작해보자.

우선 Flight protocol이 뭔지 알아야 하는데 React에서 Component tree data Server reference 등을 서버와 클라이언트 사이 효율적으로 전송하기 위한 직렬화 포맷이다.

Flight는 문자열 토큰을 가지고 JS 값을 표현하는데 그중 $F는 Server reference다.

CVE-2025-55182에 영향을 받는 react-server-dom-webpack@19.2.0에서 $F를 처리하는 부분을 보자.

case "F":
value = value.slice(2);
value = getOutlinedModel(response, value, obj, key, createModel);
return loadServerReference$1(
response,
value.id,
value.bound,
initializingChunk,
obj,
key
);

$F가 단순 data가 아니고 $F는 결국 loadServerReference$1로 가는데 이 함수는 manifest를 조회해 모듈 export를 함수처럼 복원한다.

loadServerReference$1 함수를 보자.

function loadServerReference$1(response, id, bound, parentChunk, parentObject, key) {
var serverReference = resolveServerReference(response._bundlerConfig, id);
id = preloadModule(serverReference);
if (bound) {
bound = Promise.all([bound, id]).then(function (_ref) {
var fn = requireModule(serverReference);
return fn.bind.apply(fn, [null].concat(_ref[0]));
});
} else if (id) {
bound = Promise.resolve(id).then(function () {
return requireModule(serverReference);
});
} else {
return requireModule(serverReference);
}
}

decoder는 외부 input을 처리하는 중에 이런 걸 수행한다.

resolveServerReference()
preloadModule()
requireModule()
bind.apply()

여기에서 문제가 발생하는데 역직렬화가 module loader와 함수 binding까지 건드린다.

보통 역직렬화라고 하면 문자열 숫자 배열 object 정도를 복원한다고 생각하는데 그런데 여기서는 Flight token 하나가 server reference로 복원되고 그 과정에서 module resolve와 require까지 이어진다.

React가 신뢰하는 내부 model을 만들어서 server reference 복원 경로에 넣는다.

getOutlinedModel 함수를 보자.

function getOutlinedModel(response, reference, parentObject, key, map) {
reference = reference.split(":");
var id = parseInt(reference[0], 16);
id = getChunk(response, id);
if (id.status === "fulfilled") {
parentObject = id.value;
for (key = 1; key < reference.length; key++) {
parentObject = parentObject[reference[key]];
}
return map(response, parentObject);
}
}

이 부분을 보자.

parentObject = parentObject[reference[key]];

reference:로 나뉜 path라 볼 수 있다.

chunkId:path:to:value

그러면 decoder는 chunk를 꺼낸 뒤 path를 하나씩 따라간다.

value = chunk.value
value = value[path1]
value = value[path2]
value = value[path3]

이 과정에서 자연스럽게 이런 의문이 생긴다.

값이 plain object나 array인지
prototype chain으로 빠지는지
then, constructor, __proto__ 같은 걸 넣을 수 있는지

body 안에 들어간 reference path가 단순 데이터에 접근하는 게 아닐 수도 있다.

여기서 중요한 건 getOutlinedModel()이 단순히 own property만 따라가는 게 아니다. value[path[i]] 형태라서 JS property lookup을 그대로 따라간다.

즉 object에 직접 없는 property여도 prototype chain에 있으면 읽을 수 있다.

const obj = {};
obj["toString"];

reference path를 통해 prototype chain에 있는 값을 읽을 수 있다. 이게 첫번째 primitive이다.

JS에서 then은 특이한 존재다. 어떤 객체가 then 함수를 가지면 thenable로 취급될 수 있다.

이게 prototype pollution이냐 하면 그건 또 아니다.

prototype pollution처럼 Object.prototype.polluted = true 이런 걸 심어서 객체를 오염시키는 그런 케이스가 아니고

prototype chain을 따라가서 이미 있는 gadget을 가져오는 쪽이라고 볼 수 있다.

{
then: then,
status: "resolved_model",
reason: -1,
value: FlightModel,
_response: {
_prefix: string,
_formData: {
get: prototypeChainGadget
}
}
}

이런 형식을 보자.

이 객체가 JSON 같아 보이지만 React 입장에선 좀 문제가 되는 형태일 수 있다. Chunk는 다음과 같이 구현되어 있다.

function Chunk(status, value, reason, response) {
this.status = status;
this.value = value;
this.reason = reason;
this._response = response;
}

Chunk.prototype.thenstatusresolved_model이면 model을 초기화한다.

Chunk.prototype = Object.create(Promise.prototype);
Chunk.prototype.then = function (resolve, reject) {
switch (this.status) {
case "resolved_model":
initializeModelChunk(this);
}
};

즉 JSON 객체에 status value reason _response를 맞춰 넣고 then에는 prototype chain으로 얻은 Chunk.prototype.then 같은 함수를 물려주면 평범한 객체가 decoder 입장에서는 thenable처럼 행동할 수 있다.

initializeModelChunk()도 같이 보면

function initializeModelChunk(chunk) {
var rootReference =
-1 === chunk.reason ? void 0 : chunk.reason.toString(16),
resolvedModel = chunk.value;
chunk.status = "cyclic";
chunk.value = null;
chunk.reason = null;
try {
var rawModel = JSON.parse(resolvedModel),
value = reviveModel(
chunk._response,
{ "": rawModel },
"",
rawModel,
rootReference
);
// ...
} catch (error) {
(chunk.status = "rejected"), (chunk.reason = error);
}
}

여기서 chunk.value가 다시 JSON.parse() 된다.

한 번 파싱된 object 안에 value를 또 넣고 그 value가 다시 Flight model로 파싱되도록 만들 수 있다.

value 안의 문자열 token들이 의미를 가진다.

$@도 살짝 보면 parseModelString()에서 $@는 Chunk reference를 가져온다.

case "@":
return (
(obj = parseInt(value.slice(2), 16)), getChunk(response, obj)
);

getChunk()는 response 안의 chunk cache나 formData에서 chunk를 꺼낸다.

function getChunk(response, id) {
var chunks = response._chunks,
chunk = chunks.get(id);
chunk ||
((chunk = response._formData.get(response._prefix + id)),
(chunk =
null != chunk
? new Chunk("resolved_model", chunk, id, response)
: response._closed
? new Chunk("rejected", null, response._closedReason, response)
: createPendingChunk(response)),
chunks.set(id, chunk));
return chunk;
}

특정 chunk 자체를 reference로 얻고 getOutlinedModel()의 path traversal과 엮을 수 있다.

path를 따라가면 chunk object 자체가 아니라 Chunk.prototype.then 같은 prototype 쪽으로 접근할 수 있다.

JS에선 흔히 볼 수 있는 chain을 보자.

SomeFunction.constructor === Function

path resolution이 constructor 같은 property를 따라가면 constructor에 gadget을 만들 수 있다.

함수 객체의 constructor는 보통 Function을 가리키고 Function은 문자열을 코드로 바꾸는 생성자다.

물론 여기서 Function을 직접 호출하는게 아니라 decoder의 reference path가 constructor 같은 property까지 따라간다.

$B라는 걸 보자. parseModelString()에서 $BFormData에서 blob이나 file을 꺼낼 때 쓴다.

case "B":
return (
(obj = parseInt(value.slice(2), 16)),
response._formData.get(response._prefix + obj)
);

정상적으로는 response._formData.get(...)이 FormData 조회여야 한다.

그런데 _response 안에서 _formData.get이 함수 생성자 gadget으로 바뀌어 있으면 이 호출은 더 이상 FormData 조회가 아니게 되어버린다.

정상적으로는 이렇게 가야한다.

response._formData.get(blobKey)
-> blob이나 file 조회

그런데 이상한 response를 끼워 넣으면 흐름이 이렇게 바뀐다.

response._formData.get(response._prefix + id)
-> callable gadget(string)

$B는 원래 blob을 꺼내는 token인데 이상한 _response와 엮이면 callable을 호출하는 sink처럼 쓸 수 있다.

callable(_response._formData.get)(_response._prefix + id)

원래는 FormData.get(key)였는데 이상한 _response 때문에 callable(string)이 되는 것이다.

이 객체는 then 멤버가 존재하고 그 값이 Function이기 때문에 JS에서 Thenable이 된다.

그래서 Chunk.prototype.then 호출을 통해 status value _response가 세팅된 상태로 initializeModelChunk()가 다시 호출되는데 value의 Flight model이 다시 열리고 $B 처리에서 이상한 _response._formData.get이 호출된다.

원래라면 그냥 FormData 조회여야 하지만 여기서는 _formData.get이 constructor 같은 callable gadget이고 인자로는 _prefix에 들어간 string이 넘어간다.

결국 client side에서 실행되는 JS가 아닌 server side의 node.js 런타임에서 동작하는 JS 실행으로 이어진다.

그래서

var res = process.mainModule.require('child_process').execSync('id',{{'timeout':5000}}).toString().trim();

이런 코드를 server에서 실행시킬 수 있다.

분석 끗.

Reference
https://github.com/byte16384/CVE-2025-55182/tree/main https://nvd.nist.gov/vuln/detail/CVE-2025-55182 https://react.dev/blog/2025/12/03/critical-security-vulnerability-in-react-server-components

CVE-2025-55182 분석
https://blog-temp.vercel.app/posts/블로그에쓸게없음/
Author
byte256
Published at
2026-05-05
License
CC BY-NC-SA 4.0