Protecting Save Files and Databases from Tampering
Kick the Buddy 2.20.2 keeps its progress as JSON in Android/data/com.playgendary.kickthebuddy/files/sava.dt. The gold is right there, {"currency":"SC","amount":100}. The file starts with a number, though, 139940867|, and when I changed 100 to 999999 and started the game, it threw the file away and gave me 100 gold again.
Where saves live
/data/data/<package>/filesand/databases, private to the app. Root opens them./sdcard/Android/data/<package>. Other apps lost access in Android 11. Root didn’t, and CheatDroid lists it next to the private folder.- SQLite databases. CheatDroid has a table editor for them, and SQLite stores numbers as plain numbers.
A checksum is the minimum
What Kick the Buddy does is enough against a file editor. The editor changes the text, can’t recompute the number in front, and the game notices. Use an HMAC with a key the editor doesn’t have rather than a CRC, which anyone can recompute. The number in Kick the Buddy’s save isn’t a CRC32 of the JSON; I checked, because it would have been the first thing to try.
I’d change what happens next, though. Throwing the save away hits a player whose phone ran out of battery mid-write as hard as one who cheated. Keep the last good save next to the current one, and fall back to it.
Encrypt with a key the phone keeps
If players shouldn’t even read the save, encrypt it with authenticated encryption. With AES-GCM an edited file fails to decrypt instead of decrypting to something slightly different, so you get the integrity check for free. Tink does it in a few lines, with a master key in the Android Keystore that never leaves the phone’s secure hardware:
AeadConfig.register()
val aead = AndroidKeysetManager.Builder()
.withSharedPref(context, "save_keyset", "save_keys")
.withKeyTemplate(KeyTemplates.get("AES256_GCM"))
.withMasterKeyUri("android-keystore://save_master_key")
.build()
.keysetHandle
.getPrimitive(RegistryConfiguration.get(), Aead::class.java)
val associated = "save-v1".toByteArray()
saveFile.writeBytes(aead.encrypt(json.toByteArray(), associated))
val restored = String(aead.decrypt(saveFile.readBytes(), associated))
The catch: Android’s Auto Backup copies the file to a new phone but not the Keystore key, so the restored save can’t be opened there. Leave it out of the backup and keep a copy on your server, or accept that a new phone starts over. Decide before release, because changing it later strands someone.
Databases
SQLite has no integrity check of its own. SQLCipher encrypts the whole file, and its key has the same Keystore question as above. For a few values in an otherwise harmless database, a MAC column next to each row is lighter.
A different format is not protection
Traffic Racer scrambles its save so the cash isn’t readable in the file, and I didn’t find it there. Its memory held the cash as a plain number, and the game wrote whatever was in memory back to the scrambled file on the next purchase. Protecting the file and not the value in memory moves the cheater one tab over: values in memory.