Google에서 발견한 9만 달러 상당의 원격 코드 실행 취약점 (RCE)
요약
작성자는 Google 개발자 사이트에서 OAuth 흐름을 분석하던 중, Flask 세션 쿠키의 취약점을 발견했습니다. 이 쿠키는 암호화되지 않고 서명만 되어 있어 내용을 가진 사람이 접근할 수 있으며, 심지어 클라이언트 ID와 시크릿 같은 민감 정보가 평문으로 포함되어 있었습니다. 이를 통해 공격자는 서버의 비밀 키를 알아내고 임의의 세션을 위조하여 원격 코드 실행(RCE)까지 가능하게 했습니다.
핵심 포인트
- Flask 세션 쿠키는 암호화되지 않고 서명만 되어 있어 취약합니다.
- 공개된 클라이언트 시크릿을 이용해 서버 비밀 키를 유추할 수 있습니다.
- 세션 무결성이 깨지면 공격자는 임의의 데이터를 삽입하고 위조 서명을 생성할 수 있습니다.
- 이 취약점은 OAuth 흐름과 pickle 직렬화 과정에서 발생했습니다.
제가 이것을 찾은 방법
저는 Google 개발자 문서 사이트들이 공통으로 사용하는 OAuth 흐름을 매핑하고 있었습니다. /oauth2authorize에 접속하면 Flask 세션 쿠키가 설정되고 Google 로그인 페이지로 리디렉션됩니다. 계정은 필요하지 않습니다:
curl -s -D headers.txt -o /dev/null \
'https://developers.google.com/oauth2authorize?return_url=%2F'
이 쿠키가 제 눈에 띄었습니다. Flask 특유의 세 부분으로 된 session=<페이로드>.<타임스탬프>.<서명> 형식이었지만, 약 1,200바이트라는 크기는 CSRF 토큰과 리턴 URL로는 다소 커 보였습니다. 그래서 저는 그것을 디코딩했습니다.
Flask 세션 쿠키는 암호화되지 않고 서명만 되어 있기 때문에(signed), 내용을 가진 사람이라면 누구나 그 안에 접근할 수 있습니다. 맨 앞의 점(.)은 페이로드가 zlib 압축되어 있다는 것을 의미합니다:
import base64
import json
import pickletools
...
이를 통해 저는 다음 정보를 얻었습니다:
{
"_permanent": true,
"google_oauth2_csrf_token": "",
...
b 키는 Flask가 바이트(bytes) 값을 태깅한 것입니다. 이를 Base64 디코딩하자 pickle 객체가 나왔고, pickletools.dis를 실행하여 다음과 같은 필드를 가진 oauth2client.client.OAuth2WebServerFlow 객체를 출력했습니다:
client_id = "<redacted>.apps.googleusercontent.com"
client_secret = b"<redacted>"
이 쿠키에는 pickle 직렬화된 페이로드와 Google의 OAuth client_id 및 client_secret가 평문(plaintext)으로 포함되어 있었습니다. (비밀번호는 여기에서 가려졌지만, 작성 당시에도 유효했습니다.)
저는 이 클라이언트 비밀번호를 Flask의 SECRET_KEY로 사용해 보았고, 발행된 쿠키의 서명이 이를 기반으로 검증되었습니다. 즉, 서명 키가 그 객체 안에 저장되어 있었던 것입니다.
하나의 비밀값, 두 가지 역할
유출된 클라이언트 비밀번호 자체만으로도 문제가 되었습니다. 저는 이것이 단순히 추측에 의존하는 것이 아니라 실제로 세션에도 서명되는지 확인하고 싶었습니다.
Flask는 itsdangerous를 통해 cookie-session 키 유도(key derivation) 방식으로 서명합니다. 그래서 저는 오프라인에서 서명을 재계산했습니다:
import hmac
derived_key = hmac.digest(client_secret, b"cookie-session", "sha1")
expected = hmac.digest(
...
expected
이것은 내가 몇 초 전에 받은 쿠키의 서명과 바이트 단위로 완전히 동일하게 나왔다. 혹시 우연이 아닐까 하여 서명의 문자 하나를 바꿔서 (거부됨) 원래 페이로드에 회수된 비밀 키로 다시 서명을 했더니 (승인됨). 세션 무결성이 사라진 것이다. 나는 세션의 모든 키와 값을 설정할 수 있었고, 여전히 서버가 받아들일 서명도 생성할 수 있었다.
위조 쿠키에서 코드 실행까지
콜백은 세션에서 흐름을 읽어내고 이를 언피클(unpickle)한다:
session cookie
-> verify Flask signature
-> read google_oauth2_flow_<csrf>
...
이 pickle.loads는 다른 사람이 유효한 쿠키를 위조할 수 없는 한 “안전”하다. 키가 공개되는 순간, 그 가정은 가치가 없다. 나는 임의로 만든 CSRF 토큰을 사용해 나만의 세션을 만들고, 원하는 어떤 pickle 데이터를 일치하는 flow 키 아래에 넣었다:
session = {
"_permanent": True,
"google_oauth2_csrf_token": "attacker-chosen",
...
state 매개변수 역시 나 것이므로, 서버 측에서 일치시킬 필요가 없다. 서버는 내 서명을 확인하고, 내 flow 항목을 찾은 다음, 바이트를 pickle.loads에 전달한다.
만약 pickle을 사용해 본 적이 없다면: REDUCE opcode는 호출 가능한 함수와 그 인수를 지정하며, 언피클링은 단순히 그것을 호출할 뿐이다. 공개된 서명 키와 이 싱크(sink)가 결합되면 인증되지 않은 RCE(원격 코드 실행)가 된다.
나는 Google의 프로덕션 환경에서 명령어를 실행하려 하지는 않았으므로, 시간으로 증명했다. 페이로드는 time.sleep(N)으로 축소되었다:
class Sleep:
def __init__(self, seconds):
self.seconds = seconds
...
매번 위조된 세션은 같았지만, 숫자만 바뀌었다:
time.sleep(0) -> 0.207 s
time.sleep(3) -> 3.193 s
time.sleep(5) -> 5.194 s
...
응답 시간은 pickle에 있는 숫자를 추적했으며, 이는 서버가 언피클링하는 동안 sleep(N)을 호출했음을 일관되게 보여주었다.
또한 느린 엔드포인트나 지터(jitter)를 배제하기 위해 몇 가지 제어를 시도했다:
- 쿠키 없음:
Invalid request
. - 서명 바이트 하나 위조:Invalid request
. - 유효한 서명, flow 항목 없음:Invalid request state
• 실제 흐름을 가진 유효한 서명: Google의 토큰 교환에 도달했지만, 가짜 인증 코드로는 나중에 invalid_grant 오류가 발생함.
• 슬립 페이로드를 가진 유효한 서명: 내가 지정한 숫자만큼 정확하게 지연됨.
얼마나 멀리까지 갔나
동일한 키는 developers.google.com을 넘어 작동했어.
내가 이 DevSite 호스트들에서 유효한 세션을 테스트해 본 곳은 다음과 같아:
firebase.google.com
developer.android.com
source.android.com
docs.partner.android.com
해결 방안
이 체인을 막기 위해서는 네 가지 변경 사항이 필요해:
- 노출된 OAuth 클라이언트 시크릿을 모두 교체하고, 이들로 서명된 모든 세션을 종료해야 함.
- 각 배포에 자체적인 고엔트로피(high-entropy) Flask
SECRET_KEY를 서버 측 스토어에서 제공하고, 이는 OAuth 자격 증명과는 무관해야 함. - OAuth 흐름 객체는 서버에 유지되어야 함. 쿠키에는 불투명한 참조(opaque reference)만 담고 민감한 정보는 담지 않아야 함. - 클라이언트 경계를 넘나드는 모든 경우에서
pickle사용을 중단해야 함. 스키마 검사를 거치는 데이터 전용 형식은 위조된 쿠키를 무력화시킴.
이 체인은 노출된 시크릿, 이를 서명 키로 재사용한 것, 그리고 세션으로부터 데이터를 언피클링(unpickling)하는 것에 의존했어.
타임라인
-
리포트 제출
-
검토 (Triaged)
-
수락 (Accepted)
-
핫픽스 배포 (Hotfix deployed)
-
보상 결정: $90,000
일반 Google 애플리케이션(T2), S0 RCE: $75,000 기본액 × 1.2 (예외적인 리포트 품질에 대한 가산점).
- 상태가 수정됨으로 업데이트
이 문제를 조사하고 해결해 준 Google Bug Hunters 팀과 DevSite 엔지니어들에게 감사드립니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 HN Code Generation의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기