Intercepting traffic from Android Flutter applications

Update: The explanation below explains the step for ARMv7. For ARMv8 (64bit), see this blogpost.

⚠️ Update August 2022 ⚠️
An update to this blog post was written and can be found here. It covers both iOS and Android and a convenient script / Frida codeshare to use.

Flutter is Google’s new open source mobile development framework that allows developers to write a single code base and build for Android, iOS, web and desktop. Flutter applications are written in Dart, a language created by Google more than 7 years ago.

It’s often necessary to intercept traffic between a mobile application and the backend (either for a security assessment or a bounty hunt), which is typically done by adding Burp as an intercepting proxy. Flutter applications are a little bit more difficult to proxy, but it’s definitely possible.

TL;DR

  • Flutter uses Dart, which doesn’t use the system CA store
  • Dart uses a list of CA’s that’s compiled into the application
  • Dart is not proxy aware on Android, so use ProxyDroid with iptables
  • Hook the session_verify_cert_chain function in x509.cc to disable chain validation
  • You might be able to use the script at the bottom of this article directly, or you can follow the steps below to get the right bytes or offset.

Test setup

In order to perform my tests, I installed the flutter plugin and created a Flutter application that comes with a default interactive button that increments a counter. I modified it to fetch a URL through the HttpClient class:

class _MyHomePageState extends State<MyHomePage> {
  int _counter = 0;
  HttpClient client;

  _MyHomePageState()
  {
      _start();
  }
  void _start() async
  {
    client = HttpClient();
  }
  void _incrementCounter() {
    setState(() {
      if(client != null)
      {
          client
              .getUrl(Uri.parse('http://www.nviso.eu')) // produces a request object
              .then((request) => request.close()) // sends the request
              .then((response) => print("SUCCESS - " + response.headers.value("date")));
          _counter++;
       }
    });
  }

The app can be compiled using flutter build aot and pushed to the device through adb install.

Every time we press the button, a call is sent to http://www.nviso.eu and if it’s successful it is printed to the device logs.

On my device I have Frida installed through Magisk-Frida-Server and my Burp certificate is added to the system CA store with the MagiskTrustUserCerts module. Unfortunately, Burp does not see any traffic passing through, even though the app logs indicate that the request was successful.

Sending traffic to the proxy through ProxyDroid/iptables

The HttpClient has a findProxy method and its documentation is pretty clear on this: By default all traffic is sent directly to the target server, without taking any proxy settings into account:

Sets the function used to resolve the proxy server to be used for opening a HTTP connection to the specified url. If this function is not set, direct connections will always be used.

findProxy documentation

The application can set this property to HttpClient.findProxyFromEnvironment which searches for specific environment variables such as http_proxy and https_proxy. Even if the application would be compiled with this implementation, it would be pretty useless on Android since all applications are children of the initial zygote process which does not have these environment variables.

It’s also possible to define a custom findProxy implementation that returns the preferred proxy. A quick modification on my test application indeed shows that this configuration sends all HTTP data to my proxy:

client.findProxy = (uri) {        
    return "PROXY 10.153.103.222:8888";     
};

Of course, we can’t modify the application during a black-box assessment, so another approach is needed. Luckily, we always have the iptables fallback to route all traffic from the device to our proxy. On a rooted device, ProxyDroid handles this pretty well and we can see all HTTP traffic flowing through Burp.

ProxyDroid with root access using iptables

Intercepting HTTPS traffic

This is where it gets more tricky. If I change the URL to HTTPS, Burp complains that the SSL handshake fails. This is weird since my device is set up to include my Burp certificate as a trusted root CA.

After some research, I ended up on a GitHub issue that explains the issue for Windows, but the same is applicable to Android: Dart generates and compiles its own Keystore using Mozilla’s NSS library.

This means that we can’t bypass SSL validation by adding our proxy CA to the system CA store. To solve this we have to dig into libflutter.so and figure out what we need to patch or hook in order to validate our certificate. Dart uses Google’s BoringSSL to handle everything SSL related, and luckily both Dart and BoringSSL are open source.

When sending HTTPS traffic to Burp, the Flutter application actually throws an error, which we can take as a starting point:

E/flutter (10371): [ERROR:flutter/runtime/dart_isolate.cc(805)] Unhandled exception:
 E/flutter (10371): HandshakeException: Handshake error in client (OS Error: 
 E/flutter (10371):  NO_START_LINE(pem_lib.c:631)
 E/flutter (10371):  PEM routines(by_file.c:146)
 E/flutter (10371):  NO_START_LINE(pem_lib.c:631)
 E/flutter (10371):  PEM routines(by_file.c:146)
 E/flutter (10371):  CERTIFICATE_VERIFY_FAILED: self signed certificate in certificate chain(handshake.cc:352))
 E/flutter (10371): #0      _rootHandleUncaughtError. (dart:async/zone.dart:1112:29)
 E/flutter (10371): #1      _microtaskLoop (dart:async/schedule_microtask.dart:41:21)
 E/flutter (10371): #2      _startMicrotaskLoop (dart:async/schedule_microtask.dart:50:5)
 E/flutter (10371): #3      _runPendingImmediateCallback (dart:isolate-patch/isolate_patch.dart:116:13)
 E/flutter (10371): #4      _RawReceivePortImpl._handleMessage (dart:isolate-patch/isolate_patch.dart:173:5)

The first thing we need to do is find this error in the BoringSSL library. The error actually shows us where the error is triggered: handshake.cc:352. Handshake.cc is indeed part of the BoringSSL library and does contain logic to perform certificate validation. The code at line 352 is shown below, and this is most likely the error we are seeing. The line numbers don’t match exactly, but this is most likely the result of a version difference.

if (ret == ssl_verify_invalid) {
    OPENSSL_PUT_ERROR(SSL, SSL_R_CERTIFICATE_VERIFY_FAILED);
    ssl_send_alert(ssl, SSL3_AL_FATAL, alert);
  }

This is part of the ssl_verify_peer_cert function which returns the ssl_verify_result_t enum which is defined in ssl.h at line 2290:

enum ssl_verify_result_t BORINGSSL_ENUM_INT {
  ssl_verify_ok,
  ssl_verify_invalid,
  ssl_verify_retry,
};

If we can change the return value of ssl_verify_peer_cert to ssl_verify_ok (=0), we should be good to go. However, a lot of stuff is going on in this method, and Frida can only (easily) change the return value of a function. If we change this value, it would still fail due to the ssl_send_alert() function call above (trust me, I tried 🙂 ).

Let’s find a better method to hook. Right above the snippet from handshake.cc is the following code, which is the actual part of the method that is validating the chain:

ret = ssl->ctx->x509_method->session_verify_cert_chain(
              hs->new_session.get(), hs, &alert)
              ? ssl_verify_ok
              : ssl_verify_invalid;

The session_verify_cert_chain function is defined in ssl_x509.cc at line 362. This function also returns a primitive datatype (boolean) and is a better candidate to hook. If a check fails in this function, it only reports the issue via OPENSSL_PUT_ERROR, but it doesn’t have side effects like the ssl_verify_peer_cert function. The OPENSSL_PUT_ERROR is a macro defined in err.h at line 418 that includes the source filename. This is the same macro that was used for the error that made it to the Flutter app.

#define OPENSSL_PUT_ERROR(library, reason) \
  ERR_put_error(ERR_LIB_##library, 0, reason, __FILE__, __LINE__)

Now that we know which function we want to hook, we need to find it in libflutter.so. The OPENSSL_PUT_ERROR macro is called a few times in the session_verify_cert_chain function, which makes it easy to find the correct method using Ghidra. So import the library into Ghidra, use Search -> Find Strings and search for x509.cc.

Searching for the x509.cc string

There are only 4 XREFs so it’s easy to go over them and find one that looks like the session_verify_cert_chain function:

Only 4 xrefs

One of the functions takes 2 ints, 1 ‘undefined’ and contains a single call to OPENSSL_PUT_ERROR (FUN_00316500). In my version of libflutter.so, this is FUN_0034b330. What you typically do now is calculate the offset of this function from one of the exported functions and hook it. I usually take a lazy approach where I copy the first 10 or so bytes of the function and check how often that pattern occurs. If it only occurs once, I know I found the function and I can hook it. This is useful because I can often use the same script for different versions of the library. With an offset based approach, this is more difficult.

So now we let Frida search the libflutter.so library for this pattern:

var m = Process.findModuleByName("libflutter.so"); 
var pattern = "2d e9 f0 4f a3 b0 82 46 50 20 10 70"
var res = Memory.scan(m.base, m.size, pattern, {
  onMatch: function(address, size){
      console.log('[+] ssl_verify_result found at: ' + address.toString());  
    }, 
  onError: function(reason){
      console.log('[!] There was an error scanning memory');
    },
    onComplete: function()
    {
      console.log("All done")
    }
  });

Running this script on my Flutter application gives just a single result:

 (env) ~/D/Temp » frida -U -f be.nviso.flutter_app -l frida.js --no-pause                 
 [LGE Nexus 5::be.nviso.flutter_app]-> [+] ssl_verify_result found at: 0x9a7f7040
 All done 

Now we just need to use the Interceptor to change the return value to 1 (true):

function hook_ssl_verify_result(address)
{
  Interceptor.attach(address, {
    onEnter: function(args) {
      console.log("Disabling SSL validation")
    },
    onLeave: function(retval)
    {
      console.log("Retval: " + retval)
      retval.replace(0x1);

    }
  });
}
function disablePinning()
{
 var m = Process.findModuleByName("libflutter.so"); 
 var pattern = "2d e9 f0 4f a3 b0 82 46 50 20 10 70"
 var res = Memory.scan(m.base, m.size, pattern, {
  onMatch: function(address, size){
      console.log('[+] ssl_verify_result found at: ' + address.toString());

      // Add 0x01 because it's a THUMB function
      // Otherwise, we would get 'Error: unable to intercept function at 0x9906f8ac; please file a bug'
      hook_ssl_verify_result(address.add(0x01));
      
    }, 
  onError: function(reason){
      console.log('[!] There was an error scanning memory');
    },
    onComplete: function()
    {
      console.log("All done")
    }
  });
}
setTimeout(disablePinning, 1000)

After setting up ProxyDroid and launching the application with this script, we can now finally see HTTPs traffic:

I’ve tested this on a few Flutter apps and this approach worked on all of them. As the BoringSSL library will most likely stay rather stable, this approach might work for some time to come.

Disable SSL Pinning (SecurityContext)

Finally, let’s see how we can get around SSL Pinning. One way of doing this is by defining a new SecurityContext that contains specific certificates. While this is not technically SSL pinning (you don’t protect against a compromised private key), it’s often implemented to prevent against easy eavesdropping of the communication channel.

For my app, I added the following code to have it accept only my burp certificate. The SecurityContext constructor takes one argument, withTrustedRoots, which defaults to false.

ByteData data = await rootBundle.load('certs/burp.crt');
    SecurityContext context = new SecurityContext();
    context.setTrustedCertificatesBytes(data.buffer.asUint8List());
    client = HttpClient(context: context);

The application will now automatically accept our Burp proxy as the certificate for any website, which shows that this method can be used to specify a specific certificate that the application must comply to. If we now switch this to the nviso.eu certificate, we can no longer intercept the connection.

Fortunately, the Frida script listed above already bypasses this kind of root-ca-pinning implementation, as the underlying logic still depends on the same methods of the BoringSSL library.

Disable SSL Pinning (ssl_pinning_plugin)

One of the ways Flutter developers might want to perform ssl pinning is through the ssl_pinning_plugin flutter plugin. This plugin is actually designed to send one HTTPS connection and verify the certificate, after which the developer will trust the channel and perform non-pinned HTTPS requests:

With correct timing of ProxyDroid, this can already be circumvented, but let’s just disable it anyway.

void testPin() async
  {
    List<String> hashes = new List<String>();
    hashes.add("randomhash");
    try
    {
      await SslPinningPlugin.check(serverURL: "https://www.nviso.eu", headerHttp : new Map(), sha: SHA.SHA1, allowedSHAFingerprints: hashes, timeout : 50);

      doImportanStuff()
    }catch(e)
    {
      abortWithError(e);
    }
  }

The plugin is a bridge to a Java implementation which we can easily hook with Frida:

function disablePinning()
{
    var SslPinningPlugin = Java.use("com.macif.plugin.sslpinningplugin.SslPinningPlugin");
    SslPinningPlugin.checkConnexion.implementation = function()
    {
        console.log("Disabled SslPinningPlugin");
        return true;
    }
}

Java.perform(disablePinning)

Conclusion

This was a pretty fun ride, and it went quite smoothly since both Dart and BoringSSL are open source. Due to just a few interesting strings, it’s pretty easy to find the correct place to disable the ssl verification logic, even without any symbols. My approach with scanning for the function prologue might not always work, but since BoringSSL is pretty stable, it should work for some time to come.

About the author

Jeroen Beckers is a mobile security expert working in the NVISO Cyber Resilience team and co-author of the OWASP Mobile Security Testing Guide (MSTG). He also loves to program, both on high and low level stuff, and deep diving into the Android internals doesn’t scare him. You can find Jeroen on LinkedIn.

41 thoughts on “Intercepting traffic from Android Flutter applications

  1. Dear Jeroen,

    Thanks a lot, this post is so awesome. I have one question though.Have you tried same thing for iOS?

  2. Thank for your sharing. I was deal a flutter app and found another way to bypass. My method is I hook into the “connect” function in c and return ip resolved to my ip burp and use invisible proxy with root CA to bypass SSL pining.

  3. Thanks for the post – I used a simpler method and it worked for all the Flutter Android apps I tested (just 4 of them, didn’t check more): https://hackmd.io/-kYFBsKiQleqrIyXR1eQFA?view

    The Android system CA store was sufficient, both for http.dart and HttpClient from dart:io. Tested on Pixel XL, Android 9 (LineageOS 16).

    1. burp throws “Client failed to negotiate TLS”. Since burp presents its own per-host cert to the device and even if it is in system store, it won’t work. Flutter has its own compiled CAs.

  4. Really beautiful article. learned Ghidara while following this tutorial. I found my offset for verifying function at “2d e9 f0 4f a3 b0 81 46 50 20 10 70” quite similar to what mentioned in this post. Thanks a lot.

  5. This is good stuff. I also achived the same thing by adding a few lines of code in my flutter app, as follow:

    “`
    import ‘dart:io’;

    class MyHttpOverrides extends HttpOverrides {
    @override
    HttpClient createHttpClient(SecurityContext securityContext){
    return super.createHttpClient(securityContext)
    ..badCertificateCallback =
    (X509Certificate cert, String host, int port) => true;
    }

    @override
    String findProxyFromEnvironment(_, __) {
    return ‘PROXY 192.168.0.12:8080;’; // IP address of Burp proxy
    }
    }

    void main() {

    HttpOverrides.global = new MyHttpOverrides();
    runApp(MyApp());
    }

    “`

    1. Hi John! If you have the source code and the ability to recompile the app, then adding your custom proxy in the findProxyEnvironment method is definitely the way to go. If you can’t/don’t, you’ll have to use the setup listed above 🙂 (Don’t forget to remove that certificate check bypass from your app before going to prod 😉 )

    1. Hi Sparrow, I have a post coming up on iOS. Unfortunately it’s a bit more complicated. You have to set up an openvpn server and once again disable the correct SSL validation logic using Frida.

  6. Hi Jeroen,
    First of all, thanks you for sharing, I learn a lot while trying to figure out ghidra & frida.
    Actually this is the first time for me in intercepting traffic on Android Apps. I’ve follow all the walkthrough in this post, but I failed in placing hooks on flutter apps. I am sure the apps is flutter based android apps, but when list installed module (using Process.getModules function) there is no module with name “libflutter.so”, do you why there is no libflutter.so module?

    *note: when I extract the apk (apktoo d apps-name.apk) I am sure there is libflutter.so file

    1. Hi Udin, It is possible that you’re intercepting too early, as the Application actually has to load the flutter library before you can hook it. You can increase the timeout of the setTimeout, maybe that helps.

      1. I am using Genymotion Emulator for bypassing SSL Pinning in Flutter Application. Since the emulator is x86 architecture, I have installed ARM translation tool for running flutter application (ARMv7). The application runs smoothly, libflutter.so is loaded but the script is not able to find the module name with libflutter.so.

  7. Hi Jeroen, an awesome article.
    I have a flutter app but checking the resource there is no libflutter.so.
    I used Process.enumerateModules but did not find any sign of libflutter.so.
    The only place where I found libflutter.so was in /data/app/app.name/lib/arm/libflutter.so along with libapp.so.

      1. Which problem are you trying to solve? You can find libflutter / libapp by using apktool on your apk, or you can extract them from the path that RabinDra posted after installing the apk to a device.

  8. hi.

    i increased setTimeout too, but i have same problem and it did not find any sign of libflutter.so.
    please help
    I am sure there is libflutter.so file

    1. If you decompile the app with apktool, can you find a libflutter.so? Some apps dont’ load the flutter library at start time, so you could hook the System.loadLibrary call and wait for the library to finish loading before hooking the SSL logic.

  9. latest libflutter on x64 works for me with pattern
    var pattern = “ff 03 05 d1 fd 7b 0f a9 fa 67 10 a9”;
    and because of x64 normal address
    hook_ssl_verify_result(address);

  10. Hi Jeroen,

    In my case lib folder contains “armeabi-v7a” and “arm64-v8a” both architecture folders, so might application supports both architectures.

    However, when i open libflutter.so in Ghidra i searched for “x509.cc” and found 4 unique functions s shown below:

    ————————————————————————————————————–
    s_../../third_party/boringssl/src/_002ed103 XREF[5]:

    FUN_00883774:0088380f(*),
    FUN_00883f7a:008841c9(*),
    FUN_008842e4:008844a8(*),
    FUN_008845c1:008846a7(*),
    FUN_008845c1:008846c2(*)
    002ed103 2e 2e 2f ds “../../third_party/boringssl/src/ssl/ssl_x509.
    2e 2e 2f
    74 68 69
    ————————————————————————————————————–

    how to identify that which function fulfills below requirement:

    “One of the functions takes 2 ints, 1 ‘undefined’ and contains a single call to OPENSSL_PUT_ERROR (FUN_00316500). In my version of libflutter.so, this is FUN_0034b330”

    waiting for your response

  11. Hi, Jeroen

    Im trying to replicate this using that app on your GitHub. However, the interceptor.process does not execute. May I know what version of frida-server (the one pushed in the mobile device) and frida (the one in the computer) are you using for this. Thanks

    1. Hi Bo! I recently did a rewrite of the script; Can you try out the latest version on GitHub and open a Github issue in case it doesn’t work?

      1. Hi Jeroen,

        Thank you for replying. I opened a github issue. I made it as detailed as possible.
        Can you please take a look at it.

        Thank you.

  12. Hi, Jeroen,

    It’s me again. I am getting this error that is showing on the app itself “connection closed before full header was received”.

    This only happens when Proxydroid is open but when I close it the app works perfectly

    I can also intercept the other functionality of the app but other functions like adding an account, it does not work. I am getting that “connection closed” error.

    Any idea on what is this?..

    Thanks

Leave a Reply