Post

FreeFall - 8kSec


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. Earn bonus points for destroying obstacles and advancing difficulty levels, and climb the competitive leaderboard. With realistic physics and secure, cheat-proof scoring, only the best rise to the top.


Objective:

  • Create a runtime manipulation attack that exploits the FreeFall game to achieve impossibly high scores on the leaderboard without legitimate gameplay.
  • Your goal is to bypass the game’s scoring validation mechanisms and submit arbitrary scores that would be impossible to achieve through normal play.


Restrictions:

You must perform runtime manipulation to change how the app behaves.


Explore the application

The application is a timed arcade game featuring paddle-based ball control. Players aim to destroy obstacles and accumulate points within a 60-second countdown while progressing through increasing difficulty levels.




When the 60 seconds are up, the game ends, calculates the player’s score, and prompts them to enter their name for the leaderboard.



The leaderboard displays all players along with their scores.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
└─# objection -g  com.eightksec.freefallgame.J8L462KYQ8 explore
Checking for a newer version of objection...
Using USB device `iOS Device`
Agent injected and responds ok!

     _   _         _   _
 ___| |_|_|___ ___| |_|_|___ ___
| . | . | | -_|  _|  _| | . |   |
|___|___| |___|___|_| |_|___|_|_|
      |___|(object)inject(ion) v1.11.0

     Runtime Mobile Exploration
        by: @leonjza from @sensepost

[tab] for command suggestions
...htksec.freefallgame.J8L462KYQ8 on (iPhone: 16.0) [usb] # env

Name               Path
-----------------  -------------------------------------------------------------------------------------------
BundlePath         /private/var/containers/Bundle/Application/42032971-6585-4DAB-8B95-7A119959D6B0/Runner.app
CachesDirectory    /var/mobile/Containers/Data/Application/8A0D7292-BFF7-407B-88B1-D25E95396A5C/Library/Caches
DocumentDirectory  /var/mobile/Containers/Data/Application/8A0D7292-BFF7-407B-88B1-D25E95396A5C/Documents
LibraryDirectory   /var/mobile/Containers/Data/Application/8A0D7292-BFF7-407B-88B1-D25E95396A5C/Library


The player’s name and score are saved in a SQLite database file named freefallgame.db, located in the app’s Documents directory.

1
2
3
4
iPhone:/var/mobile/Containers/Data/Application/8A0D7292-BFF7-407B-88B1-D25E95396A5C/Documents root# pwd
/var/mobile/Containers/Data/Application/8A0D7292-BFF7-407B-88B1-D25E95396A5C/Documents
iPhone:/var/mobile/Containers/Data/Application/8A0D7292-BFF7-407B-88B1-D25E95396A5C/Documents root# ls
freefallgame.db  security_tokens.db


Download the freefallgame.db file using Objection.

1
2
3
4
5
...htksec.freefallgame.J8L462KYQ8 on (iPhone: 16.0) [usb] # file download /var/mobile/Containers/Data/Application/8A0D7292-BFF7-407B-88B1-D25E95396A5C/Documents/freefallgame.db
Downloading /var/mobile/Containers/Data/Application/8A0D7292-BFF7-407B-88B1-D25E95396A5C/Documents/freefallgame.db to freefallgame.db
Streaming file from device...
Writing bytes to destination...
Successfully downloaded /var/mobile/Containers/Data/Application/8A0D7292-BFF7-407B-88B1-D25E95396A5C/Documents/freefallgame.db to freefallgame.db


Open the freefallgame.db file using sqlitebrowser.

1
└─# sqlitebrowser freefallgame.db


In the leaderboard table, the name and score columns store each player’s data.


We can hook SQLite functions (for example, sqlite3_step) with Frida to see the SQL statements executed when a player’s score is saved. This Frida script intercepts SQLite calls.

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
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
// frida-sqlite-prepare-all.js
// Hooks sqlite3_prepare, sqlite3_prepare_v2, sqlite3_prepare_v3,
// sqlite3_prepare16, sqlite3_prepare16_v2, sqlite3_prepare16_v3
// plus helpful surrounding SQLite functions to make output useful.
//
// Usage:
//   frida -U -f <bundle/id> -l frida-sqlite-prepare-all.js
// or attach:
//   frida -U -n <process> -l frida-sqlite-prepare-all.js

'use strict';

const stmts = {}; // map stmtPtr -> { sql: "...", binds: { idx: val } }

function p(ptr) {
    return ptr ? ptr.toString() : "0x0";
}

function safeReadUtf8(ptr) {
    try {
        if (!ptr || ptr.isNull()) return null;
        return Memory.readUtf8String(ptr);
    } catch (e) {
        return null;
    }
}

function safeReadUtf16(ptr) {
    try {
        if (!ptr || ptr.isNull()) return null;
        return Memory.readUtf16String(ptr);
    } catch (e) {
        return null;
    }
}

function backtrace(context) {
    try {
        return Thread.backtrace(context, Backtracer.ACCURATE)
            .map(DebugSymbol.fromAddress)
            .slice(0, 12)
            .join("\n");
    } catch (e) {
        return "(backtrace unavailable)";
    }
}

// utility to store SQL for a statement
function storeStmt(stmtPtr, sql) {
    try {
        const key = p(stmtPtr);
        if (!stmts[key]) stmts[key] = {
            sql: sql,
            binds: {}
        };
    } catch (e) {}
}

// Handler generator for prepare variants
function attachPrepare(symName, opts) {
    // opts: { sqlArgIndex: int, isUtf16: bool, ppStmtIndex: int }
    const ptr = Module.findExportByName(null, symName);
    if (!ptr) {
        // console.log(symName + " not found");
        return;
    }
    Interceptor.attach(ptr, {
        onEnter: function(args) {
            this.sym = symName;
            this.isUtf16 = !!opts.isUtf16;
            this.sqlArg = args[opts.sqlArgIndex];
            this.ppStmt = args[opts.ppStmtIndex];
            this.bt = backtrace(this.context);
            if (this.isUtf16) {
                this.sql = safeReadUtf16(this.sqlArg) || null;
            } else {
                this.sql = safeReadUtf8(this.sqlArg) || null;
            }
        },
        onLeave: function(retval) {
            try {
                const sql = this.sql || "(null)";
                if (!this.ppStmt.isNull()) {
                    const stmtPtr = Memory.readPointer(this.ppStmt);
                    if (!stmtPtr.isNull()) {
                        storeStmt(stmtPtr, sql);
                        console.log("\n== " + this.sym + " ==");
                        console.log("STMT: " + p(stmtPtr));
                        console.log("SQL: " + sql);
                        console.log("Return code: " + retval.toInt32());

                        return;
                    }
                }
                // sometimes prepare variants do not return a stmt (NULL) but still worth logging
                console.log("\n== " + this.sym + " (no stmt) ==");
                console.log("SQL: " + sql);
                console.log("Return code: " + retval.toInt32());

            } catch (e) {
                console.log(this.sym + ".onLeave error: " + e);
            }
        }
    });
}

// Attach all prepare functions you requested
const prepares = [{
        name: "sqlite3_prepare",
        opts: {
            sqlArgIndex: 1,
            isUtf16: false,
            ppStmtIndex: 3
        }
    },
    {
        name: "sqlite3_prepare_v2",
        opts: {
            sqlArgIndex: 1,
            isUtf16: false,
            ppStmtIndex: 3
        }
    },
    {
        name: "sqlite3_prepare_v3",
        opts: {
            sqlArgIndex: 1,
            isUtf16: false,
            ppStmtIndex: 3
        }
    },

    // 16-bit (UTF-16) variants usually have wchar_t* as sql arg
    {
        name: "sqlite3_prepare16",
        opts: {
            sqlArgIndex: 1,
            isUtf16: true,
            ppStmtIndex: 3
        }
    },
    {
        name: "sqlite3_prepare16_v2",
        opts: {
            sqlArgIndex: 1,
            isUtf16: true,
            ppStmtIndex: 3
        }
    },
    {
        name: "sqlite3_prepare16_v3",
        opts: {
            sqlArgIndex: 1,
            isUtf16: true,
            ppStmtIndex: 3
        }
    }
];

prepares.forEach(function(pf) {
    attachPrepare(pf.name, pf.opts);
});

// Also hook sqlite3_exec (convenient for statements executed directly via exec)
const execPtr = Module.findExportByName(null, "sqlite3_exec");
if (execPtr) {
    Interceptor.attach(execPtr, {
        onEnter: function(args) {
            this.db = args[0];
            this.sqlPtr = args[1];
            // try utf8 then utf16 fallback
            this.sql = safeReadUtf8(this.sqlPtr) || safeReadUtf16(this.sqlPtr) || null;
            this.bt = backtrace(this.context);
            console.log("\n== sqlite3_exec ==");
            console.log("DB: " + p(this.db));
            console.log("SQL: " + (this.sql || "(null)"));

        }
    });
}

// sqlite3_step: print bound params and associated SQL if we tracked it
const stepPtr = Module.findExportByName(null, "sqlite3_step");
if (stepPtr) {
    Interceptor.attach(stepPtr, {
        onEnter: function(args) {
            try {
                this.stmt = args[0];
                const key = p(this.stmt);
                const rec = stmts[key];
                if (rec) {
                    this.bt = backtrace(this.context);
                    console.log("\n== sqlite3_step ==");
                    console.log("STMT: " + key);
                    console.log("SQL: " + rec.sql);
                    if (rec.binds && Object.keys(rec.binds).length > 0) {
                        console.log("Bound params:");
                        for (let i in rec.binds) console.log("  [" + i + "] = " + rec.binds[i]);
                    } else {
                        console.log("Bound params: (none)");
                    }

                }
            } catch (e) {}
        }
    });
}

// Bind functions: record values for tracked statements
const bindFns = [{
        name: "sqlite3_bind_text",
        handler: function(args) {
            const stmt = args[0];
            const idx = args[1].toInt32();
            const txt = safeReadUtf8(args[2]) || safeReadUtf16(args[2]) || null;
            const key = p(stmt);
            if (stmts[key]) stmts[key].binds[idx] = txt === null ? "NULL" : '"' + txt + '"';
        }
    },
    {
        name: "sqlite3_bind_text16",
        handler: function(args) {
            const stmt = args[0];
            const idx = args[1].toInt32();
            const txt = safeReadUtf16(args[2]) || null;
            const key = p(stmt);
            if (stmts[key]) stmts[key].binds[idx] = txt === null ? "NULL" : '"' + txt + '"';
        }
    },
    {
        name: "sqlite3_bind_int",
        handler: function(args) {
            const stmt = args[0];
            const idx = args[1].toInt32();
            const v = args[2].toInt32();
            const key = p(stmt);
            if (stmts[key]) stmts[key].binds[idx] = v;
        }
    },
    {
        name: "sqlite3_bind_int64",
        handler: function(args) {
            const stmt = args[0];
            const idx = args[1].toInt32();
            const v = args[2].toInt64();
            const key = p(stmt);
            if (stmts[key]) stmts[key].binds[idx] = v.toString();
        }
    },
    {
        name: "sqlite3_bind_double",
        handler: function(args) {
            const stmt = args[0];
            const idx = args[1].toInt32();
            // read double safely
            let v = null;
            try {
                v = args[2].readDouble();
            } catch (e) {
                try {
                    v = args[2].toDouble();
                } catch (e2) {
                    v = "(double?)";
                }
            }
            const key = p(stmt);
            if (stmts[key]) stmts[key].binds[idx] = v;
        }
    },
    {
        name: "sqlite3_bind_null",
        handler: function(args) {
            const stmt = args[0];
            const idx = args[1].toInt32();
            const key = p(stmt);
            if (stmts[key]) stmts[key].binds[idx] = "NULL";
        }
    },
    {
        name: "sqlite3_bind_blob",
        handler: function(args) {
            const stmt = args[0];
            const idx = args[1].toInt32();
            const n = args[3].toInt32();
            const key = p(stmt);
            if (stmts[key]) stmts[key].binds[idx] = "<blob, " + n + " bytes>";
        }
    }
];

bindFns.forEach(function(item) {
    const ptr = Module.findExportByName(null, item.name);
    if (!ptr) return;
    Interceptor.attach(ptr, {
        onEnter: function(args) {
            try {
                item.handler(args);
            } catch (e) {}
        }
    });
});

// finalize: print final bound params and cleanup
const finalizePtr = Module.findExportByName(null, "sqlite3_finalize");
if (finalizePtr) {
    Interceptor.attach(finalizePtr, {
        onEnter: function(args) {
            try {
                const stmt = args[0];
                const key = p(stmt);
                const rec = stmts[key];
                if (rec) {
                    console.log("\n== sqlite3_finalize ==");
                    console.log("STMT: " + key);
                    console.log("SQL: " + rec.sql);
                    if (rec.binds && Object.keys(rec.binds).length > 0) {
                        console.log("Bound params (final):");
                        for (let i in rec.binds) console.log("  [" + i + "] = " + rec.binds[i]);
                    } else {
                        console.log("Bound params: (none)");
                    }
                    delete stmts[key];
                }
            } catch (e) {}
        }
    });
}

// reset: clear binds (statements may be reused)
const resetPtr = Module.findExportByName(null, "sqlite3_reset");
if (resetPtr) {
    Interceptor.attach(resetPtr, {
        onEnter: function(args) {
            try {
                const key = p(args[0]);
                if (stmts[key]) stmts[key].binds = {};
            } catch (e) {}
        }
    });
}

// defensive log to show script loaded
console.log("[frida] sqlite3 prepare hooks installed for: sqlite3_prepare, sqlite3_prepare_v2, sqlite3_prepare_v3, sqlite3_prepare16, sqlite3_prepare16_v2, sqlite3_prepare16_v3 (plus exec/step/bind/finalize/reset).");

The output reveals the executed SQL statement and its bound parameters, displaying the player’s name and score.

1
2
3
4
5
6
7
== sqlite3_step ==
STMT: 0x1228d0ca0
SQL: INSERT INTO leaderboard (name, score, timestamp, token) VALUES (?, ?, ?, ?)
Bound params:
  [1] = "test"
  [2] = 945
  [4] = "463549ec2c2d63f75e8659d058fe01e34f2c13910a84e719f2a8354348660596"


To modify the score, the script can be tweaked to spot the INSERT INTO leaderboard query before sqlite3_step runs, use sqlite3_bind_int to change parameter [2] to 13333337, and update the internal record so the log shows the new value.

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
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
// Adds logic to override param [2] for INSERT INTO leaderboard to 13333337

'use strict';

const stmts = {}; // map stmtPtr -> { sql: "...", binds: { idx: val } }

function p(ptr) {
    return ptr ? ptr.toString() : "0x0";
}

function safeReadUtf8(ptr) {
    try {
        if (!ptr || ptr.isNull()) return null;
        return Memory.readUtf8String(ptr);
    } catch (e) {
        return null;
    }
}

function safeReadUtf16(ptr) {
    try {
        if (!ptr || ptr.isNull()) return null;
        return Memory.readUtf16String(ptr);
    } catch (e) {
        return null;
    }
}

function backtrace(context) {
    try {
        return Thread.backtrace(context, Backtracer.ACCURATE)
            .map(DebugSymbol.fromAddress)
            .slice(0, 12)
            .join("\n");
    } catch (e) {
        return "(backtrace unavailable)";
    }
}

// utility to store SQL for a statement
function storeStmt(stmtPtr, sql) {
    try {
        const key = p(stmtPtr);
        if (!stmts[key]) stmts[key] = {
            sql: sql,
            binds: {}
        };
    } catch (e) {}
}

/* -------------------
   NEW: binder helper
   -------------------
   We will call sqlite3_bind_int(stmt, idx, value) ourselves to overwrite a bind.
   Create a NativeFunction reference if the symbol is available.
*/
let _bind_int_fn = null;
const _bind_int_ptr = Module.findExportByName(null, "sqlite3_bind_int");
if (_bind_int_ptr) {
    try {
        _bind_int_fn = new NativeFunction(_bind_int_ptr, 'int', ['pointer', 'int', 'int']);
        console.log("[frida] found sqlite3_bind_int at " + _bind_int_ptr);
    } catch (e) {
        console.log("[frida] error creating NativeFunction for sqlite3_bind_int: " + e);
    }
} else {
    console.log("[frida] sqlite3_bind_int not found; override-by-rebind will not be available.");
}

/* ------------- end new ------------- */

// Handler generator for prepare variants (same as your original)
function attachPrepare(symName, opts) {
    const ptr = Module.findExportByName(null, symName);
    if (!ptr) {
        // console.log(symName + " not found");
        return;
    }
    Interceptor.attach(ptr, {
        onEnter: function(args) {
            this.sym = symName;
            this.isUtf16 = !!opts.isUtf16;
            this.sqlArg = args[opts.sqlArgIndex];
            this.ppStmt = args[opts.ppStmtIndex];
            this.bt = backtrace(this.context);
            if (this.isUtf16) {
                this.sql = safeReadUtf16(this.sqlArg) || null;
            } else {
                this.sql = safeReadUtf8(this.sqlArg) || null;
            }
        },
        onLeave: function(retval) {
            try {
                const sql = this.sql || "(null)";
                if (!this.ppStmt.isNull()) {
                    const stmtPtr = Memory.readPointer(this.ppStmt);
                    if (!stmtPtr.isNull()) {
                        storeStmt(stmtPtr, sql);
                        console.log("\n== " + this.sym + " ==");
                        console.log("STMT: " + p(stmtPtr));
                        console.log("SQL: " + sql);
                        console.log("Return code: " + retval.toInt32());

                        return;
                    }
                }
                console.log("\n== " + this.sym + " (no stmt) ==");
                console.log("SQL: " + sql);
                console.log("Return code: " + retval.toInt32());

            } catch (e) {
                console.log(this.sym + ".onLeave error: " + e);
            }
        }
    });
}

// (attach prepares list - same as your original)
const prepares = [{
        name: "sqlite3_prepare",
        opts: {
            sqlArgIndex: 1,
            isUtf16: false,
            ppStmtIndex: 3
        }
    },
    {
        name: "sqlite3_prepare_v2",
        opts: {
            sqlArgIndex: 1,
            isUtf16: false,
            ppStmtIndex: 3
        }
    },
    {
        name: "sqlite3_prepare_v3",
        opts: {
            sqlArgIndex: 1,
            isUtf16: false,
            ppStmtIndex: 3
        }
    },
    {
        name: "sqlite3_prepare16",
        opts: {
            sqlArgIndex: 1,
            isUtf16: true,
            ppStmtIndex: 3
        }
    },
    {
        name: "sqlite3_prepare16_v2",
        opts: {
            sqlArgIndex: 1,
            isUtf16: true,
            ppStmtIndex: 3
        }
    },
    {
        name: "sqlite3_prepare16_v3",
        opts: {
            sqlArgIndex: 1,
            isUtf16: true,
            ppStmtIndex: 3
        }
    }
];

prepares.forEach(function(pf) {
    attachPrepare(pf.name, pf.opts);
});

// sqlite3_exec hook (same as original)
const execPtr = Module.findExportByName(null, "sqlite3_exec");
if (execPtr) {
    Interceptor.attach(execPtr, {
        onEnter: function(args) {
            this.db = args[0];
            this.sqlPtr = args[1];
            this.sql = safeReadUtf8(this.sqlPtr) || safeReadUtf16(this.sqlPtr) || null;
            this.bt = backtrace(this.context);
            console.log("\n== sqlite3_exec ==");
            console.log("DB: " + p(this.db));
            console.log("SQL: " + (this.sql || "(null)"));
        }
    });
}

/* ---------------------
   sqlite3_step modification
   ---------------------
   Before logging/stepping we check if the tracked SQL is the leaderboard insert.
   If so, and if we have sqlite3_bind_int available, call it to overwrite param [2].
*/
const stepPtr = Module.findExportByName(null, "sqlite3_step");
if (stepPtr) {
    Interceptor.attach(stepPtr, {
        onEnter: function(args) {
            try {
                this.stmt = args[0];
                const key = p(this.stmt);
                const rec = stmts[key];
                if (rec) {
                    // If this is the leaderboard insert, force param 2 -> 13333337
                    // We look for SQL that contains the table name (case-insensitive)
                    const sqlLower = (rec.sql || "").toLowerCase();
                    if (sqlLower.indexOf("insert into leaderboard") !== -1) {
                        const forcedValue = 13333337;
                        if (_bind_int_fn) {
                            try {
                                // call sqlite3_bind_int(stmt, 2, 13333337)
                                const rc = _bind_int_fn(this.stmt, 2, forcedValue);
                                // update our tracked binds so logs reflect the change
                                if (!rec.binds) rec.binds = {};
                                rec.binds[2] = forcedValue;
                                console.log("[frida] Overwrote parameter [2] for leaderboard INSERT -> " + forcedValue + " (sqlite3_bind_int rc=" + rc + ")");
                            } catch (e) {
                                console.log("[frida] Error calling sqlite3_bind_int: " + e);
                            }
                        } else {
                            console.log("[frida] sqlite3_bind_int not available; cannot overwrite param [2].");
                        }
                    }

                    this.bt = backtrace(this.context);
                    console.log("\n== sqlite3_step ==");
                    console.log("STMT: " + key);
                    console.log("SQL: " + rec.sql);
                    if (rec.binds && Object.keys(rec.binds).length > 0) {
                        console.log("Bound params:");
                        for (let i in rec.binds) console.log("  [" + i + "] = " + rec.binds[i]);
                    } else {
                        console.log("Bound params: (none)");
                    }

                }
            } catch (e) {}
        }
    });
}

/* Bind functions kept the same as your original script.
   They still populate stmts[..].binds for logging.
*/
const bindFns = [{
        name: "sqlite3_bind_text",
        handler: function(args) {
            const stmt = args[0];
            const idx = args[1].toInt32();
            const txt = safeReadUtf8(args[2]) || safeReadUtf16(args[2]) || null;
            const key = p(stmt);
            if (stmts[key]) stmts[key].binds[idx] = txt === null ? "NULL" : '"' + txt + '"';
        }
    },
    {
        name: "sqlite3_bind_text16",
        handler: function(args) {
            const stmt = args[0];
            const idx = args[1].toInt32();
            const txt = safeReadUtf16(args[2]) || null;
            const key = p(stmt);
            if (stmts[key]) stmts[key].binds[idx] = txt === null ? "NULL" : '"' + txt + '"';
        }
    },
    {
        name: "sqlite3_bind_int",
        handler: function(args) {
            const stmt = args[0];
            const idx = args[1].toInt32();
            const v = args[2].toInt32();
            const key = p(stmt);
            if (stmts[key]) stmts[key].binds[idx] = v;
        }
    },
    {
        name: "sqlite3_bind_int64",
        handler: function(args) {
            const stmt = args[0];
            const idx = args[1].toInt32();
            const v = args[2].toInt64();
            const key = p(stmt);
            if (stmts[key]) stmts[key].binds[idx] = v.toString();
        }
    },
    {
        name: "sqlite3_bind_double",
        handler: function(args) {
            const stmt = args[0];
            const idx = args[1].toInt32();
            let v = null;
            try {
                v = args[2].readDouble();
            } catch (e) {
                try {
                    v = args[2].toDouble();
                } catch (e2) {
                    v = "(double?)";
                }
            }
            const key = p(stmt);
            if (stmts[key]) stmts[key].binds[idx] = v;
        }
    },
    {
        name: "sqlite3_bind_null",
        handler: function(args) {
            const stmt = args[0];
            const idx = args[1].toInt32();
            const key = p(stmt);
            if (stmts[key]) stmts[key].binds[idx] = "NULL";
        }
    },
    {
        name: "sqlite3_bind_blob",
        handler: function(args) {
            const stmt = args[0];
            const idx = args[1].toInt32();
            const n = args[3].toInt32();
            const key = p(stmt);
            if (stmts[key]) stmts[key].binds[idx] = "<blob, " + n + " bytes>";
        }
    }
];

bindFns.forEach(function(item) {
    const ptr = Module.findExportByName(null, item.name);
    if (!ptr) return;
    Interceptor.attach(ptr, {
        onEnter: function(args) {
            try {
                item.handler(args);
            } catch (e) {}
        }
    });
});

// finalize: print final bound params and cleanup
const finalizePtr = Module.findExportByName(null, "sqlite3_finalize");
if (finalizePtr) {
    Interceptor.attach(finalizePtr, {
        onEnter: function(args) {
            try {
                const stmt = args[0];
                const key = p(stmt);
                const rec = stmts[key];
                if (rec) {
                    console.log("\n== sqlite3_finalize ==");
                    console.log("STMT: " + key);
                    console.log("SQL: " + rec.sql);
                    if (rec.binds && Object.keys(rec.binds).length > 0) {
                        console.log("Bound params (final):");
                        for (let i in rec.binds) console.log("  [" + i + "] = " + rec.binds[i]);
                    } else {
                        console.log("Bound params: (none)");
                    }
                    delete stmts[key];
                }
            } catch (e) {}
        }
    });
}

// reset: clear binds (statements may be reused)
const resetPtr = Module.findExportByName(null, "sqlite3_reset");
if (resetPtr) {
    Interceptor.attach(resetPtr, {
        onEnter: function(args) {
            try {
                const key = p(args[0]);
                if (stmts[key]) stmts[key].binds = {};
            } catch (e) {}
        }
    });
}

console.log("[frida] sqlite3 prepare hooks installed for: sqlite3_prepare, sqlite3_prepare_v2, sqlite3_prepare_v3, sqlite3_prepare16, sqlite3_prepare16_v2, sqlite3_prepare16_v3 (plus exec/step/bind/finalize/reset).");


Spawn the game using this Frida script. At game over, after the score is calculated, you’ll notice the value has been replaced with 13333337.

1
2
3
4
5
6
7
== sqlite3_step ==
STMT: 0x135fd0ca0
SQL: INSERT INTO leaderboard (name, score, timestamp, token) VALUES (?, ?, ?, ?)
Bound params:
  [1] = "karim"
  [2] = 13333337
  [4] = "93caa299e912be237959e48db7c2024ccdde78ea77f8730e126bee8aa9bbbe90"


The real score was 693.


The modified score will be displayed in the leaderboard.


This post is licensed under CC BY 4.0 by the author.