Trust is good, testing is better: How to pentest Flutter apps
Recently, during a weekend, Daniel and myself stumbled across Flutter-based apps that were not testable out-of-the-box due to Flutter-specific security decisions. Furthermore, we found that, despite the popularity of the framework, there is little information on how to best test such apps. We thus decided to dive deeper into the framework and came up with a blog post that first sheds some light on Flutter from a security perspective and then provides a step-by-step guide on how to test such apps. In total, it will provide four different approaches. With the increasing popularity of the framework and developers relying on Flutter-intrinsic security features, it is especially important to continue challenging them and equip more testers with the required skills to conduct tests.
Android:
In the case of Android, ProxyDroid can be installed on the rooted device. The device needs to be rooted since it internally depends on iptables, which requires kernel support. For a deep dive into ProxyDroid, check out Victor Dorneanu blog post on ProxyDroid. Alternatively, if you are familiar with iptables, you can add the required rules in a root shell without using ProxyDroid.
iOS:
On a jailbroken iPhone, the required iptables rules can be simply added in the same way as with Android.
On a high-level, the functionality can be broken down into the following steps:
Tip: The reFlutter developers have stated that the tool will automate additional aspects of the reverse engineering part in the future. It is therefore advisable to keep an eye on developments in order to put additional hurdles in the way of attackers if necessary.
The nature of Java (bytecode as an intermediate representation, as well as the existence of meta information for compiled methods and classes) in combination with strong, public documentation and mature tools simplifies RE significantly. Android apps written in Java can thus be analyzed in a relatively uncomplicated way.
Flutter, however, does not use Java but Dart, which is compiled directly to machine code. The formats used have not been publicly documented in detail, let alone fully decompiled and recompiled. For comparison, other frameworks like React Native only bundle minified JavaScript that is trivial to examine and modify. Because of these particularities and the missing documentation (at the time of writing, there were only 12 blog posts in relation to RE and pentesting Flutter apps), even one co-developer of Flutter concludes that it is almost impossible to reverse-engineer Dart (i.e., “almost safe from prying eyes”).
Approach 3 is based on the idea that Frida overwrites the certificate-checking function when the app is run, so that the app is forced to accept any certificate. One challenge is to find the function that checks the certificate.
Approach 4 is based on the idea that only the symmetric key is required in order to be able to decrypt the communication of a connection that has already been established. This results in three rough steps:
Tip: It is possible that the app does not communicate via HTTP but gRPC or custom protocols.
After successfully following approach 4.1, you will be able to decrypt and re-encrypt communication using the extracted symmetric TLS key. In order to use Burp in this workflow you have to develop a small network application that does the following things:
What is Flutter and what makes it important?
Flutter is an open-source UI SDK created by Google. Like React Native, it is used to develop cross platform applications for Android, iOS, Linux, macOS, Windows, Google Fuchsia, and the web from a single codebase. It has +140k stars on GitHub and is arguably the most popular cross-platform mobile SDK due to scalability, ease of use, development speed, and security.
To check if an Android app is using Flutter, you can simply follow the following steps:
- Extract the APK file (by renaming the file extension to .zip and extracting it)
- If the app is in the Android App Bundle format, you will notice that there are multiple APK files. Look for the file with an architecture in its name (e.g. split_config.arm64_v8a.apk)
- Navigate to the lib folder
- You will encounter a list of subfolders for different architectures
- The subfolder will contain a libflutter.so file, which is present in all flutter apps
- Extract the IPA file (by renaming the file extension to .zip and extracting it)
- Navigate to the Payload folder
- If the folder Flutter.framework is found in one of the subfolders, it is a Flutter app
How does normal pentesting of a mobile application work?
When pentesting mobile apps, establishing a man-in-the-middle (“MitM”) setup is usually the first step (see Figure 1). This allows testers to intercept, decode, understand, and manipulate the client-server communication with the objective of finding vulnerabilities. To achieve this, the communication must first be routed through a proxy (e.g., Burp Proxy). In addition, the proxy's certificate needs to be stored on the test device so that the app trusts the proxy and allows for the MitM setup.
What makes Flutter different when pentesting?
In the world of Flutter establishing a MitM setup is not as straightforward due to various special features of the framework (from a security perspective, this Flutter-owed complexity may be beneficial as it leads to increased costs for attackers):
- Non-proxy-awareness: Flutter apps are “non-proxy-aware”. Non-proxy aware apps do not have support for proxying at all or are configured to ignore the mobile OS proxy settings, making routing traffic through Burp Proxy harder.
- Separate Keystore: Flutter apps do not use the usual certificate store. More specifically, Dart, the programming language Flutter is written in, generates and compiles its own Keystore making use of Mozilla’s NSS library. SSL validation can thus not be bypassed by simply adding the Burp certificate to the system CA store.
- SSL Pinning (not Flutter specific): This allows you to pin a server’s key or a public key to the client. One of the most efficient ways to achieve this in mobile apps is embedding a trusted SSL certificate.
- Additional security layers: Flutter apps may use additional security layers (e.g., additional encryption or custom protocols) further complicating pentesting.
Sending data through the proxy
First, we need to find a way to force a Flutter app to route its traffic through the proxy (e.g., Burp Proxy). In theory, it would be possible to achieve this by setting specific environment variables such as http_proxy and https_proxy or by rewriting the findProxy implementation to return the proxy of choice. However, typical black-box pentests do not allow for modifying the app.Android:
In the case of Android, ProxyDroid can be installed on the rooted device. The device needs to be rooted since it internally depends on iptables, which requires kernel support. For a deep dive into ProxyDroid, check out Victor Dorneanu blog post on ProxyDroid. Alternatively, if you are familiar with iptables, you can add the required rules in a root shell without using ProxyDroid.
iOS:
On a jailbroken iPhone, the required iptables rules can be simply added in the same way as with Android.
Approach 1: Replacement of app-internal certificates
This approach assumes that the legitimate server certificate is placed somewhere in the app and can simply be replaced with the Burp certificate to enable a MitM setup. The certificate is sometimes placed in the app by developers in order to implement certificate pinning. This approach did not work in the Flutter apps we tested but it is worth checking.Approach 2: reFlutter (Flutter Engine Manipulation)
reFlutter is an open source tool that tries to automate the process of hooking into the certificate-checking function to adjust in a way that any certificates are accepted. Instead of manipulating the compiled form of the function, reFlutter works with the open-source code of the Flutter Engine. For detailed explanations on how to use reflutter for Android and iOS, check out its official GitHub.On a high-level, the functionality can be broken down into the following steps:
- reFlutter determines the exact Flutter Engine version used in the app
- The source code of this exact version is downloaded
- The certificate-checking function is adjusted in the source code so that any certificates are accepted
- The source code is compiled and the result - the modified Flutter Engine - replaces the original in the app
Tip: The reFlutter developers have stated that the tool will automate additional aspects of the reverse engineering part in the future. It is therefore advisable to keep an eye on developments in order to put additional hurdles in the way of attackers if necessary.
Flutter related reverse engineering challenges
Approach 3 and 4 require reverse engineering (RE). This turns out to be more complex with Flutter than with other frameworks. Generally, the possibility and complexity of RE depends largely on two factors: the programming language used and the operating system for which the app was developed.The nature of Java (bytecode as an intermediate representation, as well as the existence of meta information for compiled methods and classes) in combination with strong, public documentation and mature tools simplifies RE significantly. Android apps written in Java can thus be analyzed in a relatively uncomplicated way.
Flutter, however, does not use Java but Dart, which is compiled directly to machine code. The formats used have not been publicly documented in detail, let alone fully decompiled and recompiled. For comparison, other frameworks like React Native only bundle minified JavaScript that is trivial to examine and modify. Because of these particularities and the missing documentation (at the time of writing, there were only 12 blog posts in relation to RE and pentesting Flutter apps), even one co-developer of Flutter concludes that it is almost impossible to reverse-engineer Dart (i.e., “almost safe from prying eyes”).
Approach 3: Manual Frida hooking
Frida is a free dynamic instrumentation toolkit that allows running custom scripts in software that is traditionally locked (i.e. proprietary such as Android apps). The toolkit allows you to “hook” into live processes and add, overwrite, or debug functionality.Approach 3 is based on the idea that Frida overwrites the certificate-checking function when the app is run, so that the app is forced to accept any certificate. One challenge is to find the function that checks the certificate.
Install Frida:
- To set up Frida on both your phone and computer you can simply follow these instructions: https://frida.re/docs/installation/
- Tip: In case you have rooted your Android phone with Magisk, it is easiest to run the magisk-frida module to set up the Frida server
- A good starting point for this approach can be the error thrown by Flutter apps when sending HTTPS traffic to the Burp Proxy.
- The error needs to be found in the BoringSSL library, an open source fork of OpenSSL by Google. The good news is that that error includes information on where it was triggered: handshake.cc:352 (source).
- It can now be easily verified that the section where the error was triggered within BoringSSL contains the function responsible for checking the certificate.
- The error is triggered within the ssl_verify_peer_cert function, which returns the ssl_verify_result_t enum. In theory, the return value of the function can be changed to ssl_verify_ok. However, with Frida it is only easy to change a single return value of a function. The circumstances with ssl_verify_peer_cert are more complicated.
- By further scrutinising handshake.cc, an alternative function can be found: session_verify_cert_chain. The function is defined in ssl_x509.cc, returns a boolean, reports failing certificate checks through OPENSSL_PUT_ERROR, and does not come with the complications of ssl_verify_peer_cert.
- Having determined the function we want to hook, we need to find its location in libflutter.so. You can import libflutter.so into Ghidra and search for the “x509.cc” string. Calls to OPENSSL_PUT_ERROR can help to locate the function.
- Once the location is determined, Frida can be used with two different approaches: Pattern-based or offset-based.
Pattern-based approach:
Offset-based approach:
This approach allowed for setting up MitM in the examples we looked at but keep in mind that it is possible that the communication may remain encrypted or that the app developers opted for a custom protocol.
iOS:
The same principles and techniques apply for iOS as well, but have to be adjusted.
- Determine first couple of bytes of the function
- Check if byte combination is unique in the binary
- If it is unique, the pattern can be used for hooking
- If not, more bytes need to be used to make pattern unique
- Advantage: script can be used for different versions of libflutter.so
- A script can be found below:
const keyLogCallback = new NativeCallback((ctxPtr, linePtr) => { console.log(linePtr.readCString()); }, 'void', ['pointer', 'pointer']); function hook_ssl_log_secret(address) { Interceptor.attach(address, { onEnter: function (args) { var ssl = ptr(args[0]); var ctx = ssl.add(0x68).readPointer(); var keylog_callback = ctx.add(0x220); keylog_callback.writePointer(keyLogCallback); }, }); } function disablePinning() { var m = Process.findModuleByName("libflutter.so"); var pattern = "ff 83 01 d1 fe 1b 00 f9 f6 57 04 a9 f4 4f 05 a9 08 34 40 f9 08 11" var res = Memory.scan(m.base, m.size, pattern, { onMatch: function (address, size) { console.log('[+] ssl_log_secret found at: ' + address.toString()); // Add 0x01 because it's a THUMB function // hook_ssl_log_secret(address.add(0x01)); hook_ssl_log_secret(address); }, onError: function (reason) { console.log('[!] There was an error scanning memory'); }, onComplete: function () { console.log("All done") } }); } setTimeout(disablePinning, 1000)
- Determine the base address used in Ghidra (usually 0x100000)
- Subtract this base address from the address of the function you have located
- e.g., 0x6873d4 (function location) - 0x100000 (base address) = 0x5873d4 (offset)
- In Frida this offset will be added on the base address of libflutter.so to locate the function in the binary
- A script can be found below:
const keyLogCallback = new NativeCallback((ctxPtr, linePtr) => { console.log(linePtr.readCString()); }, 'void', ['pointer', 'pointer']); function hook_ssl_log_secret(address) { Interceptor.attach(address, { onEnter: function (args) { var ssl = ptr(args[0]); var ctx = ssl.add(0x68).readPointer(); var keylog_callback = ctx.add(0x220); keylog_callback.writePointer(keyLogCallback); }, }); } function disablePinning() { var address = Module.findBaseAddress("libflutter.so").add(0x5873d4); hook_ssl_log_secret(address); } setTimeout(disablePinning, 1000)
iOS:
The same principles and techniques apply for iOS as well, but have to be adjusted.
Approach 4.1 – Decryption and understanding
Transport Layer Security (TLS) is an encryption protocol that protects Internet communications and is often used by default in modern apps today. TLS uses both asymmetric and symmetric encryption to protect the confidentiality and integrity of data in transit. Asymmetric encryption is used to establish a secure session between a client and a server. Symmetric encryption is used to exchange data within the secured session.Approach 4 is based on the idea that only the symmetric key is required in order to be able to decrypt the communication of a connection that has already been established. This results in three rough steps:
- Find the symmetric key (e.g. in the memory of the test device or by hooking into a function that uses the key)
- Record client-server TCP communication
- Decrypt the communication with the symmetric key
const keyLogCallback = new NativeCallback((ctxPtr, linePtr) => { console.log(linePtr.readCString()); }, 'void', ['pointer', 'pointer']); function hook_ssl_log_secret(address) { Interceptor.attach(address, { onEnter: function (args) { var ssl = ptr(args[0]); var ctx = ssl.add(0x68).readPointer(); var keylog_callback = ctx.add(0x220); keylog_callback.writePointer(keyLogCallback); }, }); } function disablePinning() { var m = Process.findModuleByName("libflutter.so"); var pattern = "ff 83 01 d1 fe 1b 00 f9 f6 57 04 a9 f4 4f 05 a9 08 34 40 f9 08 11" var res = Memory.scan(m.base, m.size, pattern, { onMatch: function (address, size) { console.log('[+] ssl_log_secret found at: ' + address.toString()); // Add 0x01 because it's a THUMB function // hook_ssl_log_secret(address.add(0x01)); hook_ssl_log_secret(address); }, onError: function (reason) { console.log('[!] There was an error scanning memory'); }, onComplete: function () { console.log("All done") } }); }
Alternative options to find the symmetric key are:
- Find a function X that writes the key into an input parameter of function X. With Frida you can easily read-out input parameters at the start and the end of a function, i.e. you can hook the function either at the start and read the key or at the end right after the return statement.
- Find a function X that receives the struct that contains the key as an input parameter. The key is part of a specific struct and its location can be determined by calculating the offset in it. With Frida you can hook the function and read out the key by applying pointer arithmetic using the offsets you have calculated.
- Start recording the communication
- Start app with Frida
- As soon as the connection has been established, Frida grabs the keys
- Use the app to generate communication
- Save (dump) the communication
- Import recorded communications into WireShark
- Decrypt communications with extracted keys
Tip: It is possible that the app does not communicate via HTTP but gRPC or custom protocols.
Approach 4.2 – Manipulation
In a standard MitM setup you would be able to simply use Burp for intercepting and manipulating app communication without having to handle the decryption and re-encryption yourself. Now you have to handle it yourself.After successfully following approach 4.1, you will be able to decrypt and re-encrypt communication using the extracted symmetric TLS key. In order to use Burp in this workflow you have to develop a small network application that does the following things:
- Listen on two incoming ports: Port X and Port Y
- Port X will receive the encrypted communication from the app (using iptables rules), decrypt it and forward it to Burp
- In Burp you will be able to manipulate the traffic as usual. Using iptables rules the manipulated traffic will go through your network application on port Y before it goes to the internet
- Port Y will re-encrypt incoming plaintext communication and forward it to the intended address in the internet