Google Play 안전하지 않은 TrustManager 경고 대응하기

logo-google-play-vetor

구글이 기존의 수많은 앱들의 X509TrustManager를 무력화 시켜 구현한 것에 대한 대응을 나선 것 같습니다. 현재 HTTPS 통신을 위해 SSL 관련된 코드를 구현하였지만 그 방법이 잘못된 경우에 대해 구글 플레이에서 다음과 같은 경고가 뜨고 있습니다.

앱이 Apache HTTP 클라이언트가 있는 X509TrustManager 인터페이스의 안전하지 않은 구현을 사용하고 있어 보안 취약성에 노출되었습니다. 취약성 수정 마감일 등 자세한 내용은 Google 도움말 센터의 이 도움말을 참조하세요

영문판의 경우의 경고 메시지는 다음과 같습니다.

Your app is using an unsafe implementation of the X509TrustManager interface with an Apache HTTP client, resulting in a security vulnerability. Please see this Google Help Center article for details, including the deadline for fixing the vulnerability.

구글플레이에 앱을 출시하고 5시간(저의경우에는 하루정도 뒤에 확인 가능했었습니다)정도 뒤에 구글플레이에 앱 리스트에 경고 표시가 다음과같이 표기됩니다.

google_play_warning_x509trustmanager

이는 다음과 같은 코드가 앱내에 존재할 경우 발생하는 경고이며 2016년 5월 17일부터 해당 문제가 해결되지 않은 앱들을 차단할 예정이라고 합니다.

TrustManager tm = new X509TrustManager() {
	public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException { }
	public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException { }
	public X509Certificate[] getAcceptedIssuers() { return null; }
};

문제가 되는 부분은 checkServerTrusted 메소드의 내부 구현이 아무것도 없는것에서 기인합니다; 정확하게는 위의 코드는 SSL의 동작을 완벽하게 무력화 시켜 인증 여부를 보장할 수 없는 누구와도 통신할 수 있게 된 상태를 야기합니다. 만약 TrustManager를 커스터마이징해서 사용해야 하는 경우  내부에 원치 상황에 대해 CertificateException 또는 IllegalArgumentException 예외를 발생시키는 코드를 구현해야 합니다.

우선 문제가 되는 Apache의 HttpClient를 사용하는 올드한 형태의 예제를 확인해 보겠습니다.

KeyStore trustStore = KeyStore.getInstance(KeyStore.getDefaultType());
trustStore.load(null, null);

HttpParams params = new BasicHttpParams();
HttpProtocolParams.setVersion(params, HttpVersion.HTTP_1_0);
HttpProtocolParams.setContentCharset(params, HTTP.UTF_8);

SchemeRegistry registry = new SchemeRegistry();
registry.register(new Scheme("http", PlainSocketFactory.getSocketFactory(), 80));
registry.register(new Scheme("https", new MySSLSocketFactory(trustStore), 443));

ClientConnectionManager ccm = new ThreadSafeClientConnManager(params, registry);

HttpClient client = new DefaultHttpClient(ccm, params);
String getURL = "https://eye.pe.kr/index.html";
HttpGet get = new HttpGet(getURL);
HttpResponse responseGet = client.execute(get);
HttpEntity resEntityGet = responseGet.getEntity();

if (resEntityGet != null) {
    Log.i("RESPONSE", EntityUtils.toString(resEntityGet));
}

그리고 다음의 코드는 개발하신 스타일에 따라 다른 모습이겠지만 SSLSocketFactory와 X509TrustManager를 구현한 코드입니다. 위의 코드에서 MySSLSocketFactory라는 이름으로 가져다 사용하고 있습니다.

public class MySSLSocketFactory extends SSLSocketFactory {
    SSLContext sslContext = SSLContext.getInstance("TLS");

    public MySSLSocketFactory(KeyStore truststore) throws NoSuchAlgorithmException, KeyManagementException, KeyStoreException, UnrecoverableKeyException {
        super(truststore);

        TrustManager tm = new X509TrustManager() {
            public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException {
            }

            public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException {
            }

            public X509Certificate[] getAcceptedIssuers() {
                return null;
            }
        };

        sslContext.init(null, new TrustManager[] { tm }, null);
    }

    @Override
    public Socket createSocket(Socket socket, String host, int port, boolean autoClose) throws IOException, UnknownHostException {
        return sslContext.getSocketFactory().createSocket(socket, host, port, autoClose);
    }

    @Override
    public Socket createSocket() throws IOException {
        return sslContext.getSocketFactory().createSocket();
    }
}

SSLSocketFactory를 상속하여 내부적으로 커스터마이징 된 X509TrustManager를 사용한 SSLContext를 통하여 소켓을 생성하도록 구현되어있습니다. 이렇게 생성한 SSL Socket은 보안에 취약한 상태입니다.

사실 정상적인 CA가 발급한 SSL 인증서로 구축된 서버라면 다음과 같은 코드만으로 통신에 문제가 없습니다.

HttpParams params = new BasicHttpParams();
HttpProtocolParams.setVersion(params, HttpVersion.HTTP_1_1);
HttpProtocolParams.setContentCharset(params, HTTP.UTF_8);

SchemeRegistry registry = new SchemeRegistry();
registry.register(new Scheme("http", PlainSocketFactory.getSocketFactory(), 80));
registry.register(new Scheme("https", SSLSocketFactory.getSocketFactory(), 443));

ClientConnectionManager ccm = new ThreadSafeClientConnManager(params, registry);

HttpClient client = new DefaultHttpClient(ccm, params);
String getURL = "https://google.com";
HttpGet get = new HttpGet(getURL);
HttpResponse responseGet = client.execute(get);
HttpEntity resEntityGet = responseGet.getEntity();

if (resEntityGet != null) {
    Log.i("RESPONSE", EntityUtils.toString(resEntityGet));
}

하지만 TrustManager를 무력화 시키면서까지 HTTPS 통신을 하려 했다는건 인증서 비용등의 문제로 자체 발급한 Self-Signed 인증서를 사용중이기 때문이 아닐까 생각됩니다. 확인 할 수 없는 인증서로 인한 통신 불가 상황에서는 다음과 같은 예외가 발생합니다.

javax.net.ssl.SSLPeerUnverifiedException: No peer certificate
at com.android.org.conscrypt.SSLNullSession.getPeerCertificates(SSLNullSession.java:104)
at org.apache.http.conn.ssl.AbstractVerifier.verify(AbstractVerifier.java:98)
at org.apache.http.conn.ssl.SSLSocketFactory.createSocket(SSLSocketFactory.java:394)
at org.apache.http.impl.conn.DefaultClientConnectionOperator.openConnection(DefaultClientConnectionOperator.java:219)
at org.apache.http.impl.conn.AbstractPoolEntry.open(AbstractPoolEntry.java:172)
at org.apache.http.impl.conn.AbstractPooledConnAdapter.open(AbstractPooledConnAdapter.java:130)
at org.apache.http.impl.client.DefaultRequestDirector.executeOriginal(DefaultRequestDirector.java:1317)
at org.apache.http.impl.client.DefaultRequestDirector.execute(DefaultRequestDirector.java:707)
at org.apache.http.impl.client.AbstractHttpClient.execute(AbstractHttpClient.java:694)
at org.apache.http.impl.client.AbstractHttpClient.execute(AbstractHttpClient.java:520)
at org.apache.http.impl.client.AbstractHttpClient.execute(AbstractHttpClient.java:498)
at example.trustmanager.theeye.pe.kr.trustmanagerexample.MainActivity$1$1.run(MainActivity.java:36)
at java.lang.Thread.run(Thread.java:818)

이 문제를 해결하는 가장 간단한 방법은 정상적인 CA가 발급한 인증서를 구매하여 서버에 채택하고 위쪽의 예제 코드처럼 SSLSocketFactory 를 기본 형태로 사용하는 방법입니다.

하지만 여전히 자체 발급한 인증서로도 구글의 권고안을 따라 문제를 해결할 수 있습니다. TrustManager를 재구현하면서 무력화 시킨 코드가 문제가 되기 때문에 TrustManager를 무력화 시키지 않는 방법으로도 해결을 해보도록 하겠습니다.

우선 자체 발급한 인증서는 일반적인 공인된 CA들이 인증해 주지 않는다는것이 중요하므로 자체적인 CA의 인증서를 앱내에 탑재하여 인증을 가능케 하는 방법이 있습니다. 인증서 자체 발급에 대해서는 [Self-Signed SSL로 Apache HTTPS 구현하기]를 참고해주시기 바랍니다.

앱내에 탑재될 인증서는 CA의 PEM 포맷의 인증서이며 위의 참고글에서 언급되는 rootCA.crt 파일이 여기에 해당됩니다. 이 인증서의 내용은 BASE64 인코딩이 되어있는 형태입니다. 이것을 이용하여 코드를 수정해 보겠습니다.

String certificateString = "-----BEGIN CERTIFICATE-----\n" +
        "MIIDdzCCAl+gAwIBAgIJAMapd+KIAR0MMA0GCSqGSIb3DBQUAMFIxCzAJBgNV\n" +
        "BAYTAktSMRQwEgYDVQQIDAtHeWVvbmdnaS1kbzEUMB1UEBwwLU2VvbmduYW0t\n" +
        "c2kxFzAVBgNVBAoMDlRoZWV5ZSBDb21wYW55MB4E2MDIyMDA5MjEyMVoXDTE3\n" +
        "MDIxOTA5MjEyMVowUjELMAkGA1UEBhMCS1IxSBgNVBAgMC0d5ZW9uZ2dpLWRv\n" +
        "MRQwEgYDVQQHDAtTZW9uZ25hbS1zaTEXMA1UECgwOVGhlZXllIENvbXBhbnkw\n" +
        "ggEiMA0GCSqGSIb3DQEBAQUAA4IBDwgEKAoIBAQDbrzdE+u+cIMnGJRZWlOZ+\n" +
        "8fBil6pVPQntbu3nz92omfiTAeGjb25gkwkLamsM0QISnXSmI+gl3iJmuCyFw\n" +
        "r3F+GiAUXS7e87vJoOKclbGVIQHPtEsbx+IjK7C2MJgoToNczedFzhr1xI9NV\n" +
        "ZpFMKV+YONM7qCsKKMjVrj4TE7DCZUaWu8QlPYC6p8kR5ZM6DNtxoE2QOWpiB\n" +
        "RwRwM6k88KfUVXysuvj3LCRVOqN3GrpCGF8GjuMohrI0zcK2XHvZ6D8GaFUEA\n" +
        "TLG9QcfD1N/Jo1tHpdWe3kQIYjtGBvjZ3VfUmqt9PFsqjK5sx6Kppd9yPreQp\n" +
        "AgMBAAGjUDBOGA1UdDgQWBBTjlXdy+BlcQGHfVdF9/8QkW1ciTTAfBgNVHSME\n" +
        "GDAWgBTjl+BlcQGHfVdF9/8QkW1ciTTAMBgNVHRMEBTADAQH/MA0GCSqGSIb3\n" +
        "DQEBBQ4IBAQCpWmUEr5p4CdWSGWHSopcNGPgJIODJ4K6Ir/IFJRb5tyzYY02R\n" +
        "mfUtyP39M8GnnEz5QqYNobCNIVV3cSgAMKeUTAbBQDbE+F04LzR6DRXtdhjp7\n" +
        "VtRG6McnecEi4D2Zq2ZGdFuAncfzhvdjybgkhkn1TZnbbtsiYqazKsNhdBvzR\n" +
        "mvEpRkC7eqpAw36A9zHyjvP9tJW0mVJjlUtevARDkLyX+VpMtKOUWHYikLXgK\n" +
        "UfRm9rtQMIpCC9n0qTMvDkxuBJakdSI7YRCpPYrsv0IEP23UN3Y32j1lthVIU\n" +
        "9S5KyRYKhDzJXadgO3dNI9bFL0H2SZ6mXqYL\n" +
        "-----END CERTIFICATE-----";

ByteArrayInputStream derInputStream = new ByteArrayInputStream(certificateString.getBytes());
CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509");
X509Certificate cert = (X509Certificate) certificateFactory.generateCertificate(derInputStream);
String alias = cert.getSubjectX500Principal().getName();

KeyStore trustStore = KeyStore.getInstance(KeyStore.getDefaultType());
trustStore.load(null, null);
trustStore.setCertificateEntry(alias, cert);

HttpParams params = new BasicHttpParams();
HttpProtocolParams.setVersion(params, HttpVersion.HTTP_1_0);
HttpProtocolParams.setContentCharset(params, HTTP.UTF_8);

SchemeRegistry registry = new SchemeRegistry();
registry.register(new Scheme("http", PlainSocketFactory.getSocketFactory(), 80));
registry.register(new Scheme("https", new MySSLSocketFactory(trustStore), 443));

ClientConnectionManager ccm = new ThreadSafeClientConnManager(params, registry);

HttpClient client = new DefaultHttpClient(ccm, params);
String getURL = "https://eye.pe.kr/index.html";
HttpGet get = new HttpGet(getURL);
HttpResponse responseGet = client.execute(get);
HttpEntity resEntityGet = responseGet.getEntity();

if (resEntityGet != null) {
    Log.i("RESPONSE", EntityUtils.toString(resEntityGet));
}

다른 많은 예제들은 인증서를 앱내에 파일로 포함하여 FileInputStream등으로 읽어서 쓰는 예시가 많았습니다만 파일을 앱에 포함할 정도면 텍스트를 그대로 하드코딩 해도 문제가 되지 않겠다고 생각되어 위와 같이 작성 해 보았습니다. 위에서 언급되었던 잘못된 MySSLSocketFactory의 구현은 다음과 같이 변경되었습니다.

public class MySSLSocketFactory extends SSLSocketFactory {
    SSLContext sslContext = SSLContext.getInstance("TLS");

    public MySSLSocketFactory(KeyStore truststore) throws NoSuchAlgorithmException, KeyManagementException, KeyStoreException, UnrecoverableKeyException {
        super(truststore);

        TrustManagerFactory tmf = TrustManagerFactory.getInstance("X509");
        tmf.init(truststore);

        sslContext.init(null, tmf.getTrustManagers(), null);
    }

    @Override
    public Socket createSocket(Socket socket, String host, int port, boolean autoClose) throws IOException, UnknownHostException {
        return sslContext.getSocketFactory().createSocket(socket, host, port, autoClose);
    }

    @Override
    public Socket createSocket() throws IOException {
        return sslContext.getSocketFactory().createSocket();
    }
}

위와 같이 수정 후 통신 테스트를 해보면 잘 되는 것을 확인할 수 있습니다. 해당 CA로 부터 발급된 모든 인증서가 적용된 서버들과 통신이 문제없이 가능합니다. 만약에 지정한 CA 인증서와 연관이 없는 인증서가 적용된 서버일 경우 다음과 같은 오류가 발생합니다.

javax.net.ssl.SSLHandshakeException: java.security.cert.CertPathValidatorException: Trust anchor for certification path not found.
at com.android.org.conscrypt.OpenSSLSocketImpl.startHandshake(OpenSSLSocketImpl.java:340)
at com.android.org.conscrypt.OpenSSLSocketImpl.waitForHandshake(OpenSSLSocketImpl.java:677)
at com.android.org.conscrypt.OpenSSLSocketImpl.getInputStream(OpenSSLSocketImpl.java:639)
at org.apache.http.impl.io.SocketInputBuffer.<init>(SocketInputBuffer.java:75)
at org.apache.http.impl.SocketHttpClientConnection.createSessionInputBuffer(SocketHttpClientConnection.java:88)
at org.apache.http.impl.conn.DefaultClientConnection.createSessionInputBuffer(DefaultClientConnection.java:175)
at org.apache.http.impl.SocketHttpClientConnection.bind(SocketHttpClientConnection.java:111)
at org.apache.http.impl.conn.DefaultClientConnection.openCompleted(DefaultClientConnection.java:134)
at org.apache.http.impl.conn.DefaultClientConnectionOperator.openConnection(DefaultClientConnectionOperator.java:226)
at org.apache.http.impl.conn.AbstractPoolEntry.open(AbstractPoolEntry.java:172)
at org.apache.http.impl.conn.AbstractPooledConnAdapter.open(AbstractPooledConnAdapter.java:130)
at org.apache.http.impl.client.DefaultRequestDirector.executeOriginal(DefaultRequestDirector.java:1317)
at org.apache.http.impl.client.DefaultRequestDirector.execute(DefaultRequestDirector.java:707)
at org.apache.http.impl.client.AbstractHttpClient.execute(AbstractHttpClient.java:694)
at org.apache.http.impl.client.AbstractHttpClient.execute(AbstractHttpClient.java:520)
at org.apache.http.impl.client.AbstractHttpClient.execute(AbstractHttpClient.java:498)
at example.trustmanager.theeye.pe.kr.trustmanagerexample.MainActivity$1$1.run(MainActivity.java:93)
at java.lang.Thread.run(Thread.java:818)

만약에 서버의 CA인증서 존재 여부가 불분명하고 자체 발급하여 사용중인 인증서 역시 확인하기가 어렵다면 아쉬운대로 다음과 같은 명령을 사용하여 자체 발급된 인증서를 확인 하실 수 있습니다.

# openssl s_client -connect eye.pe.kr:443
CONNECTED(00000003)
depth=0 C = KR, ST = Gyeonggi-do, L = Seongnam-si, O = TheEye Company
verify error:num=18:self signed certificate
verify return:1
depth=0 C = KR, ST = Gyeonggi-do, L = Seongnam-si, O = TheEye Company
verify return:1
---
Certificate chain
 0 s:/C=KR/ST=Gyeonggi-do/L=Seongnam-si/O=TheEye Company
   i:/C=KR/ST=Gyeonggi-do/L=Seongnam-si/O=Theeye Company
---
Server certificate
-----BEGIN CERTIFICATE-----
MIIDIDCCAggCCQCkHog/OqFR2TANBgkqhkiG9w0BAQUFADBSMQswCQYDVQwJL
UjEUMBIGA1UECAwLR3llb25nZ2ktZG8xFDASBgNVBAcMC1Nlb25nbmFNpMRcw
FQYDVQQKDA5UaGVleWUgQ29tcGFueTAeFw0xNjAyMjAxMDI3NDlaxNzAyMTkx
MDI3NDlaMFIxCzAJBgNVBAYTAktSMRQwEgYDVQQIDAtHeWVvbaS1kbzEUMBIG
A1UEBwwLU2VvbmduYW0tc2kxFzAVBgNVBAoMDlRoZUV5ZS21wYW55MIIBIjAN
BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAthNyPJFOp3z7vvuiEwVpuz56o
Y7bmZziN0dKoh8E1DXQnUeIMeotegSvS4r1S1lzK5KqR0Aka8ft7QccOFM5zx
bqv7yvBZ57fzoKeTwNUHrURseuSj90Q4zHK68l/OUyxULDR7HYRmxtVCB/72L
W11awXCqnEyAdS26XLeFf5JZv1sSleW/J/1FGsWDy8/AcQ4hEiDj/nke9TkQ4
JlHHPuLObouKOhoJkj2wZaNlC2GuJ3+mIFFsleEVx2IXH5ezcLG4gE0FQJk+9
lohtAXZwHq3A/CF+bMspbiHVwOYT3cCLJ/Hi8J5Mz0NOzTIhvygaA4wIDAQAB
MA0GCSqGSIb3DQEBBQUAA4IBAy/LVoqKqlvYEcfDAconAGGOTlxkkxIWOMfUK
DdViDNgAvIlO8J5bvkCdi9ap0UAX8bN+XLuxXmd+t55qmHAaA/54uswhYpqz0
2pQeP/Hpt5HPlAPUMySdVkJovAJbYi0vBSv6L8WpDGDa/9AX0yPvaW1OLtzVY
VddpqTLlYGoYzcvps/GnZO7v/4/meda6gQyphg8S91WrPbUKvziGkdtex6CST
8zwSn7gTvo7FXbTjK28DoWDq/rRr3Ni8KyqWvXtcDQWGyAjUvjO6O/p7C2UPy
BC15dcAEkGL4GMYANJPJsiIjo1Umbu5kbXeE8ISW+pbyh
-----END CERTIFICATE-----

s_client 명령을 사용하여 통신하고자 하는 서버의 인증서를 확인 할 수 있습니다. 이는 CA 인증서는 아니지만 이렇게 조회된 인증서를 이용해도 1:1간의 통신에는 문제가 없습니다.

문제를 해결하고 Version Code를 올린 APK를 재등록하고 하루정도가 지나면 해당 경고메시지가 사리지는것을 확인하실 수 있습니다.

스크린샷 2016-02-22 오후 3.26.24

참고 :

  • https://support.google.com/faqs/answer/6346016
  • https://developer.android.com/intl/ko/training/articles/security-ssl.html#UnknownCa