침해로 이어지는 JWT 실수

JSON Web Token은 봉인된 봉투가 아니라 서명된 진술입니다. 점으로 연결된 세 개의 Base64url 인코딩 세그먼트, 즉 서명 알고리즘을 명시하는 헤더, 사용자나 세션에 대한 클레임의 페이로드, 그리고 처음 두 부분에 대한 서명입니다. 서명은 그 토큰이 키 보유자에 의해 발급되었고 이후 변경되지 않았음을 증명합니다. 비밀성에 대해서는 아무것도 증명하지 않습니다. 토큰을 가진 사람은 누구나 페이로드를 디코딩해 안의 모든 클레임을 읽을 수 있으며, 그래서 디코딩에는 비밀번호가 아니라 붙여넣기가 필요합니다. JWT가 운영 환경에서 무너질 때, 거의 언제나 암호 자체가 깨진 것이 아닙니다. 깨진 것은 누군가 생략한 검사입니다. 토큰 자체에서 맹신한 알고리즘, 아무도 검증하지 않은 만료, 사람이 기억으로 타이핑할 수 있는 공유 비밀값. 어떤 토큰이든 JWT 디코더에 붙여 넣으면 헤더와 클레임이 그대로 드러납니다. 이 글은 생략된 검사를 침해로 바꾸는 엔지니어링 실수들과, 각각을 막는 방어 규칙을 차례로 살펴봅니다.

서명된 것은 비밀인 것과 다르다

페이로드는 암호화가 아니라 일반 Base64 와 같은 계열의 인코딩인 Base64url입니다. 그 안의 모든 것은 토큰이 거치는 누구에게나 읽힙니다. 이메일 주소, 역할, 테넌트 ID, 내부 계정 번호, 그리고 때로는 비밀번호 재설정 플래그나 개발자가 디버깅용으로 넣고 제거하지 않은 개인정보 조각처럼 훨씬 나쁜 것들까지. 토큰은 개발자가 상상하는 것보다 멀리 여행합니다. 브라우저 저장소에 머물고, 프록시와 애플리케이션 로그를 통과하며, 크래시 리포트에 복사되고, 버그 티켓과 채팅 스레드에 붙여 넣어집니다. 여기서 곧바로 도출되는 작업 규칙은, 로그 파일에 인쇄되어도 불편하지 않을 클레임만 토큰에 넣는 것입니다. 어차피 언젠가는 거기에 나타날 테니까요.

오해가 생기는 것은 이해할 만합니다. 컴팩트한 토큰은 발음할 수 없는 문자와 점들로 이루어져 암호문처럼 보이기 때문입니다. 그러나 서명이 제공하는 것은 변조 감지뿐입니다. 클레임을 바꾸면 검증은 실패합니다. 기밀성은 같은 표준 계열의 다른 구조(암호화 토큰, JWE)의 몫이며, 일반적인 액세스 토큰이 그것인 경우는 드뭅니다. 따라서 페이로드는 서명이 붙은 공개 텍스트로 설계하십시오. 식별자와 인가 사실만 넣고, 민감한 데이터는 서버 측에서 ID로 조회하며, 수명을 짧게 유지해 새어 나가더라도 빨리 만료되게 합니다.

토큰이 스스로 알고리즘을 고르게 두기

헤더는 alg 를 선언하고, JWT 역사상 가장 오래된 실패는 검증자가 그것을 믿는 것입니다. 선언 값 중 하나는 none, 즉 서명이 전혀 없는 무서명 토큰입니다. 토큰 스스로 선언한 알고리즘을 따르는 라이브러리나 직접 만든 검사는 서명이 빈 토큰을 받아 진짜로 수용할 수 있습니다. 모범 사례 RFC는 이 점에서 단호합니다. 무서명 토큰은 채널 자체가 이미 무결성을 보장하는 경우에만 허용되며, 공용 인터넷을 통해 도착하는 베어러 토큰은 그 경우가 아닙니다. 헤더를 믿는 같은 결함이 알고리즘 혼동을 낳습니다. RS256 토큰을 발급하는 시스템은 개인 키를 보유하고 검증을 위해 대응하는 공개 키를 게시합니다. 검증자를 설득해 토큰을 HS256으로 처리하게 만들 수 있다면, 검증 재료는 공개 키 그 자체가 됩니다. 설계상 공개되어 모두가 아는 그 키가 대칭 비밀값으로 사용되고, 누구나 만들 수 있는 서명이 검증을 통과하기 시작합니다. 두 실패 모두에서 키가 도난당한 것은 아닙니다. 검증자는 발신자가 쓴 지시를 그대로 따랐을 뿐입니다.

헤더에는 두 번째로 더 조용한 위험이 있습니다. 키 포인터입니다. kid(키 ID), jku, x5u(키나 인증서가 있는 URL)와 같은 매개변수는 검증 키를 어디서 찾을지 검증자에게 알려 주며, 공격자의 영향 아래 있는 토큰 내부에 실려 도착합니다. kid 값이 엄격한 검증 없이 파일 경로, 데이터베이스 조회, 키 가져오기로 흘러 들어가면 그 조회 자체가 공격 표면이 됩니다. 방어 규칙은 모두 같은 모양입니다. 기대하는 알고리즘을 서버 측 설정에 고정하고 나머지는 전부 거부할 것. 키는 자체 레지스트리에서 해석하고 키 ID를 허용 목록과 대조할 것. 토큰이 제공하는 URL에서 키 재료를 절대 가져오지 말 것. 그리고 모든 헤더 필드를 신뢰할 수 없는 입력으로 취급할 것. 실제로 그러하니까요.

서명은 통과했다. 그러나 클레임은 검증되지 않았다

서명 검증은 단 하나의 질문에만 답합니다. 이 토큰이 키 보유자에 의해 발급되었고 손대지 않은 상태인가? 토큰이 지금, 여기에서 수용되어야 하는지에 대해서는 아무것도 말하지 않습니다. 그것은 클레임의 일이며, 클레임 검증이야말로 운영 시스템이 조용히 단계를 생략하는 곳입니다. 먼저 exp입니다. 만료를 결코 확인하지 않는 검증자는 모든 토큰을 영구 자격 증명으로 바꿉니다. 그래서 3년 전 로그에 유출된 토큰이 오늘도 인증을 통과합니다. 다음은 iss입니다. 설정한 발급자의 토큰만 수용하고, 우연히 같은 알고리즘을 쓰는 누구의 토큰도 받지 마십시오. 그리고 가장 값비싼 누락인 aud입니다. 조직은 종종 여러 서비스를 하나의 서명 키로 운영합니다. 그러면 낮은 가치의 서비스 토큰(무료 등급 API, 스테이징에 인접한 도구)이, 서명이 진짜라는 이유로, 그리고 누구도 그 토큰이 어떤 대상을 위해 발급되었는지 묻지 않았다는 이유로, 높은 가치의 서비스가 수용하는 올바르게 서명된 토큰이 됩니다.

이 중 어느 것도 생소한 이야기가 아닙니다. 이것은 체크리스트이며, 실패 양상은 라이브러리가 서명은 열심히 검증하면서 클레임 검사는 선택 사항, 비활성화, 또는 호출자의 문제로 남겨 두는 것입니다. 방어 자세는 모든 검증에서 exp, iss, aud 를 요구하고, 누락이나 불일치에는 페일 클로즈드로 임하며, 시계 오차 허용 범위를 시간이 아니라 분 단위로 두는 것입니다. 허용 범위는 부정확한 시계를 위한 것이며, 너무 넓히면 만료된 토큰을 조용히 되살립니다. 그리고 로그인 폼을 테스트하듯이 부정 사례를 테스트하십시오. 만료된 토큰, 대상이 잘못된 토큰, 알 수 없는 발급자의 토큰은 모두 거부되어야 하며, 그 거부는 보안 신호로서 기록할 가치가 있습니다.

사람이 기억할 수 있는 비밀값은 공격자가 시험할 수 있는 비밀값

HS256은 대칭입니다. 하나의 공유 비밀값이 서명도 하고 검증도 합니다. 실제 코드베이스에서 그 비밀값은 너무 자주 단어나 짧은 문구입니다. 회사 이름, 튜토리얼에서 복사한 프레임워크 기본값, 개발자가 기억으로 타이핑할 수 있는 무언가. 이것을 치명적으로 만드는 원리는 다음과 같으며, 그것은 특정 도구의 성질이 아니라 설계 자체의 성질입니다. 포획된 토큰은 추측한 비밀값을 오프라인으로 시험하는 데 필요한 모든 것을 담고 있습니다. 메시지는 알려져 있고 서명도 알려져 있으므로, 추측은 로컬에서 전속력으로 대조할 수 있고, 서버가 시도를 눈치채는 일도, 속도 제한이 적용되는 일도 없습니다. 따라서 체계 전체의 보안은 그 문자열 하나의 엔트로피로 귀결됩니다. 그리고 사람이 고른 문자열의 엔트로피는 아주 낮습니다.

해법은 화려하지 않습니다. 서명 비밀값은 기계로 생성하고 적절한 길이(HMAC이라면 최소 256비트 엔트로피)를 갖게 하며, 운영 자격 증명 그 자체로서 보관하십시오. 환경마다 고유하게 만들면 유출된 스테이징 설정의 비밀값은 운영 환경에서 아무것도 사지 못합니다. 개발용 기본값이 실제 배포에 살아남게 두지 마십시오. 많은 서비스가 토큰을 검증하는 곳에서는 RS256이나 ES256 같은 비대칭 알고리즘을 선호하십시오. 검증자는 공개 키만 보유하므로, 검증자가 침해되어도 토큰을 주조할 수 있는 것은 새어 나가지 않고, 서명 키는 한곳에 머뭅니다. 그리고 필요해지기 전에 교체 절차를 마련해 두십시오. 대칭 비밀값이 유출되어도 경보는 울리지 않습니다. 첫 징후는 대개 자신이 발급하지 않은 토큰이 자기 것으로 받아들여지는 것입니다.

브라우저가 토큰을 어디에 두는지가 누가 읽을 수 있는지를 결정한다

클라이언트 측에서 저장 위치의 선택은 보안 결정이며, 누군가 의식적으로 내렸든 아니든 그렇습니다. localStorage 에 둔 토큰은 페이지에서 실행되는 모든 스크립트가 읽을 수 있습니다. 자신의 코드, 들여온 모든 서드파티 태그와 분석 스니펫, 그리고 앱 어딘가의 이스케이프되지 않은 댓글 필드 하나에서라도 실행될 수 있는 모든 크로스 사이트 스크립팅 페이로드가 그렇습니다. 베어러 토큰은 복사 가능한 자격 증명입니다. 읽은 사람은 어디에서든 그것을 제시할 수 있고, 만료될 때까지 그 사용자 본인이 됩니다. 따라서 스크립트가 읽을 수 있는 저장소에 둔 장수명 토큰은 최악의 XSS 버그 하나만큼의 가치밖에 없습니다. 그리고 대부분의 애플리케이션은 언젠가 XSS를 갖게 됩니다.

일반적인 대안인 HttpOnly 쿠키는 스크립트 접근을 완전히 제거합니다. XSS 페이로드는 더 이상 토큰을 읽어 빼낼 수 없습니다. 그러나 브라우저가 쿠키를 자동으로 첨부하므로, 맞서야 할 위협은 사이트 간 요청 위조가 되며 SameSite 속성과 CSRF 토큰으로 대응합니다. 토큰을 메모리에만 두면 두 문제를 모두 피하지만 새로 고칠 때마다 세션을 잃습니다. 보편적으로 옳은 답은 존재하지 않으며, 존재하는 척하는 것이 팀이 각 선택지의 최악을 손에 넣는 길입니다. 저장을 침해로 바꾸는 것은 아무도 의도적으로 고르지 않은 조합입니다. 스크립트가 읽을 수 있는 저장소, 긴 수명, 넓은 범위, 그리고 복사본의 가치를 줄일 리프레시 토큰 교체 부재. 절충은 의식적으로 선택하고, 어느 방식이든 액세스 토큰은 짧게 유지하며, 그 이유를 다음 개발자가 찾을 수 있는 곳에 적어 두십시오.

출처 및 추가 자료

자주 묻는 질문

JWT 안의 데이터는 암호화되어 있나요?

아니요. 표준 서명 JWT에서 헤더와 페이로드는 Base64url로 인코딩되어 있을 뿐이며 누구나 디코딩할 수 있습니다. JWT 디코더에서 토큰을 열면 모든 클레임이 평문으로 읽힙니다. 서명이 제공하는 것은 무결성과 출처 증명이지 기밀성이 아닙니다. 암호화 JWT(JWE)는 존재하지만 실무에서는 예외입니다. 모든 클레임을 토큰 보유자에게 공개된 것으로, 그리고 토큰이 거치는 모든 로그, 프록시, 스크린샷에 공개된 것으로 취급하십시오.

서명은 검증하고 있습니다. 왜 그것으로 부족한가요?

유효한 서명은 토큰이 키 보유자에 의해 발급되었고 이후 변경되지 않았다는 것만 증명하기 때문입니다. 토큰이 만료되었는지(exp), 신뢰하는 발급자에게서 왔는지(iss), 같은 키를 공유하는 다른 서비스가 아니라 당신의 서비스를 위해 발급되었는지(aud)에 대해서는 아무것도 말하지 않습니다. 검증자가 토큰에게 알고리즘을 고르게 하거나 키 위치를 제공하게 한 경우에도 마찬가지입니다. 서명 검증은 검증의 전반부입니다. 알고리즘을 자기 쪽에 고정한 클레임 체크리스트가 후반부이며, 침해는 그 사이의 간격에 삽니다.

토큰은 localStorage와 쿠키 중 어디에 두어야 하나요?

어느 선택지든 하나의 공격을 다른 공격과 맞바꿉니다. localStorage 는 페이지의 어떤 스크립트든 읽을 수 있어, XSS 결함 하나나 악성 서드파티 스크립트 하나가 저장된 모든 토큰을 복사할 수 있습니다. HttpOnly 쿠키는 스크립트가 전혀 읽을 수 없지만 자동으로 전송되므로, SameSite 와 CSRF 토큰으로 관리해야 할 위협은 CSRF가 됩니다. 짧은 액세스 토큰 수명과 리프레시 토큰 교체를 결합하면 둘 다 방어 가능합니다. 방어할 수 없는 선택은 기본값으로 고르고, 긴 수명을 쓰며, 그 결정을 다시는 돌아보지 않는 것입니다.