RSC는 왜 필요했고, 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 프로세스와 컨테이너에 부여된 권한에 따라 달라진다. 그래서 패치만이 아니라 최소 권한과 런타임 격리까지 함께 본다.

정확히는 RSC 구현 패키지 쪽이 영향을 받았다. react와 react-dom 패키지 자체는 포함되지 않는다.

  • 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 검사 누락"]:::cost --> C
    C --> E["프로토타입 체인으로 탐색 범위 확장"]
    E --> F["React 내부 Chunk의 then 접근"]
    F --> G["공격자가 제어하는 Thenable 구성"]
    G --> H["Promise 해석 과정에서 함수 호출"]
    H --> I["Node.js 서버에서 코드 실행"]:::cost

요소마다 맡은 자리가 다르니 구분해 두는 편이 낫다.

구분 의미
구조적 조건 브라우저에서 서버로 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

breathe는 dog가 직접 가진 속성이 아니다.

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"

toString은 user가 직접 가진 속성이 아니다. 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); // "완료"

pretendPromise는 new 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) 호출"]:::focus
    E --> F["Thenable이 넘긴 값으로 계속 해석"]

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

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

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

Flight는 스트리밍 중 아직 준비되지 않은 값을 다루기 위해 내부적으로 Chunk라는 객체를 사용했다. 취약 당시 React 소스에서 Chunk는 Promise.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와 비슷하게 행동하도록 직접 만든 React 내부의 Thenable이었다.

공격자는 Flight의 참조 기능을 이용해 Chunk에 접근하고 own property 검사가 빠진 경로를 타고 Chunk.prototype.then까지 탐색할 수 있었다. 그다음 자신이 만든 일반 객체의 then 속성이 React 내부의 Chunk.prototype.then을 가리키도록 맞출 수 있었다.

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

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

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

상속받은 메서드를 호출할 때 this가 어디를 가리키느냐가 여기서 갈린다. JavaScript는 그 메서드를 호출한 객체 쪽을 this로 삼는다.

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

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

typescript
const object = {};

object.constructor === Object; // true

함수 역시 객체이므로 생성자가 따로 있다.

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

그러니 일반 객체에서 프로토타입 체인을 제한 없이 따라갈 수 있다면 이런 관계까지 도달한다.

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

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

React 공식 공지는 패치 이후 세부 공개를 예고했을 뿐 이 지점까지는 설명하지 않았다. 이후 공개된 보안 연구 분석을 종합하면, 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 검사 누락"]:::cost
    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 서버에서 함수 실행"]:::cost

같은 흐름을 단계로 끊어 보자.

  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 디코딩"]:::cost
    B --> C["Server Function 식별"]
    C --> D["개발자가 작성한 Action 본문"]
    D --> E["인증·인가·비즈니스 로직"]

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

개발자가 작성한 인증과 입력 검증은 D나 E 단계에 있어서 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

    class Server boundary
  • 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가 어디에 들어가는지도 알아야 한다.

브라우저가 개념적으로 이만큼만 받았다고 해보자.

typescript
{
  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는 서로 다른 실행 환경에서 만들어진 결과를 하나의 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 디코더"]:::focus
    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의 프로토타입 특성이 존재했기 때문에 발생한 것일까?

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

Server Functions를 위해 브라우저에서 서버로 Flight Reply를 보내고, 서버가 그 입력을 객체 그래프로 복원하는 경로가 있어야 이번 공격이 성립한다. 서버에서 브라우저로 결과를 보내는 기능만으로는 이 경로가 생기지 않는다.

JavaScript의 프로토타입도 Thenable 규칙도 그 자체로는 문제가 아니다. 디코더가 파서가 만든 own property만 따라갔다면 프로토타입 위의 React 내부 객체에 도달할 수 없었고, 공격자가 then을 제어하는 객체를 만들 수 있었기 때문에 비로소 Thenable 규칙이 함수 실행 수단으로 악용됐다.

최종 원인은 이렇게 정리할 수 있다.

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["새로운 공격 표면"]:::cost
    E --> F["엄격한 경계 검증과 패치 필요"]

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

참고

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