Android 网络安全:如何避免 Okhttp 的 HTTPS 请求被抓包

在我的上一篇 文章 中介绍了如何实现 HTTPS 抓包,这篇文章解决如何避免HTTPS 抓包。

Android 逆向工程:Charles + Android 实现 HTTPS 抓包

HTTPS 重要性

如果我们APP的API请求只使用了 HTTP,这个实在太容易被抓包了,我们的请求信息很容易就暴露,可能会被用来做不利于APP的事情。为了APP的请求安全,我们有必要改用HTTPS,用来保障我们的请求安全。但是使用HTTPS并不就代表我们的请求就是安全的了,因为还是可以实现抓包,接下来介绍如何通过杜绝 OkHttp 的 HTTPS 被抓包。

原理

杜绝 HTTPS 抓包的原理很简单,其实就是拦截非法的证书,只通过我们信任的 HTTPS 证书的请求。

代码
public class OkHttpSSLUtils {
    public static OkHttpClient.Builder getOkHttpBuilder(Context context, String baseHostname, String... assetNames) {
        OkHttpClient.Builder builder = new OkHttpClient.Builder();
        try {
            InputStream[] inputStreams = new InputStream[assetNames.length];
            for (int i = 0; i < assetNames.length; i++) {
                inputStreams[i] = context.getAssets().open(assetNames[i]);
            }
            GeneralHostnameVerifier hostnameVerifier = GeneralHostnameVerifier.getInstance(baseHostname, inputStreams);
            if (hostnameVerifier != null) {
                builder.hostnameVerifier(hostnameVerifier);
            }
            for (InputStream item : inputStreams) {
                item.close();
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
        return builder;
    }

    public static OkHttpClient.Builder getOkHttpBuilder(String baseHostname, File... files) {
        OkHttpClient.Builder builder = new OkHttpClient.Builder();
        try {
            InputStream[] inputStreams = new InputStream[files.length];
            for (int i = 0; i < files.length; i++) {
                inputStreams[i] = new FileInputStream(files[i]);
            }
            GeneralHostnameVerifier hostnameVerifier = GeneralHostnameVerifier.getInstance(baseHostname, inputStreams);
            if (hostnameVerifier != null) {
                builder.hostnameVerifier(hostnameVerifier);
            }
            for (InputStream item : inputStreams) {
                item.close();
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
        return builder;
    }
}

GeneralHostnameVerifier.java

public class GeneralHostnameVerifier implements HostnameVerifier {

    private static final int ALT_DNS_NAME = 2;
    private static final int ALT_IPA_NAME = 7;

    public static GeneralHostnameVerifier getInstance(String baseHostname, InputStream... iss) {
        try {
            CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509");
            List<Collection<? extends Certificate>> list = new ArrayList<>();
            for (InputStream is : iss) {
                list.add(certificateFactory.generateCertificates(is));
            }
            if (list.isEmpty()) {
                throw new IllegalArgumentException("expected non-empty set of trusted certificates");
            }
            return new GeneralHostnameVerifier(baseHostname, list);
        } catch (Exception e) {
            e.printStackTrace();
        }
        // Put the certificates a key store.
        return null;
    }

    /**
     * 公钥列表
     */
    private List<String> clientPublicKey = new ArrayList<>();

    private String baseHostname;

    public GeneralHostnameVerifier(String baseHostname, List<Collection<? extends Certificate>> list) {
        this.baseHostname = baseHostname;
        for (Collection<? extends Certificate> certificates : list) {
            for (Certificate certificate : certificates) {
                clientPublicKey.add(certificate.getPublicKey().toString());
            }
        }
    }

    @Override
    public boolean verify(String hostname, SSLSession sslSession) {
        // 如果不是我们自己的域名就走用默认的验证方式,不验证公钥
        if (!hostname.contains(baseHostname)) {
            return defaultVerify(hostname, sslSession);
        }
        try {
            // 从 SSLSession 获取请求的公钥信息
            List<String> pubKeys = new ArrayList<>();
            for (X509Certificate certificate : sslSession.getPeerCertificateChain()) {
                pubKeys.add(certificate.getPublicKey().toString());
            }
            // 验证请求的 SSL 证书是否合法(我们认可的证书),才继续验证,否则返回失败
            if (comparePubKey(pubKeys)) {
                return defaultVerify(hostname, sslSession);
            }
            return false;
        } catch (SSLPeerUnverifiedException e) {
            e.printStackTrace();
        }
        return defaultVerify(hostname, sslSession);
    }

    private boolean comparePubKey(List<String> servicePubKeys) throws SSLPeerUnverifiedException {
        if (servicePubKeys == null || servicePubKeys.size() <= 0) {
            return false;
        }
        if (clientPublicKey == null || clientPublicKey.size() <= 0) {
            throw new SSLPeerUnverifiedException("clientPublicKey null");
        }
        for (String key : servicePubKeys) {
            if (clientPublicKey.contains(key)) {
                return true;
            }
        }
        return false;

    }


    public boolean defaultVerify(String hostname, SSLSession session) {
        try {
            Certificate[] certificates = session.getPeerCertificates();
            return verify(hostname, (java.security.cert.X509Certificate) certificates[0]);
        } catch (SSLException e) {
            return false;
        }
    }

    public boolean verify(String hostname, java.security.cert.X509Certificate certificate) {
        return verifyAsIpAddress(hostname) ? verifyIpAddress(hostname, certificate) : verifyHostname(hostname, certificate);
    }


    /**
     * Returns true if {@code certificate} matches {@code ipAddress}.
     */
    private boolean verifyIpAddress(String ipAddress, java.security.cert.X509Certificate certificate) {
        List<String> altNames = getSubjectAltNames(certificate, ALT_IPA_NAME);
        for (int i = 0, size = altNames.size(); i < size; i++) {
            if (ipAddress.equalsIgnoreCase(altNames.get(i))) {
                return true;
            }
        }
        return false;
    }

    /**
     * Returns true if {@code certificate} matches {@code hostname}.
     */
    private boolean verifyHostname(String hostname, java.security.cert.X509Certificate certificate) {
        hostname = hostname.toLowerCase(Locale.US);
        List<String> altNames = getSubjectAltNames(certificate, ALT_DNS_NAME);
        for (String altName : altNames) {
            if (verifyHostname(hostname, altName)) {
                return true;
            }
        }
        return false;
    }

    public static List<String> allSubjectAltNames(java.security.cert.X509Certificate certificate) {
        List<String> altIpaNames = getSubjectAltNames(certificate, ALT_IPA_NAME);
        List<String> altDnsNames = getSubjectAltNames(certificate, ALT_DNS_NAME);
        List<String> result = new ArrayList<>(altIpaNames.size() + altDnsNames.size());
        result.addAll(altIpaNames);
        result.addAll(altDnsNames);
        return result;
    }

    private static List<String> getSubjectAltNames(java.security.cert.X509Certificate certificate, int type) {
        List<String> result = new ArrayList<>();
        try {
            Collection<?> subjectAltNames = certificate.getSubjectAlternativeNames();
            if (subjectAltNames == null) {
                return Collections.emptyList();
            }
            for (Object subjectAltName : subjectAltNames) {
                List<?> entry = (List<?>) subjectAltName;
                if (entry == null || entry.size() < 2) {
                    continue;
                }
                Integer altNameType = (Integer) entry.get(0);
                if (altNameType == null) {
                    continue;
                }
                if (altNameType == type) {
                    String altName = (String) entry.get(1);
                    if (altName != null) {
                        result.add(altName);
                    }
                }
            }
            return result;
        } catch (CertificateParsingException e) {
            return Collections.emptyList();
        }
    }

    /**
     * Returns {@code true} iff {@code hostname} matches the domain name {@code pattern}.
     *
     * @param hostname lower-case host name.
     * @param pattern  domain name pattern from certificate. May be a wildcard pattern such as {@code
     *                 *.android.com}.
     */
    public boolean verifyHostname(String hostname, String pattern) {
        // Basic sanity checks
        // Check length == 0 instead of .isEmpty() to support Java 5.
        if ((hostname == null) || (hostname.length() == 0) || (hostname.startsWith("."))
                || (hostname.endsWith(".."))) {
            // Invalid domain name
            return false;
        }
        if ((pattern == null) || (pattern.length() == 0) || (pattern.startsWith("."))
                || (pattern.endsWith(".."))) {
            // Invalid pattern/domain name
            return false;
        }

        // Normalize hostname and pattern by turning them into absolute domain names if they are not
        // yet absolute. This is needed because server certificates do not normally contain absolute
        // names or patterns, but they should be treated as absolute. At the same time, any hostname
        // presented to this method should also be treated as absolute for the purposes of matching
        // to the server certificate.
        //   www.android.com  matches www.android.com
        //   www.android.com  matches www.android.com.
        //   www.android.com. matches www.android.com.
        //   www.android.com. matches www.android.com
        if (!hostname.endsWith(".")) {
            hostname += '.';
        }
        if (!pattern.endsWith(".")) {
            pattern += '.';
        }
        // hostname and pattern are now absolute domain names.

        pattern = pattern.toLowerCase(Locale.US);
        // hostname and pattern are now in lower case -- domain names are case-insensitive.

        if (!pattern.contains("*")) {
            // Not a wildcard pattern -- hostname and pattern must match exactly.
            return hostname.equals(pattern);
        }
        // Wildcard pattern

        // WILDCARD PATTERN RULES:
        // 1. Asterisk (*) is only permitted in the left-most domain name label and must be the
        //    only character in that label (i.e., must match the whole left-most label).
        //    For example, *.example.com is permitted, while *a.example.com, a*.example.com,
        //    a*b.example.com, a.*.example.com are not permitted.
        // 2. Asterisk (*) cannot match across domain name labels.
        //    For example, *.example.com matches test.example.com but does not match
        //    sub.test.example.com.
        // 3. Wildcard patterns for single-label domain names are not permitted.

        if ((!pattern.startsWith("*.")) || (pattern.indexOf('*', 1) != -1)) {
            // Asterisk (*) is only permitted in the left-most domain name label and must be the only
            // character in that label
            return false;
        }

        // Optimization: check whether hostname is too short to match the pattern. hostName must be at
        // least as long as the pattern because asterisk must match the whole left-most label and
        // hostname starts with a non-empty label. Thus, asterisk has to match one or more characters.
        if (hostname.length() < pattern.length()) {
            // hostname too short to match the pattern.
            return false;
        }

        if ("*.".equals(pattern)) {
            // Wildcard pattern for single-label domain name -- not permitted.
            return false;
        }

        // hostname must end with the region of pattern following the asterisk.
        String suffix = pattern.substring(1);
        if (!hostname.endsWith(suffix)) {
            // hostname does not end with the suffix
            return false;
        }

        // Check that asterisk did not match across domain name labels.
        int suffixStartIndexInHostname = hostname.length() - suffix.length();
        if ((suffixStartIndexInHostname > 0)
                && (hostname.lastIndexOf('.', suffixStartIndexInHostname - 1) != -1)) {
            // Asterisk is matching across domain name labels -- not permitted.
            return false;
        }

        // hostname matches pattern
        return true;
    }
    private static final Pattern VERIFY_AS_IP_ADDRESS = Pattern.compile(
            "([0-9a-fA-F]*:[0-9a-fA-F:.]*)|([\\d.]+)");
    /** Returns true if {@code host} is not a host name and might be an IP address. */
    public static boolean verifyAsIpAddress(String host) {
        return VERIFY_AS_IP_ADDRESS.matcher(host).matches();
    }

}
测试

如果是直接 new OkHttpClient() 是可以抓包,如果 OkHttpClient okHttpClient = builder.build(); 就会提示证书验证错误。

OkHttpClient.Builder builder = OkHttpSSLUtils.getOkHttpBuilder(context,"test.com", "*.test.com.cer");
OkHttpClient okHttpClient = builder.build();
// OkHttpClient okHttpClient = new OkHttpClient();
Request request = new Request.Builder().url("https://api.test.com/user?uid=1").get().build();
Response response = okHttpClient.newCall(request).execute();
System.out.println(response.toString());
System.out.println(response.body().string());
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 157,298评论 4 360
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,701评论 1 290
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 107,078评论 0 237
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,687评论 0 202
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,018评论 3 286
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,410评论 1 211
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,729评论 2 310
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,412评论 0 194
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,124评论 1 239
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,379评论 2 242
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,903评论 1 257
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,268评论 2 251
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,894评论 3 233
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,014评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,770评论 0 192
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,435评论 2 269
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,312评论 2 260