Internal System && NodeJs V8 CRLF

NodeJs V8 CRLF

起初是看赵今大佬,发的虎符 CTF2021 wp。链接在此:https://www.zhaoj.in/read-6905.html

感觉这题目很有意思,出题的思路是利用:

NodeJs V8中的http存在Crlf漏洞,因为axios依赖http库,所以导致可以构造crlf来进行POST并且ssrf内网应用。

更多题目相关的东西,在赵今大佬的WP上更为详细,此篇文章就纯粹在复现学习中遇到的一些思考和研究。

crlf

首先,CRLF 指的是回车符换行符,操作系统就是根据这个标识来进行换行的。

CRLF注入漏洞检测,基本是通过修改HTTP参数,注入恶意畸形的数据,最后查看构造的恶意数据是否注入在响应头内。

如:

https://www.blackhat.com/docs/us-17/thursday/us-17-Tsai-A-New-Era-Of-SSRF-Exploiting-URL-Parser-In-Trending-Programming-Languages.pdf

这是一个过滤不严格导致的可以输入%0D%0A这两个字符制造换行效果的漏洞。

个人觉得,这种问题绝大多数都是后端代码所依赖的请求库未对恶意数据进行编码过滤导致的。

NodeJs V8 CRLF

该漏洞是由于Node.js版本8的http库处理unicode字符时候产生了错误。正常来说,当尝试发送一个路径含有控制字符的HTTP请求,它们将会被编码或者过滤:

> http.get('http://example.com/\r\n/test')
[ 'GET //test HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n']

但是范围在[6.0.0, 6.15.0) 和 [8.0.0, 8.14.0) 版本以内的nodejs都存在这个问题,也就是在处理unicode字符的时候,http包默认使用了latin1单字节编码,但是但是unicode却是多字节编码,因此在处理的时候,就会进行截断。

> Buffer.from('\u{0130}','latin1')
<Buffer 30>   =>   变成了\u30
> Buffer.from('\u{0131}','latin1')
<Buffer 31>   =>   变成了\u31

我们看看nodejs 8的源码,下载地址https://nodejs.org/download/release/v8.0.0/

nodejs下http有清晰的源码架构

Node源码解析: https://www.sweetalkos.com/post/184

  1. _http_agent.js: 对应Agent类,负责管理http客户端的连接持久性和重用。
  2. _http_client.js: 对应http.ClientRequest类,负责正在进行中的请求实现。
  3. _http_server.js: 对应http.Server类和http.ServerResponse类,负责服务器和服务器响应实现。
  4. _http_incoming.js: 对应http.IncomingMessage类,两端回调的res继承该类。
  5. _http_outgoing.js: 对应http.OutgoingMessage类,两端回调的req继承该类。
  6. _http_common.js: 负责提供上面各个http子模块都会用到的通用方法,比如http解析。

着重看_http_client.js,因为我们只是想知道它如何导致了unicode高位截断。

Example:http://127.0.0.1:8888/?param=x\u{0120}HTTP/1.1

_http_client.js最核心为ClientRequest函数,使用了url.parse来解析传入的example

function ClientRequest(options, cb) {
  OutgoingMessage.call(this);

  if (typeof options === 'string') {
    options = url.parse(options);//解析
    if (!options.hostname) {
      throw new Error('Unable to determine the domain name');
    }
  } else if (options && options[searchParamsSymbol] &&
             options[searchParamsSymbol][searchParamsSymbol]) {
    // url.URL instance
    options = urlToOptions(options);
  } else {
    options = util._extend({}, options);
  }

  var agent = options.agent;
  var defaultAgent = options._defaultAgent || Agent.globalAgent;
  ......

会根据对应的规则来解析出protocol、host、port、hostname等。

> url.parse('http://127.0.0.1:8888/?param=x\u{0120}HTTP/1.1')
Url {
  protocol: 'http:',
  slashes: true,
  auth: null,
  host: '127.0.0.1:8888',
  port: '8888',
  hostname: '127.0.0.1',
  hash: null,
  search: '?param=xĠHTTP/1.1',
  query: 'param=xĠHTTP/1.1',
  pathname: '/',
  path: '/?param=xĠHTTP/1.1',
  href: 'http://127.0.0.1:8888/?param=xĠHTTP/1.1'
}

继续跟进,往下都是一些不合法的HTTP参数的限制

var path;
  if (options.path) {
    path = '' + options.path;
    var invalidPath;
    if (path.length <= 39) { // Determined experimentally in V8 5.4
      invalidPath = isInvalidPath(path);
    } else {
      invalidPath = /[\u0000-\u0020]/.test(path);
    }
    if (invalidPath)
      throw new TypeError('Request path contains unescaped characters');
  }

  if (protocol !== expectedProtocol) {
    throw new Error('Protocol "' + protocol + '" not supported. ' +
                    'Expected "' + expectedProtocol + '"');
  }

细心的发现,这里有一个invalidPath的处理,如何检测到 /[\u0000-\u0020]/,就会抛出异常。

在官方GitHub的源码,这个位置,加了一处校验代码。https://github.com/nodejs/node/blob/v8.x/lib/_http_client.js

const INVALID_PATH_REGEX = /[^\u0021-\u00ff]/;
......
if (options.path) {
    path = String(options.path);
    var invalidPath;
    if (REVERT_CVE_2018_12116) {//加了一处判断条件
      if (path.length <= 39) { // Determined experimentally in V8 5.4
        invalidPath = isInvalidPath(path);
      } else {
        invalidPath = /[\u0000-\u0020]/.test(path);
      }
    } else {
      invalidPath = INVALID_PATH_REGEX.test(path);//使用了另一个正则
    }

一旦检测到\u0021-\u00ff或者/[\u0000-\u0020]/就会报错。

即使这样我觉得还是没完全修复,为什么呢?上面我们说到 latin1单字节编码,unicode是多字节,即使你过滤了\u0000\u00ff 但是由于多字节编码转单字节编码会被截断,所以我完全可以用\u1120 等其他unicode的来绕过你的限制,只要保证我截断之后是我想要的就可以。

因此这就是那个漏洞的原因:为什么在使用unicode编码时是u{01xx}而不是u{00xx}

但是现在最新的node,会在处理之前对其url编码(v12)https://github.com/nodejs/node/blob/master/lib/_http_client.js

所以这个问题只存在版本8以下。继续跟进,来到210行,

this._storeHeader(this.method + ' ' + this.path + ' HTTP/1.1\r\n',
                      options.headers);

准备写入header头,跟进_storeHeader,在node-v8.0.0lib_http_outgoing.js文件下

此时我们的恶意path在第一个参数,而_storeHeader的第一个入参为firstLine,并写进state中

function _storeHeader(firstLine, headers) {
  // firstLine in the case of request is: 'GET /index.html HTTP/1.1\r\n'
  // in the case of response it is: 'HTTP/1.1 200 OK\r\n'
  var state = {
    connection: false,
    connUpgrade: false,
    contLen: false,
    te: false,
    date: false,
    expect: false,
    trailer: false,
    upgrade: false,
    header: firstLine
  };
    .....
    if (headers === this[outHeadersKey]) {
    for (key in headers) {
      var entry = headers[key];
      field = entry[0];
      value = entry[1];

      if (value instanceof Array) {
        if (value.length < 2 || !isCookieField(field)) {
          for (j = 0; j < value.length; j++)
            storeHeader(this, state, field, value[j], false);//写入header头
          continue;
        }
        value = value.join('; ');
      }
   ......
   
   if (state.expect) this._send('');//发送
   }

所以紧跟state,storeHeader中有一个过滤的函数

function escapeHeaderValue(value) {
  // Protect against response splitting. The regex test is there to
  // minimize the performance impact in the common case.
  return /[\r\n]/.test(value) ? value.replace(/[\r\n]+[ \t]*/g, '') : value;
}//这里吧\r\n给过滤了。

function storeHeader(self, state, key, value, validate) {
  if (validate) {
    if (typeof key !== 'string' || !key || !checkIsHttpToken(key)) {
      throw new TypeError(
        'Header name must be a valid HTTP Token ["' + key + '"]');
    }
    if (value === undefined) {
      throw new Error('Header "%s" value must not be undefined', key);
    } else if (checkInvalidHeaderChar(value)) {
      debug('Header "%s" contains invalid characters', key);
      throw new TypeError('The header content contains invalid characters');
    }
  }
  state.header += key + ': ' + escapeHeaderValue(value) + CRLF;//过滤了一些东西
  matchHeader(self, state, key, value);//
}

其余都是一些补全http的一些状态信息,直接快进到发送

OutgoingMessage.prototype._send = function _send(data, encoding, callback) {
  // This is a shameful hack to get the headers and first body chunk onto
  // the same packet. Future versions of Node are going to take care of
  // this at a lower level and in a more general way.
  if (!this._headerSent) {
    if (typeof data === 'string' &&
        (encoding === 'utf8' || encoding === 'latin1' || !encoding)) {
      data = this._header + data;
    } else {
      var header = this._header;
      if (this.output.length === 0) {
        this.output = [header];
        this.outputEncodings = ['latin1'];
        this.outputCallbacks = [null];
      } else {
        this.output.unshift(header);
        this.outputEncodings.unshift('latin1');
        this.outputCallbacks.unshift(null);
      }
      this.outputSize += header.length;
      this._onPendingData(header.length);
    }
    this._headerSent = true;
  }
  return this._writeRaw(data, encoding, callback);
};//之后就是通讯模型的发包,原始字节输出。

再此之前必须转为原始字节输出,而nodejs又是以latin1作为默认编码,但是unicode却是多字节编码,因此在处理的时候,就会进行截断。

axios

话又说回来,axios又是从哪里引入了http?才导致这个缺陷的呢?

这篇axios源码解析文章非常棒,思路很清晰 https://lxchuan12.gitee.io/axios/

我也在文中找到了答案,因为axios是根据当前环境引入,如果是浏览器环境引入xhr,是node环境则引入http。正是在这里,如果axios引入了带有问题的http库,那么就可以导致crlf。

none
最后修改于:2021年04月17日 16:55

评论已关闭