본문 바로가기

Android/Tech

Android OKHTTP SSL Pinning 적용하기 (certificate pinning)

[클라 <-> Proxy <-> 서버]  

https 통신을 사용하더라도 위 그림과 같이 공격자가 중간자 공격(MITM)을 수행한다면 전송하는 패킷을 탈취 및 변조가 가능하다.

어떻게 중간자 공격이 가능할까? Burp Suite 프록시툴을 예로 들어 설명해보겠다.

클라이언트는 프록시를 지정해놓은 뒤 특정 서버로 접속할 때 프록시와 SSL 연결을 맺게 되고,  프록시는 클라이언트를 대신해서 특정 서버와의 SSL 연결이 맺게 된다.  클라이언트는 프록시와 SSL 연결을 맺기 때문에 접속하려는 해당 서버의 인증서가 아닌 Burp Suite가 만든 사설 인증서를 받게 되는데, 일반 단말기의 경우 해당 사설 인증서의 CA 인증서가 없기 때문에 통신에 실패한다. 그러나 단말기에 직접 Burp CA 사설인증서를 등록시켜놓으면 인증서를 신뢰하여 통신이 가능하다. (자세한 SSL HandShake 과정은 생략한다...)

이러한 원리로 중간자 공격이 가능해진다. 추가로 안드로이드는 버전 6.0 이하는 사용자가 추가한 CA를 신뢰하지만 7.0이상부터는 사용자가 추가한 CA를 신뢰하지 않는다. 

 

그렇다면 중간자 공격을 막기위해서 도입된 SSL Pinning 에 대해서 살펴보자.

쉽게 설명해서 클라이언트에 지정한 서버 호스트의 인증서일 경우에만 통신이 가능하도록 소스 코드에 명시해두는 방법이다.

클라이언트에 지정한 특정 서버의 인증서만 사용할 수 있도록 소스코드상에 하드 코딩하여 박아두면 SSL HandShake 과정에서 프록시가 전달해준 사설 인증서를 사용하지 못하기 때문에 중간자 공격을 막을 수 있다.

 

예를 들어, 앱에서 "naver.com" 호스트와 통신할때에는 서버에서 받은 인증서가 네이버의 인증서만 사용하라고 하드 코딩해둔다면 Burp가 전달한 사설인증서는 사용하지 못할것이다.


곧장 아래 예제 코드로 피닝의 구현 방법과 상세한 인증서 검증 방식을 살펴보자.

필자는 Android OKHTTP3을 사용하여 테스트 환경을 구성했다.

implementation 'com.squareup.okhttp3:okhttp:4.9.0'
String hostname = "www.naver.com";
CertificatePinner certificatePinner1 = new CertificatePinner.Builder()
.add(hostname, "sha1/AAAAAAAAAAAAAAAAAAAAAAAAAAA=")
.build();

우선 CertificatePinner 객체를 생성하고 특정 host 에는 "sha1/AA..." 인 인증서만 허용할 수 있도록 만들어보자

OkHttpClient client = new OkHttpClient.Builder().certificatePinner(certificatePinner1).build();
Request request = new Request.Builder()
	.url("https://www.naver.com").build();

try {
  client.newCall(request).enqueue(new Callback() {
    @Override
    public void onFailure(okhttp3.Call call, IOException e) {
      e.printStackTrace();
    }

    @Override
    public void onResponse(okhttp3.Call call, Response response) throws IOException {

    }
    });
    } catch (Exception e) {
  e.printStackTrace();
}

certificatePinner 함수를 사용하여 방금 만들어둔 객체를 등록하고 "https://www.naver.com" 로 통신해보자.

 

SSL Certificate pinning failure

인증서의 해시값을 "sha1/AAA..." 으로 지정해놓았기 때문에 위와 같은 에러가 발생하여 검증에 실패한다.

즉, "naver.com" 호스트와 통신할때 지정한 인증서만 허용하도록 명시해놓는것이다.

에러 로그에 친절하게 네이버의 인증서의 해시값을 알려주기 때문에 그대로 가져와서 적용해보자. 

Root, Intermediate, Leaf 인증서 중 Leaf 인증서로 선택했다.

String hostname = "naver.com";
CertificatePinner certificatePinner = new CertificatePinner.Builder()
.add(hostname, "sha256/MWZBgGbeecJrOpL+rmWvllxyB2mDV/MUVAWWDGoM03s=")
.build();

위와 같이 인증서의 해시값을 올바르게 변경하여 실행해보면 에러가 발생하지 않고 해당 서버와 정상적으로 통신이 가능하다. 그렇다면 실제 OKHTTP 소스 코드에서 인증서를 어떻게 검증하고 있는지 살펴보자.

 

CertificatePinner - check()
sha256

우선 소스코드상에 명시해놓은 해당 hostname에 등록해놓은 "SHA256/..." 해시정보를 가져온 뒤, 실제 SSL Handshake 과정에서 서버에서 가져온 인증서(peerCertificates 객체)의 해시값과 비교하는 원리이다.

Hash = x509 인증서의 공개키 -> sha256 -> base64Encode

비교하는 검증값은 인증서의 공개키를 sha256 해시하고 base64 인코딩하여 구할 수 있다.

프록시를 사용하고 있다면 네이버의 인증서가 아닌 프록시의 사설인증서를 내려받으므로 해시값이 달라진다.

 

직접 네이버(www.naver.com)의 인증서를 다운로드 받은 뒤 공개키를 확인해보자.

Public key

 네이버 인증서를 다운로드 받아 확인해본 결과 위 그림의 파란색 영역이 공개키 부분으로 확인되었다. 해당 부분을 sha256 해시화 하면 "3166418066DE79C26B3A92FEAE65AF965C7207698357F3145405960C6A0CD37B"(hex) 값이 나오며, base64 인코딩하면 "MWZBgGbeecJrOpL+rmWvllxyB2mDV/MUVAWWDGoM03s=" 값이 나오게 된다.

최종적으로 나온 이 값을 소스코드에 하드코딩하고 서버에 접속할 때 SSL Handshake 과정에서 받은 인증서의 base64(sha256(인증서의 공개키)) 값과 비교하는 원리다.

 

- ref : https://square.github.io/okhttp/3.x/okhttp/okhttp3/CertificatePinner.html


프록시 테스트

 이전에 프록시의 사설인증서를 내려받는다고 설명했었다. 실제 Burp 프록시에 붙여서 변경되는지 확인해보자. 

 

Burp Certificate

 

중간자 공격으로 인하여 네이버의 인증서를 받은게 아닌 Burp의 인증서를 받은것을 확인할 수 있다. 우리는 소스 코드에 네이버의 인증서의 해시값을 pinning 하였기 때문에 값이 달라 통신을 할 수 없다. 개발하고자 하는 앱에 맞게 통신할 호스트와 인증서의 해시값을 소스코드상에 예제와 같이 명시해두면 SSL Pinning을 구현하면 된다.

 

 

- 7.0 이상 단말기라면? https://blog.ropnop.com/configuring-burp-suite-with-android-nougat/


우회 방법

Frida SSL Pinning Bypass

사용자 코드에 SSL Pinning 적용 부분을 난독화 지정 하여 숨겨놓았더라도 실제 검증 로직인 CertificatePinner - check() 함수가 난독화가 되어 있지 않고 그대로 노출되어 있기 때문에 앱 코드를 분석할 필요 없이 후킹으로 아주 쉽게 우회가 가능하다. 

즉, 유명한 xposed나 frida등의 후킹 프레임워크를 사용하여 okhttp3.certificatePinner 클래스의 check() 함수를 후킹하면 된다.

대응 방안? 

앱 보호 솔루션 적용

앱 보호 솔루션 적용하여 후킹 여부 탐지는 물론이고 앱에서도 해당 함수가 후킹중인지 체크하고(ART 후킹 동작원리를 알면 탐지도 쉽다) 후킹중이라면 다시 원상태로 재 후킹하여(restore) 방어하면 된다. 후후

난독화 적용 및 숨겨놓기

아니면 라이브러리 통째로 코드내에 삽입한다음에 check() 함수명을 변경하고 악산, 덱스가드로 난독화를 빡시게 적용해보자. (이것도 분석하면 금방 찾겠지만..)