SUCTF-SU_easygal
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的天才,身边有很多迷妹迷弟,而不是一只笨的难以言喻的菜鸡😭
sorry,有点发疯了。我们继续来看。
进度是60,也就是我们需要选择60次对话,最后会有结局。如果我们能顺利走向成功的结局的话,应该就能顺利拿到flag啦。
那就开始逆向分析吧,撸起袖子加油干,美女跟我谈恋爱❤️
先使用Il2CppDumper工具来获得 GameAssembly.dll 的符号信息。
然后得到了如下的文件

接着用ida打开 GameAssembly.dll文件,然后去选择 File -> Script file 去应用 ida_with_struct_py3.py文件。紧接着去打开我们刚刚得到的script.json,然后再去导入头文件il2cpp.h。
等待ida加载好。还是会感到函数很多很杂,不知道具体该看哪个,那就CE一下。

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

那我们就能定位关键逻辑了。
在GameManager__Star这个函数中,我们能够知道
trueEndingValue = 322
maxWeight = 132

接着去分析GameManager__OnChoiceSelected函数

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

当走完剧情后,就会开始进行结局判断了。也就是说,如果我们想要走到真结局的话,需要满足
currentWeight <= maxWeight==132
currentValue == trueEndingValue==322
然后去看一下GameManager__FinishGame函数

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

这个函数先拼接所有的Marker,然后去把这个字符串转成UTF-8字节流,拼接完成后,调用 StringBuilder.ToString() 获取长字符串,然后通过 Encoding.UTF8.GetBytes() 将其转换为可以用于加密的字节数组(buffer)。
然后实例化一个MD5对象,并对刚才的UTF-8字节数组进行哈希计算,生成一个16字节的MD5结果 v38
最后就是进行格式化为16进制的文本。

这段 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函数,然后右键去点击,选择在反汇编器中显示。
这个 mov [rsp+08], rbx就是这个函数执行的第一步,我们在这里去编写汇编注入脚本
选择上方菜单的工具 -> 自动汇编,接着选择模板,选择代码注入。之后会弹出一个确认框(比如让你确认地址、确认名字等),直接点确定 (OK)
在 newmem: 的正下方去填入我们要改的代码。
然后去执行

现在代码长这样
然后过完剧情,就能触发真结局了
可是,这个并不是真正的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** 文件
等待加载完成后,去点击软件上方的 Asset List (资产列表)
在左上角的 Filter Type (类型过滤) 里,勾选 TextAsset
这时列表里会过滤出所有的文本文件

我们看到有个story,里面就是所有的故事场景,还有每个选项对应的weight值,value值,flag标志和marker。
直接右键导出即可。
内容太多,这里就不粘贴了
我是把这个发给了ai,让它去进一步分析。
思路应该是根据这个通关条件去找到唯一的路,然后把这条正确的路上的marker值拼接到一起,去进行MD5加密。
1 | import json |
运行脚本后结果
1 | 寻路开始: 目标 Value=322, 限制 Weight<=132, 总节点数=60 |
这样就是对的flag了
ps:这道题不难,自己还是太菜逼了😌不过当时比赛的时候写出来真的很惊喜



