Game Hacking - Godot

Hello you
On May 17th and 18th, Bsides Amman took place, and many exciting events happened and one of them was the game hacking village, many people requested a write up for this challenge and here it is :)
The event was like the following 10-15 minutes session then you’ll have the rest of the hour to find the 4 hidden flags, and most of the players were given one of these binaries
In the session I tried to emphasize on the engine specific attacks
part
Now, I’ll start working on the Linux binary BsidesAmman.x86_64
let’s do some basic static and dynamic analysis on the file:
Once we ran it from the terminal, it became clear that it was built using the Godot game engine
There is many other ways to know what engine the game is built in, and here is another way to do so:
Here we get the strings off of the game and use grep
to find words related to engines
strings BsidesAmman.x86_64 | grep -Ei 'unity|unreal|godot|cryengine|frostbite|source\sengine|id\sTech|gamebryo|rpg\s?maker|construct\s?3?|game\s?maker|lumberyard|defold|renpy|libgdx|phaser|panda3d|ogre3d|monogame|cocos2d|irrlicht|love2d|jmonkey|urho3d|strider|bge|gamestudio|haxe(flixel|punk)?|gdevelop'
Now that we know the game engine let’s check if there is any reverse engineering tool related to it, doing so you’ll find many tools that can do that, and one of them is the GDRETools
which we’ll be using in this write up
Let’s what’s this tool about, FULL PROJECT RECOVERY :0, that’s a huge thing you know
If you go through the tool github page you’ll get an idea on how it works and how to recover a project, which what we’ll be doing
We start the GDRE
tool and recover the binary that we were provided with, which will result in this output
Now we can extract the project with the full recovery
option enabled
Here we’ll be able to see all resources of the game:
if we check the scripts folder we’ll see that most of the scripts are obfuscated, however the game_manager.gdc
isn’t
Here we see that if the player score is 1000 it will access a file and decrypt it using the key: 529ca8050a00180790cf88b63468826a
, then it’ll get the text from it and show it for period of time till the timer finishes
Other scripts like the killzone.gdc & player.gdc
are obfuscated
Now that we have the full project exported we can import the project to the game engine itself :)
Godot is a really light weight engine, especially compared to engines like Unity or Unreal Engine. The engine itself is less than 100 MG
Once we install the Godot engine we can import a project, and here we can make use of the exported project from GDRE
By importing the game into Godot, you gain the same level of access as the original developer had when they created it.
Flag 1 - Scene flag#
knowing that the game is a 2D game, it would be a good idea to go and check the 2D scene of the game.
Once we do so, we’ll see the first flag drawn using purple blocks
BSIDES{ASE3TS_FL4G_1337}
Flag 2 - 1000 coins#
As we can see in the original script it only shows the flag once we have 1000 coins, but now that we have full access we can change the code to whatever we want and run the game based on that
To get the coins flag, we can call the show_flag
function as soon as the game starts by adding the built-in _ready()
function in the GDScript. Within _ready()
, we simply call show_flag()
to print the flag. Additionally, we removed the condition that checks whether the coins are equal to 1000.
Now when we run the game, we get the flag :)
BSIDES{1000_coins_:0_no_w444y}
Flag 3 - Cheat Code#
Going back to the game_manager.gd
we see this obfuscated code:
extends CharacterBody2D
func _B0rY2BDL(x: int) -> int:
return ((x / 333) + 443) ^ 49
func _cXMbdjsi(hex_str: String, xor_key: int) -> String:
var _52vOSxiX = PackedByteArray()
for i in range(_B0rY2BDL(-131202), hex_str.length(), _B0rY2BDL(-130536)):
var _HIWkb7Vg = hex_str.substr(i, _B0rY2BDL(-130536))
var _HuRfN7VQ = _HIWkb7Vg.hex_to_int()
_HuRfN7VQ = (_HuRfN7VQ - _B0rY2BDL(-135531)) & 255
_HuRfN7VQ = _HuRfN7VQ ^ xor_key
_52vOSxiX.append(_HuRfN7VQ)
return _52vOSxiX.get_string_from_ascii()
var _OPGmHj6S = _B0rY2BDL(-122211)
var _BmN378zv = - _B0rY2BDL(-52614)
var _C3yRvKBx = [_B0rY2BDL(-111555), _B0rY2BDL(-123210), _B0rY2BDL(-121545), _B0rY2BDL(-144855), _B0rY2BDL(-117216), _B0rY2BDL(-114552), _B0rY2BDL(-122211), _B0rY2BDL(-143523), _B0rY2BDL(-121878), _B0rY2BDL(-121545), _B0rY2BDL(-117549), _B0rY2BDL(-114552), _B0rY2BDL(-112554), _B0rY2BDL(-142524), _B0rY2BDL(-124542), _B0rY2BDL(-124542), _B0rY2BDL(-114552), _B0rY2BDL(-110223), _B0rY2BDL(-144855), _B0rY2BDL(-120213), _B0rY2BDL(-123210)]
@onready var _URAm0GPh = $AnimatedSprite2D
func _physics_process(delta: float) -> void :
if not is_on_floor():
velocity += get_gravity() * delta
if Input.is_action_just_pressed(_cXMbdjsi("a2a6bcaeb0b0b29da1", _B0rY2BDL(-80586))) and is_on_floor():
velocity.y = _BmN378zv
var _AW167wBW: = Input.get_axis(_cXMbdjsi("a2a6bca9b2b3a1", _B0rY2BDL(-80586)), _cXMbdjsi("a2a6bc9fa6b4a5a1", _B0rY2BDL(-80586)))
if _AW167wBW > _B0rY2BDL(-131202):
_URAm0GPh.flip_h = false
elif _AW167wBW < _B0rY2BDL(-131202):
_URAm0GPh.flip_h = true
if is_on_floor():
if _AW167wBW == _B0rY2BDL(-131202):
_URAm0GPh.play(_cXMbdjsi("a6b1b2a9", _B0rY2BDL(-80586)))
else:
_URAm0GPh.play(_cXMbdjsi("9fa2ab", _B0rY2BDL(-80586)))
else:
_URAm0GPh.play(_cXMbdjsi("a7a2aa9d", _B0rY2BDL(-80586)))
if _AW167wBW:
velocity.x = _AW167wBW * _OPGmHj6S
else:
velocity.x = move_toward(velocity.x, _B0rY2BDL(-131202), _OPGmHj6S)
move_and_slide()
var _JgKwwlid = []
var _gvHjeiQK = [_cXMbdjsi("9fa6b4a5a1", _B0rY2BDL(-80586)), _cXMbdjsi("9fa6b4a5a1", _B0rY2BDL(-80586)), _cXMbdjsi("9fa6b4a5a1", _B0rY2BDL(-80586)), _cXMbdjsi("9fa6b4a5a1", _B0rY2BDL(-80586)), _cXMbdjsi("9fa6b4a5a1", _B0rY2BDL(-80586)), _cXMbdjsi("9fa6b4a5a1", _B0rY2BDL(-80586)), _cXMbdjsi("9fa6b4a5a1", _B0rY2BDL(-80586)), _cXMbdjsi("9fa6b4a5a1", _B0rY2BDL(-80586)), _cXMbdjsi("a9b2b3a1", _B0rY2BDL(-80586)), _cXMbdjsi("a9b2b3a1", _B0rY2BDL(-80586)), _cXMbdjsi("a7a2aa9d", _B0rY2BDL(-80586)), _cXMbdjsi("a7a2aa9d", _B0rY2BDL(-80586)), _cXMbdjsi("9fa6b4a5a1", _B0rY2BDL(-80586)), _cXMbdjsi("a9b2b3a1", _B0rY2BDL(-80586)), _cXMbdjsi("9fa6b4a5a1", _B0rY2BDL(-80586)), _cXMbdjsi("a9b2b3a1", _B0rY2BDL(-80586)), _cXMbdjsi("a7a2aa9d", _B0rY2BDL(-80586)), _cXMbdjsi("a7a2aa9d", _B0rY2BDL(-80586))]
func _input(event):
if event.is_action_pressed(_cXMbdjsi("a2a6bc9fa6b4a5a1", _B0rY2BDL(-80586))):
_Ogo4wHb3(_cXMbdjsi("9fa6b4a5a1", _B0rY2BDL(-80586)))
elif event.is_action_pressed(_cXMbdjsi("a2a6bca9b2b3a1", _B0rY2BDL(-80586))):
_Ogo4wHb3(_cXMbdjsi("a9b2b3a1", _B0rY2BDL(-80586)))
elif event.is_action_pressed(_cXMbdjsi("a2a6bcaeb0b0b29da1", _B0rY2BDL(-80586))):
_Ogo4wHb3(_cXMbdjsi("a7a2aa9d", _B0rY2BDL(-80586)))
func _Ogo4wHb3(action: String):
_JgKwwlid.append(action)
if _JgKwwlid.size() > _gvHjeiQK.size():
_JgKwwlid.pop_front()
if _JgKwwlid == _gvHjeiQK:
_bD6RAqVW()
func _bD6RAqVW():
if _gvHjeiQK == [_cXMbdjsi("9fa6b4a5a1", _B0rY2BDL(-80586)), _cXMbdjsi("9fa6b4a5a1", _B0rY2BDL(-80586)), _cXMbdjsi("9fa6b4a5a1", _B0rY2BDL(-80586)), _cXMbdjsi("9fa6b4a5a1", _B0rY2BDL(-80586)), _cXMbdjsi("9fa6b4a5a1", _B0rY2BDL(-80586)), _cXMbdjsi("9fa6b4a5a1", _B0rY2BDL(-80586)), _cXMbdjsi("9fa6b4a5a1", _B0rY2BDL(-80586)), _cXMbdjsi("9fa6b4a5a1", _B0rY2BDL(-80586)), _cXMbdjsi("a9b2b3a1", _B0rY2BDL(-80586)), _cXMbdjsi("a9b2b3a1", _B0rY2BDL(-80586)), _cXMbdjsi("a7a2aa9d", _B0rY2BDL(-80586)), _cXMbdjsi("a7a2aa9d", _B0rY2BDL(-80586)), _cXMbdjsi("9fa6b4a5a1", _B0rY2BDL(-80586)), _cXMbdjsi("a9b2b3a1", _B0rY2BDL(-80586)), _cXMbdjsi("9fa6b4a5a1", _B0rY2BDL(-80586)), _cXMbdjsi("a9b2b3a1", _B0rY2BDL(-80586)), _cXMbdjsi("a7a2aa9d", _B0rY2BDL(-80586)), _cXMbdjsi("a7a2aa9d", _B0rY2BDL(-80586))]:
var _bMYs18Uy = _5Rwsqv9g(_C3yRvKBx)
print(_cXMbdjsi("cfc0c6d1d2c098", _B0rY2BDL(-80586)) + _bMYs18Uy + _cXMbdjsi("9a", _B0rY2BDL(-80586)))
func _5Rwsqv9g(input_bytes: PackedByteArray) -> String:
var _dfM7MArj = _cXMbdjsi("", _B0rY2BDL(-80586))
for c in input_bytes:
_dfM7MArj += char(c ^ _B0rY2BDL(-127539))
return _gXGLKGpI(_dfM7MArj)
func _gXGLKGpI(text: String) -> String:
var _IgY2PwoE = _cXMbdjsi("", _B0rY2BDL(-80586))
for c in text:
var _1F9heQJH = c.unicode_at(_B0rY2BDL(-131202))
if _1F9heQJH >= _B0rY2BDL(-110223) and _1F9heQJH <= _B0rY2BDL(-111888):
_1F9heQJH = ((_1F9heQJH - _B0rY2BDL(-110223) + _B0rY2BDL(-127539)) % _B0rY2BDL(-133200)) + _B0rY2BDL(-110223)
elif _1F9heQJH >= _B0rY2BDL(-120879) and _1F9heQJH <= _B0rY2BDL(-122544):
_1F9heQJH = ((_1F9heQJH - _B0rY2BDL(-120879) + _B0rY2BDL(-127539)) % _B0rY2BDL(-133200)) + _B0rY2BDL(-120879)
_IgY2PwoE += char(_1F9heQJH)
return _IgY2PwoE
One of the functions took two arguments, hex_str
and xor_key
, and returned a string. To understand what it produced and what it was decoding, we analyzed its output and found that it was responsible for decoding the player’s moves
As we looked further into the code, we found a list of items being used with the function that decodes player moves. To get a better idea of what they represent, we printed out the decoded values.
["right", "right", "right", "right", "right", "right", "right", "right", "left", "left", "jump", "jump", "right", "left", "right", "left", "jump", "jump"]
Now that we have the list of moves cheat code
let’s try it out and see what happens
That’s how we get the third flag
BSIDES{Che4t_c0des_H3ll_Y4ah}
Flag 4 - API Hacking#
Similar to the last challenge we see that the killzone.gd
is using the same obfuscation method
extends Area2D
func _l6prcnNT(hex_str: String, xor_key: int) -> String:
var _CRnrPNKJ = PackedByteArray()
for i in range(0, hex_str.length(), 2):
var _NVrPxb9S = hex_str.substr(i, 2)
var _Ixb6oMkr = _NVrPxb9S.hex_to_int()
_Ixb6oMkr = (_Ixb6oMkr - 21) & 255
_Ixb6oMkr = _Ixb6oMkr ^ xor_key
_CRnrPNKJ.append(_Ixb6oMkr)
return _CRnrPNKJ.get_string_from_ascii()
@onready var _UtsO8nIF = $Timer
var _RUH8n2EC: = _l6prcnNT("202c2c286e61616b666266666a626766686266656f61251521261b", 99)
var _FGKWO9vG: = _l6prcnNT("6a1b706668166c671b1b1a6b6c156b15676a1c1b681b69651770701568166c1c", 99)
func _on_body_entered(body: Node2D) -> void :
Engine.time_scale = 0.5
_UtsO8nIF.start()
var _cv8ZfJ8N = $"../GameManager/ScoreLabel"
var _ebUGIyEM = int(_cv8ZfJ8N.text.split(_l6prcnNT("58", 99))[-1])
_j0dhP8Z8(_ebUGIyEM)
func _j0dhP8Z8(score: int) -> void :
var _qMI9VSNS: = HTTPRequest.new()
add_child(_qMI9VSNS)
var _uDuXfrsd: = Time.get_time_dict_from_system()
var _Ub5mFaV4: = Time.get_datetime_dict_from_system()
var _fT1bFAp1: = _l6prcnNT("5b686c1c635b68661c635b68661c4c5b68661c6e5b68661c6e5b68661c4e", 99) % [_Ub5mFaV4.year, _Ub5mFaV4.month, _Ub5mFaV4.day, _uDuXfrsd.hour, _uDuXfrsd.minute, _uDuXfrsd.second]
var _FRZsYafQ: = {_l6prcnNT("2c1f231b252c172328", 99): _fT1bFAp1, _l6prcnNT("251521261b", 99): score}
var _Bk7vAwnP = [_l6prcnNT("3521222c1b222c634c2f281b6e58172828241f15172c1f2122611e252122", 99), _l6prcnNT("506337483f633d3b4f6e585b25", 99) % _FGKWO9vG]
var _tK13SJJw = JSON.stringify(_FRZsYafQ)
_qMI9VSNS.request(_RUH8n2EC, _Bk7vAwnP, HTTPClient.METHOD_POST, _tK13SJJw)
func _on_timer_timeout() -> void :
Engine.time_scale = 1.0
get_tree().reload_current_scene()
Let’s use our favorite function _ready()
to decode all the decoded values in this code:
Doing so we see a URL and what looks like a possible API key. By examining the function more closely, it becomes clear that it sends a POST
request to update the user’s score on a server every time they enter the kill zone area.
--- Kill zone decoded values ---
http://52.226.120.239/score
6e820b41eef54c5c16de0e73a88c0b4d
%04d-%02d-%02dT%02d:%02d:%02dZ
timestamp
score
Content-Type: application/json
X-API-KEY: %s
When we try connecting to the server, we see that it requires an API key. From the previous step, we noticed that the header X-API-KEY
was being used, so let’s try using it with the possible key we found.
And yeah that was the last flag
bsides{h4rd_c0d3d_4P1_k3ys}
This challenge had it all, reverse engineering, cheat codes, API keys, and a whole lot of Godot fun. Big thanks to everyone who came through the Game Hacking Village at Bsides Amman.
here you can find the binary for the game, gl&hf.
Catch you in the next one 👾