October 11, 2022

    How Classical Attacks Apply to Flutter™ Apps

    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:

    • Static tampering of data
    • Static tampering of code
    • Dynamic tampering of code via hooking

    Understanding Our Target

    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:

    1-Classical attacks on Flutter apps

    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:

    2-Classical attacks on Flutter apps

    Based on what we saw, several cheat ideas can help us beat the game:

    • We could increase the number of arrows
    • We could create invulnerable mouses
    • We could allow cats to go in the rocket

    We will try and see how the cheat could be implemented using several attack techniques that are used to target (non-Flutter) applications.

    Tampering with Flutter static objects

    What is a static tampering of data?

    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:

    • For asset files, the attacker can just replace files in theresfolder of the APK.
    • For strings in DEX files, since DEX files are disassembled intosmalifiles (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.
    • For strings in native library code, it’s almost the same: the attacker can find the strings in the binary and patch them. However there are some restrictions; the replacement string length, for example, must be smaller or equal to the original string length. If the replacement string was larger than the original one, other data in the binary would be altered, which could cause the application to crash.

    Data Tampering: The Flutter case

    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.

    3-Classical attacks on Flutter apps

    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:

    4-Classical attacks on Flutter apps

    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.

    Tampering with Flutter code

    What is static code tampering?

    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:

    • When patching a DEX file, we have a lot more flexibility since the DEX file can be disassembled into smali file and then reassembled into a DEX file. Thus there are no restrictions on the code injected or tampered with.
    • When patching a native library, we directly patch the binary code. For this reason, we need to ensure we are not corrupting other parts of the code while modifying a function. In practice, this means that when patching native libraries, most of the time, only a limited number of assembly instructions are changed.

    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:

    5-Classical attacks on Flutter apps

    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:

    6-Classical attacks on Flutter apps

    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.

    Code Tampering: The Flutter case

    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):

    1. There must be a function that is called when the user drags an arrow on the board. We need to find it in the binary.
    2. This function will probably perform various checks to see if the user is allowed to place an arrow. We may need to bypass them.
    3. This function will probably decrease the number of remaining arrows. We will need to change that to ensure there is always one remaining arrow.

    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:

    1. The Flutter application is not built using theobfuscatebuilt-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:
      7-Classical attacks on Flutter apps
    2. Application-specific function names won’t be accessible if the application is built using theobfuscatebuilt-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:

    8-Classical attacks on Flutter apps

    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:

    • The function has threeifconditions 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:
    9-Classical attacks__Flutter apps
    • At the end of the function, there is a call to theremainingArrowsfunction. 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:
    10-Classical attacks on Flutter apps--

    After repackaging the application with this tampered native library code, we have an infinite number of arrows:

    11_blog_3_infinite_arrow

    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:

    12_blog_3_invincible_mouse

    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:

    • First the attacker needs to understand the code logic to find out which instructions need to be patched. (We explained in a previous post how efficient reverse engineering can be done on Flutter applications.)
    • Then, the attacker needs to replace the instruction and repackage the modified application.

    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.

    Hooking a Flutter application

    What is hooking?

    Patching code is powerful but it has some limitations:

    • Since patching is done at the assembly level, it means that patches themselves must be written in assembly.
    • Patching one instruction is easy, but if the patch logic requires more assembly instructions, there is a risk that it will overwrite other instructions/functions that will change the application behavior unexpectedly, and will probably make it crash.
    • When prototyping a patch, it has a lot of overhead. This means you’ll need to repackage and re-install the app each time you want to try something new.

    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.

    • For changing application behavior:
      • Check hooked function parameters and patch them only if an attacker-specified condition is met
      • Perform complex operations on data, e.g. deserialize objects and patch some of their internal value
    • To ease the reverse engineering process:
      • Trace function calls to generate a dynamic call graph while using the application.
      • Inspect function parameters or process memory at runtime, as you would do with a debugger.

    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:

    • Ensure that the original instructions patched by the trampoline instructions are executed to ensure the application code will work as expected.
    • Do whatever the attacker wants, like logging function parameters, changing function parameters or replacing the function call.

    The advantage of hooking over patching is that:

    • Only a few instructions are patched, thus there is generally enough space to patch a function.
    • The real attacker code is stored in the application process memory but not where the native library code is. Thus, the attacker’s code can be as big as he wants.
    • Several hooking frameworks automate most of the low-level hooking patching required. For instance, Frida gives an easy-to-use API in Javascript which allows the development of hook logic in a high-level language while being able to hook any native (or Java) code.

    Hooking: The Flutter case

    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:

    • It uses a custom stack and a custom calling convention, which means it is not possible to access the function parameters directly from within a hook
    • The function parameters are Dart objects, thus they must be parsed to get their actual value

    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;
    }
    

    Cheating with hooking

    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:

    • It will move all entities
    • It will check the position of each entity and apply the effect of the tile on the entity:
      • Change entity direction on an Arrow tile
      • Remove entity on a Pit (which is game over if the entity is a mouse)
      • Remove entity on a Rocket (which is game over if the entity is a cat)
    • It will check if a cat is on the same tile as a mouse (which would result in a game over)

    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:

    • Hook theapplyTileEffectfunction
    • Check if itsEntityparameter is a Cat, if not we do nothing
    • Check if the Cat’s position is acceptable (e.g. we can check that it is in the 2x2 top left corner), if it is the case, we do nothing
    • Otherwise, we change the cat’sBoardPositionobject 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:

    13_blog_3_hooking_cheat

    Understanding cheat creation

    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:

    14-Classical attacks on Flutter apps

    Since all these checks seem related to the position of the entity, it leaks some information on theEntitystructure:

    • The position object of the entity seems to be located at offset 7 of the entity (which gives usENTITY_POSITION_OFFSET)
    • Inside the position object, the vertical position seems to be located at offset0xf(which gives usBOARD_POSITION_Y_OFFSET)
    • Inside the position object, the horizontal position seems to be located at offset0x7(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:

    15-Classical attacks on Flutter apps

    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:

    • Identify the function to hook
    • Hook the target function to modify its behavior or parameters

    When it comes to protection against hooking attacks, you should:

    • Obfuscate your code to make it harder for an attacker to understand your code logic or locate the function to hook
    • Check for your code integrity
    • Check for application integrity
    • Try to detect hooking framework

    Conclusion

    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.

    Boris Batteux - Security Researcher

    Discover how Guardsquare provides industry-leading protection for mobile apps.

    Request Pricing

    Other posts you might be interested in