Why NextWhy Next
목록으로
💻 개발7분 읽기

우리 앱의 TLS를 가로채던 9명을 추적했더니, 구글이었다

인증서 피닝을 report-only로 켜자 Sentry에 SPKI 미스매치 74건이 쌓였다. MITM 공격처럼 보였고, AI는 '구글은 HTTPS를 가로채지 않는다'며 스토어 심사 가능성을 단호히 배제했다. 프로덕션 API 로그에서 뽑은 실제 IP가 그 추정을 뒤집었다.

핵심 요약

인증서 피닝을 report-only 모드로 켠 첫 주, Sentry에 SPKI 미스매치 74건(유저 9명)이 올라왔다. 앱이 받은 인증서가 우리 서버의 것과 하나도 겹치지 않았으니 누군가 TLS를 가로채고 있다는 뜻이었다. 기기 지문을 보니 74건 전부 'OnePlus 8 Pro'인데 x86 아키텍처에 288x448 화면이었다. 물리기기를 위장한 에뮬레이터 팜이었다. 정체를 좁히던 중 AI 에이전트는 '구글은 당신의 HTTPS를 MITM하지 않는다'며 스토어 자동심사 가능성을 배제했지만, 미스매치 시각에 프로덕션 API로 들어온 실제 IP는 전부 구글 대역이었다. 원리에서 나온 추정이 실측 앞에서 3분 만에 무너졌다.

목차

우리 앱은 한 번 인증서 피닝 때문에 죽은 적이 있다. 2026년 4월 5일, leaf 인증서의 SPKI를 핀으로 박아뒀는데 ACM이 인증서를 자동 갱신하면서 leaf 공개키가 바뀌었고, 그 순간 모든 사용자의 API가 막혔다. 원격 킬스위치도 없어서 핫픽스 앱을 스토어에 다시 올려야 했다. 그 뒤로 레포에는 "인증서 피닝 도입 금지"라는 규칙이 남았다.

두 달 뒤 우리는 피닝을 다시 만들었다. 이번엔 leaf가 아니라 Amazon Root CA 네 개의 SPKI를 핀으로 박았다. 루트는 ACM이 갱신해도 바뀌지 않으니 같은 사고는 나지 않는다. 원격 설정으로 켜고 끌 수 있는 킬스위치도 붙였다. 6월 29일, 차단은 하지 않고 보고만 하는 report-only 모드를 원격으로 켰다.

이틀 뒤 Sentry에 이슈가 하나 올라와 있었다.

[CertPinning] SPKI mismatch (report-only)
74 events · 9 users · 2026-06-29

우리가 보낸 적 없는 해시 두 개

이벤트를 열어보니 앱이 실제로 받은 인증서의 SPKI 두 개가 찍혀 있었다.

mode: "reportOnly"
host: "web.example.com"
expected_pin_count: 4
served_spki: [
  "sha256/9hqPsoMiyQMwLCoRPk6FoCYmOsPiGqzQqUcpuZIfvgs=",
  "sha256/r9mjYco6rQO8YkTqr/XXGsQlDUuQqz2mGr67S0imt7M="
]

openssl s_client로 실제 서버 체인을 뽑아 대조했다. 우리 서버가 보내는 leaf도, 중간 인증서도, 루트도, 이 두 해시와 하나도 겹치지 않았다. 이 9명은 우리 서버의 진짜 인증서를 아예 받지 못했다. 누군가 중간에서 TLS를 끊고 자기 인증서로 다시 서명해 앱에 건네고 있었다.

served_spki가 두 개뿐이라는 것도 단서였다. 정상 경로에서 앱이 보고하는 체인에는 루트까지 세 개가 담긴다. 두 개(leaf + 중간)만 찍힌 걸 보면, 이 리포트는 다른 분기에서 나온 것이다.

여기서 나는 첫 결론을 냈다. 정확히 말하면 AI 에이전트가 냈고 나는 그럴듯하다고 생각했다. "이건 고칠 버그가 아니라 report-only 피닝이 설계대로 동작하며 남의 네트워크에서 일어나는 가로채기를 잡아낸 것이다. 회사나 학교 프록시, 백신의 웹 보호 기능, 캡티브 포털 같은 것들이다. 우리가 통제할 수 없으니 Sentry에서는 mute하고 지표로만 보자."

이왕 손대는 김에 백업 핀을 하나 추가하자는 제안이 따라왔다. 나는 그걸 골랐다.

백업 핀은 이 이슈를 한 건도 못 줄인다

핀을 추가하는 작업이 시작된 직후, 나는 이게 앞뒤가 안 맞는다는 생각이 들어 되물었다.

애초에 미스매치가 되면 안되는거 아니야?

이 한 줄에 방금 고른 방향이 무너졌다. 에이전트가 안드로이드 프로브 코드를 다시 읽고 정정했다.

val verified = verifiedChainSpki(offered, host)
if (verified == null) {
    // 신뢰저장소 검증 실패 = 가로채기/위조 -> 핀 비교 없이 무조건 보고
    report(host, offered.mapNotNull(::spkiSha256))
    return
}
// 핀 비교는 검증을 통과한 체인에 대해서만 일어난다
if (verified.none(pins::contains)) { report(host, verified) }

프로브는 두 단계다. 먼저 OS 신뢰저장소로 체인을 검증하고, 통과한 체인만 핀 목록과 대조한다. 우리 74건은 첫 단계에서 이미 실패했다. verified == null이니 핀 비교 자체를 거치지 않고 곧바로 보고된다. 백업 핀은 두 번째 단계, 즉 "검증은 됐는데 핀이 안 맞는" 경우에만 효과가 있다.

결론: Starfield G2를 핀에 추가해도 이 이슈 건수는 0건도 안 줄어듭니다.

정상 유저에게는 이 리포트가 뜰 수 없다는 것도 그때 확인됐다. 라이브 체인이 우리 핀과 정확히 맞기 때문이다. 그러니 이 9명은 정의상 정상 유저가 아니다. 고쳐야 할 버그는 여기 없었다. 이 9명이 누구인지를 밝혀야 했다.

무시하면 영영 못 켠다

"무시하고 모니터링하자"는 권고도 나는 받아들일 수 없었다.

제대로 수정해야지 이걸 실제로 나중에 켜야되는데 이렇게 되면 못키잖아

이 피닝의 최종 목표는 enforce다. enforce를 켜도 되는지 판단하는 게이트는 설계 문서에 이미 적혀 있었다. 2주간 실사용자 미스매치율이 0.5% 미만일 것. 미스매치의 정체를 모르는 채로 두면, 이 74건이 계속 게이트를 오염시킨다. mute하면 74건은 대시보드에서 사라지지만 게이트는 오염된 채로 그대로 남는다.

그래서 데이터를 더 팠다. 태그 분포를 뽑자마자 이상한 게 나왔다.

74건 전부가 같은 기기, 그런데 그 기기는 존재하지 않는다

74건의 device 태그는 100% OnePlus8Pro 하나였다. 그런데 그 기기의 지문이 이상했다.

필드이벤트 값진짜 OnePlus 8 Pro
archsx86_64, x86 포함arm64 전용 (Snapdragon 865)
화면288 x 448 px, 106 dpi1440 x 3168, 513 dpi
CPU코어 2개, 주파수 08코어
simulatorfalse-

소비자용 ARM 폰은 x86 ABI를 광고하지 않는다. 288x448에 106dpi인 안드로이드 폰도 세상에 없다. Build.MODEL만 실제 기기 이름으로 위장한 x86 가상 기기다. simulator: false로 물리기기인 척까지 하고 있었다.

9명의 유저 ID도 사람이 아니었다. 9명 전원이 6월 29일 하루에만 존재했고, 그 하루에 1.7.0, 1.8.0, 1.8.1 세 버전을 모두 돌렸다. 사람이 아니라 파이프라인이다. 지역은 74건 전부 미국인데 도시가 전부 null이었다. 데이터센터 대역에서 들어오면 이렇게 찍힌다.

여기서 나는 물었다. 그럼 누가 우리 앱을 분석하고 있는 거냐고. 에이전트는 보안 벤더의 APK 자동 분석 샌드박스를 1순위로 꼽았고, 앱 인텔리전스 업체와 APK 미러 사이트가 뒤를 이었다. 스토어 자동심사는 4순위였다.

4순위였던 가설이 IP 세 개로 1순위가 됐다

나는 4순위가 걸렸다. 그래서 물었다.

Google play console이나 apple store일 경우도 있나?

돌아온 답은 단호했다.

Google Play pre-launch report / Test Lab - 거의 아닙니다. 결정적 이유: 구글은 당신의 HTTPS를 MITM하지 않습니다. Play pre-launch는 표준 신뢰저장소로 당신의 진짜 인증서를 그대로 받습니다. 스토어 크롤러는 트래픽을 가로채 재서명하지 않습니다. 그건 분석 샌드박스의 행위입니다.

원리로만 보면 흠잡을 데 없는 논증이다. 그리고 틀렸다.

없는 건 IP 증거였다. Sentry는 IP를 PII로 스크럽하기 때문에 이벤트의 user.ip가 전부 null이었고, 지난 이벤트의 원본 IP는 복구할 수 없다. 정체를 밝히려면 IP가 필요한데, IP를 가진 도구가 없었다.

대신 프로덕션 API의 액세스 로그가 남아 있었다. 요청마다 X-Forwarded-ForUser-Agent를 기록했고, 우리 앱의 User-Agent는 Dart/3.12 (dart:io)로 고유했다. 그리고 6월 29일은 아직 로그 보존 기간(7일) 안이었다.

먼저 Sentry에서 74건의 이벤트 시각을 뽑아 세 덩어리로 묶었다. 02:12-02:20, 11:30-11:41, 17:32-17:40 (UTC). 그다음 그 세 구간에 Dart/3.12로 들어온 요청의 IP를 CloudWatch에서 조회했다.

02:12-02:20  ...  66.249.84.132, 66.249.84.141
11:30-11:41  ...  66.102.7.69
17:32-17:40  ...  66.249.84.141

66.249.x.x는 Googlebot이 쓰는 대역이고, 66.102.x.x도 구글이다. 세 구간 모두에 구글 IP가 있었다. 그중 두 구간은 그 시간대에 들어온 앱 요청이 구글 IP 하나뿐이었다. 나머지 한 구간에는 실사용자로 보이는 IP도 함께 있었지만, 구글 IP는 거기에도 있었다.

3분 뒤 에이전트가 스스로를 정정했다.

정정합니다. 앞에서 "구글은 MITM 안 하니 아닐 것"이라고 말씀드렸는데, IP 증거가 그 추정을 뒤집습니다. 이건 구글이 맞습니다. 실측 IP가 추정보다 강한 증거입니다.

우리 앱을 뜯어보고 있던 건 Play에 올라온 앱이라면 누구나 받는 구글의 일상적인 자동 스캔이었다. 공격자도, 경쟁사도, 보안 벤더도 아니었다. 그 스캔 환경은 트래픽을 계측한다. 그래서 우리 프로브의 눈에는 검증되지 않는 체인으로 보였던 것이다.

그래서 무엇을 고쳤나

피닝은 그대로 두고, 프로브를 어디에 적용할지를 바꿨다. 스캐너 환경에서는 피닝을 아예 켜지 않기로 했다.

무엇으로 스캐너를 알아볼지가 남았다. simulator: false를 그대로 믿을 수 없다는 게 이미 드러났으니, 스푸핑되지 않는 신호가 필요했다. CPU 아키텍처는 못 숨긴다.

// 결정적 신호: 소비자용 ARM 폰은 x86 ABI를 절대 광고하지 않는다.
// 구글의 스캐너는 Build.MODEL 을 실제 기기 이름으로 위장해도(관측: x86 위의 "OnePlus8Pro")
// x86_64 라는 사실은 숨기지 못한다.
if (supportedAbis.any((abi) => abi.contains('x86') || abi.contains('i686'))) {
  return true;
}

여기에 Cuttlefish/GCE 계열 하드웨어 이름과 test-keys 핑거프린트를 추가로 검사한다. 판별기는 의도적으로 "에뮬레이터다" 쪽으로 편향돼 있다. 오탐이 나면 그 환경 하나에서 피닝을 건너뛸 뿐인데, 에뮬레이터에서 앱을 돌리는 공격자는 어차피 앱을 패치해서 클라이언트 피닝을 무력화할 수 있으니 잃는 게 없다. 반대로 미탐은 스캐너를 차단 화면에 가두는 것이라 훨씬 나쁘다.

이게 왜 중요하냐면, enforce를 그냥 켰다면 이런 일이 벌어졌을 것이기 때문이다. 구글의 pre-launch 크롤러가 우리 앱의 "안전하지 않은 연결" 차단 화면에 걸린다. 그대로 Play Console의 pre-launch 리포트에 실패로 기록된다. 코드에는 아무 문제가 없는데, 스토어 심사 화면에 붉은 줄이 그어진다.

남은 것

Sentry는 IP를 PII로 스크럽한다. 옳은 기본값이다. 하지만 그 바람에 사건의 정체를 밝혀줄 유일한 필드가 이벤트에서 사라져 있었다. 결국 앱의 User-Agent와 사건 발생 시각 두 개를 교차해, Sentry가 버린 정보를 액세스 로그에서 되찾았다. 한 도구에 없다고 어디에도 없는 건 아니었다.

"구글은 HTTPS를 가로채지 않는다"는 문장은 논증으로는 매끄러웠다. 스토어 크롤러의 목적, 표준 신뢰저장소의 동작, 샌드박스와의 행위 차이까지 짚어가며 4순위로 밀어냈다. 그런데 IP 세 개를 들이대자 3분 만에 결론이 뒤집혔다. AI에게 원리를 물으면 원리가 돌아온다. 그 원리가 현실과 맞는지는 로그를 봐야 알 수 있다.

이 74건을 Sentry에서 mute했다면 대시보드는 깨끗해졌을 것이고, enforce를 켜는 날 우리는 이유를 모른 채 스토어 심사에서 붉은 줄을 받았을 것이다.

우리는 아직 enforce를 켜지 않았다. 게이트는 2주간 실사용자 미스매치율이 0.5% 미만일 것을 요구한다. 스캐너를 걸러냈으니, 그 2주를 처음부터 다시 세야 한다.

자주 묻는 질문

인증서 피닝을 report-only로 켰더니 미스매치 리포트가 올라옵니다. 백업 핀을 추가하면 줄어드나요?

리포트가 어느 코드 경로에서 나왔는지부터 봐야 합니다. 피닝 프로브는 보통 두 단계입니다. 먼저 OS 신뢰저장소로 인증서 체인을 검증하고, 검증이 통과한 뒤에야 그 체인을 핀 목록과 대조합니다. 체인 검증 자체가 실패하면(가로채기·위조) 핀 비교를 아예 거치지 않고 곧바로 보고합니다. 우리 리포트는 전부 후자였고, 그래서 백업 핀을 아무리 추가해도 리포트는 한 건도 줄지 않습니다. 백업 핀은 '검증은 됐는데 핀이 안 맞는' 경우에만 효과가 있습니다.

Sentry 이벤트에 IP가 없습니다. 그래도 접속 출처를 알 수 있나요?

Sentry는 IP를 PII로 스크럽하기 때문에 이벤트에 남지 않는 경우가 많고, 지난 이벤트의 원본 IP는 복구할 수 없습니다. 대신 다른 로그와 시각을 교차하면 됩니다. 우리는 서버 액세스 로그가 요청마다 X-Forwarded-For와 User-Agent를 남기고 있었고, 앱의 User-Agent가 'Dart/3.12 (dart:io)'로 고유했습니다. Sentry에서 미스매치가 발생한 정확한 시각 구간을 뽑고, 그 구간에 그 User-Agent로 들어온 요청의 IP를 조회하니 출처가 드러났습니다.

인증서 피닝을 enforce로 켜면 Google Play 심사에 문제가 되나요?

될 수 있습니다. Play의 자동 스캔 환경은 트래픽을 계측하는 것으로 보이고, 그 환경에서 앱이 피닝 차단 화면을 띄우면 pre-launch 리포트에 실패로 기록될 수 있습니다. 스캐너나 에뮬레이터 환경에서는 피닝 프로브 자체를 건너뛰도록 만들어두는 편이 안전합니다. 어차피 에뮬레이터에서 앱을 돌리는 공격자는 앱을 패치해 클라이언트 피닝을 무력화할 수 있으므로, 그 환경을 예외로 두는 것이 보호 대상을 줄이지 않습니다.

관련 글

댓글 0