CVE-2025-55182 (React2Shell) — Next.js 15.x 비인증 RCE, 우리가 뚫린 이유
1편에서 Coolify 배포 로그 속에 숨어 있던 크립토마이너를 발견했습니다. /tmp에 바이너리가 있었고, 마이닝 풀에 연결된 흔적이 남아 있었습니다. 그런데 어떻게 들어왔을까요. 이번 글에서는 그 침입 경로를 추적합니다.
배포된 버전을 확인하다
가장 먼저 한 일은 Coolify에 실제로 배포된 커밋을 확인하는 것이었습니다. 배포 기록을 보니 커밋 4504557이 마지막으로 배포된 상태였습니다. 해당 커밋의 pnpm-lock.yaml을 열어 Next.js 버전을 찾았습니다.
15.1.3이었습니다.
그리고 lock 파일에는 이런 문구가 적혀 있었습니다.
next@15.1.3: deprecated, security vulnerability. See https://nextjs.org/blog/CVE-2025-66478
패키지 매니저가 경고하고 있었습니다. pnpm install을 실행할 때마다 이 메시지가 출력되었을 텐데, 저는 그것을 무시했습니다. 아니, 솔직히 말하면 눈에 들어오지 않았다는 표현이 더 정확합니다. deprecated 경고는 너무 자주 보다 보면 배경 소음이 되어버립니다.
CVE-2025-55182 — React2Shell
Next.js 15.1.3에 영향을 미치는 취약점은 여러 개 있었지만, 이번 침해의 직접적인 원인은 CVE-2025-55182, 일명 React2Shell이었습니다. CVSS 점수 10.0 — 최대 심각도입니다.
취약점의 본질
React Server Components(RSC)의 Flight 프로토콜에 prototype pollution 취약점이 있었습니다. 조금 풀어서 설명하겠습니다.
JavaScript의 모든 객체는 프로토타입 체인을 가지고 있습니다. {}로 빈 객체를 만들어도 Object.prototype을 상속받습니다. Prototype pollution이란, 공격자가 이 프로토타입 체인에 임의의 속성을 주입하는 공격입니다. 한 객체의 프로토타입을 오염시키면, 그 프로토타입을 상속받는 모든 객체가 영향을 받습니다.
Next.js의 RSC Flight 프로토콜은 서버와 클라이언트 사이에서 컴포넌트 데이터를 직렬화/역직렬화합니다. 이 과정에서 사용자 입력에 대한 검증이 불충분했고, 공격자가 __proto__를 통해 프로토타입을 오염시킬 수 있었습니다.
prototype pollution에서 RCE까지
prototype pollution만으로는 원격 코드 실행(RCE)에 도달하기 어렵습니다. 하지만 React2Shell은 그 경로를 찾아냈습니다.
핵심은 requireModule 함수였습니다. 이 함수가 모듈을 로드할 때 hasOwnProperty 검사를 하지 않았습니다. 프로토타입이 오염된 상태에서 requireModule이 호출되면, 공격자가 주입한 속성을 자신의 속성으로 착각하고 처리합니다. 이 체인을 따라가면 결국 child_process.execSync()에 도달할 수 있었습니다.
정리하면 이런 흐름입니다.
POST 요청 (Next-Action: dontcare 헤더 + 조작된 multipart form data)
→ Flight 프로토콜 역직렬화
→ __proto__ 오염 (prototype pollution)
→ requireModule()에서 hasOwnProperty 미검사
→ 오염된 속성을 정상 모듈로 처리
→ child_process.execSync() 도달
→ 임의 명령어 실행 (RCE)
공격의 조건
이 취약점이 특히 위험했던 이유가 있습니다.
- 인증이 필요 없습니다. 누구나 POST 요청 하나로 공격할 수 있습니다.
- App Router를 사용하는 모든 Next.js 앱이 대상입니다. 특정 설정이나 코드 패턴에 의존하지 않습니다.
- PoC가 공개 직후 대규모 악용이 시작되었습니다. 2025년 12월 3일 CVE 공개, 12월 4일 PoC 공개, 그날부터 자동화된 스캐닝이 시작되었습니다.
보안 연구 그룹의 보고에 따르면, UAT-10608이라 명명된 공격 그룹이 이 취약점을 이용해 766개 이상의 호스트를 침해한 것으로 기록되어 있습니다. 제 서버도 그 숫자 중 하나였던 셈입니다.
패치는 있었습니다
여기서부터가 가장 뼈아픈 부분입니다.
깃 로그를 확인해보니, 커밋 f5edf08에 "긴급 보안 패치 7건"이라는 메시지가 있었습니다. 4월 5일에 커밋된 것이었습니다. Next.js 버전 업그레이드를 포함한 보안 패치가 분명히 존재했습니다.
문제는 이 커밋이 Coolify에 배포되지 않았다는 것입니다.
배포된 커밋 4504557과 패치 커밋 f5edf08 사이에는 5개의 커밋이 있었습니다. 패치를 작성하고, 커밋하고, 푸시까지 했지만, 정작 프로덕션에 배포하는 마지막 단계를 빠뜨렸습니다. 코드는 안전해졌지만 서버는 여전히 취약한 상태로 돌아가고 있었습니다.
돌이켜 생각해보면, "패치를 커밋했으니 됐다"는 안도감이 있었던 것 같습니다. 하지만 배포되지 않은 패치는 패치가 아닙니다. 코드 저장소에 있는 수정 사항은 서버에 반영되기 전까지 아무런 보호막이 되지 못합니다.
뚫린 이유를 하나씩 세어보면
하나의 취약점이 침해로 이어지려면 보통 여러 조건이 겹쳐야 합니다. 우리 경우도 마찬가지였습니다.
| 조건 | 상태 | 설명 |
|---|---|---|
| Next.js 버전 | 15.1.3 (취약) | CVE-2025-55182 영향 범위 |
| 라우터 | App Router | RSC Flight 프로토콜 사용 |
| 호스팅 | 셀프호스팅 (Coolify) | Vercel은 플랫폼 레벨에서 자동 보호 |
| 패치 상태 | 커밋됨, 미배포 | 5개 커밋 차이로 방치 |
| 미들웨어 | 없음 | Edge 레이어 보안 부재 |
| CSP 헤더 | 없음 | Content Security Policy 미설정 |
| 이미지 호스트 | 와일드카드 (**) |
SSRF 벡터 열림 |
어느 하나만 달랐어도 결과가 바뀌었을 수 있습니다. Vercel에 배포했다면 플랫폼이 알아서 막아줬을 것이고, 패치를 배포까지 했다면 취약점 자체가 없었을 것이며, 미들웨어가 있었다면 비정상적인 요청을 걸러냈을 수도 있습니다.
셀프호스팅은 자유도가 높은 만큼, 이 모든 방어 계층을 직접 챙겨야 한다는 뜻이기도 합니다. 그리고 저는 그중 상당 부분을 챙기지 못했습니다.
공격 체인 재구성
로그와 남아 있는 흔적을 바탕으로, 공격이 어떻게 진행되었는지 재구성해봤습니다.
1. 자동화된 스캐너가 Shodan/Censys에서 Next.js 앱 탐지
2. CVE-2025-55182 exploit POST 전송 → child_process.execSync() RCE 획득
3. wget 또는 curl로 /tmp에 마이너 바이너리 다운로드
4. chmod 777 → 실행
5. 마이닝 풀(47.239.74.230:33333) 연결
6. 채굴 시작
몇 가지 특징이 있었습니다. 환경변수를 탈취한 흔적이 없었고, 다른 컨테이너로 횡적 이동을 시도한 흔적도 없었습니다. 이것은 표적 공격이 아니라 자동화된 대량 봇이었다는 것을 의미합니다. 취약한 서버를 발견하면 기계적으로 마이너를 심고 다음 서버로 넘어가는 방식입니다.
어떻게 보면 다행이었습니다. 만약 환경변수에 있는 데이터베이스 접속 정보나 API 키가 탈취되었다면, 피해 범위가 훨씬 넓어졌을 것입니다. 하지만 "다행"이라는 표현을 쓰기엔, 서버가 남의 코인을 채굴하고 있었다는 사실이 충분히 부끄럽습니다.
15.1.3이 품고 있던 다른 취약점들
CVE-2025-55182만이 문제가 아니었습니다. Next.js 15.1.3에는 다른 심각한 취약점들도 함께 존재했습니다.
| CVE | CVSS | 내용 |
|---|---|---|
| CVE-2025-29927 | 9.1 | 미들웨어 인증 우회 |
| CVE-2025-55184 | - | 무한 루프를 통한 DoS |
| CVE-2025-55183 | - | 소스 코드 노출 |
| CVE-2025-49826 | - | 캐시 포이즈닝 |
미들웨어 인증 우회(CVE-2025-29927)도 CVSS 9.1로 치명적이었습니다. 만약 미들웨어로 인증을 구현하고 있었다면 이것만으로도 심각한 문제가 되었을 것입니다. 소스 코드 노출이나 캐시 포이즈닝도 공격 표면을 넓히는 데 기여할 수 있는 취약점들이었습니다.
하나의 버전에 이렇게 많은 취약점이 겹쳐 있었다는 사실이, 버전 업데이트를 미루는 것이 얼마나 위험한지를 보여줍니다.
배포까지가 패치입니다
이번 사건에서 가장 크게 느낀 것은 결국 이것이었습니다. 코드를 고치는 것과 서버를 고치는 것은 다른 일이라는 것입니다.
git commit과 git push는 코드 저장소를 업데이트할 뿐입니다. Coolify에서 "배포" 버튼을 누르거나, CI/CD 파이프라인이 자동으로 배포를 트리거하기 전까지, 프로덕션 서버는 이전 코드 그대로 돌아갑니다. 저는 그 간극을 5개 커밋 동안 방치했습니다.
다음 편에서는 이 사건 이후 어떻게 대응하고, 같은 일이 반복되지 않도록 어떤 방어 체계를 구축했는지 이야기하겠습니다. 패치 자동화, 미들웨어 도입, CSP 설정, 이미지 호스트 제한, 그리고 Coolify 배포 파이프라인 개선까지 — 뚫린 만큼 배운 것들이 있습니다.
관련 글
배포 실패 로그를 추적하다 크립토마이너를 발견한 이야기
Coolify에서 NestJS 배포가 타임아웃으로 실패했습니다. 서버 리소스를 확인하려고 docker stats를 열었는데, Next.js 프론트엔드 컨테이너가 CPU 123%, 메모리 2.41GB를 점유하고 있었습니다. 컨테이너 안에서 발견한 정체불명의 바이너리와 마이닝 풀 연결까지, 배포 실패에서 크립토마이너를 발견하기까지의 추적 기록입니다.
SaaS 블로그 플랫폼을 Coolify로 배포하며 마주친 실전 문제들
글력(SaaS 블로그 플랫폼)을 Coolify에 처음 배포하면서 겪은 메모리 최적화, OG 이미지 문제, 봇 스캔 방어, Redis 연결, CORS까지. 실전에서 하나씩 해결한 과정을 정리했습니다.
Deep Health Check로 Next.js 배포 안정성 높이기: 롤링 업데이트의 함정을 피하는 방법
Coolify 롤링 업데이트에서 Health Check가 성공해도 실제 페이지 렌더링이 실패하는 문제를 경험했습니다. ESM 모듈 호환성 에러와 정적 렌더링 실패를 해결하고, Deep Health Check를 구현하여 배포 안정성을 개선한 과정을 공유합니다.