Reverse Engineering an iOS Flutter Game: Bypassing FreeFall's Scoring System
Introduction
Reverse engineering an iOS application can feel like solving a puzzle, especially when it’s built with a cross-platform framework like Flutter. In this post, I’ll guide you through the process of reverse engineering FreeFall, an addictive iOS game from 8ksec iOS Application Exploitation Challenges, to bypass its scoring system and submit impossibly high scores. As someone passionate about iOS security and reverse engineering, I’m excited to share this journey in a way that’s approachable for beginners while offering enough depth for seasoned engineers.
FreeFall is a fast-paced ball game where players navigate obstacles using paddle controls under a tight 60-second time limit. The goal is to earn points by destroying obstacles and advancing through difficulty levels, ultimately climbing the leaderboard. The catch? The game claims to have a “secure, cheat-proof scoring” system. Our task is to bypass this validation to submit arbitrary scores that would be impossible through normal gameplay. To do this, we’ll use powerful tools like reFlutter, IDA, LLDB, and Frida, each playing a critical role in unraveling the app’s internals.
Understanding the Challenge
Before we jump into the technical details, let’s break down the objective. FreeFall challenges players to achieve high scores by skillfully navigating a ball through obstacles. The game’s scoring system is designed to prevent cheating, ensuring only legitimate scores make it to the leaderboard. However, our goal is to manipulate the score submission process to post an outrageously high score, like 99,999,999, that no player could realistically achieve. Since FreeFall is built with Flutter, a cross-platform framework, we’ll need to adapt traditional iOS reverse engineering techniques to account for Flutter’s unique architecture. Let’s start by examining the app’s structure.
Identifying the App as a Flutter Application
To begin, we need to understand the app’s composition. An iOS app is distributed as an IPA file, which is essentially a zipped archive containing the application’s binary and resources. For those new to iOS, think of an IPA file as a container that holds everything the app needs to run on your device. Unzipping the FreeFall IPA reveals the following structure:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
$ cd 11-FreeFall/Payload/Runner.app
$ ls -1
_CodeSignature
AppFrameworkInfo.plist
AppIcon60x60@2x.png
AppIcon76x76@2x~ipad.png
Assets.car
Base.lproj
embedded.mobileprovision
Frameworks
Info.plist
PkgInfo
Runner
$ ls -1 Frameworks
App.framework
flutter_secure_storage.framework
Flutter.framework
libswiftCore.dylib
libswiftCoreAudio.dylib
libswiftCoreFoundation.dylib
libswiftCoreGraphics.dylib
libswiftCoreImage.dylib
libswiftCoreMedia.dylib
libswiftDarwin.dylib
libswiftDispatch.dylib
libswiftFoundation.dylib
libswiftMetal.dylib
libswiftObjectiveC.dylib
libswiftos.dylib
libswiftQuartzCore.dylib
libswiftsimd.dylib
libswiftUIKit.dylib
path_provider_foundation.framework
shared_preferences_foundation.framework
sqflite_darwin.framework
Notice the Runner
binary and frameworks like App.framework
and Flutter.framework
. These are signs that FreeFall is built with Flutter, Google’s open-source SDK for creating apps across mobile, web, and desktop from a single codebase. For newcomers, Flutter allows developers to write code in Dart, a programming language, which is then compiled into native machine code for iOS using Ahead-of-Time (AOT) compilation. This process, known as AOT compilation, converts Dart code into platform-specific machine code before the app runs, making it fast but challenging to analyze statically. The Dart code runs inside a Dart Virtual Machine (VM), provided by the Flutter Engine, which we’ll explore next.
Understanding Flutter’s Architecture
To reverse engineer FreeFall, we need to grasp Flutter’s architecture, as shown below:
Flutter apps consist of three main components:
- Framework: Written in Dart, this contains the app’s logic and user interface, stored in
App.framework
. - Engine: A platform-specific runtime that hosts the Dart VM, found in
Flutter.framework
. It translates Dart code into native instructions. - Embedder: A thin layer that integrates the Engine with the target platform (iOS, in this case).
When a Flutter app is compiled, the Engine uses AOT compilation to produce an AppSnapshot, a precompiled bundle of machine code that includes both the Framework and the developer’s Dart code. This makes static analysis—examining the binary without running it—tricky, as the compiled code is obfuscated and lacks clear symbols. To overcome this, we’ll use reFlutter, a specialized tool for Flutter reverse engineering.
Using reFlutter for Dynamic Analysis
Static analysis of Flutter binaries is challenging, so we turn to reFlutter, a powerful tool designed to simplify Flutter app reverse engineering. reFlutter uses a patched version of the Flutter library to modify the snapshot deserialization process, enabling dynamic analysis by dumping runtime information. Let’s see how it works:
1
2
3
4
5
6
7
8
$ reflutter -p 1-FreeFall.ipa
[*] Processing...
SnapshotHash: d91c0e6f35f0eb2e44124e8f42aa44a7
The resulting ipa file: ./release.RE.ipa
Please sign & install the ipa file.
Configure Potatso (iOS) to use your Burp Suite proxy server.
Running the reflutter
command processes the IPA and generates a modified version, release.RE.ipa
. We then resign and install this modified app on a jailbroken iOS device using ios-deploy
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ ios-deploy --bundle Payload/Runner.app
[....] Waiting for iOS device to be connected
...
[ 52%] CreatingStagingDirectory
[ 57%] ExtractingPackage
[ 60%] InspectingPackage
[ 65%] PreflightingApplication
[ 70%] VerifyingApplication
[ 75%] CreatingContainer
[ 80%] InstallingApplication
[ 85%] PostflightingApplication
[ 90%] SandboxingApplication
[ 95%] GeneratingApplicationMap
[100%] InstallComplete
[100%] Installed package Payload/Runner.app
Once installed, we run the app and check the device logs using macOS’s Console.app
. The patched app creates a dump.dart
file in the app’s sandbox directory:
1
/private/var/mobile/Containers/Data/Application/DFFD772E-29E4-4127-BC29-9168828313DE/Documents/dump.dart
This file is a goldmine for reverse engineers, containing runtime information about the app’s structure.
Analyzing dump.dart
To access dump.dart
, we copy it from the device using scp
:
1
2
3
$ scp -P 2222 ip8:/private/var/mobile/Containers/Data/Application/DFFD772E-29E4-4127-BC29-9168828313DE/Documents/dump.dart ./
root@localhost's password:
dump.dart 100% 1762KB 31.5MB/s 00:00
Opening dump.dart
in a text editor reveals JSON-like objects, such as:
1
{"method_name":"_showGameOverDialog","offset":"0x00000000001711ac","library_url":"package:freefallgame\/screens\/game_screen.dart","class_name":"_GameScreenState"}
These objects contain valuable details like method names, offsets, class names, and library URLs, which help us understand the app’s structure. However, the file isn’t valid JSON due to missing separators. To make it usable, we need to sanitize it by adding commas between objects and wrapping it in square brackets. This sanitized data can then be used to rename symbols in IDA, a popular disassembler, though this step is optional for our challenge.
Renaming Symbols in IDA (Optional)
While not required to solve the challenge, renaming symbols in IDA can make static analysis easier. The Flutter app’s logic resides in App.framework/App
, but loading this binary into IDA reveals generic function names like sub_XXXXXX
, making it hard to identify key functions:
Figure: App before symbols renaming
Using dump.dart
, we can write an IDA Python script to rename functions based on their offsets relative to the exported symbol _kDartIsolateSnapshotInstructions
(located at 0x000000000000E8C0
in this case):
Figure: Exported _kDartIsolateSnapshotInstructions address
Here’s the script used to rename symbols:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
# IDA_rename_symbols.py
import idaapi
import idautils
import idc
import json
import os
def modify_and_load_json(file_path):
# Read the file content
with open(file_path, 'r') as file:
content = file.read()
# Replace '}{' with '},{'
modified_content = content.replace('}{', '},{')
# Append '[' at start and ']' at end
modified_content = f'[{modified_content}]'
# Load as JSON
json_data = json.loads(modified_content)
return json_data
def get_exported_symbol_address(symbol_name):
"""Retrieve the address of an exported symbol."""
for entry in idautils.Entries():
if entry[3] == symbol_name:
return entry[1]
return None
def rename_functions_from_json(json_data, snapshot_addr):
"""Read JSON array and rename functions based on provided data."""
try:
total_function_renamed = 0
for item in json_data:
method_name = item.get('method_name')
offset = item.get('offset')
class_name = item.get('class_name')
# Skip invalid entries
if not method_name or not offset or not class_name:
continue
# Skip optimized out methods and anonymous closures
if method_name in ['<optimized out>', '<anonymous closure>']:
continue
# Replace :: with DoubleColon for class_name
if class_name == '::':
class_name = 'DoubleColon'
# Replace special characters in method_name
if '=' in method_name:
method_name = method_name.replace('=', 'EqualSign')
if '#' in method_name:
method_name = method_name.replace('#', 'PoundSign')
if '*' in method_name:
method_name = method_name.replace('*', 'StarSign')
if '~' in method_name:
method_name = method_name.replace('~', 'TildeSign')
if '/' in method_name:
method_name = method_name.replace('/', 'SlashSign')
if '<' in method_name:
method_name = method_name.replace('<', 'LessSign')
if '>' in method_name:
method_name = method_name.replace('>', 'GreatSign')
try:
# Convert offset from hex string to integer
offset_int = int(offset, 16)
# Calculate function address
function_addr = snapshot_addr + offset_int
# Get current function name
current_name = idc.get_func_name(function_addr)
if not current_name:
current_name = f"sub_{function_addr:X}"
# Create new function name
new_name = f"{class_name}__{method_name}__{current_name}"
# Rename the function
if idc.set_name(function_addr, new_name, idc.SN_CHECK):
print(f"Renamed function at 0x{function_addr:X} from {current_name} to {new_name}")
total_function_renamed = total_function_renamed + 1
else:
print(f"Failed to rename function at 0x{function_addr:X}")
except ValueError as e:
print(f"Error processing offset {offset}: {str(e)}")
print(f">>>> Total functions renamed: {total_function_renamed}\n\n\n")
except json.JSONDecodeError as e:
print(f"Error decoding JSON file: {str(e)}")
except Exception as e:
print(f"Unexpected error: {str(e)}")
def main():
"""Main function to execute the renaming process."""
# Get the address of _kDartIsolateSnapshotInstructions
snapshotInstructionsAddr = get_exported_symbol_address('_kDartIsolateSnapshotInstructions')
if snapshotInstructionsAddr is None:
print("Error: Could not find _kDartIsolateSnapshotInstructions export symbol")
return
print(f"Found _kDartIsolateSnapshotInstructions at 0x{snapshotInstructionsAddr:X}")
# Path to dump.dart file
dump_dart_file_path = "/Users/xyz/Downloads/8ksec/11-FreeFall/dump.dart"
json_data = modify_and_load_json(dump_dart_file_path)
# Process the JSON data and rename functions
rename_functions_from_json(json_data, snapshotInstructionsAddr)
if __name__ == '__main__':
main()
This script successfully renames 10,734 out of 14,518 functions, making the binary much easier to navigate:
Figure: App after symbols renaming
With clearer function names, we can now focus on finding the logic responsible for score submission.
Locating the Score Submission Logic
Our goal is to manipulate the score submission process, so we search IDA’s Functions tab for the keyword “score” to identify relevant methods:
DatabaseHelper__getTopScores__sub_1586F0 __text 00000000001586F0
DatabaseHelper__insertScore__sub_180058 __text 0000000000180058
GameEngine__submitScore__sub_1807D0 __text 00000000001807D0
GameEngine___increaseScore__sub_1813D8 __text 00000000001813D8
The GameEngine__submitScore
method stands out, as it likely handles the final score submission. By cross-referencing (XREF) this method in IDA, we find the following assembly code where submitScore
is called:
__text:000000000017FF28 LDUR X2, [X1,#0x2F]
__text:000000000017FF2C LDUR X1, [X2,#0x27]
__text:000000000017FF30 MOV X16, X1
__text:000000000017FF34 MOV X1, X2
__text:000000000017FF38 MOV X2, X16
__text:000000000017FF3C BL GameEngine__submitScore__sub_1807D0
The registers X1
and X2
are set just before the call, suggesting they may hold the score. To confirm, we’ll use LLDB, a debugger, to inspect these registers dynamically.
Patching the Score with LLDB
To debug the app, we need a jailbroken iOS device and a debug server. First, we SSH into the device and start debugserver
:
1
2
3
4
5
6
7
$ ssh ip8
root@localhost's password:
iPhone $ debugserver 0.0.0.0:1234 -w Runner
debugserver-@(#)PROGRAM:LLDB PROJECT:lldb-1403.2.3.13
for arm64.
Waiting to attach to process Runner...
Listening to port 1234 for a connection from 0.0.0.0...
Next, on our local machine, we use LLDB to attach to the running FreeFall app:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ lldb
Platform: remote-ios
Connected: no
SDK Path: "/Users/xyz/Library/Developer/Xcode/iOS DeviceSupport/16.4.1 (20E252) arm64e"
SDK Roots: [ 0] "/Users/xyz/Library/Developer/Xcode/iOS DeviceSupport/iPhone10,4 16.7.11 (20H360)"
Process 1783 stopped
* thread #1, stop reason = signal SIGSTOP
...
Target 0: (Runner) stopped.
(lldb) image list -o -f App
[ 0] 0x0000000103adc000 /private/var/containers/Bundle/Application/1E500AEE-EC1D-4809-BC61-C732349A21E4/Runner.app/Frameworks/App.framework/App(0x0000000103adc000)
(lldb) br s -a 0x0000000103adc000+0x17FF3C
Breakpoint 1: where = App`___lldb_unnamed_symbol7158 + 112, address = 0x0000000103c5bf3c
(lldb) continue
Process 1783 resuming
(lldb)
We set a breakpoint at the address where GameEngine__submitScore
is called (0x103c5bf3c
). Playing the game until the “Game Over” screen (showing a score of 563) triggers the breakpoint:
Examining the registers at this point:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
(lldb)
Process 1783 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = breakpoint 1.1
frame #0: 0x0000000103c5bf3c App`___lldb_unnamed_symbol7158 + 112
App`___lldb_unnamed_symbol7158:
-> 0x103c5bf3c <+112>: bl 0x103c5c7d0 ; ___lldb_unnamed_symbol7162
0x103c5bf40 <+116>: mov x1, x0
0x103c5bf44 <+120>: stur x1, [x29, #-0x20]
0x103c5bf48 <+124>: bl 0x103b0d4f8 ; ___lldb_unnamed_symbol828
Target 0: (Runner) stopped.
(lldb) register read
General Purpose Registers:
x0 = 0x000000033e67b861
x1 = 0x000000033541b8a1
x2 = 0x0000000000000233
x3 = 0x000000033e65d8b1
x4 = 0x0000000000000014
x5 = 0x000000034bdddf61
x6 = 0x0000000333f88081
x7 = 0x0000000333f880a1
x8 = 0x0000000334db4251
x9 = 0x903bf274fd6000c6
x10 = 0x0000000000000000
x11 = 0x0000000000000002
x12 = 0x0000000000000002
x13 = 0x0000000000000000
x14 = 0x00000000000007fb
x15 = 0x000000016d9a7900
x16 = 0x0000000000000233
x17 = 0x0000000000000072
x18 = 0x0000000000000000
x19 = 0x000000016d8cf000
x20 = 0x0000000102cda848 Flutter`tonic::FfiDispatcher<void, void (*)(_Dart_Handle*), &flutter::DartRuntimeHooks::ScheduleMicrotask(_Dart_Handle*)>::Call(_Dart_Handle*)
x21 = 0x0000000526108000
x22 = 0x0000000333f88081
x23 = 0x0000000523099e00
x24 = 0x0000000333f88081
x25 = 0x000000016d8cf000
x26 = 0x0000000523099e00
x27 = 0x0000000335380080
x28 = 0x0000000800000000
fp = 0x000000016d9a7940
lr = 0x0000000103c5bf04 App`___lldb_unnamed_symbol7158 + 56
sp = 0x000000016d8cf000
pc = 0x0000000103c5bf3c App`___lldb_unnamed_symbol7158 + 112
cpsr = 0x20000000
(lldb) po $x2
563
The X2
register holds the score (563). To patch it, we modify X2
and resume execution:
1
2
3
4
(lldb) register write x2 99999999
(lldb) continue
Process 1783 resuming
(lldb)
The leaderboard now reflects our hacked score of 99,999,999:
Figure: Hack score leaderboard using LLDB
Success! We’ve bypassed the scoring validation using LLDB. But there’s another approach using Frida for dynamic instrumentation.
Patching the Score with Frida
Frida is a dynamic instrumentation toolkit that allows us to hook into running processes and modify their behavior. We use a modified Frida script to intercept the FlutterMethodCall
class’s methodCallWithMethodName:arguments:
method, which handles communication between Flutter’s Dart code and native iOS code via a Method Channel. A Method Channel is a mechanism that enables Flutter to call native functions, such as database operations. Here’s the script:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
/**
* run the script to a running app: frida -U -F -l flutter_ios.js
*/
// https://codeshare.frida.re/@denizaydemir/ios-proxy-check-bypass/
// https://codeshare.frida.re/@dki/ios-app-info/
// https://gist.github.com/AICDEV/630feed7583561ec9f9421976e836f90
// #############################################
// HELPER SECTION START
var colors = {
"resetColor": "\x1b[0m",
"green": "\x1b[32m",
"yellow": "\x1b[33m",
"red": "\x1b[31m"
}
function logSection(message) {
console.log(colors.green, "#################################################", colors.resetColor);
console.log(colors.green, message, colors.resetColor);
console.log(colors.green, "#################################################", colors.resetColor);
}
function logMessage(message) {
console.log(colors.yellow, "---> " + message, colors.resetColor);
}
function logError(message) {
console.log(colors.red, "---> ERRROR: " + message, colors.resetColor);
}
function getAllClasses() {
var classes = [];
for (var cl in ObjC.classes) {
classes.push(cl);
}
return classes;
}
function filterFlutterClass() {
var matchClasses = [];
var classes = getAllClasses();
for (var i = 0; i < classes.length; i++) {
if (classes[i].toString().toLowerCase().includes('flu')) {
matchClasses.push(classes[i]);
}
}
return matchClasses;
}
function getAllMethodsFromClass(cl) {
return ObjC.classes[cl].$ownMethods;
}
function listAllMethodsFromClasses(classes) {
for (var i = 0; i < classes.length; i++) {
var methods = getAllMethodsFromClass(classes[i]);
for (var a = 0; a < methods.length; a++) {
logMessage("class: " + classes[i] + " --> method: " + methods[a]);
}
}
}
function blindCallDetection(classes) {
for (var i = 0; i < classes.length; i++) {
var methods = getAllMethodsFromClass(classes[i]);
for (var a = 0; a < methods.length; a++) {
var hook = ObjC.classes[classes[i]][methods[a]];
try {
Interceptor.attach(hook.implementation, {
onEnter: function (args) {
this.className = ObjC.Object(args[0]).toString();
this.methodName = ObjC.selectorAsString(args[1]);
logMessage("detect call to: " + this.className + ":" + this.methodName);
}
})
} catch (err) {
logError("error in trace blindCallDetection");
logError(err);
}
}
}
}
function singleBlindTracer(className, methodName) {
try {
var hook = ObjC.classes[className][methodName];
Interceptor.attach(hook.implementation, {
onEnter: function (args) {
this.className = ObjC.Object(args[0]).toString();
this.methodName = ObjC.selectorAsString(args[1]);
logMessage("detect call to: " + this.className + ":" + this.methodName);
}
})
} catch (err) {
logError("error in trace singleBlindTracer");
logError(err);
}
}
// #############################################
//HELPER SECTION END
// #############################################
// BEGIN FLUTTER SECTION
// #############################################
function listAllFlutterClassesAndMethods() {
var flutterClasses = filterFlutterClass();
for (var i = 0; i < flutterClasses.length; i++) {
var methods = getAllMethodsFromClass(flutterClasses[i]);
for (var a = 0; a < methods.length; a++) {
logMessage("class: " + flutterClasses[i] + " --> method: " + methods[a]);
}
}
}
// https://main-api.flutter.dev/ios-embedder/interface_flutter_method_call.html#ad5ec921ce8616518137964e054753ff7
/*
After enable this tracing, once submit the score we can find the log as below:
---> FlutterMethodCall:methodCallWithMethodName:arguments:
---> method: insert
---> args: {
arguments = (
hjj,
945,
1751972579382,
9dd5b3f5ff4d2a9d70c0f09055f62a7dd11ae38eb43b418e147b58e68e3830f0
);
id = 2;
sql = "INSERT INTO leaderboard (name, score, timestamp, token) VALUES (?, ?, ?, ?)";
}
As we can see, the args is the dictionary that contains the key "arguments" with an array of value where second item
in the array is the score.
What we can achieve is that we need to modify this 2nd item to a new score before insert to the database to achieve the goal
*/
const NSString = ObjC.classes.NSString;
const NSNumber = ObjC.classes.NSNumber;
const NSArray = ObjC.classes.NSArray;
const NSMutableArray = ObjC.classes.NSMutableArray;
function traceFlutterMethodCall() {
var className = "FlutterMethodCall"
var methodName = "+ methodCallWithMethodName:arguments:"
var hook = ObjC.classes[className][methodName];
try {
Interceptor.attach(hook.implementation, {
onEnter: function (args) {
this.className = ObjC.Object(args[0]).toString();
this.methodName = ObjC.selectorAsString(args[1]);
logMessage(this.className + ":" + this.methodName);
const method = ObjC.Object(args[2]).toString()
logMessage("method: " + method);
var argsDict = ObjC.Object(args[3])
logMessage("args: " + argsDict.toString());
logMessage("args type: " + argsDict.class().toString());
var argsKeys = argsDict.allKeys();
var isInsertMethod = false
// this array will contain modified score (2nd item)
var modifiedSqlInsertItemsArray = NSMutableArray.array();
for (var i = 0; i < argsKeys.count(); i++) {
var key = argsKeys.objectAtIndex_(i);
var value = argsDict.objectForKey_(key);
logMessage(key + ":" + value)
// only handle if this is the call to insert score to database
if (method == "insert" && key == "arguments") {
isInsertMethod = true
// as we observed after printing key "arguments" it is an array, hence we can interate to find the 2nd item in the array to modify
const count = value.count().valueOf();
for (let i = 0; i !== count; i++) {
const element = value.objectAtIndex_(i);
// found score item at 2nd index (based-zero)
if (i == 1) {
// modify score to impossible number
const modifiedScoreObj = NSNumber.numberWithInt_(88888888)
modifiedSqlInsertItemsArray.addObject_(modifiedScoreObj)
} else {
modifiedSqlInsertItemsArray.addObject_(element)
}
}
// console.log("value of arguments type: ", value.class().toString())
}
}
// Now we check if it's insert method, then modify the args
if (isInsertMethod) {
// copy existing args to mutable dictionary
var mutableDict = ObjC.Object(args[3]).mutableCopy()
// update "arguments" item to modified array which include modified new score
mutableDict.setObject_forKey_(modifiedSqlInsertItemsArray, "arguments");
// update original args
args[3] = mutableDict
// Logging new modified args
console.log("Modified args: ", mutableDict.toString())
}
}
})
} catch (err) {
logError("error in trace FlutterMethodCall");
logError(err);
}
}
// https://api.flutter.dev/objcdoc/Classes/FlutterMethodChannel.html#/c:objc(cs)FlutterMethodChannel(im)invokeMethod:arguments:
function traceFlutterMethodChannel() {
var className = "FlutterMethodChannel"
var methodName = "- setMethodCallHandler:"
var hook = ObjC.classes[className][methodName];
try {
Interceptor.attach(hook.implementation, {
onEnter: function (args) {
this.className = ObjC.Object(args[0]).toString();
this.methodName = ObjC.selectorAsString(args[1]);
logMessage(this.className + ":" + this.methodName);
logMessage("method: " + ObjC.Object(args[2]).toString());
}
})
} catch (err) {
logError("error in trace FlutterMethodChannel");
logError(err);
}
}
// enum function from defined classes
function inspectInteresingFlutterClasses(classes) {
logSection("START BLIND TRACE FOR SPECIFIED METHODS");
for (var i = 0; i < classes.length; i++) {
logMessage("inspect all methods from: " + classes[i]);
var methods = getAllMethodsFromClass(classes[i]);
for (var a = 0; a < methods.length; a++) {
logMessage("method --> " + methods[a]);
blindTraceWithPayload(classes[i], methods[a]);
}
}
}
function blindTraceWithPayload(className, methodName) {
try {
var hook = ObjC.classes[className][methodName];
Interceptor.attach(hook.implementation, {
onEnter: function (args) {
this.className = ObjC.Object(args[0]).toString();
this.methodName = ObjC.selectorAsString(args[1]);
logMessage(this.className + ":" + this.methodName);
logMessage("payload: " + ObjC.Object(args[2]).toString());
},
})
} catch (err) {
logError("error in blind trace");
logError(err);
}
}
// #############################################
// END FLUTTER SECTION
// #############################################
/**
* check if a method in the specified class get called
*/
logSection("BLIND TRACE NATIVE FUNCTION");
var blindCallClasses = [
"FlutterStringCodec",
]
blindCallDetection(blindCallClasses);
/**
* List found flutter classes and there methods
*/
logSection("SEARCH ALL FLUTTER CLASSES AND METHODS");
listAllFlutterClassesAndMethods();
/**
* define custom class for further investigation. be careful: it calls blindTraceWithPayload logMessage("payload: " + ObjC.Object(args[2]).toString());
* If you are not sure if the arg[2] is present read the function docs or do some try catch
*/
var interestingFlutterClasses = [
//https://api.flutter.dev/objcdoc/Protocols/FlutterMessageCodec.html#/c:objc(pl)FlutterMessageCodec(im)encode:
"FlutterJSONMessageCodec",
//https://api.flutter.dev/objcdoc/Protocols/FlutterMethodCodec.html
"FlutterJSONMethodCodec",
//"FlutterStandardReader",
//https://api.flutter.dev/objcdoc/Classes/FlutterEventChannel.html
"FlutterEventChannel",
//https://api.flutter.dev/objcdoc/Classes/FlutterViewController.html
//"FlutterViewController",
//https://api.flutter.dev/objcdoc/Classes/FlutterBasicMessageChannel.html
"FlutterBasicMessageChannel",
]
// inspectInteresingFlutterClasses(interestingFlutterClasses)
/**
* trace implementation for
* https://api.flutter.dev/objcdoc/Classes/FlutterMethodCall.html
* https://api.flutter.dev/objcdoc/Classes/FlutterMethodChannel.html
*/
logSection("TRACING FLUTTER BEHAVIOUR");
traceFlutterMethodCall();
// traceFlutterMethodChannel();
// logSection("SINGLE BLIND TRACING");
// singleBlindTracer("FlutterObservatoryPublisher","- url")
Running the script with Frida:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
$ frida -U -F -l modified_flutter_ios.js
____
/ _ | Frida 17.2.11 - A world-class dynamic instrumentation toolkit
| (_| |
> _ | Commands:
/_/ |_| help -> Displays the help system
. . . . object? -> Display information about 'object'
. . . . exit/quit -> Exit
. . . .
. . . . More info at https://frida.re/docs/home/
. . . .
. . . . Connected to iPhone (id=xxx)
---> FlutterMethodCall:methodCallWithMethodName:arguments:
---> method: insert
---> args: {
arguments = (
"frida score",
677,
1758016244782,
48d8816dca7093011c8941506976ca1e2af4e6c75a84b6c5c9d17aae193adeb7
);
id = 2;
sql = "INSERT INTO leaderboard (name, score, timestamp, token) VALUES (?, ?, ?, ?)";
}
---> args type: __NSDictionaryM
---> sql:INSERT INTO leaderboard (name, score, timestamp, token) VALUES (?, ?, ?, ?)
---> arguments:(
"frida score",
677,
1758016244782,
48d8816dca7093011c8941506976ca1e2af4e6c75a84b6c5c9d17aae193adeb7
)
---> id:2
Modified args: {
arguments = (
"frida score",
88888888,
1758016244782,
48d8816dca7093011c8941506976ca1e2af4e6c75a84b6c5c9d17aae193adeb7
);
id = 2;
sql = "INSERT INTO leaderboard (name, score, timestamp, token) VALUES (?, ?, ?, ?)";
}
The script intercepts the insert
method call, modifies the score in the arguments
array to 88,888,888 and updates the leaderboard:
Figure: Hack score leaderboard using Frida
Conclusion
Reverse engineering FreeFall was a rewarding challenge that showcased the power of combining tools like reFlutter, IDA, LLDB, and Frida. By leveraging reFlutter’s dynamic analysis capabilities, we extracted critical runtime information. LLDB allowed us to pinpoint and patch the score in memory, while Frida offered a dynamic way to intercept and modify method calls. This approach avoided deep dives into Flutter’s Dart VM internals, making it accessible yet effective. I hope this guide inspires you to explore iOS reverse engineering with curiosity and confidence!
Figure: 8ksec challenge feedback