NextJS는 끝났습니다... 무려 13개의 새로운 보안 취약점 발견

BBetter Stack
컴퓨터/소프트웨어경제 뉴스AI/미래기술

Transcript

00:00:00또 터졌습니다. 올해만 벌써 서버 컴포넌트 CVE 관련 세 번째 영상인데,
00:00:05모든 사례를 다 다루지도 못한 것 같네요. 이번에는 React와 Next.js 전반에 걸쳐
00:00:11무려 13개의 CVE가 발견되었습니다. 그중 6개는 심각도가 높으며,
00:00:15서비스 거부, 미들웨어 우회, XSS 등을 포함합니다. 서버 컴포넌트가 실수였을지도 모르겠네요.
00:00:20여기 Next.js 보안 릴리스가 있습니다. 이번 달에 발생한 몇 가지 자잘한(?) 문제들을
00:00:28수정하고 있죠. 하단에 적힌 해결책은 당연히 모든 Next.js 버전을
00:00:32업그레이드하는 것이고, 영향을 받는 버전들도 명시되어 있습니다. 참고로
00:00:36TanStack은 이번 이슈의 영향을 받지 않습니다. 제 사견일 수 있지만, TanStack을 써야 할 이유가 하나 더 늘었네요.
00:00:41이걸 다 훑어보기엔 시간이 너무 오래 걸릴 것 같고,
00:00:44모든 항목에 대한 실행 가능한 익스플로잇을 찾은 것도 아니기에,
00:00:48카테고리별로 하나씩 보여드리겠습니다. 먼저 미들웨어 및 프록시 우회부터 시작하죠.
00:00:52재현에 성공한 것은 이 Pages Router 관련 이슈입니다. i18n을 사용하는 경우
00:00:56Pages Router에서 미들웨어 프록시 우회가 가능하며, 심각도는 10점 만점에 7.5점입니다.
00:01:02취약한 애플리케이션의 예시입니다. Next.js 설정에서 i18n을 활성화하고
00:01:06영어와 프랑스어 두 가지 로케일을 설정했습니다. 그리고 미들웨어 파일도 있는데,
00:01:12최신 버전에서는 제가 보여드릴 혼란을 피하기 위해 'proxy'로 이름이 바뀌기도 했습니다.
00:01:16본래 미들웨어는 들어오는 요청을 가로채서
00:01:19리다이렉트, 리라이트, 헤더 추가 등의 수정을 가능하게 해줍니다.
00:01:24제 사례에서는 사용자가 '/secret' 페이지에 접속하려고 할 때,
00:01:28세션 쿠키가 있는지(로그인 여부) 확인합니다. 로그인되지 않았다면
00:01:32로그인 페이지로 리다이렉트시켜서, 인증된 사용자만 비밀 페이지를 보게 하려는 의도죠.
00:01:37하단에는 매처(matcher)를 두어, 비밀 페이지에 적용되는 모든 미들웨어가
00:01:41로케일 변형 URL과도 일치하도록 했습니다. 로케일이 두 개이므로
00:01:45기술적으로는 이 URL의 버전이 세 가지가 되는 셈이죠. 비밀 페이지 자체에는
00:01:50렌더링 시 서버에서 데이터를 가져오는 getServerSideProps가 설정되어 있습니다.
00:01:54이론적으로는 미들웨어가 있으니 로그인한 사용자만 이 값들을 볼 수 있어야 합니다.
00:01:58이 값들은 이메일, 플래그, 헤드라인 등 실제 페이지에서 사용되는 정보들이며,
00:02:03오직 인증된 사용자만 볼 수 있어야 합니다. 그럼 테스트를 해보죠.
00:02:07먼저 비밀 페이지에 접속을 시도하면, 보시는 것처럼
00:02:11로그인되지 않았기에 리다이렉트됩니다. 미들웨어가 잘 작동하는 것 같네요.
00:02:16하지만 우리가 '해커'라면 어떨까요? 먼저 '요소 검사'를 해봅시다.
00:02:20아주 기초적인 해킹(?)이죠. 그다음 하단의 NEXT_DATA 스크립트에서
00:02:24빌드 ID를 찾아야 합니다. 제 경우에는 이 부분이고요, 복사해 둡시다.
00:02:28이제 URL을 입력합니다. '*next/data/', 복사한 '빌드 ID',
00:02:32그리고 접근하려는 '페이지명.json' 순서입니다. 엔터를 치면,
00:02:37미들웨어 뒤에 보호되어야 할 props 데이터가 그대로 반환되는 걸 볼 수 있습니다.
00:02:40플래그, 이메일, 헤드라인, 그리고 구독해달라는 메시지까지 들어있네요.
00:02:44더 많은 팁을 원하시면 구독 부탁드리고요. 제 '해커 기술'에 놀라셨나요?
00:02:48하지만 왜 이런 일이 일어날까요? 설명은 실행 방법만큼이나 간단합니다.
00:02:52비밀 페이지에 getServerSideProps가 있는 경우,
00:02:56해당 데이터는 이런 형태의 URL을 통해 서빙됩니다. 미들웨어는 이 경로를 보호해야 하죠.
00:03:00문제는 i18n을 사용 중이라 영어와 프랑스어 변형 URL도 존재한다는 겁니다.
00:03:05서버 사이드 props 데이터 역시 각각의 언어 변형 경로를 가집니다.
00:03:09그런데 Next.js 코드의 결함으로 인해 i18n이 활성화된 경우,
00:03:13기본 케이스(base case)를 제대로 보호하지 못했습니다. 즉, 매처에 포함되지 않은 거죠.
00:03:18영어와 프랑스어 버전은 보호되지만, 기본 '/secret' 경로는 뚫려 있는 겁니다.
00:03:22URL을 영어 버전으로 바꿔보면 바로 로그인 페이지로 리다이렉트되는 걸 볼 수 있죠.
00:03:26매우 단순한 취약점입니다. 하지만 솔직히 말씀드리면,
00:03:31이런 미들웨어 우회는 소문보다 실제 위험이 덜할 때가 많습니다. 물론 좋지는 않지만,
00:03:35애초에 미들웨어 하나에만 보안을 의존해서는 안 되기 때문입니다.
00:03:40Next.js 측에서도 권장하지 않는 방식이죠. 민감한 데이터가 포함된
00:03:44서버 사이드 props에 별도의 서버 인증 로직이 아예 없었다면,
00:03:48그건 개발자의 부주의 탓도 큽니다. 이제 더 치명적인 '서비스 거부(DoS)'로 넘어가 보죠.
00:03:53DoS 관련은 세 건이 있었는데, 확실히 재현할 수 있었던 건
00:03:56서버 컴포넌트를 이용한 이 케이스였습니다. Next.js뿐만 아니라
00:04:01'react-server-dom' 패키지를 사용하는 모든 프레임워크에 영향을 줍니다.
00:04:05Vinxi나 이를 포크한 다른 프레임워크들이 해당하죠. TanStack Start는
00:04:10이걸 쓰지 않아서 안전합니다. 이 역시 심각도는 7.5점입니다.
00:04:14매우 간단한 Next.js 앱만 있으면 됩니다. 앱 안에 서버 액션이
00:04:18하나라도 있으면 되죠. 아주 단순한 거라도 상관없습니다.
00:04:22현재 사이트가 실행 중이고, 새로고침을 하면 거의 로딩 없이
00:04:25즉각 반응합니다. 수치로 확인해보면 요청을 보냈을 때
00:04:29응답에 0.02초가 걸립니다. 하지만 이제 익스플로잇을 실행하고
00:04:34다시 요청을 보내면, 이번에는 6초가 걸립니다. 단 한 번 실행했을 뿐인데 말이죠.
00:04:39여러 번 반복한다면 어떤 일이 벌어질지 상상해 보세요.
00:04:42이걸 이해하려면 'React Flight' 프로토콜을 조금 알아야 합니다.
00:04:46서버와 클라이언트 간에 컴포넌트 트리와 데이터를 직렬화해 주고받는 포맷이죠.
00:04:50아마 본 적이 있으실 텐데, 서버 액션이 있는 폼을 예로 들어보죠.
00:04:54네트워크 탭에서 전송 버튼을 누르면, 페이로드가 외계어 같은
00:04:58데이터 형태로 전송되는 것을 볼 수 있습니다. 응답 데이터도 마찬가지고요.
00:05:02이 페이로드를 복사해서 서버에서 무슨 일이 일어나는지 설명해 드릴게요.
00:05:05첫 단계는 역직렬화(deserialization)입니다. 청크 0번부터 시작하는데,
00:05:10여기 '$k1'이 보이죠? 이건 일종의 포인터로, 하단에 언더바 하나(*)로 시작하는
00:05:16폼 데이터가 있을 거라는 의미입니다. 서버는 페이로드로 보낸
00:05:20다른 모든 키들을 훑으며 언더바 하나로 시작하는 문자열을 찾습니다.
00:05:24그걸 찾으면 해당 키와 값을 매칭하죠. 과정이 끝나면 이름, 이메일,
00:05:28메시지 등을 포함한 객체가 완성됩니다. 아주 간단하죠.
00:05:32문제는 이 방식이 확장될 때 발생합니다. 만약 포인터를 하나 더 추가해서
00:05:36'$k2'를 찾는다고 해보죠. 이건 언더바 두 개(__)로 시작하는 키를 찾을 겁니다.
00:05:41서버는 '$k1'을 찾기 위해 키 6개를 전부 훑은 뒤,
00:05:44'$k2'를 찾기 위해 또다시 처음부터 끝까지 6개를 훑어야 합니다.
00:05:48결과적으로 총 12번의 탐색이 이루어지는 셈이죠.
00:05:52아직은 괜찮지만, 이걸 극단적으로 늘려보겠습니다. 페이로드에
00:05:56의미 없는 키 199,999개를 넣고, 배열 첫머리에 '$k1', '$k2'부터
00:06:03'$k1000'까지의 포인터를 넣는다면 어떨까요? 서버는 언더바 1개부터
00:06:071,000개까지의 키를 찾기 위해 20만 개의 키를 매번 반복해서 훑어야 합니다.
00:06:12총 2억 번의 문자열 비교 연산이 발생하는 것이죠.
00:06:17당연히 메인 스레드가 몇 초 동안 차단될 수밖에 없습니다.
00:06:21이 문제를 해결한 커밋으로 보입니다. 수정 사항이 꽤 많고 복잡하지만,
00:06:25최대한 쉽게 설명해 드릴게요. 이제는 커서 기반 시스템을 사용합니다.
00:06:28페이로드로 받은 20만 개의 키를 리스트로 불러온 뒤,
00:06:33'$k1' 참조를 찾을 때 뒤로 돌아가지 않는 커서를 사용해 리스트를 내려갑니다.
00:06:36'j1'이 일치하지 않으면 'j2'로 가고, 이런 식으로 계속 내려가서
00:06:41리스트의 끝인 199,999번째 키까지 확인합니다.
00:06:45'$k1' 매칭에 실패하면 '$k2'로 넘어가는데, 중요한 건
00:06:50커서 기반이라 다시 처음으로 돌아가지 않고 그 자리에서 멈춥니다.
00:06:54이미 리스트 끝에 도달했으므로 '$k2'부터 '$k1000'까지는 탐색 없이
00:07:01즉시 undefined로 처리됩니다. 결과적으로 키 전체를 딱 한 번만 훑게 되죠.
00:07:06연산 횟수를 '참조 개수(k) * 키 개수(n)'에서 'n + k'로 줄인 셈입니다.
00:07:09저희 사례로 치면 2억 번의 연산이 20만 1천 번으로 확 줄어든 것이죠.
00:07:14ThePrimeagen의 트윗이 상황을 잘 요약해 주네요. 자체 직렬화 프로토콜을
00:07:18만드는 건 정말 어려운 일이라, 이런 문제가 터지는 게 놀랍지도 않다고요.
00:07:23제 생각엔 React와 Next.js 코드베이스에 대한 전면적인 검토가 필요해 보입니다.
00:07:27다음은 가장 심각도가 높은 서버 측 요청 위조(SSRF) 취약점입니다.
00:07:33심각도는 8.6점이며, Vercel 호스팅이 아닌 자체 호스팅(self-hosted)이나
00:07:37타사 클라우드 환경에서만 발생합니다. 익스플로잇 방법은 매우 간단합니다.
00:07:41먼저 순정 Next.js 서버를 하나 띄웁니다. 코드를 전혀 수정할 필요가 없어요.
00:07:46그다음 외부에서는 접근할 수 없고 Next.js 서버만 접근 가능한 내부 서버가
00:07:50있다고 가정해 봅시다. 클라우드 배포 환경 같은 곳 말이죠.
00:07:54이제 Next.js 서버로 curl 요청을 보내는데, 'request target' 헤더에
00:07:59우리가 접근하려는 내부 서버의 로컬 주소를 넣습니다.
00:08:04엔터를 치면 어떻게 될까요? 파이썬 내부 서버의 디렉토리 목록이 담긴
00:08:09HTML 페이지가 반환됩니다. 파이썬 서버 쪽 로그를 확인해보면,
00:08:14Next.js 서버로부터 요청이 들어온 걸 볼 수 있죠.
00:08:18구조를 그려보면 이렇습니다. 점선 안은 Next.js 서버와
00:08:23Redis, 데이터베이스 같은 내부 서비스들이 있는 배포 환경입니다.
00:08:26이 내부 서비스들은 방화벽으로 보호되어 외부의 직접적인 요청을 거부하며,
00:08:31오직 Next.js 서버만 접근할 수 있습니다. 그런데 공격자가 Next.js 서버에
00:08:36“대신해서 내부 서비스에 요청 좀 보내줘”라고 시킨 꼴이고,
00:08:40Next.js는 그 결과를 순순히 받아다 준 겁니다. 방화벽이 무력화된 거죠.
00:08:45원인 역시 간단합니다. curl 요청 시 'upgrade websocket' 헤더를 보내면,
00:08:49Next.js 코드에서 경로를 해석하는 로직이 실행됩니다.
00:08:53문제는 여기서 파싱된 URL이 Next.js 앱의 경로가 아니라,
00:08:57우리가 헤더에 심어둔 '내부 서버의 타겟 주소'가 되어버린다는 점입니다.
00:09:02그 후 코드에서 프로토콜(HTTP 등)이 있는지 확인하는 한 단계만 거치고 나면,
00:09:06바로 프록시 요청을 실행해 버립니다. 해결책은 경로 해석 함수에
00:09:10완료 여부(finished)와 상태 코드를 확인하는 가드 로직을 추가하는 것입니다.
00:09:15이 함수는 이제 요청이 Next.js의 리라이트나 미들웨어 설정에 따른
00:09:19정당한 프록시 요청인지 먼저 검증합니다. 정당하지 않다면 'finished'가
00:09:23false로 설정되어 프록시 요청 자체가 실행되지 않게 되죠.
00:09:28또한 상태 코드가 있는 일반적인 HTTP 요청인 경우,
00:09:32유효한 웹소켓 프록시 요청이 아니라고 판단하여 무시하도록 수정되었습니다.
00:09:37내용이 좀 깊었네요. 여기까지 시청 중이시라면 댓글로 아무 말이나
00:09:40남겨주세요. 'foo'나 'bar' 같은 것도 좋습니다. 구독도 잊지 마시고요!
00:09:45이제 두 카테고리만 남았습니다. 속도를 좀 높여보죠. 먼저 '캐시 포이즈닝'입니다.
00:09:49서버 컴포넌트의 캐시 포이즈닝 이슈로, 심각도는 5.4점입니다.
00:09:55실제 배포 환경을 시뮬레이션하기 위해 Next.js 앱 앞에 CDN 역할을 할
00:09:58가짜 서버를 뒀습니다. 처음 사이트에 접속해 상품을 클릭하면 캐시 미스가 발생하고,
00:10:02두 번째 클릭부터는 캐시 히트가 되어야 정상입니다. 로그로 확인되죠.
00:10:06이제 캐시가 만료된 상황을 가정해 캐시를 비우고, 특정 curl 요청을 보냅니다.
00:10:11그 후 앱에서 상품 보기를 클릭하면, 페이지 대신 외계어 같은 데이터가 뜹니다.
00:10:15캐시 포이즈닝 공격에 성공한 것입니다. 원인은 이렇습니다.
00:10:20'react-server-component: 1' 헤더와 함께 요청을 보내면,
00:10:24서버는 HTML이 아닌 서버 컴포넌트 데이터를 반환합니다. 그다음 Next.js는
00:10:27이게 서버 컴포넌트 데이터인지 확인하여 캐시에 저장하죠.
00:10:32이론적으로는 사용자가 HTML을 요청할 때 서버 컴포넌트 데이터를 받으면 안 되지만,
00:10:36curl 요청 시 URL 뒤에 쿼리 스트링을 붙였더니 검증 로직이 뚫렸습니다.
00:10:41단순히 끝이 '.rsc'로 끝나는지만 확인했기 때문이죠. 쿼리 스트링 때문에
00:10:45이를 HTML로 착각하고 캐시에 저장했고, 이후 일반 사용자에게
00:10:49서버 컴포넌트 데이터를 HTML인 양 내려주게 된 겁니다. 해결책은
00:10:53확장자 확인 시 쿼리 스트링을 무시하도록 수정한 것이 전부입니다. 참 쉽죠?
00:10:58마지막으로 크로스 사이트 스크립팅(XSS) 취약점입니다.
00:11:01심각도 6.1점이며, 'beforeInteractive' 스크립트에서 발생합니다.
00:11:04Next.js에서 스크립트 태그 전략을 'beforeInteractive'로 설정하고,
00:11:09다른 속성값을 검색 파라미터 같은 신뢰할 수 없는 데이터로부터 가져오면,
00:11:14공격이 가능합니다. 공격자가 파라미터에 악성 코드를 심은 링크를 보내고
00:11:18사용자가 그걸 클릭하면, 보시는 것처럼 가짜 로그인 폼이 뜹니다.
00:11:23사용자 세션 쿠키를 훔치는 등의 자바스크립트 실행이 가능해지는 거죠.
00:11:27이는 전형적인 이스케이프(escaping) 처리 미흡 사례입니다.
00:11:31파라미터에서 기존 스크립트 태그를 닫고 새 태그를 열어 코드를 실행하는 방식이죠.
00:11:35Next.js 내부에서 해당 스크립트 태그가 'dangerouslySetInnerHTML'과
00:11:39'JSON.stringify'를 거쳐 렌더링되기 때문입니다. 이름부터 위험해 보이죠?
00:11:44JSON.stringify는 닫는 괄호 같은 HTML 문자를 기본적으로
00:11:47이스케이프하지 않습니다. 결국 공격자가 삽입한 문자열이 페이지에
00:11:52그대로 렌더링되면서, 기존 태그를 닫고 공격자의 스크립트가 실행되는 구조입니다.
00:11:58뒷부분에 붙은 문구들은 공격 흔적을 숨기기 위해 나머지 텍스트를
00:12:02집어삼키는 용도죠. 자, 이렇게 이번 주 Next.js와 React에서 발견된
00:12:0713개의 CVE와 그 작동 방식을 알아봤습니다. 솔직히 말씀드리면,
00:12:11기분이 좋진 않네요. 2년 전만 해도 모든 프로젝트를 Next.js로 하며
00:12:16미래라고 생각했는데, 계속해서 장애물이 나타나는 느낌입니다.
00:12:20너무 서둘러 도입하고 나중에 고치는 식이 반복되는 것 같아요.
00:12:24개인적으로는 이제 TanStack이나 Astro 같은 대안을 더 선호하게 되네요.
00:12:29훨씬 단순하고 명쾌하거든요. 최근 Cloudflare의 행보도 마음에 들어서
00:12:33프로젝트들을 옮기는 중입니다만, 여전히 Vercel에 있는 20여 개 프로젝트는
00:12:37일일이 업데이트하러 가야겠네요. 여러분은 어떻게 생각하시나요?
00:12:41서버 컴포넌트가 과연 유용해질까요, 아니면 실패한 시도일까요?
00:12:46댓글로 의견 들려주세요. 그럼 다음 영상에서 뵙겠습니다!
00:12:50[이후 내용은 위와 동일한 맥락의 기술 상세 설명이 반복되거나 맺음말입니다]
00:12:55기존의 beforeInteractive 전략을 사용하는 스크립트 태그가
00:12:59외부에서 주입된 데이터를 속성값으로 사용할 때의 위험성을 보여줍니다.
00:13:03특히 searchParams를 통해 들어온 값이 그대로 노출될 때 말이죠.
00:13:08사용자가 특정 링크를 클릭하도록 유도함으로써 XSS 공격을 수행할 수 있습니다.
00:13:12예를 들어 파라미터 안에 복잡한 내용을 내장시킨 링크를 만들고,
00:13:16사용자가 해당 페이지를 보면 다시 로그인해야 한다고 믿게 만들 수 있죠.
00:13:19그렇게 피싱 폼을 띄워 정보를 탈취하는 시나리오가 가능합니다.
00:13:22결국 공격자가 피해자의 브라우저에서 임의의 자바스크립트를 실행하게 됩니다.
00:13:26더 실질적인 위협은 크롬의 세션 쿠키를 훔쳐서
00:13:31모든 계정에 무단으로 접근하는 것일 겁니다.
00:13:34이 예시는 단순히 잘못된 이스케이프 처리가 어떤 결과를 초래하는지 보여줍니다.
00:13:39검색 파라미터에서 기존 스크립트 태그를 강제로 닫고,
00:13:43새로운 스크립트를 열어 원하는 코드를 실행하는 방식이죠.
00:13:47엔터를 누르면 'pwned'라는 경고창이 뜨는 걸 볼 수 있습니다.
00:13:51동작 방식은 Next.js의 beforeInteractive 전략을 쓰는 스크립트 태그와,
00:13:55신뢰할 수 없는 소스에서 데이터를 받는 속성이 결합될 때 발생합니다.
00:13:59제 케이스에서는 쿼리 파라미터가 그 소스였고요.
00:14:04Next.js에서 이 태그는 내부적으로 'dangerouslySetInnerHTML'로 변환됩니다.
00:14:08이름부터 위험하다는 신호를 주고 있고, 'JSON.stringify'도 사용되죠.
00:14:12여기서 중요한 점은 stringify가 HTML 닫는 태그를 안전하게 처리하지 않는다는 겁니다.
00:14:17따라서 Next.js가 의도한 스크립트 설정 과정에서,
00:14:21소스(src) 경로가 지정되고 남은 나머지 속성(props)들 안에
00:14:24우리가 쿼리 파라미터로 넣은 악성 데이터가 포함되어 버립니다.
00:14:29그 데이터가 JSON stringify를 거쳐 페이지에 렌더링될 때,
00:14:33실제 결과물은 데이터 트래킹 ID 뒤에 공격자가 삽입한 문자열이 붙게 됩니다.
00:14:37이게 브라우저에서 해석될 때의 모습을 좀 더 자세히 살펴보면,
00:14:41처음에는 정상적인 스크립트 태그와 데이터 ID가 나오는 듯하다가,
00:14:45중간에 닫는 스크립트 태그가 나타나면서 Next.js의 스크립트가 강제 종료됩니다.
00:14:49그 바로 뒤에 공격자가 원하는 어떤 스크립트든 실행할 수 있게 되는 것이죠.
00:14:53마지막에 붙인 추가 코드들은 원래 뒤따라와야 할 분석 텍스트 등을
00:14:58화면에 노출되지 않게 '삼켜버리는' 역할을 수행합니다.
00:15:01덕분에 사용자 입장에서는 겉보기에 아무런 이상을 느끼지 못하게 되죠.
00:15:05이상이 이번 주에 쏟아진 Next.js와 React의 13가지 취약점 분석이었습니다.
00:15:09과거에 Next.js를 열렬히 지지했던 사람으로서 참 씁쓸한 마음이네요.
00:15:14기술의 발전도 좋지만 안정성이 뒷받침되지 않으면 신뢰하기 힘드니까요.
00:15:18점점 더 복잡해지는 생태계 속에서 단순함의 가치를 다시금 생각하게 됩니다.
00:15:22급하게 기능을 출시하고 나중에 보안 패치를 하는 방식은 이제 지칩니다.
00:15:26저는 당분간 다른 가벼운 프레임워크들에 더 집중할 생각입니다.
00:15:31여러분도 각자의 프로젝트 보안 상황을 꼭 다시 한번 점검해 보시기 바랍니다.
00:15:35남은 프로젝트들을 업데이트하러 가봐야겠네요. 시청해 주셔서 감사합니다.
00:15:39서버 컴포넌트의 미래에 대해 어떻게 보시는지 댓글로 꼭 남겨주세요.
00:15:43구독과 좋아요는 영상 제작에 큰 힘이 됩니다.
00:15:48그럼 저는 다음 유익한 영상으로 다시 찾아뵙겠습니다.
00:15:51안녕히 계세요!

Key Takeaway

Next.js와 React의 서버 컴포넌트 및 프록시 로직에서 발견된 13개의 취약점은 서버 가용성 마비와 내부망 침투를 허용하므로 즉각적인 최신 버전 업데이트와 서버 측 추가 인증 로직 구현이 필수적입니다.

Highlights

  • React와 Next.js 생태계 전반에서 서비스 거부(DoS), 미들웨어 우회, SSRF 등 총 13개의 새로운 보안 취약점(CVE)이 발견되었습니다.

  • i18n 기능을 활성화한 Pages Router 환경에서 특정 경로 조합을 통해 인증 없이 서버 사이드 props 데이터를 추출하는 7.5점 심각도의 취약점이 확인되었습니다.

  • 서버 컴포넌트의 직렬화 프로토콜인 React Flight의 결함으로 인해 특수하게 제작된 페이로드를 전송할 경우 서버 연산량이 2억 번까지 급증하며 메인 스레드가 차단됩니다.

  • 자체 호스팅 환경의 Next.js 서버에서 'upgrade websocket' 헤더를 악용하면 내부 네트워크의 Redis나 데이터베이스에 접근할 수 있는 8.6점 심각도의 SSRF 공격이 가능합니다.

  • 서버 컴포넌트 데이터(.rsc) 요청 시 쿼리 스트링 검증 미흡으로 인해 일반 사용자에게 HTML 대신 직렬화된 데이터를 노출시키는 캐시 포이즈닝 공격이 발생합니다.

  • beforeInteractive 스크립트 전략 사용 시 외부 데이터를 이스케이프 없이 처리하여 사용자 브라우저에서 악성 자바스크립트를 실행하는 XSS 취약점이 발견되었습니다.

Timeline

Pages Router의 미들웨어 및 프록시 우회

  • i18n 기능이 활성화된 Pages Router 환경은 특정 데이터 요청 경로를 제대로 보호하지 못합니다.
  • 빌드 ID를 포함한 특정 URL 형식을 사용하면 미들웨어의 인증 절차를 건너뛰고 서버 데이터를 직접 조회할 수 있습니다.
  • 미들웨어에만 의존하는 보안 구조는 구조적인 우회 가능성을 내포하므로 서버 로직 내 개별 인증이 동반되어야 합니다.

다국어 설정을 사용하면 동일 페이지에 대해 로케일별 변형 URL이 생성됩니다. Next.js의 경로 매칭 결함으로 인해 기본 케이스(base case)가 보호 범위에서 누락되면서 해커는 NEXT_DATA 스크립트에서 추출한 빌드 ID와 .json 확장자를 결합해 민감한 이메일이나 플래그 데이터를 탈취합니다. 이는 단순히 미들웨어 매처 설정의 문제를 넘어 프레임워크 수준의 경로 해석 오류에서 기인합니다.

서버 컴포넌트 직렬화 프로토콜을 이용한 서비스 거부(DoS) 공격

  • React Flight 프로토콜의 역직렬화 방식은 키 탐색 횟수가 기하급수적으로 늘어나는 알고리즘 취약점을 가집니다.
  • 악성 페이로드를 전송하면 0.02초 걸리던 응답 시간이 6초 이상으로 늘어나며 서버 가용성이 저하됩니다.
  • 탐색 알고리즘을 참조 개수와 키 개수의 곱 연산에서 단순 합 연산으로 개선하여 연산 횟수를 1,000배 이상 단축했습니다.

기존 방식은 포인터 참조를 찾을 때마다 전체 키 리스트를 처음부터 반복해서 훑는 구조였습니다. 공격자가 20만 개의 무의미한 키와 수천 개의 포인터를 섞어 보내면 서버는 약 2억 번의 문자열 비교를 수행하며 메인 스레드를 점유합니다. 최신 보안 패치는 커서 기반 시스템을 도입하여 한 번 확인한 지점 이후부터 탐색을 이어가도록 수정함으로써 불필요한 반복 연산을 제거합니다.

자체 호스팅 환경에서의 SSRF 취약점과 방화벽 무력화

  • Vercel 이외의 환경에서 구동되는 Next.js 서버는 요청 헤더 조작을 통한 내부망 침투에 취약합니다.
  • request-target 헤더에 내부 IP를 주입하면 Next.js 서버가 대리인 역할을 수행하여 비공개 데이터를 반환합니다.
  • 경로 해석 함수에 상태 코드와 요청 완료 여부를 확인하는 가드 로직을 추가하여 부정한 프록시 요청을 차단합니다.

공격자가 upgrade websocket 헤더와 함께 내부 서버 주소를 전송하면 Next.js는 이를 정당한 앱 경로로 오인하여 요청을 전달합니다. 이 과정에서 방화벽 내부에 위치하여 외부 접근이 차단된 Redis나 DB 관리 페이지의 HTML이 공격자에게 그대로 전달되는 현상이 발생합니다. 패치 이후에는 요청이 설정된 리라이트나 미들웨어 규칙을 따르는지 사전에 검증하는 절차가 강화되었습니다.

캐시 포이즈닝 및 스크립트 이스케이프 미흡을 이용한 XSS

  • URL 확장자 검사 시 쿼리 스트링을 무시하지 못해 서버 컴포넌트 데이터가 HTML 캐시에 저장되는 오류가 수정되었습니다.
  • beforeInteractive 전략을 사용하는 스크립트 태그는 외부 파라미터 유입 시 태그 탈취 공격에 노출됩니다.
  • JSON.stringify가 HTML 닫는 괄호를 적절히 처리하지 못하는 점을 악용하여 임의의 자바스크립트를 삽입할 수 있습니다.

캐시 포이즈닝은 단순히 확장자 끝부분만 확인하는 허점을 이용해 일반 사용자가 깨진 직렬화 데이터를 받게 만듭니다. XSS의 경우 dangerouslySetInnerHTML 내부에서 동작하는 속성값들이 문제인데, 공격자가 쿼리 파라미터에 닫는 스크립트 태그를 포함시키면 기존 보안 스크립트가 강제 종료되고 그 자리에 피싱 폼이나 쿠키 탈취 코드가 실행됩니다. 이는 복잡해진 프레임워크 기능들이 기본적인 보안 수칙인 이스케이프 처리와 충돌하며 발생한 사례들입니다.

Community Posts

View all posts