RSC는 왜 필요했고, CVE-2025-55182는 왜 발생했을까

CVE-2025-55182가 공개된 날, 나는 제주도를 여행하고 있었다.

인증 없이 원격 코드 실행이 가능한 취약점. CVSS 10.0. React Server Components를 지원하는 프레임워크가 영향 범위에 포함된다는 공지가 이어졌다.

처음 공지만 보면 주말 안에 긴급 보안 대응이 필요한 상황이었다. 어떤 서비스가 영향을 받는지, 사용 중인 버전은 무엇인지, 외부에 노출된 경로가 있는지부터 확인해야 했다.

다행히 당시 우리가 운영하던 서비스는 Next.js의 Pages Router를 사용하고 있었다. 이 취약점의 직접적인 영향 대상은 아니었다.

급한 불은 없었다. 그렇다고 궁금증까지 사라진 것은 아니었다.

여행에서 돌아온 뒤 취약한 환경을 별도의 Kubernetes Pod에 구성하고 직접 재현해 봤다. 정상적인 Action 본문에 로그를 넣은 것이 아니라, Server Action 요청을 처리하는 취약한 경로를 거쳐 내가 지정한 문자열이 서버 프로세스 로그에 남는 것을 확인했다.

Pod 로그에 찍힌 것은 별 의미 없는 짧은 문자열이었다. 하지만 그 문자열이 거기에 나타났다는 사실의 의미는 작지 않았다.

외부에서 만든 요청이 React의 디코더를 통과해 서버의 실행 흐름에 영향을 줄 수 있다는 뜻이었다.

재현 과정을 팀에 공유했을 때 반응이 좋았다. 늘 주어진 업무를 처리하다가 이런 문제를 함께 파고드니 재미있다는 이야기가 나왔다.

그 말을 듣고 속으로 생각했다.

재밌어? 그럼 평생 해줄게.

조금 과장하면 그렇다는 이야기다.

팀 리드 역할에서 내가 가장 즐거워했던 부분도 이런 것이었다. 조금 거칠게 말하면, 팀의 문화를 내 책임 아래 직접 설계하고 실험해 볼 수 있었다.

회고에서 반복되는 문제를 찾고, 일하는 방식을 바꾸고, 팀을 멈추게 하는 블로커를 확인하고, 꽉 막힌 지점을 뚫어 다시 움직이게 하는 일이 즐거웠다.

이번 취약점도 비슷했다.

영향 여부와 패치만 확인하고 끝낼 수도 있었다. 하지만 이왕 팀원들이 재미있다고 했으니 조금 더 깊이 들어가 보기로 했다.

도대체 이 취약점은 왜 발생했을까.

RSC에 해결할 수 없는 구조적 결함이 있는 걸까. 아니면 구현 과정에서 생긴 버그에 불과할까.

그리고 JavaScript의 프로토타입은 이 취약점에서 정확히 어떤 역할을 했을까.

먼저, 어떤 취약점이었나

CVE-2025-55182는 React Server Components 구현에서 발생한 인증 없는 원격 코드 실행 취약점이다.

인증되지 않은 공격자가 조작된 HTTP 요청을 보내 서버에서 JavaScript를 실행할 수 있었고, 심각도는 CVSS 10.0으로 평가됐다. 단순한 브라우저 오류나 클라이언트 측 코드 실행이 아니라 애플리케이션 서버의 권한으로 코드가 실행될 수 있는 문제였다.

공격에 성공하면 서버의 실행 권한에 따라 다음과 같은 피해로 이어질 수 있다.

  • 환경 변수와 런타임 비밀값 탈취
  • 서버 파일과 내부 자원 접근
  • 애플리케이션 데이터 변조
  • 악성 프로세스 실행
  • 동일 네트워크에 있는 다른 시스템으로 공격 범위 확대
  • 서버 프로세스 또는 컨테이너 장악

실제 피해 범위는 Node.js 프로세스와 컨테이너에 부여된 권한에 따라 달라진다. 패치뿐 아니라 최소 권한과 런타임 격리가 중요한 이유다.

정확히는 reactreact-dom 패키지 자체가 아니라 다음 RSC 구현 패키지가 영향을 받았다.

  • react-server-dom-webpack
  • react-server-dom-parcel
  • react-server-dom-turbopack

React의 최초 공지 기준으로 영향을 받은 버전은 19.0.0, 19.1.0, 19.1.1, 19.2.0이었다. React 공식 보안 공지

Next.js에서는 이 특정 RCE를 기준으로 다음 구성이 영향을 받았다.

구분 구성
영향 대상 Next.js 15.x App Router
영향 대상 Next.js 16.x App Router
영향 대상 Next.js 14.3.0-canary.77 이후 일부 canary
직접적인 영향 대상 아님 Next.js 13.x
직접적인 영향 대상 아님 Next.js 14.x 안정판
직접적인 영향 대상 아님 Pages Router
직접적인 영향 대상 아님 Edge Runtime

따라서 “Next.js를 사용하면 모두 취약했다”거나 “App Router가 있는 모든 버전이 취약했다”고 설명하면 정확하지 않다.

우리 서비스가 당시 Pages Router를 사용하고 있어 직접적인 영향이 없었던 것도 이 때문이다.

Next.js는 업스트림 취약점의 영향을 설명하는 공지에서 CVE-2025-66478이라는 번호를 사용했다. 하지만 이 번호는 이후 CVE-2025-55182와 중복이라는 이유로 Rejected 처리됐다.

현재 기준으로는 두 개의 독립된 취약점이 아니다.

  • CVE-2025-55182: React RSC 구현에서 발생한 실제 취약점
  • CVE-2025-66478: Next.js 영향 공지에 사용됐지만 중복으로 Rejected된 번호

최초 RCE 패치 이후 RSC 주변 코드에서 별도의 서비스 거부와 소스 코드 노출 취약점도 추가로 발견됐다. 따라서 과거 글에 기록된 최초 패치 버전을 현재의 최종 권고 버전으로 사용해서는 안 된다. 실제 대응 시점에는 React와 프레임워크 공식 공지에서 현재 릴리스 라인의 최신 보안 패치를 확인해야 한다.

결론부터 말하면, 네 가지가 연결됐다

이 취약점을 “RSC가 브라우저 요청을 서버에서 받아서 생긴 문제”라고만 설명하면 부족하다.

JavaScript의 프로토타입 때문에 발생했다고만 설명하는 것도 정확하지 않다.

실제 취약점은 다음 네 가지가 연결되면서 발생했다.

  1. Server Functions를 위해 브라우저에서 서버로 Flight Reply를 보내는 경로가 생겼다.
  2. 서버의 디코더는 단순 JSON보다 풍부한 객체와 참조 관계를 복원했다.
  3. 객체 경로를 따라갈 때 own property 여부를 검사하지 않은 구현 결함이 있었다.
  4. JavaScript의 프로토타입 탐색과 Thenable 규칙이 데이터 접근을 함수 실행으로 연결했다.
flowchart TD
    A["신뢰할 수 없는 브라우저 요청"] --> B["Flight Reply 디코더"]
    B --> C["참조와 객체 그래프 복원"]
    D["own property 검사 누락"] --> C
    C --> E["프로토타입 체인으로 탐색 범위 확장"]
    E --> F["React 내부 Chunk의 then 접근"]
    F --> G["공격자가 제어하는 Thenable 구성"]
    G --> H["Promise 해석 과정에서 함수 호출"]
    H --> I["Node.js 서버에서 코드 실행"]

각 요소를 구분하는 것이 중요하다.

구분 의미
구조적 조건 브라우저에서 서버로 React 전용 요청이 들어온다
복잡성 Flight가 참조, Promise, Blob 등 풍부한 값을 표현한다
실제 구현 결함 객체를 탐색하면서 own property를 확인하지 않았다
공격에 사용된 언어 특성 프로토타입 체인과 Thenable 해석
최종 영향 서버의 Node.js 권한으로 코드가 실행됐다

RSC의 통신 구조는 공격의 진입점을 만들었다.

Flight 프로토콜의 두 방향과 신뢰 수준 차이

JavaScript의 객체 모델은 공격자가 사용할 수 있는 기능을 제공했다.

하지만 둘 중 어느 것도 그 자체로 취약점은 아니다. 외부 입력을 해석하던 디코더가 데이터와 런타임 객체 사이의 경계를 지키지 못한 것이 실제 결함이었다.

이제 이 결론을 이해하기 위해 JavaScript의 프로토타입부터 살펴보자.

JavaScript 객체는 클래스를 복사해서 만들어지지 않는다

Java나 C++처럼 클래스 기반 언어에 익숙하면 객체가 특정 클래스의 구조와 메서드를 물려받는다고 생각하기 쉽다.

JavaScript는 조금 다르다.

JavaScript의 객체는 자신의 속성을 담고 있는 동적인 컨테이너에 가깝다. 그리고 각 객체는 다른 객체를 가리키는 내부 링크를 하나 가질 수 있다.

이 링크를 [[Prototype]]이라고 부른다.

JavaScript 프로토타입 체인 탐색

flowchart LR
    A["dog 객체<br/>name: 보리"] -->|"[[Prototype]]"| B["animal 객체<br/>breathe 함수"]
    B -->|"[[Prototype]]"| C["Object.prototype"]
    C -->|"[[Prototype]]"| D["null"]

예를 들어 다음과 같이 객체를 만들 수 있다.

typescript
const animal = {
  breathe() {
    return '숨을 쉰다';
  },
};

const dog = Object.create(animal);
dog.name = '보리';

dog가 직접 가지고 있는 속성은 name뿐이다.

typescript
Object.hasOwn(dog, 'name'); // true

breathedog가 직접 가진 속성이 아니다.

typescript
Object.hasOwn(dog, 'breathe'); // false

그런데도 다음 코드는 동작한다.

typescript
dog.breathe(); // "숨을 쉰다"

JavaScript가 dog.breathe를 평가할 때 dog 자신에게서만 속성을 찾지 않기 때문이다.

먼저 dog의 own property를 찾는다. 없으면 dog[[Prototype]]animal로 이동한다. 거기에도 없다면 다시 animal[[Prototype]]으로 이동한다.

이 과정은 속성을 찾거나 프로토타입이 null이 될 때까지 이어진다.

flowchart TD
    A["dog.breathe 조회"] --> B{"dog가 breathe를<br/>직접 가지고 있는가?"}
    B -->|"예"| C["dog.breathe 반환"]
    B -->|"아니오"| D["dog의 [[Prototype]]으로 이동"]
    D --> E{"animal이 breathe를<br/>직접 가지고 있는가?"}
    E -->|"예"| F["animal.breathe 반환"]
    E -->|"아니오"| G["다음 prototype으로 이동"]
    G --> H["Object.prototype"]
    H --> I["null까지 없으면 undefined"]

이것이 JavaScript의 프로토타입 체인이다.

MDN의 프로토타입 설명도 객체의 속성을 조회할 때 객체 자신뿐 아니라 프로토타입과 그 위의 프로토타입까지 순서대로 탐색한다고 설명한다.

prototype, [[Prototype]], __proto__는 서로 다르다

이 세 단어는 이름이 비슷해 혼동하기 쉽다.

이름 의미
Constructor.prototype 생성자 함수가 가진 일반적인 속성
object.[[Prototype]] 각 객체가 다른 객체를 가리키는 내부 링크
object.__proto__ [[Prototype]]에 접근하는 오래된 접근자

생성자 함수를 이용하면 다음 관계가 만들어진다.

typescript
function Dog(name) {
  this.name = name;
}

Dog.prototype.bark = function () {
  return '멍';
};

const dog = new Dog('보리');

이때 관계는 다음과 같다.

flowchart LR
    A["dog 인스턴스<br/>name: 보리"] -->|"[[Prototype]]"| B["Dog.prototype<br/>bark 함수"]
    B -->|"[[Prototype]]"| C["Object.prototype"]
    C -->|"[[Prototype]]"| D["null"]
    E["Dog 생성자 함수"] -->|"prototype 속성"| B

다음 두 식은 같은 객체를 가리킨다.

typescript
Object.getPrototypeOf(dog) === Dog.prototype; // true

__proto__도 비슷한 값을 보여 주지만, 일반적인 코드에서는 Object.getPrototypeOf()를 사용하는 편이 낫다.

typescript
dog.__proto__ === Dog.prototype; // true

중요한 점은 __proto__가 단순한 문자열 속성처럼 보이면서도 객체의 프로토타입에 접근할 수 있다는 것이다.

공격자가 객체 경로를 문자열 배열로 전달할 수 있고 애플리케이션이 이를 그대로 따라간다면 다음과 같은 일이 가능해진다.

typescript
const path = ['__proto__', 'bark'];

let value = dog;

for (const name of path) {
  value = value[name];
}

첫 번째 접근은 dog의 프로토타입인 Dog.prototype에 도달한다. 두 번째 접근은 그 위에 있는 bark 함수에 도달한다.

문제는 경로의 각 이름을 공격자가 정할 수 있을 때다.

own property와 inherited property

JavaScript 객체의 속성은 크게 두 종류로 나눌 수 있다.

  • 객체 자신이 직접 가진 own property
  • 프로토타입 체인에서 상속받은 inherited property

다음 객체에는 role만 직접 들어 있다.

typescript
const user = {
  role: 'member',
};

하지만 toString에도 접근할 수 있다.

typescript
typeof user.toString; // "function"

toStringuser가 직접 가진 속성이 아니다. Object.prototype에서 찾은 상속 속성이다.

typescript
Object.hasOwn(user, 'role'); // true
Object.hasOwn(user, 'toString'); // false

'role' in user; // true
'toString' in user; // true

in 연산자는 프로토타입 체인까지 확인한다. Object.hasOwn()은 객체가 직접 가진 속성만 확인한다.

보통 애플리케이션 코드에서는 상속된 속성을 사용하는 것이 자연스럽다. 배열에서 map()을 호출하거나 객체에서 toString()을 호출할 수 있는 것도 이 구조 덕분이다.

하지만 신뢰할 수 없는 입력을 이용해 객체 그래프를 탐색할 때는 이야기가 달라진다.

다음 코드는 단순해 보인다.

typescript
value = value[name];

그러나 name을 공격자가 결정할 수 있다면 value의 own property뿐 아니라 프로토타입 위에 있는 모든 속성도 탐색할 수 있다.

안전한 데이터 역직렬화기는 파서가 직접 만든 객체 그래프 안에서만 이동해야 한다.

typescript
if (Object.hasOwn(value, name)) {
  value = value[name];
}

또는 React 패치처럼 신뢰할 수 있는 hasOwnProperty 함수를 명시적으로 호출할 수 있다.

typescript
if (
  typeof value === 'object' &&
  Object.prototype.hasOwnProperty.call(value, name)
) {
  value = value[name];
}

value.hasOwnProperty(name)을 직접 호출하지 않는 이유도 있다. 공격자가 hasOwnProperty라는 이름의 속성을 직접 만들어 원래 메서드를 가릴 수 있고, null prototype 객체에는 그 메서드가 없을 수도 있기 때문이다.

이번 문제는 전형적인 Prototype Pollution과는 다르다

프로토타입이 등장한다고 해서 모든 공격을 Prototype Pollution이라고 부를 수는 없다.

전형적인 Prototype Pollution은 공유되는 프로토타입 객체를 변경하는 공격이다.

typescript
Object.prototype.isAdmin = true;

const user = {};

user.isAdmin; // true

공격자가 Object.prototype 같은 공유 객체에 속성을 기록하면 이후에 만들어진 다른 객체에서도 해당 속성이 상속된 것처럼 보일 수 있다.

CVE-2025-55182의 핵심은 조금 다르다.

공격자가 프로토타입에 새로운 속성을 기록해야 했던 것이 아니다. Flight 디코더가 객체 경로를 따라갈 때 inherited property까지 읽을 수 있었고, 그 결과 파서가 만들지 않은 React 내부 객체와 JavaScript 런타임 기능에 도달했다.

따라서 이번 취약점은 다음 표현이 더 정확하다.

  • 프로토타입 체인 탐색
  • 프로토타입 경계 우회
  • 파서가 생성하지 않은 런타임 객체에 대한 접근
  • prototype-chain traversal

React 패치 작성자도 빠져 있던 hasOwnProperty 검사가 핵심 수정이며, 이 검사가 없으면 파서가 만들지 않은 객체 안으로 들어갈 수 있었다고 설명했다. React 패치 PR

프로토타입에 접근한 것만으로 코드가 실행되지는 않는다

여기까지 이해해도 한 가지 의문이 남는다.

프로토타입 위의 속성을 읽을 수 있다는 것이 왜 원격 코드 실행으로 이어질까?

프로토타입 체인에 접근할 수 있다는 사실만으로는 코드가 실행되지 않는다. 공격자는 그 위에 존재하는 실행 가능한 기능을 찾아 연결해야 한다.

이 취약점에서는 React 내부의 Chunk와 JavaScript의 Thenable 규칙이 중요한 연결 고리가 됐다.

Thenable은 진짜 Promise가 아니어도 된다

JavaScript에서 Promise처럼 취급되기 위해 반드시 Promise 인스턴스일 필요는 없다.

호출 가능한 then 속성을 가진 객체라면 Thenable로 취급될 수 있다.

typescript
const pretendPromise = {
  then(resolve) {
    resolve('완료');
  },
};

const result = await pretendPromise;

console.log(result); // "완료"

pretendPromisenew Promise()로 만든 객체가 아니다.

그런데 await와 Promise의 resolve 과정은 객체에서 호출 가능한 then을 발견하면 그 함수를 호출한다. MDN의 Promise 설명에서도 resolve에 Thenable이 전달되면 해당 객체의 then 메서드를 가져와 호출한다고 설명한다.

flowchart TD
    A["Promise가 어떤 값으로 resolve됨"] --> B{"값이 객체인가?"}
    B -->|"아니오"| C["일반 값으로 완료"]
    B -->|"예"| D{"호출 가능한 then이 있는가?"}
    D -->|"아니오"| C
    D -->|"예"| E["then(resolve, reject) 호출"]
    E --> F["Thenable이 넘긴 값으로 계속 해석"]

이 규칙은 여러 Promise 구현과 비동기 값을 자연스럽게 연결하기 위해 필요하다.

문제는 공격자가 객체의 then을 자신이 원하는 함수로 만들 수 있을 때다. 그 객체가 Promise 해석 과정에 들어가면 JavaScript가 그 함수를 대신 호출해 줄 수 있다.

React의 Chunk도 자체적인 then을 가지고 있었다

Flight는 스트리밍 중 아직 준비되지 않은 값을 다루기 위해 내부적으로 Chunk라는 객체를 사용했다.

취약 당시 React 소스에서 ChunkPromise.prototype을 상속하고 자체적인 .then()을 가지고 있었다.

개념적으로는 다음과 비슷하다.

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

Chunk.prototype = Object.create(Promise.prototype);

Chunk.prototype.then = function (resolve, reject) {
  // Chunk 상태에 따라 초기화하거나 resolve
};

정확히는 네이티브 Promise가 아니라 Promise와 비슷하게 행동하는 React 내부 Thenable이었다.

공격자는 Flight의 참조 기능을 이용해 Chunk에 접근하고, own-property 검사가 빠진 경로를 통해 Chunk.prototype.then까지 탐색할 수 있었다.

그다음 공격자가 만든 일반 객체의 then 속성이 React 내부의 Chunk.prototype.then을 가리키도록 구성할 수 있었다.

flowchart LR
    A["공격자가 만든 객체"] -->|"then 속성"| B["Chunk.prototype.then"]
    A --> C["조작된 status"]
    A --> D["조작된 value"]
    A --> E["조작된 response"]

이제 일반 객체가 React의 Chunk처럼 행동하게 된다.

Chunk.prototype.then 안에서 this는 메서드를 실제로 호출한 객체를 가리킨다. 따라서 함수는 React가 생성한 정상 Chunk가 아니라 공격자가 status, value, _response를 구성한 객체를 대상으로 동작하게 된다.

JavaScript에서 상속받은 메서드를 호출할 때 this가 메서드를 가진 프로토타입이 아니라 호출한 객체를 가리키는 특성이 여기서 중요해진다.

함수도 객체고, 생성자에 다시 접근할 수 있다

JavaScript에서는 함수도 객체다.

일반 객체에서 constructor를 따라가면 그 객체를 만든 생성자 함수에 접근할 수 있다.

typescript
const object = {};

object.constructor === Object; // true

함수 역시 객체이므로 생성자를 가진다.

typescript
Object.constructor === Function; // true

따라서 일반 객체에서 프로토타입 체인을 제한 없이 따라갈 수 있다면 다음과 같은 관계에 도달할 수 있다.

typescript
object.constructor.constructor === Function; // true

Function 생성자는 문자열로 새로운 함수를 만들 수 있다. 일반적인 애플리케이션 코드에서는 거의 사용할 이유가 없지만, 공격자가 여기에 도달해 입력한 문자열을 전달할 수 있다면 데이터가 실행 가능한 JavaScript로 바뀔 수 있다.

CVE-2025-55182에서는 Flight의 Blob과 FormData 복원 경로가 이 실행 가능한 함수를 만드는 데 이용됐다.

핵심은 Blob 자체가 위험했다는 것이 아니다.

공격자가 조작한 _response 객체에서 _formData.get처럼 원래 함수여야 하는 위치에 프로토타입 체인을 통해 얻은 함수 생성자를 넣을 수 있었다는 점이 중요하다.

전체 공격 흐름을 연결하면

취약점의 흐름을 개념적으로 정리하면 다음과 같다.

flowchart TD
    A["조작된 Server Function HTTP 요청"] --> B["Flight Reply 디코더 진입"]
    B --> C["Chunk와 참조 값 복원"]
    C --> D["공격자가 지정한 객체 경로 탐색"]
    D --> E["own property 검사 누락"]
    E --> F["프로토타입 체인으로 이동"]
    F --> G["Chunk.prototype.then 획득"]
    G --> H["공격자 제어 객체를 Thenable로 구성"]
    H --> I["Blob·FormData 복원 경로 조작"]
    I --> J["호출 가능한 함수 생성자 도달"]
    J --> K["문자열에서 JavaScript 함수 생성"]
    K --> L["Promise 해석이 then 호출"]
    L --> M["Node.js 서버에서 함수 실행"]

이를 단계별로 풀면 다음과 같다.

  1. 외부 요청이 Server Function 요청을 처리하는 Flight Reply 디코더에 도달한다.
  2. 디코더는 요청에 포함된 Chunk와 참조 관계를 복원한다.
  3. 공격자가 지정한 경로를 따라 객체 속성을 탐색한다.
  4. own property 검사가 없으므로 프로토타입 체인 위의 속성까지 읽는다.
  5. React 내부의 Chunk.prototype.then에 도달한다.
  6. 공격자가 만든 객체를 React Chunk처럼 동작하는 Thenable로 구성한다.
  7. Blob과 FormData 복원 경로에서 호출 가능한 함수 생성자에 도달한다.
  8. Promise 해석 과정이 공격자가 만든 Thenable의 then을 호출한다.
  9. 생성된 JavaScript 함수가 Node.js 서버에서 실행된다.

패치는 이 체인의 앞부분을 끊었다.

typescript
const name = path[i];

if (
  typeof value === 'object' &&
  Object.prototype.hasOwnProperty.call(value, name)
) {
  value = value[name];
}

참조가 파서가 직접 만든 own property만 따라갈 수 있다면 __proto__, constructor, 상속된 then과 같은 프로토타입 위의 속성에 도달할 수 없다.

CVE-2025-55182 공격 체인

인증과 입력 검증으로 막을 수 있었을까

Server Action 내부에서 인증과 입력 검증을 했다면 이 공격을 막을 수 있었을 것처럼 보일 수 있다.

하지만 이 취약점은 Action 본문이 실행되기 전에 발생했다.

flowchart LR
    A["HTTP 요청"] --> B["Flight Reply 디코딩"]
    B --> C["Server Function 식별"]
    C --> D["개발자가 작성한 Action 본문"]
    D --> E["인증·인가·비즈니스 로직"]

공격은 B 단계에서 성공할 수 있었다.

개발자가 작성한 인증과 입력 검증은 DE 단계에 있다. 따라서 Action 내부를 안전하게 작성하는 것만으로 디코더 RCE를 막을 수는 없었다.

React 공식 공지에 따르면 애플리케이션이 명시적인 Server Function을 작성하지 않았더라도, RSC를 지원하는 프레임워크 구성이 취약한 요청 처리 경로를 노출했다면 영향을 받을 수 있었다.

다음과 같은 판단은 안전하지 않았다.

text
우리 코드에는 Server Action이 없다.
→ 실행할 서버 함수가 없다.
→ 취약점과 무관하다.

영향 여부는 애플리케이션 코드만이 아니라 프레임워크와 번들러가 포함한 react-server-dom-* 구현을 기준으로 확인해야 했다.

그렇다면 RSC에 구조적 결함이 있는 걸까

여기까지 보면 RSC 자체가 위험한 구조처럼 느껴질 수 있다.

브라우저가 서버 함수를 참조하고, 서버가 브라우저에서 온 React 전용 payload를 객체 그래프로 복원한다.

분명 새로운 공격 표면이다.

하지만 공격 표면과 취약점은 같은 말이 아니다.

REST API도 외부 JSON을 파싱하고, GraphQL 서버도 외부 쿼리를 해석하며, RPC 프레임워크도 함수 참조와 인자를 복원한다. 외부 입력을 서버의 실행 기능과 연결하는 모든 구조에는 검증이 필요하다.

RSC와 Server Functions의 특징은 그 데이터 모델이 일반적인 JSON보다 풍부하다는 데 있다.

  • 객체와 배열
  • 순환·공유 참조
  • Promise와 Chunk
  • Map, Set, Date
  • Blob과 FormData
  • JSX
  • Client Component 모듈 참조
  • Server Function 참조
  • Suspense와 스트리밍 상태

표현력이 높을수록 디코더가 해야 하는 일도 많아진다. 결과적으로 검토해야 할 공격 표면도 넓어진다.

따라서 다음처럼 구분하는 편이 정확하다.

RSC와 Server Functions는 새로운 구조적 위험을 만들었다. CVE-2025-55182는 그 구조 안에서 발생한 수정 가능한 구현 결함이었다.

JavaScript의 프로토타입 모델도 마찬가지다.

프로토타입 체인은 JavaScript의 상속과 메서드 공유를 가능하게 하는 정상적인 기능이다. Thenable 규칙 역시 Promise 생태계의 상호운용성을 위한 정상적인 기능이다.

문제는 역직렬화기가 공격자가 지정한 경로를 따라 이 기능들에 도달하도록 허용한 것이다.

그런데 React는 왜 이런 복잡한 구조를 만들었을까

이제 질문을 반대로 돌려볼 수 있다.

React는 왜 Flight 같은 별도의 데이터 형식이 필요했을까? 애초에 서버에서 HTML만 만들어 보내면 안 됐을까?

이 질문에 답하려면 RSC가 나오기 전의 SSR과 hydration부터 다시 봐야 한다.

서버에서 HTML을 만들었는데 hydration은 왜 필요했을까

서버에서 React 컴포넌트를 실행해 HTML을 만들었다면 브라우저는 그 HTML을 그대로 사용하면 될 것처럼 보인다.

서버가 다음 HTML을 보냈다고 해보자.

html
<button>좋아요</button>

브라우저는 버튼을 표시할 수 있다.

하지만 버튼을 클릭했을 때 어떤 함수를 실행해야 하는지는 모른다.

HTML에는 다음 정보가 없다.

  • onClick 함수
  • React state
  • 컴포넌트가 참조하는 클로저
  • effect와 생명주기
  • 클라이언트 라우터 상태
  • 이후 UI를 어떻게 갱신할지에 대한 컴포넌트 로직

HTML은 실행 중인 애플리케이션이 아니라 특정 시점에 애플리케이션이 만든 결과다.

따라서 빠른 초기 화면과 React의 상호작용을 모두 제공하려면 HTML과 JavaScript가 함께 필요했다.

기존 SSR과 RSC의 모듈 경계 비교

flowchart TD
    A["서버에서 React 컴포넌트 실행"] --> B["HTML 생성"]
    B --> C["브라우저가 HTML을 먼저 표시"]
    D["React와 애플리케이션 JS 다운로드"] --> E["브라우저에서 React 트리 재구성"]
    C --> F["기존 DOM"]
    E --> G["상태와 이벤트 연결"]
    F --> G
    G --> H["상호작용 가능한 화면"]

이 과정이 hydration이다.

SSR은 초기 화면을 빠르게 보여 주는 데 유리하다. 하지만 화면을 상호작용 가능한 React 애플리케이션으로 만들려면 브라우저도 해당 컴포넌트 코드를 내려받고 초기 React 트리를 구성해야 한다.

서버와 브라우저가 같은 UI를 각각 계산하는 구조가 된 것이다.

여기에는 비용이 따른다.

  • 서버에서 렌더링한 UI 컴포넌트 코드를 브라우저도 내려받는다.
  • JavaScript를 다운로드하고 파싱하고 실행한다.
  • React가 초기 트리를 다시 구성한다.
  • 서버가 만든 DOM과 클라이언트가 만든 결과를 맞춘다.

hydration mismatch도 이 지점에서 발생한다.

서버와 클라이언트의 첫 렌더링 결과가 같으면 문제가 없다. 하지만 현재 시간, 랜덤값, 사용자 로케일, 브라우저 전용 API처럼 환경에 따라 결과가 달라지는 값이 렌더링에 개입하면 두 결과가 달라질 수 있다.

text
Server render result

Client first render result

React가 불필요하게 같은 일을 반복하려고 설계된 것은 아니다.

빠른 HTML과 SPA식 상호작용을 함께 얻기 위해 서버 렌더링과 클라이언트 실행을 결합했고, 두 환경의 첫 결과가 같아야 한다는 제약이 생긴 것이다.

그렇다면 다시 질문이 생긴다.

모든 컴포넌트를 정말 브라우저에서 다시 실행해야 할까?

RSC가 바꾼 것은 서버 렌더링이 아니라 모듈 경계다

RSC 이전의 SSR도 이미 서버에서 React 컴포넌트를 실행했다.

따라서 RSC의 핵심을 “컴포넌트를 서버에서 렌더링한다”라고만 설명하면 기존 SSR과 차이가 드러나지 않는다.

RSC가 바꾼 것은 어떤 모듈을 브라우저 애플리케이션에 포함할지 결정하는 경계다.

Markdown 문서를 HTML로 변환하는 컴포넌트를 생각해 보자.

이 컴포넌트는 서버에서 파일을 읽고 Markdown 파서와 문법 강조기를 실행한다. 렌더링이 끝난 뒤에는 클릭 이벤트도, state도, 브라우저 API도 필요하지 않다.

기존 SSR 프레임워크도 DB 클라이언트나 getServerSideProps 같은 명백한 서버 전용 코드를 클라이언트 번들에서 제외할 수 있었다.

RSC의 차이는 이 서버 경계를 별도의 데이터 로더에만 두지 않고 컴포넌트와 모듈 의존성 그래프 안으로 가져왔다는 것이다.

flowchart TD
    subgraph Server["서버 모듈 그래프"]
        A["Page Server Component"]
        B["ProductInfo Server Component"]
        C["DB·Markdown·서버 전용 라이브러리"]
        A --> B
        B --> C
    end

    subgraph Client["클라이언트 모듈 그래프"]
        D["LikeButton Client Component"]
        E["상태·이벤트·브라우저 API"]
        D --> E
    end

    A -->|"Client Component 참조와 props"| D
  • Server Component의 구현 코드는 브라우저로 전달되지 않는다.
  • 브라우저는 Server Component 함수 대신 그 함수가 계산한 결과를 받는다.
  • Client Component는 state, 이벤트, effect, 브라우저 API를 담당한다.
  • 'use client'는 서버와 클라이언트 모듈 그래프의 경계를 만든다.
  • Client Component가 import하는 하위 의존성도 클라이언트 번들에 포함될 수 있다.

다만 Client Component라는 이름은 오해를 만들기 쉽다.

Client Component는 “클라이언트에서만 실행되는 컴포넌트”가 아니다. 초기 페이지의 HTML을 만들기 위해 서버에서도 미리 렌더링될 수 있다. 이후 브라우저에서 해당 코드를 내려받아 hydrate하고 상태와 이벤트를 이어 간다.

반대로 Server Component는 브라우저에서 다시 실행되지 않는다.

RSC는 hydration을 없앤 것이 아니다.

hydration이 필요한 영역을 Client Component 경계 안으로 줄인 것이다.

RSC와 SSR은 초기 요청에서 함께 동작한다

Next.js App Router의 초기 요청을 단순화하면 다음과 같다.

flowchart TD
    A["Server Component 실행"] --> B["RSC Payload 생성"]
    C["Client Component 모듈"] --> D["초기 HTML 생성"]
    B --> D
    D --> E["브라우저가 HTML 표시"]
    B --> F["브라우저에서 전체 React 트리 연결"]
    C --> G["Client Component hydration"]
    E --> G
    F --> G

각 결과의 역할이 다르다.

  • HTML은 사용자가 즉시 볼 초기 화면을 제공한다.
  • RSC Payload는 서버와 클라이언트에 나뉜 React 트리를 이어 준다.
  • JavaScript는 브라우저에서 실행해야 하는 Client Component에 필요하다.
  • hydration은 Client Component의 상태와 이벤트를 기존 DOM에 연결한다.

따라서 RSC를 사용해도 HTML과 JavaScript와 hydration이 모두 존재할 수 있다.

다만 모든 Server Component의 구현을 브라우저에서 다시 실행할 필요는 없다.

Flight는 왜 필요했을까

서버에서 실행한 결과를 HTML로만 보내면 초기 화면은 만들 수 있다.

하지만 React는 초기 화면 이후에도 동작해야 한다.

사용자가 페이지를 이동할 때 기존 Client Component의 state를 유지하면서 서버에서 새로 렌더링한 UI를 받아야 한다. Server Component의 결과 사이에 Client Component가 어디에 들어가는지도 알아야 한다.

브라우저가 다음과 같은 개념적 정보만 받았다고 해보자.

json
{
  type: 'LikeButton',
  runtime: 'client',
  props: {
    initialCount: 10,
  },
}

브라우저는 "LikeButton"이라는 문자열만 보고 어느 JavaScript 모듈의 어떤 export를 실행해야 하는지 알 수 없다.

다음 정보가 더 필요하다.

  • Server Component의 렌더링 결과
  • Client Component가 들어갈 위치
  • Client Component의 모듈 참조
  • 서버에서 클라이언트로 전달할 props
  • 아직 완료되지 않은 Promise와 Chunk
  • Suspense 경계
  • 스트리밍되는 청크 사이의 참조 관계
  • Server Function 참조

이 정보를 React가 이해할 수 있는 형태로 전달하는 것이 Flight 또는 RSC Payload다.

Flight는 HTML의 대체재가 아니다.

서로 다른 실행 환경에서 만들어진 결과를 하나의 React 트리로 합치기 위한 React 전용 wire format에 가깝다.

Server Functions가 반대 방향의 경로를 만들었다

RSC 결과의 기본 흐름은 서버에서 브라우저로 향한다.

text
Server

RSC Payload

Browser

하지만 Client Component가 Server Function을 호출하려면 반대 방향도 필요하다.

브라우저는 서버 함수의 소스 코드를 보내지 않는다. 특정 서버 함수를 가리키는 참조와 직렬화된 인자를 보낸다.

flowchart LR
    A["Server Components"] -->|"RSC Payload<br/>서버에서 브라우저"| B["Browser"]
    B -->|"Flight Reply<br/>브라우저에서 서버"| C["Reply 디코더"]
    C --> D["Server Function"]
    D -->|"결과 또는 새로운 RSC Payload"| B

React의 현재 용어로는 서버에서 실행되는 함수 전체를 Server Function이라고 한다. 이 함수가 <form action>이나 Action 문맥에서 사용될 때 Server Action이라고 부른다.

React 패치 PR에 따르면 두 방향의 구현은 완전히 대칭적이지 않았다.

  • ReactFlightClient: 서버에서 브라우저로 온 결과를 해석
  • FlightReplyServer: 브라우저에서 서버로 온 요청을 해석

서버에서 브라우저로 보내는 데이터는 애플리케이션 서버가 만든다.

반대로 브라우저에서 서버로 들어오는 Flight Reply는 공격자가 직접 만들 수 있다. 같은 React 데이터 모델을 사용하더라도 신뢰 수준은 전혀 다르다.

CVE-2025-55182는 바로 이 클라이언트→서버 Reply 디코더에서 발생했다.

최종적으로 무엇이 원인이었나

처음의 질문으로 돌아가 보자.

이 취약점은 RSC의 Flight 프로토콜이 브라우저에서 서버로 요청을 받을 수 있는 구조가 됐고, JavaScript의 프로토타입 특성이 존재했기 때문에 발생한 것일까?

방향은 맞지만 조금 더 정확하게 말해야 한다.

RSC만으로는 설명이 부족하다. 서버에서 브라우저로 결과를 보내는 기능만 있었다면 이번 공격 경로는 성립하지 않는다. Server Functions를 위해 브라우저에서 서버로 Flight Reply를 보내고, 서버가 그 입력을 객체 그래프로 복원하는 경로가 필요했다.

JavaScript에 프로토타입이 있다는 사실만으로도 취약점은 발생하지 않는다. 디코더가 파서가 만든 own property만 따라갔다면 프로토타입 위의 React 내부 객체와 함수에 도달할 수 없었다.

Thenable 규칙 역시 취약점이 아니다. 공격자가 then을 제어하는 객체를 Promise 해석 경로에 넣을 수 있었기 때문에 함수 실행 수단으로 악용됐다.

따라서 최종 원인은 다음과 같이 정리할 수 있다.

Server Functions가 외부의 Flight Reply를 서버에서 해석하는 새로운 진입점을 만들었다. Flight 디코더는 참조와 Promise 같은 풍부한 객체 관계를 복원해야 했다. 이 과정에서 own property 검사가 누락되면서 공격자가 파서의 데이터 그래프를 벗어나 JavaScript의 프로토타입 체인을 탐색할 수 있었다. 여기에 React 내부 Chunk의 then, JavaScript의 Thenable 해석, 함수 생성자까지 연결되며 신뢰할 수 없는 데이터가 서버 코드 실행으로 바뀌었다.

더 짧게 줄이면 다음과 같다.

text
RSC와 Server Functions가 진입점을 만들었다.
Flight의 표현력이 복잡한 디코더를 만들었다.
검증 누락이 데이터와 런타임의 경계를 열었다.
JavaScript의 프로토타입과 Thenable이 그 틈을 코드 실행으로 연결했다.

RSC가 RCE를 필연적으로 만든 것은 아니다.

JavaScript의 프로토타입이 잘못된 것도 아니다.

신뢰할 수 없는 입력을 풍부한 JavaScript 객체 그래프로 복원하면서, 파서가 만든 데이터와 런타임 객체를 분리해야 할 경계가 무너진 것이 핵심이다.

이 취약점에서 무엇을 배울 수 있을까

프레임워크 보안과 애플리케이션 보안을 분리해서 봐야 한다.

CVE-2025-55182는 개발자가 작성한 Action 본문에 도달하기 전 React 디코더에서 발생했다.

이 계층의 대응은 다음과 같다.

  1. React와 프레임워크의 최신 보안 패치를 적용한다.
  2. WAF나 호스팅 업체의 임시 완화책을 패치의 대체재로 보지 않는다.
  3. 취약한 서버가 외부에 노출됐다면 로그 조사와 비밀값 교체를 검토한다.
  4. 서버 프로세스와 컨테이너에 최소 권한을 적용한다.
  5. 앱 코드뿐 아니라 프레임워크가 포함한 react-server-dom-* 버전을 확인한다.

Flight payload는 같은 의미를 여러 형태로 표현할 수 있다. 특정 문자열만 차단하는 WAF 규칙은 변형된 요청을 모두 막기 어렵다. 공식 패치가 근본적인 대응이다.

정상적인 Server Function 호출을 이용해 비즈니스 로직을 악용하는 공격은 별개의 문제다.

이 계층에서는 기존 API와 같은 원칙이 필요하다.

  1. Server Function을 내부 함수가 아니라 외부 API처럼 다룬다.
  2. 각 함수에서 현재 사용자를 다시 확인한다.
  3. 인증뿐 아니라 대상 리소스에 대한 인가를 검사한다.
  4. TypeScript 타입을 보안 경계로 믿지 않는다.
  5. 런타임 입력 검증을 수행한다.
  6. URL 파라미터, headers, cookies, Client Component에서 온 값을 신뢰하지 않는다.

RSC에는 데이터 노출과 관련된 원칙도 있다.

서버에서 사용한 데이터라고 해서 자동으로 비밀이 되는 것은 아니다.

Server Component의 구현 코드는 브라우저로 전달되지 않는다. 하지만 렌더링한 텍스트와 Client Component에 전달한 props는 RSC Payload를 통해 브라우저에 도달할 수 있다.

DB와 환경 변수에 접근할 수 있다는 사실과 그 결과가 클라이언트에 노출되지 않는다는 것은 전혀 다른 이야기다.

다시 Pod 로그를 보며

처음 재현에 성공했을 때 Pod 로그에 남은 것은 내가 지정한 짧은 문자열뿐이었다.

그 문자열을 이해하기 위해 SSR부터 hydration, RSC Payload, Flight Reply, JavaScript의 프로토타입 체인과 Thenable까지 꽤 먼 길을 돌아왔다.

처음에는 “취약점이 있으니 패치해야 한다”는 문제였다.

조금 더 들어가니 “왜 외부 요청이 이 디코더에 도달하는가”라는 질문이 됐다.

더 들어가니 “왜 React에 이런 디코더가 필요한가”라는 질문이 됐다.

마지막에는 “왜 우리는 서버에서 실행한 React를 브라우저에서 다시 실행하고 있었는가”라는 질문까지 도달했다.

결국 이번 취약점은 RSC가 잘못된 아이디어였다는 증거라기보다 RSC가 선택한 구조의 비용을 보여 준 사건에 가까웠다.

서버와 클라이언트의 코드를 나누면 브라우저가 실행할 JavaScript를 줄일 수 있다. 하지만 두 환경을 다시 하나의 React 트리로 연결하는 프로토콜이 필요해진다.

그 프로토콜이 더 많은 의미를 표현할수록 구현은 복잡해진다. 외부 입력을 해석하는 서버 경계는 그만큼 엄격하게 보호해야 한다.

flowchart LR
    A["실행 환경 분리"] --> B["서버·클라이언트 경계"]
    B --> C["두 환경을 연결할 프로토콜"]
    C --> D["풍부한 직렬화와 참조 복원"]
    D --> E["새로운 공격 표면"]
    E --> F["엄격한 경계 검증과 패치 필요"]

아키텍처의 이점과 보안 비용은 서로 다른 곳에서 우연히 만난 것이 아니었다. 같은 설계 결정의 양면이었다.

팀원들이 재미있다고 했던 것도 취약점 자체는 아니었을 것이다.

주어진 패치만 적용하고 끝내지 않고, 왜 그런 문제가 생겼는지 질문하고, 안전한 환경에서 직접 확인하고, 이해한 내용을 다시 팀의 언어로 공유하는 과정이 재미있었던 것이라고 생각한다.

팀 리드로서 내가 만들고 싶었던 문화도 그런 것이었다.

회고에서 문제를 발견해도 움직이지 못하는 팀보다, 궁금한 것이 생기면 끝까지 따라가 볼 수 있는 팀. 블로커를 발견하면 누군가 해결해 주기를 기다리기보다 함께 구조를 이해하고 길을 만드는 팀.

그런 일이 재미있다면, 조금 오래 해도 괜찮을 것 같다.

참고

React2Shell(CVE-2025-55182) 취약점 완전 분석