평문 차단과 인증서 신뢰 범위를 XML로 선언하는 설정.
<!-- res/xml/network_security_config.xml -->
<network-security-config>
<base-config cleartextTrafficPermitted="false" />
<domain-config>
<domain includeSubdomains="true">api.example.com</domain>
<pin-set expiration="2027-01-01">
<pin digest="SHA-256">AAAA...=</pin>
<pin digest="SHA-256">BBBB...=</pin> <!-- 백업 핀 필수 -->
</pin-set>
</domain-config>
</network-security-config>
Android 9(P) 부터 평문 HTTP 는 기본 차단이다
- http:// 를 부르면 CleartextNotPermittedException
인증서 고정이 막는 것
일반 HTTPS 는 '신뢰할 수 있는 CA 가 서명했으면' 통과한다
문제
- 사용자가 기기에 CA 를 설치할 수 있다 (Charles · mitmproxy)
- 기업 MDM 이 CA 를 심을 수 있다
- CA 자체가 뚫릴 수 있다
핀을 걸면 '내가 아는 그 인증서(의 공개키 해시)' 여야만 통과한다
// OkHttp 로도 가능하다
val pinner = CertificatePinner.Builder()
.add("api.example.com", "sha256/AAAA...=")
.add("api.example.com", "sha256/BBBB...=")
.build()
OkHttpClient.Builder().certificatePinner(pinner).build()
고정의 위험 — 앱이 통째로 죽는다
서버 인증서를 갱신했는데 앱의 핀을 안 바꿨다
- 설치된 모든 앱이 네트워크에 연결하지 못한다
- 앱 업데이트를 받아야 고쳐지는데, 업데이트도 네트워크가 필요할 수 있다
반드시
- 백업 핀을 함께 넣는다 (차기 인증서의 핀)
- expiration 을 설정해 만료 후에는 고정을 해제한다
- 인증서 갱신 절차에 '앱 핀 교체' 를 넣는다
금융·의료가 아니면 고정을 안 하는 편이 나을 수 있다 — 운영 비용이 실재한다
디버그 빌드에서만 프록시 허용
<debug-overrides>
<trust-anchors>
<certificates src="user" /> <!-- 사용자 설치 CA 를 디버그에서만 신뢰 -->
</trust-anchors>
</debug-overrides>
- debug-overrides 는 android:debuggable="true" 일 때만 적용된다
- 릴리스에 새어 나갈 수 없다
면접 함정
- ❌ "핀을 하나만 넣어도 된다" → 인증서 갱신 순간 앱이 전부 죽는다.
- ❌ "인증서 고정이면 리버싱도 막는다" → 앱을 뜯어 핀 검사를 제거할 수 있다. 중간자 공격을 어렵게 할 뿐이다.
핀을 어떻게 뽑나
# 서버 인증서의 공개키 해시
openssl s_client -servername api.example.com -connect api.example.com:443 \
| openssl x509 -pubkey -noout \
| openssl pkey -pubin -outform der \
| openssl dgst -sha256 -binary | base64
OkHttp 는 더 간단한 방법도 준다 — 일부러 틀린 핀으로 요청하면 예외 메시지에 실제 핀들이 찍힌다 (개발 중에만)
어느 인증서를 고정하나
-
리프(서버) 인증서 — 가장 엄격. 갱신 주기가 짧아 위험하다 (Let's Encrypt 는 90일)
-
중간 CA — 갱신 주기가 길다. 실무에서 가장 흔한 선택
-
루트 CA — 가장 느슨하다. 그 CA 로 발급된 아무 인증서나 통과한다
-
중간 CA 를 고정하고 백업으로 다른 중간 CA 를 넣는 것이 균형점이다
평문이 필요한 예외
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">localhost</domain>
<domain includeSubdomains="false">10.0.2.2</domain> <!-- 에뮬레이터의 호스트 -->
</domain-config>
전체를 여는 것(base-config cleartextTrafficPermitted="true") 이 아니라 필요한 도메인만 연다. 그리고 debug 소스셋에만 둔다