This is part 3 of a blog series.
Read part 1 here: The Current State & Future of Reversing Flutter™ Apps.
Read part 2 here: Obstacles in Dart Decompilation & the Impact on Flutter™ App Security.
In our two last blog posts, we explained how to recover class and function names for a Flutter application and demonstrated that, with limited tool development, it is possible to make it very close to standard reverse engineering.
For the final blog post of the three-part series, we wanted to investigate if classical attacks that we frequently see on mobile applications are also applicable to Flutter applications.
Just like in the previous posts, we will use our obfuscated build of NyaNya Rocket! and see how cheats could be developed. Note that this is for educational purposes only. Since the game is open source, all the things that we will show can be done by modifying the game and recompiling it. However, we want to demonstrate that it can be achieved without the source code. The material used for this blogpost can be found on GitHub.
In this post, we will start by looking at the game we will target. Then we will investigate how three common techniques used in cheats apply to Flutter applications:
The first step in attacking an application is getting an overall understanding of what the app is doing. Only after gaining this level of clarity is it possible to decide which kind of attack to implement.
So, let’s first take a quick tour of NyaNya Rocket!, a game that requires you to solve multiple puzzles of increasing difficulty. A puzzle looks as follows:
Each puzzle is defined by its layout, the number of cats and mice, and the number of available arrows.
There are several ways you can lose:
Based on what we saw, several cheat ideas can help us beat the game:
We will try and see how the cheat could be implemented using several attack techniques that are used to target (non-Flutter) applications.
One popular attack that we see on mobile applications consists in repackaging the application while changing some parts.
For instance, for Android applications, there are several tools available (e.g.apktool) that automate most of the unpacking and repackaging of the application. These tools are also able to disassemble the DEX file intosmalifiles. Therefore, attackers only have to focus on what they are trying to change.
In this first section, we will discuss the easiest type of tampering which requires very little experience: asset or data tampering.
All mobile applications use some data, which is typically very easily understandable by an attacker, such as graphical assets or strings. Depending on the application architecture, this data can be stored in asset files and/or directly in the executable binary, but in any case, the attacker can quickly locate and patch them:
resfolder of the APK.smalifiles (which are text files with assembly instructions in them), the attacker only has to search for the target strings in the smali files and replace them by the strings they want.Since Flutter apps store assets in the same way as regular mobile apps, patching asset files in a Flutter application is as easy as patching them on any mobile application. For instance, in NyaNya Rocket!, the cat animation is stored inassets/flutter_assets/assets/animations/cat.riv. We can edit it or replace it with any other animation.
In this example, there is no gain for the attacker, but in some other games, changing assets can be used to get a visual edge, like making something smaller or easier to spot.
For the purpose of this blog post, we will use gray cats on screenshots and videos where the game is attacked, and orange cats for the regular game.
When it comes to data (e.g. strings) patching in Flutter application, it is important to remember that all Dart code is compiled into a native library (libapp.so) and that all Dart objects (including the strings) are serialized and stored in this binary.
Therefore, patching strings in a Flutter application is exactly the same as patching a string in a native library of a standard mobile application.
In the NyaNya Rocket! Code, a puzzle is specified by a string (source code here):
class OriginalPuzzles extends StatefulWidget {
class OriginalPuzzles extends StatefulWidget {
static final List puzzles = jsons
.map(
(String json) => NamedPuzzleData.fromJson(jsonDecode(json)))
.toList();
static List jsons = [
'{"name":"Where to go?","gameData":"{...}","arrows":[0,1,0,0]}',
'{"name":"Roundabout!","gameData":"{...}","arrows":[0,0,0,1]}',
'{"name":"Zigzag","gameData":"{...}","arrows":[1,0,0,0]}',
...
]
}
It stores a lot of information including puzzle name, layout, starting mice and cats positions, and the number of available arrows.
Thus, if we find the string associated with a puzzle in thelibapp.sobinary, we can modify it to, for instance, change the number of available arrows.
Let’s consider the followingSupa Sonicpuzzle. We can search for this string in the native library:
$ strings libapp.so | grep "Supa Sonic"
{"name":"Supa Sonic","gameData":"{...}”,"arrows":[0,1,0,0]}
To patch this library, no advanced reverse engineering tool is needed. We can just use a simple Python script:
def change_puzzle_arrows(libapp_path, puzzle_name, nb_arrows, output_path):
with open(libapp_path, "rb") as fp:
orig_data = fp.read()
puzzle_string_index = orig_data.index(puzzle_name)
puzzle_arrows_string_index = puzzle_string_index +
orig_data[puzzle_string_index:].index(b"\"arrows\"")
Patched_arrows =
F"\"arrows\":[{nb_arrows[0]},{nb_arrows[1]},{nb_arrows[2]},{nb_arrows[3]}]"
.encode("ascii")
patched_data = orig_data[:puzzle_arrows_string_index] + patched_arrows + \
orig_data[puzzle_arrows_string_index + len(patched_arrows):]
with open(output_path, "wb") as fp:
fp.write(patched_data)
After repackaging the application with the patchedlibapp.so, the number of arrows for this puzzle has been increased and winning is easier:
In this example, we patched a string, but the same technique works exactly in the same way when patching any static data (i.e. every Dart serialized object) included in an application. However, since Flutter data is stored in a native library, the patched data must have the same length as the original data. Thus, in the previous example, we can’t change the number of arrows to 10 or more because it would add digits which would change the string size.
We looked into several attack scenarios which rely on data tampering and noticed that there is absolutely no difference between a regular and a Flutter application. In both cases, the attacker just has to locate the data and patch it. Thus, to protect your application against it, you should encrypt your app sensitive data and check for your app integrity.
Depending on what the cheat is trying to do, patching application data may not be enough. The attacker may try something that requires a little more reverse engineering skills: code tampering.
The goal of code tampering is to modify the application logic by changing one or several instructions directly in the compiled binary:
But, even only patching a couple of assembly instructions, can have a huge impact on the application. Here are two popular use cases:
We can replace a function call with a very small stub by replacing the two first instructions of a function:
As an example, if the application has a functionwas_hit, which performs various checks and returns 1 if you were hit and 0 otherwise, an attacker can patch the prolog of this function with these two assembly instructions so all checks are bypassed and the function always returns 0. By doing that, the attacker will never get hit.
We can also force a branch to be always (or never) taken:
Patching anifstatement has many uses because it allows bypassing any application logic checks, such as checking if the user has won or if the user has paid for a feature.
One may think that writing the assembly code to perform this type of path is hard, but this is actually not the case. For instance, shell-storm allows you to assemble code directly from your browser, and there is a really good IDA Pro plugin to do it very easily inside IDA.
Thus, the only hard part is finding which function or instruction to patch. Doing that requires reverse engineering on the application.
Since Dart code is compiled into thelibapp.sonative library, patching it is exactly the same as patching any native part of a mobile application. As a result, the main challenge is the reverse engineering part. But with the techniques that we demonstrated during our previous blog posts (automatic renaming of function and removal of Dart artifact in decompiled code), finding relevant pieces of code can be done relatively easily.
As a first example, let’s see how we can get infinite arrows in NyaNya Rocket! Though there are several ways of doing that, we’re going to focus on this possible plan of attack (based on playing the game, not reverse engineering it):
We will not use the source code of the application in the attack, but we provide the code of the two relevant functions so the steps are easier to follow:
int remainingArrows(Direction direction) =>
puzzle.availableArrows[direction.index] - placedArrows[direction.index].length;
bool placeArrow(int x, int y, Direction direction) {
if (_canPlaceArrow &&
puzzle.availableArrows[direction.index] > placedArrows[direction.index].length &&
game.board.tiles[x][y] is Empty) {
game.board.tiles[x][y] =
Arrow.notExpiring(player: PlayerColor.Blue, direction: direction);
placedArrows[direction.index].add(Position(x, y));
updateGame();
remainingArrowsStreams[direction.index].value = remainingArrows(direction);
return true;
}
return false;
}
The first step is finding the relevant functions. For this part, there are two main possibilities:
obfuscatebuilt-in option. In this case, as explained in our first blog post, it is easy to recover all function names and addresses. Thus, finding these functions is as simple as searching for all functions witharrowin their name. There might be several functions but it should be easy to find the relevant ones:obfuscatebuilt-in option. Thus it will require other standard reverse engineering techniques to find them. We won't go too deep here since it isn’t really Flutter specific, but it could be done using dynamic analysis to trace all functions called while the user drags an arrow to the board.The second step is where the real reverse engineering takes place. (We won’t go in-depth in Flutter specific reverse engineering, but if you want more information, you can have a look at our previous blog post.) Here is what the decompiled code of theplaceArrowlook like after some manual reverse engineering:
It may seem like a lot, but it is not necessary to understand everything to find the code to patch.
Let’s have a look at what matters:
ifconditions which makes the function immediately return false. It is possible to patch them all without overthinking, but the only one that we need to patch is the second one; we don’t want the application to detect that we are using more arrows than the initial number of arrows for a puzzle:remainingArrowsfunction. This function returns the number of remaining arrows, which is later stored in the puzzle state. The problem here is that when there are no remaining arrows, the arrow button will become gray and we can no longer drag arrows. This can be fixed by patching this function and making it always return 1 for instance:After repackaging the application with this tampered native library code, we have an infinite number of arrows:
Sometimes, even infinite arrows are not enough. By patching two additional instructions, we can unlock the invincible mice and allow cats to reach the rocket:
Since Flutter apps are compiled to native code, we decided to try code tampering as we would do on the native components of a mobile application.
We demonstrated that it works in exactly the same way as this type of attack is usually done:
So, to protect your application against code tampering, you should use obfuscation to make it harder for an attacker to understand your code logic and check for your application and code integrity.
Patching code is powerful but it has some limitations:
To overcome these limitations, attackers can use another type of attack to implement more complex patches: hooking.
Hooking has virtually unlimited possibilities. It can be used to change application behavior and ease the reverse engineering process. Let’s look at some examples.
Hooking can be performed at various levels, for instance by redirecting calls to imported functions to attacker code, or by directly patching binary code with a trampoline which will jump to attacker code.
Let’s focus on inline hooking, which relies on patching code after it has been loaded into memory. The core idea of inline hooking is to replace the first assembly instructions of a function with a trampoline (i.e. a jump to attacker-controlled code). The attacker code will:
The advantage of hooking over patching is that:
Hooking Dart code works similarly to classical native code hooking. Well known hooking frameworks, such as Frida, can put their trampoline code and they can inject attacker code when the function is called or when it returns.
However, Dart code has two specificities that we discussed in our previous blog post that we must take into account:
Because of this, Frida is not able to (out of the box) retrieve the function parameters.
To give a concrete example, here is a Frida hook for a C and an equivalent Dart function (strcmp)
Regular hook int strcmp(const char *s1, const char *s2):
Interceptor.attach(ADDRESS_STRCMP, {
onEnter: function (args) {
let s1 = args[0].readUtf8String();
let s2 = args[1].readUtf8String();
console.log(`Comparing ${s1} to ${s2}`)
}
})
Dart code hook int strcmp(String s1, String s2):
Interceptor.attach(ADDRESS_STRCMP, {
onEnter: function (args) {
let s1_dart_string_pointer = dart_get_arg(this.context, 0);
let s2_dart_string_pointer = dart_get_arg(this.context, 1);
let s1 = get_dart_string_data(s1_dart_string_pointer);
let s2 = get_dart_string_data(s2_dart_string_pointer);
console.log(`Comparing ${s1} to ${s2}`)
}
})
As you can see, the overall structure is the same, but we had to create a couple of helper functions to recover the Dart function parameters. The important thing to note is that we can re-use these Frida helper functions while hooking any other Flutter function:
function dart_get_arg(context, arg_index){
var x15 = context.x15;
return x15.add(8 * arg_index).readPointer();
}
function read_smi(smi_ptr){
let smi_data = smi_ptr.readU64();
if (parseInt(smi_data & 0x1, 10) == 0){
return smi_data >> 1;
}
console.log(
`Invalid SMI pointer ${smi_ptr} -> 0x${smi_data.toString(16)}: Smi LSB should be 0`)
return null
}
function parse_dart_string(dart_string_ptr, ){
if (dart_string_ptr.and(0x1).toInt32() == 1) {
dart_string_ptr = dart_string_ptr.sub(1)
}
let tag = dart_string_ptr.readU32();
let class_id = (tag >> 16) & 0xffff;
if (class_id == 0x55){
let string_length = read_smi(dart_string_ptr.add(8));
let string_data_ptr = dart_string_ptr.add(16)
let string_data = string_data_ptr.readCString(string_length);
return [string_data_ptr, string_length, string_data]
}
return null
}
function get_dart_string_data(dart_string_ptr){
let dart_string_info = parse_dart_string(dart_string_ptr);
if (dart_string_info != null){
return dart_string_info[2];
}
return null;
}
To demonstrate that hooking allows the development of more complex patches, we will use it to change the game logic itself to restrict the cat positions on the board.
In a real attack scenario, an attacker will have to reverse engineer the application to understand the different parts of the game logic and find out what to hook. In this post, we wanted to focus on the attack itself rather than how to locate and identify the code to hook, so we will use the source code of the application to explain the game logic and find out an attack strategy.
Here are the game internals that we need to understand to design the attack:
A puzzle contains multiple Tile objects, which can be either Empty, an Arrow, a Pit or a Rocket & Mouse and Cat are both extending the abstract Entity class. This is a very simple class that contains a BoardPosition object which stores the position of the entity.
All updates of the puzzle are handled by the GameSimulator class:
Additionally, theapplyTileEffectfunction of the GameSimulator is called at each tick and takes an entity as the first parameter.
Thus, here is the attack strategy to trap all cats in the top left corner of the board:
applyTileEffectfunctionEntityparameter is a Cat, if not we do nothingBoardPositionobject to where we want the cat to be (i.e. x = 0 and y = 0)Let’s first see what the Frida script looks like:
let OFFSET_APPLY_TILE_EFFECT = 0x458d10
let APPLY_TILE_EFFECT_ENTITY_PARAMETER_INDEX = 2;
let ENTITY_TYPE_OFFSET = 1;
let ENTITY_TYPE_CAT_VALUE = 1166;
let ENTITY_POSITION_OFFSET = 7;
let BOARD_POSITION_X_OFFSET = 7;
let BOARD_POSITION_Y_OFFSET = 0xf;
function reset_cat_position(){
var base_address = Module.findBaseAddress("libapp.so");
Interceptor.attach(base_address.add(OFFSET_APPLY_TILE_EFFECT), {
onEnter: function () {
let entity = dart_get_arg(this.context, APPLY_TILE_EFFECT_ENTITY_PARAMETER_INDEX);
let entity_type = entity.add(ENTITY_TYPE_OFFSET).readInt() * 2
if (entity_type == ENTITY_TYPE_CAT_VALUE){
let entity_position =
get_pointer_with_heap_bit(entity, ENTITY_POSITION_OFFSET, this.context);
let entity_position_x =
entity_position.add(BOARD_POSITION_X_OFFSET).readInt();
let entity_position_y =
entity_position.add(BOARD_POSITION_Y_OFFSET).readInt();
if ((entity_position_x > 1) || (entity_position_y > 1)){
console.log(
`Resetting position of cat (${entity}):
(${entity_position_x}, ${entity_position_y})`
);
entity_position.add(BOARD_POSITION_X_OFFSET).writeInt(0);
entity_position.add(BOARD_POSITION_Y_OFFSET).writeInt(0);
}
}
}
});
}
Here is a short video demonstrating the impact of this small script on the game:
The previous script may seem like it contains a bunch of magic constants coming out of nowhere. In this section, we will see where these constants in the script come from. There are multiple ways to determine these constants, but here is an example of how it could be done using static reverse engineering where we take advantage of the techniques discussed in our two previous posts.
The first step is to recover the address of the targeted function (OFFSET_APPLY_TILE_EFFECT), which is trivial if the build is not obfuscated. Otherwise, the attacker could perform dynamic analysis to trace all function calls and have a short list of functions that are called at each game update.
Once the function is found, it is possible to start reverse engineering it. But it is not required to understand it fully to find what we need.
For instance, here is the beginning of the function:
Since all these checks seem related to the position of the entity, it leaks some information on theEntitystructure:
ENTITY_POSITION_OFFSET)0xf(which gives usBOARD_POSITION_Y_OFFSET)0x7(which gives usBOARD_POSITION_X_OFFSET)All these guesses can be verified by hooking theapplyTileEffectfunction and logging the value of the data stored at these offsets (still using a Frida script). Then, by playing the game, it is possible to validate that what is logged is coherent with the position of entities on the puzzle.
Similarly, we can observe the following code further down in the function:
From this snippet, we can see that the entity type is stored at offset 1 of theEntityobject (which gives usENTITY_TYPE_OFFSET). We can also see that the entity type is an integer and that the code compares it to 1166 and 1164.
Like we did for validating the guess on position, we can log entity type using a Frida script, and observe the game running while looking at the log to confirm thatENTITY_TYPE_CAT_VALUEis 1166.
In this last experiment, we wanted to see if some specificities of Flutter, notably the custom calling convention, have significant impact on hooking based attacks.
We noticed the custom calling convention initially prevented Frida from being able to correctly intercept and modify Flutter function parameters. However, after a limited number of helper functions to access function parameters, we were able to hook every function as we would do for any native library.
Thus, the typical attack scenario would be the same as for a regular app:
When it comes to protection against hooking attacks, you should:
In this post, we investigated how classical game cheat techniques could be applied to Flutter applications.
We noticed that Flutter applications stored their assets and data the same way as regular applications do. Thus, it is possible to modify them in exactly the same way as it is done on a regular application.
And when it comes to patching code, an attacker can use the classical techniques used for patching any native libraries contained in a regular application because Flutter applications are compiled to native code.
Finally, we investigated how hooking can be used to target Flutter applications. The main challenge is that there is no out-of-the-box support to recover or parse function parameters. Thus some work is required to be able to inspect or modify the parameters of a Dart function using a hook. That being said, we demonstrated that with very small generic functions it was possible to already hook a Flutter application and change its parameters.
Although we focused on one specific Flutter application in this post, the methodology and attack techniques demonstrated are not specific to this application. That means that this can be applied to any Flutter (or regular) mobile application.