SUCTF-SU_easygal

题目描述

疏狂在上学期间,遇见了总把情绪藏得很深的艺术系学生环奈。

从校门口被风吹散的画稿开始,到毕业展结束后的樱花树下,这段关系会在一连串看似普通的选择里慢慢推进
你需要在 60 个剧情节点中不断做出选择
每个选项都会悄悄改变故事最后的走向,而真正的结局,只会留给那条唯一正确的路
当你终于走到她面前,也许你会得到一句迟来的回答
也许,你只会看着春天结束
During his school years, Shukuang meets Kanna, an art student who always hides her emotions deep inside.

Their story begins with a stack of drawings scattered by the wind at the school gate, and stretches all the way to beneath the cherry blossom tree after the graduation exhibition. Along the way, the relationship slowly develops through a series of seemingly ordinary choices.

You will need to make decisions across 60 story nodes.

Each option quietly changes the direction of the story’s final outcome, and the true ending exists only on the one correct path.

When you finally stand before her, you might receive a long-delayed answer.

Or perhaps…
you will simply watch spring come to an end. 🌸

解题思路

这个题是一个il2cpp打包的Unity游戏逆向题,个人感觉还是很有意思的,出的也很有质量。

关于il2cpp打包的Unity游戏逆向题如何去做,我之前的博文记录过一次。

传送门CTF游戏逆向入门

那下面就开始分析吧。

先打开游戏看看是个啥,恋爱物语嘛?有点意思。有点像早年的橙光游戏(那真是一份美好的回忆😋),也有点像galgame……嘎啦给木里不是这样的,我应该是一眼洞穿flag的天才,身边有很多迷妹迷弟,而不是一只笨的难以言喻的菜鸡😭

image-20260318205914734

sorry,有点发疯了。我们继续来看。

进度是60,也就是我们需要选择60次对话,最后会有结局。如果我们能顺利走向成功的结局的话,应该就能顺利拿到flag啦。

那就开始逆向分析吧,撸起袖子加油干,美女跟我谈恋爱❤️

先使用Il2CppDumper工具来获得 GameAssembly.dll 的符号信息。

08181a69f2c59a3888f5a7d4d96d6639

然后得到了如下的文件

image-20260318210815494

接着用ida打开 GameAssembly.dll文件,然后去选择 File -> Script file 去应用 ida_with_struct_py3.py文件。紧接着去打开我们刚刚得到的script.json,然后再去导入头文件il2cpp.h

等待ida加载好。还是会感到函数很多很杂,不知道具体该看哪个,那就CE一下。

image-20260318213131925

能够看到有个很可疑的类名 GameManager,那就去ida里搜索带有这个类名的函数。

image-20260318213513147

那我们就能定位关键逻辑了。

在GameManager__Star这个函数中,我们能够知道

trueEndingValue = 322

maxWeight = 132

image-20260319132704774

接着去分析GameManager__OnChoiceSelected函数

image-20260319133957527

进行大概分析后我们能够知道,这个游戏一共是有60个情景,让你去选择对话,这些对话分别有不同的负重值和好感度,有的有flag标志的字符,或者是marker字符串,然后每选择一次,都会更新当前的状态,并保存,也就是自动存档。

我们收集到的好感度就存在currentValue变量里,我们收集到到的负重就存在currentWeight里面。

继续往下分析

image-20260319135751313

当走完剧情后,就会开始进行结局判断了。也就是说,如果我们想要走到真结局的话,需要满足

currentWeight <= maxWeight==132

currentValue == trueEndingValue==322

然后去看一下GameManager__FinishGame函数

image-20260319141340580

从这里知道了,当触发真结局的时候,会调用FlagUtility__BuildTrueEndingFlag函数去动态生成flag。

那就跟进去看看

image-20260319142752871

这个函数先拼接所有的Marker,然后去把这个字符串转成UTF-8字节流,拼接完成后,调用 StringBuilder.ToString() 获取长字符串,然后通过 Encoding.UTF8.GetBytes() 将其转换为可以用于加密的字节数组(buffer)。

然后实例化一个MD5对象,并对刚才的UTF-8字节数组进行哈希计算,生成一个16字节的MD5结果 v38

最后就是进行格式化为16进制的文本。

image-20260319143032774

这段 while(1) 循环遍历了 MD5 算出来的 16 个字节,把每一个字节转换成两位小写字母的十六进制字符串(比如把 255 变成 "ff"),拼成一个 32 位的字符串。

这就是我们flag的生成过程。

我在做这个题的时候,第一反应就是先用了CE,尝试去看能不能手动调出来。如果能够手动去实现

currentWeight <= maxWeight==132

currentValue == trueEndingValue==322

这两个条件的话,就能直接到达真结局了。

点击Lookup instances, CE就会自动的给出一堆候选地址(不一定每次都准确),只需要自己去找到真正的地址,可以用向下的快捷方向键。

这里定位后,可以手动改值,让currentWeight <= maxWeight==132很容易,直接把currentWeight 设置成0,把maxWeight设置成大于132的数就可以,但是要让currentValue == trueEndingValue==322很困难,因为这个选项每次加的好感度是不一样的,不太容易把握。

然后我就换了一种方法,要想跳转到真正结局,可以修改FinishGame传入的参数。

在 64 位汇编的 FastCall 调用约定中,函数的第二个参数(endingType)永远是通过 edx 寄存器传递的。真结局的值是 1。我们只要在函数开头强行把 edx 改成 1,就能永远跳转到真结局了。

在Methods这个列表里,去找到FinishGame函数,然后右键去点击,选择在反汇编器中显示。

image-20260319150011784

这个 mov [rsp+08], rbx就是这个函数执行的第一步,我们在这里去编写汇编注入脚本

选择上方菜单的工具 -> 自动汇编,接着选择模板,选择代码注入。之后会弹出一个确认框(比如让你确认地址、确认名字等),直接点确定 (OK)

image-20260319150700589

newmem: 的正下方去填入我们要改的代码。

然后去执行

image-20260319150854656

现在代码长这样

然后过完剧情,就能触发真结局了

image-20260319151110180

可是,这个并不是真正的flag!我当时写的时候,很粗糙的去看ida的逻辑,忽视了flag是根据你选的选项的marker字符串合并起来去转UTF-8字符,再进行MD5计算得到的。

那么通往真结局的路只有一条,真flag也只有一个。在这条路线上, currentValue 会精准达到 322,currentWeight会刚好不超过 132,并且沿途收集到正确的 markers来拼出真正的 flag。

我们可以去解包JSON剧本

使用工具 AssetStudio

这里简单说一下这个工具

AssetStudio 是一款在游戏逆向工程、解包和 Mod(模组)制作领域大名鼎鼎的开源工具,它专门用于探索、提取和导出 Unity 引擎制作的游戏和应用程序中的资源。AssetStudio 的主要作用是将 Unity 游戏打包后变成的“黑盒”(比如 .assets.bundle 文件)重新解析成人类可读和可用的标准格式文件。

点击左上角的 File -> Load file

去加载**resources.assets** 文件

image-20260319152225656

等待加载完成后,去点击软件上方的 Asset List (资产列表)

在左上角的 Filter Type (类型过滤) 里,勾选 TextAsset

这时列表里会过滤出所有的文本文件

image-20260319152437746

我们看到有个story,里面就是所有的故事场景,还有每个选项对应的weight值,value值,flag标志和marker。

直接右键导出即可。

内容太多,这里就不粘贴了

我是把这个发给了ai,让它去进一步分析。

思路应该是根据这个通关条件去找到唯一的路,然后把这条正确的路上的marker值拼接到一起,去进行MD5加密。

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
import json
import hashlib

def solve_ctf():
# 1. 读取解包出的 JSON 剧本
try:
with open(r"E:\CTF\SU\app\TextAsset\story.txt", 'r', encoding='utf-8') as f:
data = json.load(f)
except FileNotFoundError:
print("[-] 找不到 story.txt 文件,请确认文件路径。")
return

nodes = data['nodes']
max_weight = data['meta']['maxWeight']
target_value = data['meta']['trueEndingValue']

print(f"寻路开始: 目标 Value={target_value}, 限制 Weight<={max_weight}, 总节点数={len(nodes)}")

# 记忆化搜索字典,用于剪枝加速
memo = {}

# 2. 核心算法:DFS 深度优先搜索
def dfs(idx, current_w, current_v):
# 剪枝:如果超载,这条路直接作废
if current_w > max_weight:
return None

# 终点判定:如果 60 步走完,检查是否刚好命中目标分数
if idx == len(nodes):
if current_v == target_value:
return [] # 成功,返回空路径开始回溯
return None

# 状态查表:如果这个状态算过,直接返回结果
state = (idx, current_w, current_v)
if state in memo:
return memo[state]

# 遍历当前节点的两个选项
for i, choice in enumerate(nodes[idx]['choices']):
res = dfs(idx + 1, current_w + choice['weight'], current_v + choice['value'])

# 如果这条路走得通,记录 marker 并向上层返回
if res is not None:
path = [choice['marker']] + res
memo[state] = path
return path

# 走不通则记录为 None
memo[state] = None
return None

# 3. 执行搜索并输出结果
path = dfs(0, 0, 0)

if path:
print("突破成功!找到完美路线!")
marker_string = "".join(path)
print(f"拼接后的 Markers: {marker_string}")

# 4. 模拟游戏底层的 Flag 生成器 (MD5)
flag_hash = hashlib.md5(marker_string.encode('utf-8')).hexdigest()
print(f"Flag: SUCTF{{{flag_hash}}}")
else:
print("未找到符合条件的路线,请检查 JSON 数据是否完整。")

if __name__ == '__main__':
solve_ctf()

运行脚本后结果

1
2
3
4
寻路开始: 目标 Value=322, 限制 Weight<=132, 总节点数=60
突破成功!找到完美路线!
拼接后的 Markers: m1bm2bm3am4bm5am6am7bm8am9am10am11am12am13am14am15bm16bm17am18bm19am20am21am22am23bm24bm25bm26bm27am28bm29bm30bm31bm32bm33bm34bm35bm36am37am38am39bm40am41am42am43bm44am45bm46am47am48am49bm50bm51bm52am53bm54bm55bm56bm57am58am59am60b
Flag: SUCTF{92d1c2c3f6e55fabbc3a6ffde57c7341}

这样就是对的flag了

ps:这道题不难,自己还是太菜逼了😌不过当时比赛的时候写出来真的很惊喜