使用HttpDns降低DNS劫持风险

2022-11-30 10:57:41 浏览数 (2)

基本概念

中国互联网经过这么多年的沉浮,地下黑色产业链已经有了很大的变化。随着免费杀毒软件的流行,中国互联网发生了一些比较明显的变化,比如曾经盗号木马横行,现在就很少见了。但是黑色产业并没有消失,而是转型做起来其他的买卖,比如买卖流量等。

运营商劫持

运营商是指那些提供宽带服务的ISP,包括三大运营商中国电信、中国移动、中国联通,还有一些小运营商,比如长城宽带、歌华有线宽带。运营商提供最最基础的网络服务, 掌握着通往用户物理大门的钥匙。 网络运营商为了卖广告或者其他经济利益,有时候会直接劫持用户的访问,目前,运营商比较常见的作恶方式有两种,分别是DNS劫持和HTTP劫持。

DNS劫持

DNS是Domain Name System的简写,即域名系统,它作为可以将域名和IP地址相互映射的一个分布式数据库,能够使人更方便的访问互联网。通俗的说,当你在浏览器中输入网站的域名时,DNS服务器会将域名转为具体的IP地址。

DNS劫持主要有以下几种表现:

  • 弹出的迷你浏览器直接跳转到某个导航网站;
  • 内置浏览器被跳转到某个宣传赚钱的网页,诱导消费;

而避免DNS劫持的尽量不要使用运营商默认的DNS。

Http劫持

在使用者与其目的网络服务所建立的专用数据通道中,监视特定数据信息,提示当满足设定的条件时,就会在正常的数据流中插入精心设计的网络数据报文,目的是让用户端程序解释“错误”的数据,并以弹出新窗口的形式在使用者界面展示宣传性广告或者直接显示某网站的内容。

上面问题造成的原因,根本上是运营商的问题,所以尽量不要使用运营商提供的默认的东西

HttpDns

HttpDns服务则是基于HTTP协议自建DNS服务,或者选择更加可靠的DNS服务提供商来完成DNS服务,以降低发生安全问题的风险。HttpDns还可以为精准调度提供支持。 通常大公司都有自己的HttpDns服务器,例如微博团队开源的HttpDns方案,腾讯有开放自己的HttpDns服务。 DNSPod 还推出了商业化的产品。当然,如果有需要可以自己搭建一套HtppDns服务。

Android接入HttpDns

在Android开发中,我们通常不会关心Http请求的详细执行过程,因为具体的网络请求会使用一些第三方库,如okHttp,retrofit等。

在Android开发中,使用HttpDns将获得的IP地址应用请求的最简单方式是,将域名替换为IP,然后用新的URL发起HTTP请求。这样就能有效的防止DNS劫持的行为。

然而,标准的HTTP协议中服务端会将HTTP请求头中HOST字段的值作为请求的域名,在我们没有主动设置HOST字段的值时,网络库也会自动地从URL中提取域名,并为请求做设置。但使用HttpDns后,URL中的域名信息丢失,这时候就需要设置HOST字段值。例如:

代码语言:javascript复制
String originalUrl = "http://www.sina.com/";
        URL url = new URL(originalURL);
        String originalHost = url.getHost();
        // 同步接口获取IP
        String ip = httpdns.getIpByHost(originalHost);
        HttpURLConnection conn;
        if (ip != null) {
            // 通过HTTPDNS获取IP成功,进行URL替换和HOST头设置
            url = new URL(originalUrl.replaceFirst(originalHost, ip));
            conn = (HttpURLConnection) url.openConnection();
            // 设置请求HOST字段
            conn.setRequestProperty("Host", originHost);
        } else {
            conn = (HttpURLConnection) url.openConnection();
        }

当然,进行上面的修改后,需要通知其他的使用方,具体的,在客户端的网络库中,有以下几个地方需要修改。

  • COOKIE存取。支持COOKIE存取的网络库,在存取COOKIE时,从URL中提取的域名通常是key的重要部分。
  • 连接管理。连接的 Keep-Alive参数,可以让执行HTTP请求的TCP连接在请求结束后不会被立即关闭,而是先保持一段时间。为新发起的请求查找可用连接时,主要的依据也是URL中的域名。针对相同域名同时执行的HTTP请求的最大个数6 个的限制,也需要借助于URL中的域名来完成。
  • HTTPS的SNI及证书验证。SSL/TLS的SNI扩展用于支持虚拟主机托管。在SSL/TLS握手期间,客户端通过该扩展将要请求的域名发送给服务器,以便可以取到适当的证书。SNI信息也来源于URL中的域名。

常见问题

HTTPS 域名证书验证问题

许多服务并不是多服务(域名)共用一个物理IP的,因而丢失SNI信息并不是特别的要紧,针对以上的情况,解决掉域名证书的验证问题即可。

HttpsURLConnection方式

如果针对传统的HttpsURLConnection请求方式,可以使用下面的方式来解决证书验证问题。

代码语言:javascript复制
try {
            String url = "https://140.225.164.59/?sprefer=sypc00";
            final String originHostname = "www.wolfcstech.com";
            HttpsURLConnection connection = (HttpsURLConnection) new URL(url).openConnection();
            connection.setRequestProperty("Host", originHostname);
            connection.setHostnameVerifier(new HostnameVerifier() {

                @Override
                public boolean verify(String hostname, SSLSession session) {
                    return HttpsURLConnection.getDefaultHostnameVerifier().verify(originHostname, session);
                }
            });
            connection.connect();
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
        }

主要思路即是自定义证书验证的逻辑。HostnameVerifier 的 verify() 传回来的域名是url中的ip地址,但我们可以在定制的域名证书验证逻辑中,使用原始的真实的域名与服务器返回的证书一起做验证。

SNI问题解决方案

对于多个域名部署在相同IP地址的主机上的场景,除了要处理域名证书验证外,SNI的设置也是必须的。给出的解决方案是,自定义SSLSocketFactory,控制SSLSocket的创建过程,在SSLSocket被创建成功之后,立即设置SNI信息进去。例如,下面是SSLSocketFactory的实现方式:

代码语言:javascript复制
public class TlsSniSocketFactory extends SSLSocketFactory {
    private final String TAG = TlsSniSocketFactory.class.getSimpleName();
    HostnameVerifier hostnameVerifier = HttpsURLConnection.getDefaultHostnameVerifier();
    private HttpsURLConnection conn;
    public TlsSniSocketFactory(HttpsURLConnection conn) {
        this.conn = conn;
    }
    @Override
    public Socket createSocket() throws IOException {
        return null;
    }
    @Override
    public Socket createSocket(String host, int port) throws IOException, UnknownHostException {
        return null;
    }

    @Override
    public Socket createSocket(String host, int port, InetAddress localHost, int localPort) throws IOException, UnknownHostException {
        return null;
    }

    @Override
    public Socket createSocket(InetAddress host, int port) throws IOException {
        return null;
    }
    @Override
    public Socket createSocket(InetAddress address, int port, InetAddress localAddress, int localPort) throws IOException {
        return null;
    }
    // TLS layer
    @Override
    public String[] getDefaultCipherSuites() {
        return new String[0];
    }
    @Override
    public String[] getSupportedCipherSuites() {
        return new String[0];
    }
    @Override
    public Socket createSocket(Socket plainSocket, String host, int port, boolean autoClose) throws IOException {
        String peerHost = this.conn.getRequestProperty("Host");
        if (peerHost == null)
            peerHost = host;
        Log.i(TAG, "customized createSocket. host: "   peerHost);
        InetAddress address = plainSocket.getInetAddress();
        if (autoClose) {
            // we don't need the plainSocket
            plainSocket.close();
        }
        // create and connect SSL socket, but don't do hostname/certificate verification yet
        SSLCertificateSocketFactory sslSocketFactory = (SSLCertificateSocketFactory) SSLCertificateSocketFactory.getDefault(0);
        SSLSocket ssl = (SSLSocket) sslSocketFactory.createSocket(address, port);
        // enable TLSv1.1/1.2 if available
        ssl.setEnabledProtocols(ssl.getSupportedProtocols());
        // set up SNI before the handshake
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
            Log.i(TAG, "Setting SNI hostname");
            sslSocketFactory.setHostname(ssl, peerHost);
        } else {
            Log.d(TAG, "No documented SNI support on Android <4.2, trying with reflection");
            try {
                java.lang.reflect.Method setHostnameMethod = ssl.getClass().getMethod("setHostname", String.class);
                setHostnameMethod.invoke(ssl, peerHost);
            } catch (Exception e) {
                Log.w(TAG, "SNI not useable", e);
            }
        }
        // verify hostname and certificate
        SSLSession session = ssl.getSession();
        if (!hostnameVerifier.verify(peerHost, session))
            throw new SSLPeerUnverifiedException("Cannot verify hostname: "   peerHost);
        Log.i(TAG, "Established "   session.getProtocol()   " connection with "   session.getPeerHost()  
                " using "   session.getCipherSuite());
        return ssl;
    }
}

只定制 SSLSocketFactory 的方法,看起来是比较难以达成目的了,有人就想通过更深层的定制,即同时自定义SSLSocket来实现,如GitHub中的 NetCipher。但是此种方案也不能解决解决问题,因为支持SSL扩展的许多接口,都不是标准的SSLSocket接口,比如用于支持SNI的setHostname()接口,用于支持ALPN的setAlpnProtocols() 和 getAlpnSelectedProtocol() 接口等。这样的接口还会随着SSL/TLS协议的发展而不断增加。

到目前为止,接入HttpDns的最好方法是,不要替换请求的URL中的域名部分,只在需要Dns的时候才使用HttpDns。具体而实现上,使用那些可以定制Dns逻辑的网络库,比如OkHttp,或者使用Chromium的网络库基础上做的库,实现域名解析的接口,并在该接口的实现中通过HttpDns模块来执行域名解析。例如:

代码语言:javascript复制
private static class MyDns implements Dns {

        @Override
        public List<InetAddress> lookup(String hostname) throws UnknownHostException {
            List<String> strIps = HttpDns.getInstance().getIpByHost(hostname);
            List<InetAddress> ipList;
            if (strIps != null && strIps.size() > 0) {
                ipList = new ArrayList<>();
                for (String ip : strIps) {
                    ipList.add(InetAddress.getByName(ip));
                }
            } else {
                ipList = Dns.SYSTEM.lookup(hostname);
            }
            return ipList;
        }
    }

    private OkHttp3Utils() {
        okhttp3.OkHttpClient.Builder builder = new okhttp3.OkHttpClient.Builder();
        builder.dns(new MyDns());
        mOkHttpClient = builder.build();
    }

0 人点赞