TLS Pinning

DOUIK·2022년 7월 30일
1

Android

목록 보기
7/7

Basic TLS Pinning 우회

TLS 프로토콜 : 클라이언트와 서버간의 암호화된 통신을 통해 제3자가 패킷을 획득하더라도 내용을 알 수 없도록 보호해주는 프로토콜

TLS 프로토콜이 동작하기 위해서는 서버와 클라이언트 간의 Handshake 과정을 통해 키교환을 수행해야 한다. 이 과정에서 클라이언트는 서버가 정상적인 서버인지 검사하기 위해 서버의 인증서를 확인한다. 만약 TLS 프로토콜을 사용하는 통신의 패킷 내용을 확인하고 싶다면 Man In The Middle(MITM) 공격을 통해 중간에서 새롭게 발급된 인증서를 이용해 패킷 내용을 복호화 해야 한다.

TLS Pinning은 이와 같이 중간에서 서버의 인증서를 조작하는 공격을 막기 위해 클라이언트 단에서 인증서를 고정해두는 것을 의미한다. 고정되어있는 인증서 외의 인증서를 서버에서 반환한다면 이는 중간에 변조된 것으로 판단하고 통신을 중단하기 위함이다.

TLS 패킷의 내용을 중간에서 획득할 수 있으면 앱 전체를 분석하지 않고도 앱이 서버와 어떤 내용을 주고 받는지 알 수 있으며, 패킷을 조작하기에도 용이하다. 이 때문에 패킷 캡처를 허용하지 않는 앱은 인증서 변조를 통한 MITM을 막기 위해 TLS Pinning 기능을 포함하고 있는 경우가 많다.

TLS Pinning 기법
1. 안드로이드의 Root CA 인증서를 이용한 TLS Pinning
2. 클라이언트가 지정한 서버의 CA 인증서를 이용한 TLS Pinning

안드로이드의 Root CA 인증서를 이용한 TLS Pinning 우회

코드 분석

doSendAnHttpRequest 함수 분석

TLSPinnerFragment.doSendAnHttpRequest()

private fun doSendAnHttpRequest() {
    if (doSendAnHttpRequestLock)
        return
    doSendAnHttpRequestLock = true
    val x = tlsPinnerSender.run("https://dreamhack.io/test1")
    handler.post {
        val t = view?.findViewById<TextView>(R.id.btnTLSTest)
        if (x != null && x.contains("<title>Dreamhack</title>")){
            t?.text = resources.getString(R.string.msg_Passed)
            t?.setBackgroundResource(R.drawable.button_passed)
        } else {
            t?.text = resources.getString(R.string.msg_Detected)
            t?.setBackgroundResource(R.drawable.button_detected)
        }
    }
    doSendAnHttpRequestLock = false
}

tlsPinnerSender.run 함수를 호출하여 인자로 전달한 URL에 요청을 수행한다. 만약 서버의 인증서가 변조되었다면 요청에 실패하기 때문에 요청의 응답값을 비교

run 함수 분석

TLSPinnerSender.TLSPinnerSender()

public TLSPinnerSender() {
    client = new OkHttpClient();
    certificatePinner = new CertificatePinner.Builder()
            .add("dreamhack.io", "sha256/QnvZVPjqAxkt5Rnr/bI96PF6dFJal/p6sGBUprcSynQ=")
            .build();
    enhancedClient = new OkHttpClient.Builder()
            .certificatePinner(certificatePinner)
            .build();
}

TLSPinnerSender는 TLS pinning을 수행하는 클래스로, 관련 함수들이 구현되어 있으며 위 코드는 클래스의 생성자이다. 생성자에서 초기화하는 객체 중 run 함수에서는 TLS 통신을 위해 client 객체를 사용한다.

TLSPinnerSender.run(String url)

String run(String url) {
    final CountDownLatch latch = new CountDownLatch(1);
    final String [] retValue = new String[1];                      
    Thread t = new Thread("TLS Pinner Thread - 1"){            
        public synchronized void run(){
            retValue[0] = null;
            Request request = new Request.Builder()             
                    .url(url)
                    .build();
            try (Response response = client.newCall(request).execute()) {           
                retValue[0] = Objects.requireNonNull(response.body()).string();      
            } catch (Exception ignore) {
            }
            latch.countDown();
        }
    };
    t.start();
    try {
        latch.await();
    } catch (InterruptedException ignore) {
    }
    return retValue[0];
}

run 함수는 OKHttpClient 객체인 client를 통해서 파라미터로 전달받은 url로 요청을 전송함
이때 TLS 통신을 위해 TLS Handshake를 수행하는 과정에서 서버의 인증서가 기기의 Root CA에 의해 신뢰할 수 있는지 검사함. 만약 신뢰하는 인증서라면 정상적으로 요청을 수행하고 아니면 Handshake에 실패한다.

  1. 반환값을 저장할 retValue변수는 서버로의 요청에 대한 응답을 저장해야하므로 String 타입으로 생성한다..
  2. 서버로 요청을 보내는 동작은 네트워크 환경/기기 환경에 따라 응답까지 소요되는 시간이 상이하기 때문에 스레드로 동작시킨다.
  3. 함수의 인자로 전달받은 url을 사용하여 Request객체를 생성한다.
  4. client 객체를 통해 전달받은 url로 요청을 전송한다.
  5. 요청 전송에 대한 결과값의 body 부분을 retValue[0]에 String 타입으로 저장하고, retValue[0]를 반환한다.

우회 아이디어

1. Root CA 체인을 통한 서버 인증서 검증 함수 반환값 조작

TrustManagerImpl.getTrustedChainForServer

/**
  * Returns the full trusted certificate chain found from {@code certs}.
  *
  * Throws {@link CertificateException} when no trusted chain can be found from {@code certs}.
  */
public List<X509Certificate> getTrustedChainForServer(X509Certificate[] certs,
        String authType, Socket socket) throws CertificateException {
    SSLSession session = null;
    SSLParameters parameters = null;
    if (socket instanceof SSLSocket) {
        SSLSocket sslSocket = (SSLSocket) socket;
        session = getHandshakeSessionOrThrow(sslSocket);
        parameters = sslSocket.getSSLParameters();
    }
    return checkTrusted(certs, authType, session, parameters, false /* client auth */);
}

위 함수는 Root CA를 이용해서 서버의 인증서 체인을 신뢰할 수 있는지 검사함
https://github.com/google/conscrypt/blob/168dca2f7036723344157abde7add4833cb9b920/common/src/main/java/org/conscrypt/TrustManagerImpl.java#L327-L342

이 함수는 인자로 전달된 서버 인증서 체인(certs)를 신뢰할 수 있는지 검사하고 신뢰할 수 있으면 인증서 체인을 반환함. 만약 신뢰하는 체인이 아니면 CertificateException이 발생
따라서 이 함수를 후킹해서 첫 번째 인자로 전달된 certs 리스트를 그대로 반환하기만 하면 이를 신뢰할 수 있는 인증서로 판단해서 Root CA 체인 검증을 우회할 수 있음

2. SSLContext.init 후킹을 통한 PortSwigger 인증서 신뢰

Basic TLS Pinning 검사는 기본적으로 서버의 인증서가 신뢰할 수 있는 CA 목록에 있는지의 여부를 기반으로 검사한다. 따라서 SSLContext.init 함수를 후킹하여 PortSwigger CA를 신뢰하도록 할 수 있다. Frida는 임의의 함수를 호출하는 것도 가능하기 때문에 PortSwigger의 인증서를 이용해 직접 TrustManager를 생성하고, SSLContext.init 함수가 호출되는 시점에 해당 TrustManager로 파라미터를 바꾸는 방식으로 후킹도 가능하다.
https://developer.android.com/training/articles/security-ssl?hl=ko#UnknownCa

우회 스크립트 작성

1. Root CA 체인을 통한 서버 인증서 검증 함수 반환값 조작

Bypass_Basic_TLSPinning_modifygetTrustedChainForServer.js

function modifygetTrustedChainForServer() {
    Java.perform(function () {
        var TrustManagerImpl = Java.use("com.android.org.conscrypt.TrustManagerImpl");
        var Arrays = Java.use("java.util.Arrays");
        TrustManagerImpl.getTrustedChainForServer.overload('[Ljava.security.cert.X509Certificate;', 'java.lang.String', 'java.net.Socket').implementation = function(certs, authType, socket) {
            return Arrays.asList(certs);
        }
    });
}
modifygetTrustedChainForServer()

함수의 반환값이 인자로 전달된 certs를 그대로 반환하도록 후킹한다. 이 때 certs의 타입은 X509Certificate[]로 배열이고, 반환 타입은 List<X509Certificate>로 리스트이기 때문에 배열을 리스트로 변환해주는 과정이 필요하다. Java.use 함수를 이용해 Arrays 클래스의 Wrapper를 제공받고, Arrays.asList 함수를 이용해 리스트를 배열로 변환할 수 있다.

2. SSLContext.init 후킹을 통한 PortSwigger 인증서 신뢰

Bypass_Basic_TLSPinning_modifyRootCA.js

function modifyRootCA(cert) {
    Java.perform(function () {
        try {
            var CertificateFactory = Java.use("java.security.cert.CertificateFactory");
            var FileInputStream = Java.use("java.io.FileInputStream");
            var BufferedInputStream = Java.use("java.io.BufferedInputStream");
            var X509Certificate = Java.use("java.security.cert.X509Certificate");
            var KeyStore = Java.use("java.security.KeyStore");
            var TrustManagerFactory = Java.use("javax.net.ssl.TrustManagerFactory");
            var SSLContext = Java.use("javax.net.ssl.SSLContext");
            var cf = CertificateFactory.getInstance("X.509");
            var caInput = BufferedInputStream.$new(FileInputStream.$new(cert));
            var ca = cf.generateCertificate(caInput);
            console.log("ca=" + Java.cast(ca, X509Certificate).getSubjectDN());
            caInput.close();
            var keyStoreType = KeyStore.getDefaultType();
            var keyStore = KeyStore.getInstance(keyStoreType);
            keyStore.load(null, null);
            keyStore.setCertificateEntry("ca", ca);
            
            var tmfAlgorithm = TrustManagerFactory.getDefaultAlgorithm();
            var tmf = TrustManagerFactory.getInstance(tmfAlgorithm);
            tmf.init(keyStore);
            var SSLContext_init = SSLContext.init.overload("[Ljavax.net.ssl.KeyManager;", "[Ljavax.net.ssl.TrustManager;", "java.security.SecureRandom");
            SSLContext_init.implementation = function(km, tm, random) {
                SSLContext_init.call(this, km, tmf.getTrustManagers(), random);
            }
        }
        catch (err) {
            console.log(err);
        }
    });
}
modifyRootCA("/data/local/tmp/cacert.der")

위는 SSLContext.init 함수를 통해 초기화할 때 파라미터에 PortSwigger 인증서를 이용해 생성한 TrustManager를 전달하도록 후킹하는 함수이다. modifyRootCA 함수는 파라미터로 인증서의 경로를 전달받는데, 이 때 PortSwigger CA를 미리 기기 내에 넣어두어야 한다. PortSwigger CA는 Burp Suite를 실행한 상태에서 localhost:8080 에 접속하여 우측 상단의 CA Certificate 버튼을 클릭하면 다운로드 받을 수 있다.

이후 CA 파일을 TrustManager 형태로 변환하는 과정이 필요한데 해당 과정은 https://developer.android.com/training/articles/security-ssl?hl=ko#UnknownCa 의 소스코드를 참조하여 작성하였다. 프리다는 Java.use 함수를 이용해 임의 클래스의 Wrapper를 제공받고, 클래스의 함수를 호출할 수 있기 때문에 CA 파일로부터 TrustManager로 변환하는 과정을 그대로 자바스크립트로 구현하였다.
마지막 39번째 줄에서 SSLContext.init의 implementation을 덮어 TrustManager의 파라미터 값을 PortSwigger CA로 생성한 TrustManager를 넘겨 PortSwigger CA를 신뢰할 수 있도록 조작하면 TLS pinning을 우회할 수 있다.

클라이언트가 지정한 서버의 CA 인증서를 이용한 TLS Pinning 우회

코드 분석

doSendAnEnhancedPinningHttpRequest 함수 분석

TLSPinnerFragment.doSendAnEnhancedPinningHttpRequest()

private fun doSendAnEnhancedPinningHttpRequest() {
        if (doSendAnHttpRequestLock)
            return
        doSendAnHttpRequestLock = true
        val x = tlsPinnerSender.enhancedRun("https://dreamhack.io/test1")
        handler.post {
            val t = view?.findViewById<TextView>(R.id.btnEnhancedTLSTest)
            if (x != null && x.contains("<title>Dreamhack</title>")) {
                t?.text = resources.getString(R.string.msg_Passed)
                t?.setBackgroundResource(R.drawable.button_passed)
            } else {
                t?.text = resources.getString(R.string.msg_Detected)
                t?.setBackgroundResource(R.drawable.button_detected)
            }
        }
        doSendAnHttpRequestLock = false
    }
  1. tlsPinnerSender.enhancedRun 함수를 호출하면서 https://dreamhack.io/test1를 인자로 전달하여 TLS pinning 여부를 확인
  2. 해당 함수의 반환 결과 x가 타이틀에 "Dreamhack"을 포함할 경우 클라이언트가 지정된 서버의 인증서를 잘 사용하고 있는 것으로 판단하고 패스. 만약 x가 null이거나 타이틀이 "Dreamhack"을 포함하지 않는 경우 클라이언트가 지정된 인증서 외에 다른 인증서를 사용하거나 인증서를 사용하고 있지 않은 것으로 판단하여 DETECTED!를 반환

enhancedRun 함수 분석

TLSPinnerSender()

public TLSPinnerSender() {
    client = new OkHttpClient();
    certificatePinner = new CertificatePinner.Builder()
            .add("dreamhack.io", "sha256/QnvZVPjqAxkt5Rnr/bI96PF6dFJal/p6sGBUprcSynQ=")
            .build();
    enhancedClient = new OkHttpClient.Builder()
            .certificatePinner(certificatePinner)
            .build();
}

TLSPinnerSender는 TLS pinning을 수행하는 클래스로, 관련 함수들이 구현되어 있으며 위 코드는 okhttp의 CertificatePinner를 통해 클라이언트의 인증서를 지정한다.

TLSPinnerSender.enhancedRun()

String enhancedRun(String url) {
    final CountDownLatch latch = new CountDownLatch(1);
    final String [] retValue = new String[1];
    Thread t = new Thread("TLS Pinner Thread - 2"){
        public synchronized void run(){
            retValue[0] = null;
            Request request = new Request.Builder()
                    .url(url)
                    .build();
            try (Response response = enhancedClient.newCall(request).execute()) {
                retValue[0] = Objects.requireNonNull(response.body()).string();
            } catch (Exception ignore) {
            }
            latch.countDown();
        }
    };
    t.start();
    try {
        latch.await();
    } catch (InterruptedException ignore) {
    }
    return retValue[0];
}

enhacedRun함수는 okhttpclient의 객체인 enhancedClient를 통해 서버의 인증서를 가지고 있어야만 클라이언트가 서버와 통신할 수 있도록 인증서를 고정(pinning)하여, 서버의 인증서와 동일하다면 정상적인 요청에 대한 결과값을 반환한다.

  1. 반환값을 저장할 retValue변수는 서버로의 요청에 대한 응답을 저장해야하므로 String 타입으로 생성.
  2. TLSPinnner클래스에서 함수들은 스레드로 동작하기 때문에 enhancedRun함수도 마찬가지로 스레드로 동작.
  3. 함수의 인자로 전달받은 url을 사용하여 Request객체를 생성.
  4. enhancedClient를 통해 전달받은 url 의 인증서와 클라이언트에 지정된 dreamhack.io서버의 인증서가 동일한지 검사하고 request인스턴스에 포함된 url로 요청을 전송.
  5. 요청 전송에 대한 결과값의 body 부분을 retValue[0]에 String 타입으로 저장하고, retValue[0]를 반환.

지정할 서버 호스트와 인증서

TLSPinnerSender클래스의 생성자로, doSendAnEnhancedPinningHttpRequest 함수에서 객체 생성시에 동작한다. 생성자에서는 enhancedRun함수에서 사용한 enhancedClient를 생성한다. enhancedClient에는 클라이언트에 고정할 서버의 호스트와 인증서 정보가 포함된다.

  1. CertificatePinner 객체를 생성 시 인증서을 지정할 서버의 호스트 정보와 인증서 해시를 인자로 전달한다. 따라서 certificatePinner 객체는 dreamhack.io호스트에 접근할 때sha256/QnvZVPjqAxkt5Rnr/bI96PF6dFJal/p6sGBUprcSynQ= 인증서만을 통해 접근할 수 있다.

  2. 만들어진 certificatePinner객체를 OkHttpClient객체 생성시 인자로 사용한다. 이를 통해 enhancedClient를 사용하여 dreamhack.io로 요청을 보낼때는 지정된 인증서를 사용해야만 정상적인 응답을 받을 수 있다.

우회 아이디어

CertificatePinner.check

@Throws(SSLPeerUnverifiedException::class)
  fun check(hostname: String, peerCertificates: List<Certificate>) {
    return check(hostname) {
      (certificateChainCleaner?.clean(peerCertificates, hostname) ?: peerCertificates)
          .map { it as X509Certificate }
    }
  }
  internal fun check(hostname: String, cleanedPeerCertificatesFn: () -> List<X509Certificate>) {
    val pins = findMatchingPins(hostname)
    if (pins.isEmpty()) return
    val peerCertificates = cleanedPeerCertificatesFn()
    for (peerCertificate in peerCertificates) {
      // Lazily compute the hashes for each certificate.
      var sha1: ByteString? = null
      var sha256: ByteString? = null
      for (pin in pins) {
        when (pin.hashAlgorithm) {
          "sha256" -> {
            if (sha256 == null) sha256 = peerCertificate.sha256Hash()
            if (pin.hash == sha256) return // Success!
          }
          "sha1" -> {
            if (sha1 == null) sha1 = peerCertificate.sha1Hash()
            if (pin.hash == sha1) return // Success!
          }
          else -> throw AssertionError("unsupported hashAlgorithm: ${pin.hashAlgorithm}")
        }
      }
    }
    // If we couldn't find a matching pin, format a nice exception.
    val message = buildString {
      append("Certificate pinning failure!")
      append("\n  Peer certificate chain:")
      for (element in peerCertificates) {
        append("\n    ")
        append(pin(element))
        append(": ")
        append(element.subjectDN.name)
      }
      append("\n  Pinned certificates for ")
      append(hostname)
      append(":")
      for (pin in pins) {
        append("\n    ")
        append(pin)
      }
    }
    throw SSLPeerUnverifiedException(message)
  }
  @Deprecated(
      "replaced with {@link #check(String, List)}.",
      ReplaceWith("check(hostname, peerCertificates.toList())")
  )

1. 함수 반환값 조작

doSendAnEnhancedPinningHttpRequest 함수에서 enhancedRun 함수의 반환값이 <title>Dreamhack</title>를 포함하고 있는지 검사하여 TLS pinning의 성공여부를 확인한다. 따라서 enhancedRun 함수의 반환값인 retValue[0]가 <title>Dreamhack</title>를 항상 포함하도록 enhancedRun 함수를 후킹하여 반환값을 고정하면 해당 검사를 우회할 수 있다.

2. 인증서 검사 함수 조작

enhancedRun 함수는 요청 전송 시에 지정된 인증서와 동일한 인증서가 맞는지 검사하기위해 enhancedClient를 사용한다. enhancedClient는 내부적으로 Figure 4 okhttp의 CertificatePinner.check 함수를 사용하여 고정된 인증서와 요청 전송 시 인증서를 비교한다. CertificatePinner.check 함수는 검사에 성공할 경우 null을 반환한다. 따라서 인증서 검사 함수인 CertificatePinner.check의 반환값을 항상 null로 고정하면 해당 검사를 우회할 수 있다.

우회 스크립트 작성

1. enhancedRun 함수 반환값 변조

Bypass_TLSPinner_enhancedRun_modifyEnhancedRunRet.js

function modifyEnhancedRunRet() {
    Java.perform(function() {
        var TLSPinnerSender = Java.use("android.com.dream_detector.TLSPinnerSender");
        var str = Java.use('java.lang.String');
        str = "<title>Dreamhack</title>"
        TLSPinnerSender.enhancedRun.implementation = function(url) {
            return str;
        }
    });
}
modifyEnhancedRunRet();

함수의 반환값이 항상 <title>Dreamhack</title>를 포함하도록 str변수에 <title>Dreamhack</title>을 저장하고 enhancedRun함수가 str을 반환하도록 후킹한다. 이를 통해 enhancedRun함수를 우회할 수 있다.

2. CertificatePinner.check 함수 반환값 변조

Bypass_TLSPinner_enhancedRun_modifyCertificateCheckRet.js

function modifyCertificateCheckRet() {
    Java.perform(function() {
        var CertificatePinner = Java.use("okhttp3.CertificatePinner");
        CertificatePinner.check.overload('java.lang.String', '[Ljava.security.cert.Certificate;').implementation = function(p0, p1){
            return
        };
        CertificatePinner.check.overload('java.lang.String', 'java.util.List').implementation = function(p0, p1){
            return
        };
    });
}
modifyCertificateCheckRet();

check 함수는 인자의 개수에 따라 오버로드되므로, overload 함수를 사용하여 모든 인자에 관계없이 모든 check 함수의 반환값을 변조한다. implementation을 사용하여 해당 함수에 대한 반환값을 null로 고정하고 CertificatePinner.check 함수를 우회할 수 있다.

0개의 댓글