인증서 피닝이란?
인증서 피닝은 앱이 통신할 서버를 미리 지정하는 보안 기법입니다. 일반적인 HTTPS 통신에서는 기기에 설치된 인증 기관(CA)이 발급한 인증서라면 모두 신뢰하지만, 인증서 피닝을 사용하면 특정 인증서나 공개키를 가진 서버하고만 통신하도록 제한할 수 있습니다.
만약, 공격자가 중간자 공격(MITM Attack)을 시도할 때, 가짜 인증서를 만들어 통신을 가로챌 수 있기 때문에 앱 내부에 올바른 인증서 정보를 미리 넣어둔다면, 유효한 인증서를 가진 공격자라도 우리가 지정한 인증서가 아니면 연결을 거부할 수 있습니다.
완벽하진 않다
인증서 피닝을 통해 앱의 보안성을 높일순 있지만, 완벽한 방어막은 아닙니다. 앱의 인증서 검증 로직 자체를 변조하거나, 앱 내부에 저장된 인증서 파일을 교체 및 네트워크 보안 설정 파일을 수정하는 방식으로 공격자는 우회가 가능합니다. 이런 작업을 진행했을 때 앱 내부의 서명이 무효화되므로 공격자는 APK를 다시 패키징 후 재서명이 필요합니다.
이런 위험이 존재하므로 인증서 피닝 외에도 무결성 검사, 런타임 검증, 코드 난독화 등의 추가 보안 조치가 이뤄지면 다층 방어막을 쌓아 보안성을 향상시킬 수 있습니다.
네트워크 보안 구성(NSC)
Android 7.0(API 레벨 24)부터는 네트워크 보안 구성(Network Security Configuration)이라는 기능이 도입되었습니다. 이것이 현재 안드로이드에서 인증서 피닝을 구현하는 가장 권장되는 방법입니다.
이 방법은 XML 설정 파일만으로 보안 정책을 선언할 수 있어 유지보수가 쉽고 실수할 가능성이 적습니다. 또한 HttpsURLConnection 기반의 모든 네트워크 통신 및 WebView 요청에 자동으로 적용됩니다.
작동 원리
시스템이 원격 서버와 연결을 시도할 때 다음과 같은 검증 과정을 거칩니다.
- 먼저 서버로부터 받은 인증서를 검증한 후, 그 인증서에서 공개키를 추출합니다.
- 그 다음 추출한 공개키의 해시값을 계산하고, 이 해시값을 앱에 미리 설정해둔 핀(Pin) 값들과 비교합니다.
- 설정된 핀 중 하나라도 일치하면 인증서 체인이 유효한 것으로 판단하고 연결을 허용합니다.
구현 예시
먼저 res/xml 폴더에 network_security_config.xml 파일을 생성합니다.
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<domain-config>
<domain includeSubdomains="true">owasp.org</domain>
<pin-set expiration="2028-12-31">
<pin digest="SHA-256">YLh1dUR9y6Kja30RrAn7JKnbQG/uEtLMkBgFF2Fuihg=</pin>
<pin digest="SHA-256">Vjs8r4z+80wjNcr1YKepWQboSIRi63WsWXhIMN+eWys=</pin>
</pin-set>
</domain-config>
</network-security-config>
그리고 AndroidManifest.xml에서 이 설정 파일을 참조합니다.
<application android:networkSecurityConfig="@xml/network_security_config">
서버 인증서가 예기치 않게 변경되는 상황에 대비하기 위해 백업 핀을 포함해야 합니다. 또한 expiration 속성으로 핀의 유효 기간을 설정할 수 있는데, 만료일이 지난 경우 피닝이 자동으로 비활성화됩니다. 그렇기 때문에 만료일 관리도 중요합니다.
이 설정은 HttpsURLConnection을 사용하는 연결에만 적용된다는 점에 있어 OkHttp 같은 다른 네트워킹 라이브러리인 경우 별도의 설정이 필요합니다.
TrustManager
네트워크 보안 구성이 도입되기 전 커스텀 TrustManager를 직접 구현하는 방식을 사용했습니다. 현재도 유효한 방법이며, 세밀한 제어가 필요하거나 호환성을 중요시할 때 사용할 수 있습니다.
구현 예시
- 서버 인증서를 KeyStore에 로드한 뒤, KeyStore의 인증서만 신뢰하는 커스텀 TrustManager를 생성
- 생성한 TrustManager로 SSLContext를 초기화한 뒤, 초기화된 SSLContext를 네트워크 연결의 소켓 팩토리로 설정
이 방식은 로우 레벨의 접근이기 때문에 잘못된 설정으로 인한 보안 취약점을 만들 수 있습니다. 특히 SSLSocket은 호스트 이름을 자동으로 검증하지 않아, 안전한 HostnameVerifier 구현이 함께 있어야 합니다.
다양한 상황에서의 인증서 피닝
서드파티 라이브러리 활용
직접 구현이 부담스럽다면, 라이브러리 활용이 가능합니다. 예를 들어 많이 사용되는 OkHttp 라이브러리는 CertificatePinner라는 편리한 기능을 제공합니다. 간단한 설정만으로 피닝 설정이 가능합니다.
WebView 피닝
안드로이드는 같은 앱 내의 WebView 트래픽에도 NSC 규칙을 자동으로 적용하기 때문에, 네트워크 보안 구성을 사용하면 쉽게 피닝을 적용할 수 있습니다. 세밀한 제어가 필요하다면 shouldInterceptRequest 메서드를 오버라이드해서 요청을 가로채고 직접 검증할 수도 있습니다.
네이티브 코드에서의 구현
C, C++, Rust 같은 네이티브 언어로 피닝을 구현하면 리버싱을 통한 우회를 어렵게 만들 수 있습니다. 컴파일된 네이티브 라이브러리 파일 안에 인증서를 포함시키거나 동적으로 검증하는 방식입니다. 하지만 이 방법에서는 유지보수와 디버깅이 복잡해진다는 단점이 존재합니다.
Flutter, React Native, Cordova, Xamarin 같은 크로스 플랫폼 프레임워크를 사용한다면 네이티브 안드로이드 네트워크 스택을 그대로 사용하지 않을 수 있습니다. 예를 들어, Flutter는 자체 HttpClient를 사용하며 내부적으로 BoringSSL을 사용합니다. 각 프레임워크마다 피닝 구현 방법이 다르므로, 해당 프레임워크의 문서를 확인하거나 전용 플러그인을 사용해야 합니다.
네트워크 보안 구성(NSC) 심화
네트워크 보안 구성을 사용한다면, 인증서 피닝뿐만 아니라 다양한 네트워크 보안 정책을 선언적으로 관리 가능합니다.
주요 기능들
- 평문 트래픽 제어 기능으로 암호화되지 않은 HTTP 통신을 차단하거나 허용 가능
- 커스텀 신뢰 앵커 설정으로 앱이 신뢰할 인증 기관 직접 지정 가능
- 예를 들어 회사 내부의 사설 인증서를 신뢰하도록 설정하거나, 반대로 시스템 CA를 제한할 수 있음
- 개발 중에만 특정 보안 설정을 변경하고, 릴리스 빌드에서 엄격한 보안 설정 가능
- 개발의 편의성 및 보안성 향상 가능
설정 범위와 우선순위
네트워크 보안 구성은 계층적 구조를 가집니다. base-config는 앱의 모든 네트워크 연결에 적용되는 기본 설정이고, domain-config는 특정 도메인에 대한 설정으로 기본 설정을 덮어씁니다.
예를 들어 모든 도메인에서 평문 통신을 차단하지만, localhost만 예외로 허용하고 싶다면 다음과 같이 설정합니다.
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="false" />
<domain-config cleartextTrafficPermitted="true">
<domain>localhost</domain>
</domain-config>
</network-security-config>
이렇게 하면 개발 중 로컬 서버 테스트는 가능하면서도 운영 환경에서는 안전하게 보호됩니다.
API 레벨별 기본 설정
앱의 타겟 API 레벨에 따라 기본 보안 설정이 달라집니다.
- Android 9(API 28) 이상은 평문 통신이 기본적으로 차단되고 시스템 CA만 신뢰
- Android 7.0(API 24)부터 Android 8.1(API 27)까지는 평문 통신이 허용되지만 시스템 CA만 신뢰
- Android 6.0(API 23) 이하에서는 평문 통신이 허용되고 시스템 CA와 사용자가 설치한 CA를 모두 신뢰
앱을 업데이트하면서 타겟 API 레벨을 올릴 때는 이런 보안 정책 변경사항을 반드시 확인해야 합니다. 갑자기 네트워크 연결이 작동하지 않는다면 대부분 이런 보안 정책 변경 때문입니다.
마치며
인증서 피닝은 안드로이드 앱의 네트워크 보안을 강화하는 기술입니다. 네트워크 보안 구성을 사용하면 비교적 쉽게 구현이 가능하지만, 백업 핀 관리, 만료일 설정, 인증서 갱신 등을 신중하게 진행해야 합니다.
추가적으로 Flutter, React Native, Cordova, Xamarin 같은 크로스 플랫폼 프레임워크를 사용한다면 네이티브 안드로이드 네트워크 스택을 그대로 사용하지 않을 수 있습니다. Flutter는 자체 HttpClient를 사용하며 내부적으로 BoringSSL을 사용합니다. 각 프레임워크마다 피닝 구현 방법이 다르므로, 해당 프레임워크의 문서를 확인하거나 전용 플러그인을 사용해야 합니다.
Comments
Sign in with GitHub to leave a comment.