8kSec FreeFallGame¶
Description: Experience the thrill of FreeFall, an addictive iOS ball game that challenges your reflexes and precision! Navigate a fast-moving ball through obstacles using intuitive paddle controls and all under a 60-second time limit.
Link: https://academy.8ksec.io/course/ios-application-exploitation-challenges
Install an IPA file can be difficult. So, for make it more easy, I made a YouTube video with the process using Sideloadly. LINK: https://www.youtube.com/watch?v=YPpo9owRKGE
Once you have the app installed, let's proceed with the challenge. unzip the .ipa file.
Recon¶
We are facing an arcade game, which if we look inside Payload/Runner.app we can notice that we are dealing with a flutter app.
So, in first place, we need use reFlutter.
reFlutter is a tool for decompiling and rebuilding Flutter apps:
-  
The Flutter app on iOS compiles the entire Dart file into the App.framework/App (Mach-O binary).
 -  
What reFlutter does is extract and disassemble those sections, reconstructing the Dart AOT snapshots into readable Dart pseudo-code.
 -  
It allows you to reverse engineer Flutter logic into an API, just like on Android: internal paths, validations, strings, endpoints, debug flags, etc.
 -  
The difference is that on iOS, the binary is in Mach-O, not in ELF like Android (
libapp.so). 
Install reFlutter using pip 
.ipa file: release.RE.ipa Install using sideloadly the new file (uninstalling the original version before).
And then, using ssh on our iPhone will need find the dump.dart file. 
iPhoneHack:/var/mobile/Containers/Data/Application root# ls -l /var/mobile/Containers/Data/Application/
FreeFallGame:  In my case, the UUID is C402751A-E920-41A5-8EEF-2DE3BF9A4007. Inside of Documents directory, you will find the dump.dart file: 
iPhoneHack:/var/mobile/Containers/Data/Application/C402751A-E920-41A5-8EEF-2DE3BF9A4007/Documents root# ls
dump.dart  freefallgame.db  security_tokens.db
Let's use scp for file transfer: 
scp root@192.168.0.248:/var/mobile/Containers/Data/Application/C402751A-E920-41A5-8EEF-2DE3BF9A4007/Documents/dump.dart .
dump.dart looking for methods and classes that we can use for complete the challenge. I found the method submitScore from GameEngine class. 
{"method_name":"submitScore","offset":"0x0000000000171f10","library_url":"package:freefallgame\/game_engine.dart","class_name":"GameEngine"}
Also, among the important Flutter functions:
-  
Leaderboard.submitScore(score) -  
GameEngine._score -  
GameEngine._submitScore()— relevant, seems to be the final internal -  
LeaderboardDatabase.insert()— SQL logic with token and score -  
Flutter bindings:
FlutterMethodCall,FlutterMethodChannel 
This pattern suggests a Flutter → ObjC → SQLite → Backend encapsulation.
Anyway, I decide work with +[FlutterMethodCall methodCallWithMethodName:arguments:].
-  
The
+indicates that it's a class method (not an instance method). That is, it can be called directly on theFlutterMethodCallclass and not on a created object. -  
The
methodCallWithMethodName:arguments:signature is a constructor/factory method that creates an instance ofFlutterMethodCallfrom:-  
methodName→ the name of the Flutter method being called. -  
arguments→ the associated arguments. 
 -  
 
So, in the practice:
-  
Flutter uses
FlutterMethodChannelwhen communicating between Dart and iOS (Objective-C/Swift). -  
Each invocation on the Dart side is translated into a
FlutterMethodCallobject on iOS. -  
This class method constructs that object, packaging the name and args in a form that the
MethodChannelcan dispatch to the native handler. 
So, when you trace it with Frida, hooking +[FlutterMethodCall methodCallWithMethodName:arguments:] lets you see all the calls Flutter sends to the native iOS side: the method names and their raw arguments.
It's a strategic hook point for inspecting or manipulating the communication layer between Dart and the native bridge.
Let's hook!
frida-trace -U -f com.eightksec.freefallgame.YX4C7J2RLK -m '+[FlutterMethodCall methodCallWithMethodName:arguments:]'
And, a file is autogenerated by Frida:
__handlers__/FlutterMethodCall/methodCallWithMethodName_arguments_.js
Let's modify that for get more context and information:
Just modify the methodCallWithMethodName_arguments_.js file and put this JavaScript code: 
defineHandler({
  onEnter: function (log, args, state) {
    try {
      var methodName = new ObjC.Object(args[2]).toString();   // NSString*
      var dict       = new ObjC.Object(args[3]);              // NSDictionary*
      log("method=" + methodName);
      log("args=" + dict.toString());
      var arr = dict.objectForKey_("arguments");
      if (arr) log("arguments=" + arr.toString());
    } catch (e) { log("ERR " + e); }
  },
  onLeave: function (log, retval, state) { }
});
Now if we running again the frida-trace command, when you submit the score: 
/* TID 0x6a07 */
 92185 ms  method=openDatabase
 92185 ms  args={
    path = "/var/mobile/Containers/Data/Application/A67DB2A3-E846-40B5-AA10-B6B60B75254B/Documents/security_tokens.db";
    singleInstance = 1;
}
 92188 ms  method=query
 92188 ms  args={
    id = 3;
    sql = "PRAGMA user_version";
}
 92190 ms  method=query
 92190 ms  args={
    arguments =     (
        1758461569262
    );
    id = 3;
    sql = "SELECT * FROM tokens WHERE expiry > ? LIMIT 1";
}
 92190 ms  arguments=(
    1758461569262
)
           /* TID 0x32f3 */
 92192 ms  method=closeDatabase
 92192 ms  args={
    id = 3;
}
 92196 ms  method=openDatabase
 92196 ms  args={
    path = "/var/mobile/Containers/Data/Application/A67DB2A3-E846-40B5-AA10-B6B60B75254B/Documents/security_tokens.db";
    singleInstance = 1;
}
 92197 ms  method=query
 92197 ms  args={
    id = 4;
    sql = "PRAGMA user_version";
}
 92198 ms  method=query
 92198 ms  args={
    arguments =     (
        1758461569270
    );
    id = 4;
    sql = "SELECT * FROM tokens WHERE expiry > ? LIMIT 1";
}
 92198 ms  arguments=(
    1758461569270
)
 92200 ms  method=closeDatabase
 92200 ms  args={
    id = 4;
}
 92201 ms  method=openDatabase
 92201 ms  args={
    path = "/var/mobile/Containers/Data/Application/A67DB2A3-E846-40B5-AA10-B6B60B75254B/Documents/security_tokens.db";
    singleInstance = 1;
}
 92202 ms  method=query
 92202 ms  args={
    id = 5;
    sql = "PRAGMA user_version";
}
 92203 ms  method=query
 92203 ms  args={
    arguments =     (
        5d9670b4e79f841111dec8ba52d1e33f0053c9c277b4692b8f8facd780727208,
        1758461569275
    );
    id = 5;
    sql = "SELECT * FROM tokens WHERE token = ? AND expiry > ?";
}
 92203 ms  arguments=(
    5d9670b4e79f841111dec8ba52d1e33f0053c9c277b4692b8f8facd780727208,
    1758461569275
)
 92205 ms  method=closeDatabase
 92205 ms  args={
    id = 5;
}
 92206 ms  method=insert
 92206 ms  args={
    arguments =     (
        test2,
        720,
        1758461569278,
        5d9670b4e79f841111dec8ba52d1e33f0053c9c277b4692b8f8facd780727208
    );
    id = 2;
    sql = "INSERT INTO leaderboard (name, score, timestamp, token) VALUES (?, ?, ?, ?)";
}
 92206 ms  arguments=(
    test2,
    720,
    1758461569278,
    5d9670b4e79f841111dec8ba52d1e33f0053c9c277b4692b8f8facd780727208
)
           /* TID 0x6a07 */
 92216 ms  method=query
 92216 ms  args={
    id = 2;
    sql = "SELECT * FROM leaderboard";
}
           /* TID 0x103 */
 92218 ms  method=read
 92218 ms  args={
    key = "db_encryption_key";
    options =     {
        accessibility = unlocked;
        accountName = "flutter_secure_storage_service";
        synchronizable = false;
    };
}
 92220 ms  method=write
 92220 ms  args={
    key = "db_signature";
    options =     {
        accessibility = unlocked;
        accountName = "flutter_secure_storage_service";
        synchronizable = false;
    };
    value = 6064ad2b75dfa8144654071ef2149e313d2a93df6ed2c47c89963152d3fe0545;
}
 92228 ms  method=TextInput.clearClient
 92228 ms  args=nil
 92228 ms  ERR TypeError: not a function
 92229 ms  method=TextInput.hide
 92229 ms  args=nil
 92229 ms  ERR TypeError: not a function
 92234 ms  method=TextInputClient.onConnectionClosed
 92234 ms  args=(
    0
)
 92234 ms  ERR TypeError: not a function
           /* TID 0x32f3 */
 92244 ms  method=query
 92244 ms  args={
    id = 2;
    sql = "SELECT * FROM leaderboard ORDER BY score DESC LIMIT 20";
}
We can see that the application performs a series of SQL operations with the files we previously found in the Documents directory (where we extracted the dump.dart).
Score flow¶
- The player taps the screen to keep the ball in the air.
 - Each collision with an obstacle adds X point.
 - The 
GameEnginemaintains a_scoreproperty, which is incremented locally. - When the* game ends*, the 
_submitScore()function is called. - The score information is serialized and sent as a 
FlutterMethodCallwith the insert method. - In Objective-C (iOS), this method calls 
+[FlutterMethodCall methodCallWithMethodName:arguments:]. - An SQL call is constructed with the following parameters: Where score is index 1 of the "arguments" array.
 
In the frida-server output we can check that: 
92206 ms  method=insert
 92206 ms  args={
    arguments =     (
        test2,
        720,
        1758461569278,
        5d9670b4e79f841111dec8ba52d1e33f0053c9c277b4692b8f8facd780727208
    );
    id = 2;
    sql = "INSERT INTO leaderboard (name, score, timestamp, token) VALUES (?, ?, ?, ?)";
}
 92206 ms  arguments=(
    test2,
    720,
    1758461569278,
    5d9670b4e79f841111dec8ba52d1e33f0053c9c277b4692b8f8facd780727208
)
PoC¶
What's the objective? Intercept +[FlutterMethodCall methodCallWithMethodName:arguments:] and overwrite the score value before it is serialized to SQLite.
'use strict';
const NSNumber = ObjC.classes.NSNumber;
const NSMutableArray = ObjC.classes.NSMutableArray;
const FlutterMethodCall = ObjC.classes.FlutterMethodCall;
const method = FlutterMethodCall["+ methodCallWithMethodName:arguments:"];
Interceptor.attach(method.implementation, {
  onEnter(args) {
    try {
      const methodName = ObjC.Object(args[2]).toString();
      if (methodName !== "insert") return;
      const originalDict = ObjC.Object(args[3]);
      const modifiedArgs = NSMutableArray.array();
      const values = originalDict.objectForKey_("arguments");
      for (let i = 0; i < values.count(); i++) {
        const item = values.objectAtIndex_(i);
        modifiedArgs.addObject_(i === 1 ? NSNumber.numberWithInt_(13371337) : item);
      }
      const mutable = originalDict.mutableCopy();
      mutable.setObject_forKey_(modifiedArgs, "arguments");
      args[3] = mutable;
    } catch (_) {}
  }
});
So, Its method + methodCallWithMethodName:arguments: is invoked just before the "insert" method call.
-  
We intercept this method to:
-  
Read the original dictionary (
NSDictionary). -  
Replace index 1 (score) with
NSNumber.numberWithInt_(13371337). -  
Inject the modified dictionary into
args[3]. 
 -  
 
This happens before any serialization, meaning the DB and backend receive a valid but altered score.
-  
NSDictionary/NSMutableDictionary: Foundation's immutable/mutable dictionary. -  
NSMutableArray: Objective-C dynamic array. -  
NSNumber: Wrapper for primitive types such as int, float, etc. Necessary to maintain type integrity when injecting the new score. -  
ObjC.Object(args[n]): Frida API that allows you to convert a native pointer to an Objective-C object for inspection or modification. 
So, launch the app with the PoC script:
Tap play and just wait, then, put your name for leaderboard and see the13371337 score! You can also set -1 as score, just because 8kSec mention as goal:
- submit arbitrary scores that would be impossible to achieve through normal play
 
I hope you found it useful (:

