gitbook/代码精进之路/docs/89213.md
2022-09-03 22:05:03 +08:00

17 KiB
Raw Permalink Blame History

40 | 规范,代码长治久安的基础

如果从安全角度去考察,软件是非常脆弱的。今天还是安全的代码,明天可能就有人发现漏洞。安全攻击的问题,大部分出自信息的不对称性;而维护代码安全之所以难,大部分是因为安全问题是不可预见的。那么,该怎么保持代码的长治久安呢?

评审案例

有些函数或者接口可能在我们刚开始写程序的时候就已经接触了解甚至熟知了它们比如说C语言的read()函数、Java语言的InputStream.read()方法。我一点都不怀疑我们熟知这些函数或接口的规范。比如说C语言的read()函数在什么情况下返回值为0 InputStream.read() 方法在什么情况下返回值为-1

我知道我们用错read()的概率很小。但是今天,我要和你讨论一两个不太常见,且非常有趣,的错误的用法。

让我们一起来看几段节选改编的C代码代码中的socket表示网络连接的套接字文件描述符file descriptor。 你能够找到这些代码里潜在的问题吗?

#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>

void clientHello(int socket) {
    char buffer[1024];
    char* hello = "Hello from client!";

    send(socket, hello, strlen(hello), 0);
    printf("Hello message sent\n");

    int n = read(socket, buffer, 1024);
    printf("%s\n", buffer);
}

#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>

void serverHello(int socket) {
    char buffer[1024];
    char* hello = "Hello from server!";

    int n = read(socket, buffer, 1024);
    printf("%s\n", buffer);

    send(socket, hello, strlen(hello), 0);
    printf("Hello message sent\n");
}

#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>

void serverHello(int socket) {
    char buffer[1024];
    char* hello = "Hello from server!";

    int n = read(socket, buffer, 1024);
    if (n == 0) {
        close(socket);
    } else {
        printf("%s\n", buffer);

        send(socket, hello, strlen(hello), 0);
        printf("Hello message sent\n");
    }
}


现在我们集中寻找read()函数返回值的使用问题。为了方便你分析我把一个标准的read()函数返回值的规范摘抄如下:

RETURN VALUES
If successful, the number of bytes actually read is returned. Upon reading
end-of-file, zero is returned. Otherwise, a -1 is returned and the global
variable errno is set to indicate the error.

上面三段代码里read()函数的返回值使用都有什么问题? 上面的函数能够实现编码者所期望的功能吗?

案例分析

上述代码可以作为教学示范的一部分,它们简洁地展示了套接字文件描述符的一些使用方法。但是,这些代码离真正的工业级产品的质量要求还有很大的一段距离。当然了,如果你把上述的代码运行一万次,那么这一万次可能都不会辜负你的期望;运行一百万次,一百万次也可能都是成功的。但是,不论是理论上还是实际上,这些代码还是有可能出现错误的,它们并不是可靠的代码。

问题出在哪儿呢如果我们仔细阅读read()函数返回值规范可以注意到read()函数的返回值是实际读取的字节数。一段信息套接字的底层实现可能会分段传输分段接收。所以read()函数并不能保证一次调用就返回完整的一段信息,传送和接收也未必是一一对应的,即使这段信息很短。

在上述的例子中如果期望接收到的信息是“Hello from server!”那么一次read()函数的执行实际接收到的信息可能是完整的信息也可能是一个开头的字母“H”。套接字的底层实现并不能保证通过调用一次或者两次read()函数,就能够接收到这条完整的信息。

这其实带来了一个不小的麻烦。如果调用read()函数的次数无法确定,那么接收端就要一直读取,直到接收到完整的信息。可是,什么样的信息才是完整的信息呢?接收端似乎并没有办法知道一条信息是否完整。

比如在上面的例子中对于接收端来说怎么知道“H”不是一条完整的信息 “Hello”也不是一条完整的信息而“Hello from server!”就是一条完整的信息呢无法判断信息的完整性就会面临信息丢失或者读取阻塞的问题。所以应用层面的设计必须考虑如何检验接收消息是否完整。比如对于HTTP协议而言请求行必须以“CRLF”结束。那么接收端读取到“CRLF”就能够确定请求行的数据传输完整了。

在实际运行中如果信息足够短比如上面的“Hello from server!”,那么套接字底层的实现和网络环境,大部分情况下都能够一次传输完整的信息。所以,上述代码运行一万次,可能这一万次都是成功的。即便如此,也不能保证每次传输的都是完整的信息。

这里面还有另一个不太小的麻烦是关于read()函数的实现的。函数的规范要求数据传输结束End-Of-Fileread()函数应该返回0。那么read()函数返回0是不是就表示数据传输结束呢 是的。不然的话,应用程序如何判断数据传输结束又是一个大麻烦。

可是的确存在类似的实现读取操作返回了0但是数据传输才刚刚开始。下面我要给你讲的这个例子就是这样的一个看似微不足道,但后果却很严重的问题,把互联网协议的重要安全变更,耽搁了整整十年

十年的死局

安全套接字协议( Secure Socket Layer, 简称SSL是用来确保网络通信和事务安全的最常见的标准。现在只要你使用互联网几乎就是这个标准的使用者。这个标准最初由网景公司NetScape设计并且实现后来移交给了国际互联网工程任务组The Internet Engineering Task Force简称 IETF管理并且更名为传输层安全协议Transport Layer Security简称TLS

我们通过浏览器输入,并且传输到网站的用户名和密码必须只有我们自己知道,不能在传输的过程中被第三者窃取,也不能传送给指定网站以外的服务器。一般来说,浏览器和服务器之间需要建立安全传输连接。这样,网站的真实性是经过校验的,浏览器和网站之间传输的所有数据都是经过加密的,只有我们自己和网站服务器可以解密、理解传输的数据。

传输层安全协议就是用来满足这些安全需求的。那它是怎么做到呢?传输层安全协议需要使用一系列的密码技术,来保证安全连接的建立。

保证数据的私密性使用的是数据加密技术。其中,影响最大的一类数据加密技术使用的是一种叫作链式加密(Cipher Block ChainingCBC)的模式。简单地说,就是前一个加密数据的最后一个数据块,被用来作为后一个数据块加密的输入参数。这样,就形成了后一个加密数据依赖前一个加密数据的链条。

1999年1月传输层安全协议第一版发布一般简称为TLS 1.0。TLS 1.0使用链式加密模式作为其加密传输数据的一个技术方案。TLS 1.0获得了巨大的成功。我们很难想象如果没有TLS协议互联网会是一个什么样子。然而完美的东西渴求不来也偶遇不到。

2001年9月的密码学进展大会上一位密码学研究者Hugo Krawczyk发表了一篇论文该论文研究了链式加密的缺陷以及对于TLS协议的影响。利用链式加密的缺陷攻击者可以破解出加密密码使用这个密码就可以解密加密的传输数据从而获取传输信息。从此链式加密一个有着最广泛影响的技术开始淡出历史舞台。然而这个进程非常缓慢非常缓慢。在新技术替代的过程中老技术的现有问题以及新老技术的衔接会出现很多非常复杂和棘手的问题。原有的技术使用得越多部署得越广泛这些问题越复杂。

2002年OpenSSL一个被广泛使用的实现传输层安全协议的类库发布了针对链式加密缺陷的安全补丁和缺陷报告。这个解决方案的目的就是打破链式加密模式的链条在数据块之间插入随机数据。由于随机数据插到了加密数据链之间解决了链式加密模式的上述缺陷这使得链式加密的形式和算法得以保留。

幸运的是TLS协议的设计恰巧允许这种使用方法那么TLS协议在理论上仍可以继续使用。既然是随机数据那就是没有任何意义的数据不能用于实际的应用接收端必须忽略这些随机数据。TLS协议通过传输一个空数据段然后再传输有效数据就可以达到添加随机数据的目的。 在理论上,这是一个很好的解决方案。然而,现实比想象的还要精彩。

该解决方案的真正落地需要read()函数或者类似的方法有一个好的实现。在接收到空数据段所代表的随机数据时需要忽略该数据段继续等待真正有效的数据不能返回0。为什么不能返回0呢还记得上面的read()返回值规范吗返回值为0代表数据传输结束应用程序就不应该继续使用该通道了后续的数据都会被丢弃。可是对于这个解决方案如果read()返回0意味着真正的数据传输才刚刚开始而不是结束。

如果这样的实现存在,那么这个解决方案不但没有解决安全缺陷问题,还直接导致应用程序不能继续使用。

有没有这样一时糊涂的实现呢? OpenSSL的缺陷报告里提到了一个这样的糊涂的实现。有这么有一个产品名字的简写是MSIE。曾经它是一种特立独行般的存在到了哪里哪里就会绽放出不一样的烟火。考虑到MSIE及其相关家族产品巨大的市场使用份额谁采用该安全缺陷修复解决方案谁就自绝于市场自绝于广大的用户。遇到了这种巨大的互操作性问题后OpenSSL随后缺省关闭了这个安全漏洞修补方案。随后其他公司比如Google也曾经尝试在他们的产品中做类似的安全修复都因为这种灾难性的互操作性问题而放弃。安全诚可贵自由价更高

对于这样糊涂的实现而言,这只能算是一个芝麻蒜皮的小问题。修复这样的问题也应该不是多么困难的事情。可是,真正的困难在于,这样的产品已经有了非常广泛的用户群体,以及产品部署,包括个人计算机、自动取款机、商超收银机以及银行柜员机等各种各样的形式。

很多产品的部署形式使得产品的升级非常困难,更别提还有很多产品的实现,是以固件的方式存在的了。比如我们家里用的路由器,部署在计算机房里的交换机,以及每辆汽车里的计算机,这些都是升级非常困难的产品。用户越广泛,部署越广泛,升级就越困难,安全变更面临的挑战就越大。芝麻蒜皮的小问题,都可能构筑困难的障碍,带来巨大的风险,从而造成严重的损失

可能你会有疑问,我换一个浏览器不就没事了吗?如果服务器使用的是这样糊涂的实现,那么一个浏览器是没有办法访问这样的服务器提供的服务的。如果这样的服务器被广泛使用,那么一个浏览器的合理策略,就是不开启这种安全缺陷修复。很多网站不能访问的浏览器,是一个不会有人使用的浏览器。

那么,我自己的服务器是不是可以启动这个安全修复呢?问题又回到了客户端,如果客户端使用了这样糊涂的实现,它也没有办法访问修复了的服务器。如果这样的客户端被广泛使用,比如说最流行的浏览器,那么一个服务器的最合理策略就是不开启这种安全修复。假如一个网站有很多用户不能访问,这实在不是网站设计者和拥有者的初衷。

看起来,这似乎是一个死局!

当时的共识是,针对该漏洞的攻击并不会轻易得手,所以即使不修复该漏洞,估计也不是一个多大的问题。同时,针对该漏洞的升级协议也有条不紊地开始了。

2006年经过4年的反复敲打传输层安全协议版本1.1发布一般简称为TLS 1.1。TLS 1.1的一个重要的任务,就是解决链式加密的缺陷。然而,任何一个标准从制定到落实,都有一段很长的路要走。TLS 1.1并没有得到业界及时的响应和应有的重视。携带着安全缺陷的TLS 1.0依然统治着传输安全的世界,似乎大家并没有觉得有太多的不妥之处。 时间来到了十年后2011年9月。

无奈的少数派

针对链式加密安全漏洞的攻击真的不会轻易得手吗2010年一个年轻人Juliano Rizzo在印度尼西亚的海滩上阅读了OpenSSL的缺陷报告。在优美的印尼海滩上他发现了一种可能非常有效的攻击方法。

2011年9月两位天才般的研究人员Juliano RizzoThai Duong表示给他们几分钟时间他们就可以利用该漏洞入侵你的支付账户。他们给这个攻击技术取了一个超酷的名字BEAST。你要是搜索一下“the BEAST attack”就知道这是一个多么轰动的攻击技术。

他们的研究成果受到了密码学家的高度赞美。但是业界厂商的处境就比较尴尬了。毕竟这是他们十年前尝试修复但是最后不得不放弃修复的漏洞。十年后的今天原来阻碍这个漏洞修复的现实障碍并没有减少。原计划2011年7月份公开发表论文的日期不得不推迟。 因为直到7月份还是没有合适的修复方案。这让人感到有些失望有些沮丧。

7月20日事情有了转机。

如果传输空数据段不被接受那么传输一个字节呢空数据的read()实现可能返回0一个字节的read()实现应该毫无例外地返回1。在TLS 1.0的链式加密模式下传输一个字节时有足够随机的数据插入链式加密数据块之间简单有效地打破链式加密模式的链条。基于这个想法7月20日一个通常被称为1/n-1分割的解决方案被提出并且得到了验证。

由于该方法简单有效主流厂商迅速采纳了这个方案发布了对应产品的安全补丁。幸运的是TLS 1.0续命了十年,业界有更多的时间完成产品的升级换代。不幸但也在预料之中的是,该方案也不是一点兼容性影响都没有。

比如我们案例中讨论的代码就出了大问题。预期收到一条完整的信息“Hello from server!”。 使用了这个安全补丁后就必须要接收被分割的两条信息“H”以及“ello from server!”。如果应用不能处理分割的信息,就不能好好工作了。

幸运的是虽然不能处理分割信息的应用依然存在但是数量很少。而且这是应用自身的问题很难抱怨安全补丁的不是。由于主流的厂商拥抱了1/n-1分割法而存在问题的应用又是少数派这些少数派不得不亲手解决他们自身的问题。否则就面临着应用不得不停工的损失或者承受安全攻击的风险。

对于某一个特定的问题来说,一旦我们成为少数派的一部分,就有可能面临软件安全的风险,以及在兼容性方面做妥协。对于接口规范来说,我们应该严格遵从白名单原则,没有明文规定的行为规范,就不是能依赖的行为规范。

小结

通过对这个评审案例的讨论,我想和你分享下面几点个人看法。

  1. 对于应用接口API的使用一定要严格遵守规范小失误可能造成大麻烦

  2. 对于应用接口API的定义一定要清晰简单描述一定要详实周到。如果使用者对规范的理解感到困难或者困惑可能会带来难以预料的问题

  3. 对于应用接口API的实现一定要在规范许可范围内自由发挥。越是影响广泛的实现越不要逾越规范的界限

这是一个特殊的案例,我们好像聊了一个故事。对这个案例,你还有什么看法吗?

一起来动手

我们讨论了read()函数返回值的问题可是上述的案例还有其他的问题存在。你还发现了什么问题这些问题该怎么更改你可以使用Java或者你熟悉的语言来修改。这可并不是一个简单的修改我知道你一定会遇到很多问题欢迎留言分享你的修改或者问题。

如果让你给clientHello()或者serverHello()加上规范描述,你会怎么描述?你会用什么样的文字,告诉这个接口的使用者,该怎么正确地使用这个应用接口?这同样不是一个简单的小练习,欢迎分享你的规范描述。

如果你觉得这篇文章有所帮助,欢迎点击“请朋友读”,把它分享给你的朋友或者同事。