Skip to content

Most visited

Recently visited

navigation

通过 HTTPS 和 SSL 确保安全

安全套接字层 (SSL)(现在技术上称为传输层安全协议 (TLS))是一个通用构建块,用于在客户端与服务器之间进行加密通信。应用很可能以错误的方式使用 SSL,从而导致恶意实体能够拦截网络上的应用数据。为了帮助您确保您的应用不会出现这种情况,本文重点介绍了使用安全网络协议的常见陷阱,并解决对使用公钥基础结构 (PKI) 关注较多的问题。

概念

在典型的 SSL 使用场景中,会使用一个包含公钥及与其匹配的私钥的证书配置服务器。作为 SSL 客户端与服务器握手的一部分,服务器将通过使用公钥加密签署其证书来证明自己具有私钥。

不过,任何人都可以生成他们自己的证书和私钥,因此,一个简单的握手只能说明服务器知道与证书公钥匹配的私钥,除此之外什么都证明不了。解决此问题的一个方法是让客户端拥有其信任的一个或多个证书集。如果证书不在此集合中,则不会信任服务器。

但这个简单的方法有几个缺点。服务器应能够随时间的推移升级到更强的密钥(“密钥旋转”),使用新的公钥替换证书中的公钥。遗憾的是,客户端应用现在必须根据服务器配置发生的变化进行更新。如果服务器不在应用开发者的控制下(例如,如果服务器是一个第三方网络服务),则很容易出现问题。如果应用必须与网络浏览器或电子邮件应用等任意服务器通信,那么,此方法也会带来问题。

为弥补这些缺点,通常使用来自知名颁发者(称为证书颁发机构 (CA))发放的证书配置服务器。主机平台一般包含其信任的知名 CA 的列表。从 Android 4.2 (Jelly Bean) 开始,Android 目前包含在每个版本中更新的 100 多个 CA。CA 具有一个证书和一个私钥,这点与服务器相似。为服务器发放证书时,CA 使用其私钥签署服务器证书。然后,客户端可以验证该服务器是否具有平台已知的 CA 发放的证书。

不过,在解决一些问题的同时,使用 CA 也会引发其他问题。因为 CA 为许多服务器发放证书,因此,您仍需要某种方式来确保您与您需要的服务器通信。为解决这个问题,CA 发放的证书通过 gmail.com 等具体名称或 *.google.com 等通配型主机集识别服务器。

以下示例会让这些概念更具体。下面的代码段来自命令行,openssl 工具的 s_client 命令将查看 Wikipedia 的服务器证书信息。它指定端口 443,因为此端口是 HTTPS的默认端口。此命令将 openssl s_client 的输出发送到 openssl x509,后者将根据 X.509 标准格式化与证书有关的信息。具体而言,此命令会要求相关主题,主题包含服务器名称信息和可识别 CA 的颁发者。

$ openssl s_client -connect wikipedia.org:443 | openssl x509 -noout -subject -issuer
subject= /serialNumber=sOrr2rKpMVP70Z6E9BT5reY008SJEdYv/C=US/O=*.wikipedia.org/OU=GT03314600/OU=See www.rapidssl.com/resources/cps (c)11/OU=Domain Control Validated - RapidSSL(R)/CN=*.wikipedia.org
issuer= /C=US/O=GeoTrust, Inc./CN=RapidSSL CA

您会看到证书是由 RapidSSL CA 为与 *.wikipedia.org 匹配的服务器发放的。

一个 HTTPS 示例

假设您有一个由知名 CA 发放证书的网络服务器,那么,您可以使用如下简单代码发起安全的请求:

URL url = new URL("https://wikipedia.org");
URLConnection urlConnection = url.openConnection();
InputStream in = urlConnection.getInputStream();
copyInputStreamToOutputStream(in, System.out);

没错,就这么简单。如果您要调整 HTTP 请求,您可以切换到 HttpURLConnection。有关 HttpURLConnection 的 Android 文档就如何处理请求和响应标头,以及如何发布内容、管理 Cookie、使用代理、缓存响应等提供了更多示例。但对于验证证书和主机名的细节,Android 框架在 API 中为您考虑了这些细节。这些是您尽可能想要实现的目标。不过,下面还有一些其他注意事项。

验证服务器证书的常见问题

假设没有从 getInputStream() 接收内容,将引发异常:

javax.net.ssl.SSLHandshakeException: java.security.cert.CertPathValidatorException: Trust anchor for certification path not found.
        at org.apache.harmony.xnet.provider.jsse.OpenSSLSocketImpl.startHandshake(OpenSSLSocketImpl.java:374)
        at libcore.net.http.HttpConnection.setupSecureSocket(HttpConnection.java:209)
        at libcore.net.http.HttpsURLConnectionImpl$HttpsEngine.makeSslConnection(HttpsURLConnectionImpl.java:478)
        at libcore.net.http.HttpsURLConnectionImpl$HttpsEngine.connect(HttpsURLConnectionImpl.java:433)
        at libcore.net.http.HttpEngine.sendSocketRequest(HttpEngine.java:290)
        at libcore.net.http.HttpEngine.sendRequest(HttpEngine.java:240)
        at libcore.net.http.HttpURLConnectionImpl.getResponse(HttpURLConnectionImpl.java:282)
        at libcore.net.http.HttpURLConnectionImpl.getInputStream(HttpURLConnectionImpl.java:177)
        at libcore.net.http.HttpsURLConnectionImpl.getInputStream(HttpsURLConnectionImpl.java:271)

出现此情况的原因有很多,其中包括:

  1. 颁发服务器证书的 CA 未知
  2. 服务器证书不是由 CA 签署的,而是自签署
  3. 服务器配置缺少中间 CA

下面几部分将讨论如何解决这些问题,同时保持与服务器的连接处于安全状态。

未知的证书颁发机构

在这种情况下,由于您具有系统不信任的 CA,将发生 SSLHandshakeException。原因可能是您有一个来自 Android 还未信任的新 CA 的证书,或您的应用在没有 CA 的较旧版本上运行。CA 未知的原因通常是因为它不是公共 CA,而是政府、公司或教育机构等组织发放的仅供自己使用的私有 CA。

幸运的是,您可以指示 HttpsURLConnection 信任特定的 CA 集。此过程可能有点复杂,下面的示例展示了这个过程,从 InputStream 获取一个特定的 CA,用该 CA 创建 KeyStore,然后用后者创建和初始化 TrustManagerTrustManager 是系统用于从服务器验证证书的工具,可以使用一个或多个 CA 从 KeyStore 创建,而创建的 TrustManager 将仅信任这些 CA。

如果是新的 TrustManager,此示例将初始化一个新的 SSLContext,后者可以提供一个 SSLSocketFactory,您可以通过 HttpsURLConnection 用它来替换默认的 SSLSocketFactory。这样一来,连接将使用您的 CA 验证证书。

下面是使用华盛顿大学的机构 CA 的完整示例:

// Load CAs from an InputStream
// (could be from a resource or ByteArrayInputStream or ...)
CertificateFactory cf = CertificateFactory.getInstance("X.509");
// From https://www.washington.edu/itconnect/security/ca/load-der.crt
InputStream caInput = new BufferedInputStream(new FileInputStream("load-der.crt"));
Certificate ca;
try {
    ca = cf.generateCertificate(caInput);
    System.out.println("ca=" + ((X509Certificate) ca).getSubjectDN());
} finally {
    caInput.close();
}

// Create a KeyStore containing our trusted CAs
String keyStoreType = KeyStore.getDefaultType();
KeyStore keyStore = KeyStore.getInstance(keyStoreType);
keyStore.load(null, null);
keyStore.setCertificateEntry("ca", ca);

// Create a TrustManager that trusts the CAs in our KeyStore
String tmfAlgorithm = TrustManagerFactory.getDefaultAlgorithm();
TrustManagerFactory tmf = TrustManagerFactory.getInstance(tmfAlgorithm);
tmf.init(keyStore);

// Create an SSLContext that uses our TrustManager
SSLContext context = SSLContext.getInstance("TLS");
context.init(null, tmf.getTrustManagers(), null);

// Tell the URLConnection to use a SocketFactory from our SSLContext
URL url = new URL("https://certs.cac.washington.edu/CAtest/");
HttpsURLConnection urlConnection =
    (HttpsURLConnection)url.openConnection();
urlConnection.setSSLSocketFactory(context.getSocketFactory());
InputStream in = urlConnection.getInputStream();
copyInputStreamToOutputStream(in, System.out);

借助一个知道您的 CA 的自定义 TrustManager,系统能够验证您的服务器证书是否来自值得信任的颁发者。

注意:许多网站都会介绍一个糟糕的替代解决方案,让您安装一个没用的 TrustManager。如果您这样做还不如不加密通信,因为任何人都可以在公共 WLAN 热点下,使用伪装成您的服务器的代理发送您的用户流量,通过 DNS 欺骗攻击您的用户。然后,攻击者可以记录密码和其他个人数据。此方法之所以有效是因为攻击者可以生成一个证书,且没有可以切实验证证书是否来自值得信任的来源的 TrustManager,从而使您的应用可与任何人通信。因此,不要这样做,暂时性的也不行。如果您可以始终让您的应用信任服务器证书的颁发者,那就这样做吧。

自签署的服务器证书

导致出现 SSLHandshakeException 的第二种情况是自签署证书,表示服务器将按照自己的 CA 进行操作。这与证书颁发机构未知的情况相似,因此,您可以使用前面部分介绍的方法。

您可以创建自己的 TrustManager,这次直接信任服务器证书。这种方法具有前面所述的将应用与证书直接关联的所有弊端,但可以安全地操作。不过,您应谨慎为之,以确保您的自签署证书具有合理的强密钥。从 2012 年开始,可以接受一个指数为 65537 的 2048 位 RSA 签名,此签名的有效期为一年。旋转密钥时,您应查看颁发机构(例如 NIST)针对可接受的密钥提供的建议

缺少中间证书颁发机构

导致出现 SSLHandshakeException 的第三种情况是缺少中间 CA。大多数公共 CA 不直接签署服务器证书。相反,它们使用自己的主要 CA 证书(称为根 CA)签署中间 CA。这样一来,根 CA 可以离线存储,从而降低泄露风险。不过,Android 等操作系统通常仅直接信任根 CA,这会在服务器证书(由中间 CA 签署)与证书验证程序(了解根 CA)之间留下一个小的信任缺口。为了解决这个问题,服务器在 SSL 握手期间不会仅向客户端发送它的证书,而是发送一个证书链,包括服务器 CA 以及到达可信的根 CA 所需要的任意中间证书。

要了解其实际应用,请看一下通过 openssl s_client 命令查看的 mail.google.com 证书链:

$ openssl s_client -connect mail.google.com:443
---
Certificate chain
 0 s:/C=US/ST=California/L=Mountain View/O=Google Inc/CN=mail.google.com
   i:/C=ZA/O=Thawte Consulting (Pty) Ltd./CN=Thawte SGC CA
 1 s:/C=ZA/O=Thawte Consulting (Pty) Ltd./CN=Thawte SGC CA
   i:/C=US/O=VeriSign, Inc./OU=Class 3 Public Primary Certification Authority
---

这表明服务器会为 mail.google.com 发送一个由 Thawte SGC CA(中间 CA)发放的证书,同时为 Thawte SGC CA 发送一个由 Verisign CA(Android 信任的主要 CA)发放的证书。

不过,对服务器进行配置以便不添加必要的中间 CA 也是屡见不鲜。例如,下面的服务器会引发 Android 浏览器错误和 Android 应用异常:

$ openssl s_client -connect egov.uscis.gov:443
---
Certificate chain
 0 s:/C=US/ST=District Of Columbia/L=Washington/O=U.S. Department of Homeland Security/OU=United States Citizenship and Immigration Services/OU=Terms of use at www.verisign.com/rpa (c)05/CN=egov.uscis.gov
   i:/C=US/O=VeriSign, Inc./OU=VeriSign Trust Network/OU=Terms of use at https://www.verisign.com/rpa (c)10/CN=VeriSign Class 3 International Server CA - G3
---

有趣的是,在大多数桌面浏览器中访问此服务器不会引发完全未知的 CA 或自签署服务器证书所引发的类似错误。这是因为大多数桌面浏览器都会将可信的中间 CA 缓存一段时间。当浏览器从某个网站访问和了解中间 CA 后,下次它就不需要将中间 CA 添加在证书链中。

有些网站会专门为提供资源的辅助网络服务器这样做。例如,他们可能让具有完整证书链的服务器提供主 HTML 页面,让不包含 CA 的服务器提供图像、CSS 或 JavaScript 等资源,以节省带宽。遗憾的是,这些服务器有时候可能会提供您正在尝试从 Android 应用调用的网络服务,这一点让人难以接受。

可以通过两种方法解决此问题:

主机名验证的常见问题

正如本文开头所述,验证 SSL 连接有两个关键环节。首先是验证证书是否来自值得信任的来源,这是前面部分重点讲述的内容。而此部分侧重于第二个环节:确保您正在通信的服务器提供正确的证书。如果没有提供,您通常会看到类似于下面的错误:

java.io.IOException: Hostname 'example.com' was not verified
        at libcore.net.http.HttpConnection.verifySecureSocketHostname(HttpConnection.java:223)
        at libcore.net.http.HttpsURLConnectionImpl$HttpsEngine.connect(HttpsURLConnectionImpl.java:446)
        at libcore.net.http.HttpEngine.sendSocketRequest(HttpEngine.java:290)
        at libcore.net.http.HttpEngine.sendRequest(HttpEngine.java:240)
        at libcore.net.http.HttpURLConnectionImpl.getResponse(HttpURLConnectionImpl.java:282)
        at libcore.net.http.HttpURLConnectionImpl.getInputStream(HttpURLConnectionImpl.java:177)
        at libcore.net.http.HttpsURLConnectionImpl.getInputStream(HttpsURLConnectionImpl.java:271)

出现此错误的一个原因是服务器配置错误。配置服务器所使用的证书不具有与您尝试连接的服务器匹配的主题或主题备用名称字段。许多不同的服务器可能使用一个证书。例如,使用 openssl s_client -connect google.com:443 | openssl x509 -text 查看 google.com 证书,您不仅可以看到一个支持 *.google.com 的主题,而且还能看到适用于 *.youtube.com*.android.com 等的主题备用名称。仅当您要连接的服务器名称没有被证书列为可接受时才会发生这种错误。

不幸的是,还有另外一个原因也会引发此错误,即虚拟托管。当多个使用 HTTP 的主机名共享服务器时,网络服务器可以通过 HTTP/1.1 请求识别客户端正在寻找哪个目标主机名。遗憾的是,使用 HTTPS 会使情况变得复杂,因为服务器必须在看到 HTTP 请求前知道返回哪个证书。为了解决此问题,较新的 SSL 版本(特别是 TLSv.1.0 及更高版本)支持服务器名称指示 (SNI),后者允许 SSL 客户端向服务器指定预期的主机名,以便可以返回正确的证书。

幸运的是,自 Android 2.3 开始,HttpsURLConnection 就支持 SNI。如果您需要支持 Android 2.2(及更旧的版本),一种解决办法是在一个唯一端口上设置备用虚拟主机,以便了解要返回哪个服务器证书。

比较极端的替代方法是不使用服务器默认情况下返回的验证程序,而是将 HostnameVerifier 替换为不使用您的虚拟机主机名的验证程序。

注意:如果其他虚拟主机不在您的控制之下,则更换 HostnameVerifier 非常危险,因为中间人攻击会在您不知情的情况下将流量引向其他服务器。

如果您仍确定要替换主机名验证,请看下面的示例,它将针对单个 URLConnection 的验证程序替换为确认主机名至少符合应用预期的验证程序:

// Create an HostnameVerifier that hardwires the expected hostname.
// Note that is different than the URL's hostname:
// example.com versus example.org
HostnameVerifier hostnameVerifier = new HostnameVerifier() {
    @Override
    public boolean verify(String hostname, SSLSession session) {
        HostnameVerifier hv =
            HttpsURLConnection.getDefaultHostnameVerifier();
        return hv.verify("example.com", session);
    }
};

// Tell the URLConnection to use our HostnameVerifier
URL url = new URL("https://example.org/");
HttpsURLConnection urlConnection =
    (HttpsURLConnection)url.openConnection();
urlConnection.setHostnameVerifier(hostnameVerifier);
InputStream in = urlConnection.getInputStream();
copyInputStreamToOutputStream(in, System.out);

但请记住,如果您发现自己更换了主机名验证,特别是因虚拟托管引起的更换,那么,当其他虚拟主机不在您的控制之下时,这样做仍非常危险,您应找到一个可以避免此问题的备用托管安排。

有关直接使用 SSLSocket 的警告

到目前为止,所举示例都是侧重于使用 HttpsURLConnection 的 HTTPS。有时候应用需要单独使用 SSL与 HTTP。例如,某个电子邮件应用可能使用 SSL 的变体 SMTP、POP3 或 IMAP。在这些情况下,应用将需要直接使用 SSLSocket,与 HttpsURLConnection 在内部执行的操作非常相似。

目前为止所介绍的用于处理证书验证问题的技术也适用于 SSLSocket。事实上,使用自定义 TrustManager 时,传递到 HttpsURLConnection 的是 SSLSocketFactory。因此,如果您需要使用一个带有 SSLSocket 的自定义 TrustManager,请遵循相同的步骤,并使用 SSLSocketFactory 创建您的 SSLSocket

注意SSLSocket 不会执行主机名验证。由您的应用执行自己的主机名验证,最好通过使用预期的主机名调用 getDefaultHostnameVerifier() 进行验证。另外,请注意,出现错误时,HostnameVerifier.verify() 不会引发异常,而是返回一个布尔结果,您必须明确地检查该结果。

以下示例向您展示了如何执行此操作。该示例显示在没有 SNI 支持的情况下连接到 gmail.com 端口 443 时,您将收到 mail.google.com 的证书。在此情况下,这正是期待的结果,因此,请执行检查以确保证书确实是 mail.google.com 的证书:

// Open SSLSocket directly to gmail.com
SocketFactory sf = SSLSocketFactory.getDefault();
SSLSocket socket = (SSLSocket) sf.createSocket("gmail.com", 443);
HostnameVerifier hv = HttpsURLConnection.getDefaultHostnameVerifier();
SSLSession s = socket.getSession();

// Verify that the certicate hostname is for mail.google.com
// This is due to lack of SNI support in the current SSLSocket.
if (!hv.verify("mail.google.com", s)) {
    throw new SSLHandshakeException("Expected mail.google.com, "
                                    "found " + s.getPeerPrincipal());
}

// At this point SSLSocket performed certificate verificaiton and
// we have performed hostname verification, so it is safe to proceed.

// ... use socket ...
socket.close();

列入黑名单

为了仅向正确验证的服务器和域的所有者发放证书,SSL 非常依赖 CA。少数情况下,CA 也会受骗,如 ComodoDigiNotar 出现了信息泄露,从而导致某个主机名的证书被发放给服务器或域的所有者以外的其他人。

为了降低此风险,Android 提供了将某些证书甚至整个 CA 列入黑名单的功能。尽管此名单过去已内置到操作系统中,但从 Android 4.2 开始,可以远程更新此名单,便于处理将来的泄露问题。

证书固定

通过名称为证书固定的技术,应用可以更好地保护自己免受以欺诈方式发放的证书的攻击。这里基本上使用上面未知 CA 案例中提供的示例,将应用的可信 CA 限制在一个很小的 CA 集范围内,应用的服务器将使用这个集合。这样可以防止因泄露系统中其他 100 多个 CA 中的某个 CA 而破坏应用安全通道。

客户端证书

本文重点讲述 SSL 用户与服务器进行安全通信。SSL 也支持客户端证书的概念,客户端证书允许服务器验证客户端的身份。尽管这超出了本文的讨论范围,但使用的技术与指定自定义 TrustManager 相似。请在 HttpsURLConnection 的相关文档中查看有关创建自定义 KeyManager 的讨论。

Nogotofail:网络流量安全测试工具

对于已知的 TLS/SSL 漏洞和错配置,可以通过 Nogotofail 轻松确认您的应用程序是否安全。它是一款自动执行的工具,功能强大并且可扩展,用于测试通过它传送网络流量的任意设备的网络安全问题。

Nogotofail 可用于三个主要用例:

Nogotofail 适用于 Android、iOS、Linux、Windows、Chrome 操作系统、OSX。事实上,任何用于连接互联网的设备都可以使用 Nogotofail。在 Android 和 Linux 上提供了一个易于使用的客户端来配置设置和获取通知,同时还提供了一个本身可作为路由器、VPN 服务器或代理部署的攻击引擎。

您可以在 Nogotofail 开源项目网站上访问此工具。

This site uses cookies to store your preferences for site-specific language and display options.

Hooray!

This class requires API level or higher

This doc is hidden because your selected API level for the documentation is . You can change the documentation API level with the selector above the left navigation.

For more information about specifying the API level your app requires, read Supporting Different Platform Versions.

Take a one-minute survey?
Help us improve Android tools and documentation.