NextJS는 끝났습니다... 무려 13개의 새로운 보안 취약점 발견
BBetter Stack
Computing/SoftwareBusiness NewsInternet Technology
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안녕히 계세요!