2026년 5월의 React/Next.js 보안 배치를 정리하던 중 한 critical이 끝까지 살아남았습니다. 스와이퍼(Swiper), CVSS 9.4. 직접 의존성으로 잡힌 단 하나의 critical이었습니다.
다시 돌아온 손님 — CVE-2026-27212
같은 라이브러리의 같은 함수에서 5년 만에 두 번째 프로토타입 오염(prototype pollution) 이 발견된 사례였습니다.
| 구분 | CVE-2021-23370 | CVE-2026-27212 |
|---|---|---|
| 공개 시점 | 2021년 | 2026년 2월 |
| 영향 범위 | < 6.5.1 | 6.5.1 ~ 12.1.1 |
| 패치 버전 | 6.5.1 | 12.1.2 |
| 방어 전략 | 위험한 키 이름을 블랙리스트로 거름 | 블랙리스트 + 검사 함수의 무결성 가정 제거 |
2021년 패치는 사용자 입력에 __proto__나 constructor 같은 위험한 키가 들어 있는지 검사해 막는 방식이었습니다. 합리적으로 보였습니다. 그러나 그 검사 자체가 자바스크립트 표준 라이브러리의 한 메서드를 통해 이뤄지고 있었다는 점이 두 번째 취약점의 진입점이 됐습니다.
공격자가 다음 한 줄로 검사용 메서드를 먼저 덮어쓰면 됩니다.
// 검사 함수가 항상 "발견되지 않음"을 반환하도록 사전 오염
Array.prototype.indexOf = () => -1;
그 다음에 위험한 키가 든 입력을 흘려보내면, 블랙리스트 검사는 어떤 키든 통과시킵니다. 한 줄의 사전 오염이 한 줄의 방어를 무력화한다는 뜻입니다. Node든 Bun이든 동일한 자바스크립트 런타임이라면 그대로 동작합니다.
흥미로운 부분은 첫 번째 패치가 단순히 “구멍이 있었다”가 아니라, 블랙리스트(blacklist) 라는 방어 전략의 가정 자체였다는 점입니다. 블랙리스트는 “위험한 키의 집합이 알려져 있고, 그 검사 도구는 신뢰할 수 있다”는 두 전제를 깔고 있습니다. 두 번째 전제가 깨지면 첫 번째도 따라 무너집니다. 화이트리스트나 프로토타입이 없는 객체(Object.create(null)) 위에서 작업하는 방식이었다면 두 번째 우회는 성립하기 어려웠을 것입니다.
advisory가 정의한 영향 범위는 6.5.1부터 12.1.1까지였습니다. 즉 첫 번째 패치 이후의 거의 모든 버전이 다시 취약한 셈입니다.
코드를 열어보기 전과 후 — 첫 번째 앱
저장소 한 곳은 사내 어드민 앱이었습니다. 같은 백엔드를 공유하는 자매 저장소(소비자용 서비스)의 코드베이스를 시작점으로 떼어 만든 앱이라, 의존성 트리를 그대로 이어받았습니다. 스와이퍼도 그 중 하나였습니다. 즉 옮겨 심으면서 같이 따라온 셈이었습니다.
직접 의존성으로 잡혀 있었으니 audit이 critical로 보고하는 것은 정확한 동작입니다. 그러나 그 다음 한 줄이 핵심이었습니다. grep -rn "from 'swiper". 결과는 0건이었습니다. 코드 어디에서도 스와이퍼의 자바스크립트 API를 import하지 않았고, 클래스 이름을 직접 사용하는 곳도 없었습니다. 글로벌 스타일 진입점에 CSS만 세 줄 import되어 있었을 뿐입니다.
이 상태에서는 advisory가 지목한 취약 함수가 런타임에 한 번도 호출되지 않습니다. 취약한 코드 경로가 살아 있지 않은데 의존성 트리에만 빨간 줄이 그어진 셈입니다. 실효 위험은 사실상 “사용되지 않는 스타일시트가 번들에 포함되어 있음” 수준이었습니다.
업그레이드가 아니라 제거가 답이었습니다. CSS import 세 줄을 지우고 의존성을 빼니 critical 한 줄이 깨끗하게 사라졌습니다. 빌드도 통과했고, UI가 깨질 일도 없었습니다. 깨질 UI가 애초에 존재하지 않았기 때문입니다.
같은 CVE, 다른 앱 — 두 번째 앱
같은 백엔드를 공유하는 다른 저장소가 있었습니다. 일반 사용자용 공개 서비스 쪽입니다. 동일한 버전, 동일한 critical이 떴습니다.
다만 grep 결과가 달랐습니다. 한 컴포넌트에서 스와이퍼를 실제로 import해 쓰고 있었습니다. 게시글 본문에 첨부된 이미지가 둘 이상일 때 활성화되는 갤러리였습니다. 즉 취약한 코드 경로가 사용자 입력을 받는 곳에서 실제로 실행되는 환경이었습니다.
여기서는 제거가 답이 아니라 메이저 업그레이드였습니다. 11에서 12로 올리는 작업이 따라붙었습니다.
| 항목 | 첫 번째 앱 | 두 번째 앱 |
|---|---|---|
| 실제 사용 방식 | CSS import만 | 한 컴포넌트의 이미지 갤러리 |
| 취약 경로 도달 | 없음 | 있음 |
| 결정 | 의존성 제거 | 메이저 업그레이드 (11 → 12) |
| UI 검수 | 불필요 | 필요 (수동) |
다행히 사용 중인 옵션은 코어 props 몇 개에 한정되어 있었습니다. 모듈 시스템(Navigation/Pagination/Autoplay)을 JSX에서 직접 결합하지 않는 형태였고, 핵심 import 경로는 v9 이후로 안정적이었습니다. 메이저 범프임에도 코드 변경 사항은 사실상 없었습니다. 빌드도 타입체크도 모두 통과했습니다.
그러나 여기서 멈출 수 없었습니다. 빌드가 통과한다는 사실은 UI가 깨지지 않는다는 뜻이 아닙니다. 스와이퍼는 슬라이드 간격, 좌우 오프셋, 터치 동작 같은 런타임 레이아웃을 다루는 라이브러리입니다. 빌드 도구도 타입 체커도 이런 부분은 검증하지 못합니다. 실제 단말기에서 갤러리를 열어 손가락으로 쓸어보는 검수가 마지막 단계로 남았습니다.
실무에서 챙길 것
같은 라이브러리, 같은 CVE, 같은 조직이었습니다. 그러나 한쪽은 제거로, 다른 한쪽은 업그레이드로 갔습니다. 이 분기를 만든 것은 advisory가 아니라 grep 한 줄이었습니다. CVSS 점수도 영향 버전도 두 앱에 동일하게 적용됐지만, 코드가 그 라이브러리를 어떻게 호출하는지는 별개의 이야기였습니다. 의존성의 실효 위험(actual risk) 은 advisory의 점수가 아니라 사용 방식에서 결정됩니다.
오해는 없어야 합니다. critical을 무시해도 된다는 이야기가 아닙니다. 그대로 두는 것은 잘못된 선택입니다. 다만 “critical = 무조건 메이저 업그레이드”라는 자동 반사를 의심하자는 뜻입니다. 데드 코드(dead code)라면 제거가 답이고, 실사용이면 업그레이드가 답이며, 업그레이드가 매끄럽더라도 UI 라이브러리라면 사람의 눈이 한 단계 더 들어가야 합니다.
같은 작업 중에 npm audit이 next를 9.3.3으로, firebase-admin을 10.3.0으로 다운그레이드하라고 권고한 일이 있었습니다. 각각 6년 전, 3년 전 버전입니다. 도구의 권고가 항상 옳은 결론은 아니라는 점을 그 두 줄이 다시 확인해줬습니다. 입력으로 받고 판단은 사람이 합니다.
참고 자료
- GitHub Advisory Database. GHSA-hmx5-qpq5-p643 (CVE-2026-27212): Prototype pollution in swiper. https://github.com/advisories/GHSA-hmx5-qpq5-p643
- nolimits4web/swiper. Security Advisory: GHSA-hmx5-qpq5-p643. https://github.com/nolimits4web/swiper/security/advisories/GHSA-hmx5-qpq5-p643
- Snyk Vulnerability Database. CVE-2021-23370: Prototype Pollution in swiper. https://security.snyk.io/vuln/SNYK-JS-SWIPER-1089689
- nolimits4web/swiper. Release v12.1.2. https://github.com/nolimits4web/swiper/releases/tag/v12.1.2