首页 > 基础资料 博客日记

第十九届全国大学生信息安全竞赛_babygame:Godot 游戏逆向与 AES 运行时密钥替换

2026-04-01 21:00:02基础资料围观1

文章第十九届全国大学生信息安全竞赛_babygame:Godot 游戏逆向与 AES 运行时密钥替换分享给大家,欢迎收藏极客资料网,专注分享技术知识

正文开始前,在此夹带一点点“私货”:我最近动手搭了一个属于自己的个人博客!虽然目前还是个“毛坯房”,主题和排版还在慢慢打磨中(可能有些地方功能还不全面、甚至可能报错😅),但这毕竟是我在互联网上的独立小天地。这篇文章也同步归档在我的小站里,除了长篇 WP,我以后也会在那边发一些零碎的踩坑记录。欢迎大家来我的小站串门、挑错。
我的个人博客:https://beini-faxianl.github.io/

一、分析附件

题目给了一个 .exe 文件,对于未知二进制的第一步不是上 IDA,而是先识别它是什么。

010 Editor 搜索关键字符串是成本最低的侦察手段,丢到 010 中分析一波:

file-20260401193318610

file-20260401193559321

在查找“flag”字符串的时候,发现了文件 flag.gdflag.scn 文件,上网搜索之后得知这是一个 Godot 引擎打包的游戏:

file-20260401194504463

与该引擎相关的文件后缀:

后缀 是什么 编译前版本
project.godot 项目配置,文本 -
project.binary 项目配置,二进制 project.godot
.gd GDScript 源码 -
.gdc GDScript 字节码 .gd
.tscn 场景文件,文本 -
.scn 场景文件,二进制 .tscn
.tres 资源文件,文本 -
.res 资源文件,二进制 .tres
.remap 资源重定向索引 -

关于该引擎有专门的工具用于反编译,我这用的是"gdsdecomp"

项目地址:https://github.com/GDRETools/gdsdecomp

二、反编译

将游戏文件拖入工具中:

file-20260401194924303

1、查看项目入口

上面提到 .binary 是项目入口,查看其代码(工具会帮我们反编译成 .godot 文件):

; Engine configuration file.
; It's best edited using the editor UI and not directly,
; since the parameters that go here are not all obvious.
;
; Format:
;   [section] ; section goes between []
;   param=value ; assign values to parameters

config_version=5

[application]

config/name="babygame"
run/main_scene="uid://4oheuinvn0ol"
config/features=PackedStringArray("4.5", "Forward Plus")
config/icon="res://icon.svg"

[autoload]

Music="*res://scenes/music.tscn"
Flag="*res://scenes/flag.tscn"

[dotnet]

project/assembly_name="first-game"

[input]

jump={
"deadzone": 0.2,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":32,"key_label":0,"unicode":32,"location":0,"echo":false,"script":null)
]
}
move_left={
"deadzone": 0.2,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194319,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":65,"key_label":0,"unicode":97,"location":0,"echo":false,"script":null)
]
}
move_right={
"deadzone": 0.2,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194321,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":68,"key_label":0,"unicode":100,"location":0,"echo":false,"script":null)
]
}

[rendering]

textures/canvas_textures/default_texture_filter=0
textures/vram_compression/import_etc2_astc=true

关键信息:

[autoload]
Music="*res://scenes/music.tscn"
Flag="*res://scenes/flag.tscn"

autoload 单例会在游戏启动就创建,直到游戏关闭才销毁。任何场景、任何脚本都可以直接用 Flag.xxx 访问它的变量。

找到文件 /scenes/flag.tscn(只能找到 /scenes/flag.tscn.remap):

[remap]

path="res://.godot/exported/133200997/export-ed01e640138f262d3a3519429431a67d-flag.scn"

从之前的后缀说明可以看出这是起重定向的作用,接着找 /.godot/exported/133200997/export-ed01e640138f262d3a3519429431a67d-flag.scn 这个文件,其代码:

[gd_scene load_steps=2 format=3 uid="uid://dkq0of6k4nvab"]

[ext_resource type="Script" uid="uid://dguwugfoefc7s" path="res://scripts/flag.gd" id="1"]

[node name="Flag" type="CenterContainer"]
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1")

[node name="PanelContainer" type="PanelContainer" parent="."]
layout_mode = 2

[node name="VBoxContainer" type="VBoxContainer" parent="PanelContainer"]
layout_mode = 2

[node name="Label" type="Label" parent="PanelContainer/VBoxContainer"]
layout_mode = 2
theme_override_font_sizes/font_size = 32
text = "Please input your flag:"

[node name="FlagTextEdit" type="TextEdit" parent="PanelContainer/VBoxContainer"]
custom_minimum_size = Vector2(800, 42)
layout_mode = 2
theme_override_font_sizes/font_size = 32

[node name="Label2" type="Label" parent="PanelContainer/VBoxContainer"]
visible = false
layout_mode = 2
theme_override_colors/font_color = Color(0.886275, 0.937255, 0.478431, 1)
theme_override_font_sizes/font_size = 24
text = "you are great~!"

[node name="HBoxContainer" type="HBoxContainer" parent="PanelContainer/VBoxContainer"]
layout_mode = 2
size_flags_horizontal = 4

[node name="Button" type="Button" parent="PanelContainer/VBoxContainer/HBoxContainer"]
custom_minimum_size = Vector2(100, 42)
layout_mode = 2
theme_override_font_sizes/font_size = 24
text = "Submit"

[node name="Button2" type="Button" parent="PanelContainer/VBoxContainer/HBoxContainer"]
custom_minimum_size = Vector2(100, 42)
layout_mode = 2
theme_override_font_sizes/font_size = 24
text = "Back"

[connection signal="ready" from="." to="." method="_on_ready"]
[connection signal="pressed" from="PanelContainer/VBoxContainer/HBoxContainer/Button" to="." method="submit"]
[connection signal="pressed" from="PanelContainer/VBoxContainer/HBoxContainer/Button2" to="." method="back"]

根据内部的英文提示词:

  • "you are great~!"
  • "Please input your flag:"

我们大致可以猜出获得 flag 的流程应该是:输入 $\to$ 校验 $\to$ 输出

聚焦:

[node name="Label2" type="Label" parent="PanelContainer/VBoxContainer"]
visible = false
layout_mode = 2
theme_override_colors/font_color = Color(0.886275, 0.937255, 0.478431, 1)
theme_override_font_sizes/font_size = 24
text = "you are great~!"

Label2 并不可见(visible = false),说明游戏中并不能看到关键信息(顺带一提,我的一个队友按要求通关游戏之后,屏幕显示了“flag”,之后的事情,想必大家都知道了 -)。

聚焦文件的开头:

res://scripts/flag.gd

这就是我们在 010 中看到的那个,并且根据后缀可知,这是 GDScript 源码,必然成为重点。

三、AES

点开文件 /scripts/flag.gd(工具中看到的是 flag.gdc,点开后自动反编译成 flag.gd,后面不再提及此时,请自行注意):

extends CenterContainer

@onready var flagTextEdit: Node = $PanelContainer / VBoxContainer / FlagTextEdit
@onready var label2: Node = $PanelContainer / VBoxContainer / Label2

static var key = "FanAglFanAglOoO!"
var data = ""

func _on_ready() -> void :
    Flag.hide()

func get_key() -> String:
    return key

func submit() -> void :
    data = flagTextEdit.text

    var aes = AESContext.new()
    aes.start(AESContext.MODE_ECB_ENCRYPT, key.to_utf8_buffer())
    var encrypted = aes.update(data.to_utf8_buffer())
    aes.finish()

    if encrypted.hex_encode() == "d458af702a680ae4d089ce32fc39945d":
        label2.show()
    else:
        label2.hide()

func back() -> void :
    get_tree().change_scene_to_file("res://scenes/menu.tscn")

关键信息:

  • AES,模式为 ECB,并且告知密钥为 FanAglFanAglOoO!
  • label2.show():我们期望看到的秘密
  • 检验方式:if encrypted.hex_encode() == "d458af702a680ae4d089ce32fc39945d"

总结一下就是,需要对 ciphertext 进行 AES 解密,将解密后的结果作为输入,以此来使验证成立,最终显示出秘密数据。

四、坑点

坑点出现了,根据已有信息进行解密输出的结果是不可读的:

尝试:

from Crypto.Cipher import AES

key = b"FanAglFanAglOoO!"
ciphertext = "d458af702a680ae4d089ce32fc39945d"

cipher = AES.new(key,AES.MODE_ECB)
ciphertext = bytes.fromhex(ciphertext)
plaintext = cipher.decrypt(ciphertext)
print(plaintext)

输出:

┌──(penv)─(zyf㉿zhengyifeng)-[~/Templates]
└─$ python decode.py
b'i1$\xa2\xf1\x84\xb3\xe5\xda\x0f\x89\\!bC\xf7'

密文是写死的,但是回想初始 key 是 static var,挂在全局单例 Flag 上,理论上可以被任何脚本修改。因此不能只看 flag.gd,需要排查所有可能修改 Flag.key 的脚本。

回到题目的提示信息上"请找出隐藏的Flag。请注意只有收集了所有的金币,才能验证flag。"

难道和金币有关系?

金币的英文 coin,通过文件名找到 coin.gdc

extends Area2D

@onready var game_manager: Node = %GameManager
@onready var animation_player: AnimationPlayer = $AnimationPlayer

func _on_body_entered(body: Node2D) -> void :
    game_manager.add_point()
    animation_player.play("pickup")

这里调用了 add_point() 这个方法,去找找 game_manager 这个类。

根据文件名可以直接定位(game_manager,.gdc),其代码:

extends Node

@onready var fan = $"../Fan"

var score = 0

func add_point():
    score += 1
    if score == 1:
        Flag.key = Flag.key.replace("A", "B")
        fan.visible = true

拨云见日,原来当金币+1的时候,就会将 Flag.key 中的"A"都换成"B",那么吃完所有的金币 score 值必然大于等于 1,换言之 key 必然变成:

FanBglFanBglOoO!

五、获得 Flag

修改脚本:

from Crypto.Cipher import AES

key = b"FanBglFanBglOoO!"
ciphertext = "d458af702a680ae4d089ce32fc39945d"

cipher = AES.new(key,AES.MODE_ECB)
ciphertext = bytes.fromhex(ciphertext)
plaintext = cipher.decrypt(ciphertext)
print(plaintext)

输出:

┌──(penv)─(zyf㉿zhengyifeng)-[~/Templates]
└─$ python decode.py
b'wOW~youAregrEaT!'

成功!

六、启发

  1. 看到校验逻辑时,永远问"这个关键变量在运行时会被改吗"。静态初始值不等于运行时实际值。
  2. 工具选择依赖目标识别,目标识别先于工具选择。不知道目标是什么之前,用最轻量的侦察手段,而不是上最重的工具。

文章来源:https://www.cnblogs.com/youdiscovered1t/p/19808719
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:jacktools123@163.com进行投诉反馈,一经查实,立即删除!

标签:

相关文章

本站推荐

标签云