Tuesday, May 7, 2024

Bypassing Certificate Pinning on Flutter-based Android Apps. A new guide.

One of the preliminary activities when analyzing mobile application, more usually than not, is to be able to sniff HTTP/S traffic via a MitM proxy
This is quite straightforward in the case of naive applications, but can be quite challenging when applications use certificate pinning techniques. In this post I'll try to explain the methodology I used to make this possible for a Flutter-based Android sample application in a reliable way.


It was indeed the need to bypass a certificate validation on a Flutter framework during a mobile application penetration testing activity for a customer of ours, that led to this research. 

As a first approach, as usual, we tried some of the specific exploits/bypasses we found on the web. 
Alas, in this case, they failed.

Some of the main concepts that are going to be explained, actually, overlap in what those articles contain; what it differs is the technique used for identifying and hooking at runtime the routine used for certificate verification.

While minimizing effort was a key objective in bypassing certificate pinning controls, the chosen approach turned out to be overly generic.  Unlike pattern matching techniques, which can be tailored to specific scenarios, this method becomes unreliable across different platforms, architectures, and even builds.

While the article's findings may not address all the case studies, it proposes an effective methodology that can be readily applied, with minimal adjustments, to similar scenarios. 

Moreover, as a different approach, reFlutter would be a valid tool for such a similar job. Anyway, as it statically patches code to deactivate runtime checks, if the mobile application was signed and/or had file integrity checks, static approach would definitely be challenging.  

In the next paragraphs, we're going to cover the following topics:
  • Why Flutter is so different
  • Flutter based APK structure
  • Force traffic forwarding to my proxy
  • Diving into BoringSSL source code
  • Reverse-engineering on libflutter.so
  • Frida hooking script
Finally, in order to give some testbed, we created a basic Flutter application which fetches data from the URL https://dummy.restapi.example.ltd/api/v1/employees, using code that correctly implements certificate pinning.
The entire application code, the build and the Frida script are available on Github at the following link:

Why Flutter is so Different

This is, obviously, not the first time we have to deal with bypassing certificate pinning, so, why is Flutter so different from other scenarios?
  • Flutter is a Google's open source portable toolkit able to create natively compiled applications for mobile, web, and desktop from a single codebase. 
  • The final Flutter applications are written in Dart, whilst the framework engine is built mainly in C++. 
  • The compilation obviously differs on the target platform: for Android, the engine is compiled using the Android's NDK (for this blogpost we are going to focus only on the Android ARMv8 platform). 

In this development environment more blazoned SSL pinning bypass methodswould not work, as the real checks are implemented in "native code", thus results compiled in machine code and many details may change among different framework builds. 

In addition, for our activities of analysis of the HTTP request sent to remote servers we would like to forward the application's traffic throughout a forward HTTP proxy:
- the first idea that an unaware analyst could have is to set this in the Network settings of Android system, but this would not work at all. 

Flutter, at low level, uses dart:io library for managing socket, file and other I/O support in non browser-based applications. 
As dart:io documentation says, by default the HttpClient uses the proxy configuration available from the environment, i.e it will take into account enviroment variables http_proxy, https_proxy, no_proxy, HTTP_PROXY, HTTPS_PROXY, NO_PROXY which, on Android based OSs, are not affected by any settings change, since every application runs in a child process of Zygote inheriting its environment variables, that are loaded at system boot.

Flutter Based APK Structure

The resource/lib folder inside the APK contains the compiled Dart code (compiled to native binary code using the Dart VM's Ahead-of-Time compilation) for the Flutter application. 
The compiled code is stored in various folders organized according to the target platform, such as arm64-v8a for 64-bit ARM, armeabi-v7a  for 32-bit ARM etc. 
Specifically the two main shared objects are:
  • libapp.so: which contains the compiled native code that corresponds to the Dart code of your Flutter app. This native code is executed when the Flutter app is launched on a device.
  • libflutter.socontaining the native code of the Flutter engine, which powers the Flutter framework and enables the execution of Flutter apps on Android devices. This library includes the core functionalities of the Flutter framework

Forcing Traffic Forwarding to the Proxy

We know that the Flutter application will not honor the proxy settings at system level, thus we need a finer technique for fundamental part.
Once granted root privileges on our Android device (Google Pixel 4a), we have the possibility to set an iptables rule for the redirection of the desired HTTP traffic table throughout the a Burp Pro proxy instance running on my laptop:

  1. connect the android device via USB and run an adb shell as root.
    adb shell su
  2. setup the redirection:
    iptables -t nat -A OUTPUT -p tcp -d destination-host.domain --dport 443 \-j DNAT --to-destination
  3. reverse map the port 8080 of the android device over the proxy running on my laptop on port 8080:
    adb reverse tcp:8080 tcp:8080
Note: proxydroid could also be adopted equivalently to the iptables rule: it is just a frontend for iptables.

Finally, it is also important to point out that we need to set up our Burp listening server as invisible, so that it won't be necessary to explicitly set a proxy on the device, and the proxy itself will act as a perfect man-in-the-middle and directly intercept the SSL/TLS handshaking.  

At this point the traffic is redirected to Burp as shown in a screenshot of the test application taken just after the triggering of the sample HTTP request and after the setting up of the proxy interception:

Sample application's error message after having forced traffic to Burp Pro proxy instance

The error is pretty verbose about the code which triggered the HandshakeException.
Someone at this point might say: "Well, production applications usually don't show such verbose errors!"
We're using our test bed, once we have the know-how to bypass such controls, we'll be able to replicate it to any Flutter-based application. 

Diving into BoringSSL Source Code

Flutter Engine relies on boringssl library for SSL/TLS implementation, which is the Google's fork of the well known openssl. 
As the error suggested, the certificate verification which trapped was performed in boringssl/ssl/handshake.cc at line 393, coherently with what displayed in the previous screenshot, there is the raising of the represented error concerning the failure of the certificate verification. 

Point of triggering in BoringSSL source code of the TLS certificate verification error

The interesting part of this function is the else block immediately above: assuming no custom_verify_callback has been set, the code is invoking ssl->ctx->x509_method->session_verify_cert_chain and is setting ret by evaluating the boolean result of the same. 

That method is a function pointer referring ssl_crypto_x509_session_verify_cert_chain, which is defined in borisngssl/ssl/ssl_x509.cc. To bypass the check was only needed to make this function return always true.  

Reverse-Engineering on libflutter.so

At this point, we unpack the application's apk and open in IDA the resources/lib/arm64-v8a/libflutter.so shared object; which is the Flutter core engine natively compiled for the platform Android ARM64
The ELF file was stripped and had just a single exported symbol: JNI_Onload (address 0x00382940). For this reason, identifying the portion of assembly which was related to ssl_crypto_x509_session_verify_cert_chain was not so trivial.

Here follows the integral source code of that function.

ssl_crypto_x509_session_verify_cert_chain implementation taken from BoringSSL repository

As it can be seen, the function at a certain point uses two string literals: "ssl_client" and "ssl_server". Those are both stored in the .rodata section of libflutter.so. So, the strategy used was to analyze the XREFS (cross-references) of those strings to find a function which locally addressed both. The single procedure which used both was that at address 0x005FDF64.

Decompiled ssl_crypto_x509_session_verify_cert_chain by IDA Pro 7.7 

Frida Hooking Script

After identifying the native subroutine that should have been hooked, it is possible to implement a simple Frida script to intercept calls to the function  ssl_crypto_x509_session_verify_cert_chain and force its return value to true (i.e., 0x01).
To find the absolute address which depends on the memory mapping of libflutter.so into application proces,s the reader will have to:
  • Calculate the relative logical offset between ssl_crypto_x509_session_verify_cert_chain and JNI_Onload: this is done by simply subtract the JNI_Onload logical address inside libflutter.so, which is readable into the shared object exports table, to the ssl_crypto_x509_session_verify_cert_chain logical address address, whose value was previously found with the technique shown in the previous section; in this sample analysis they are respectively 0x005FDF64 and 0x00382940, thus the resulting offset would be:
     0x005FDF64 - 0x00382940 = 0x0027b624.
  • Since JNI_OnLoad is an exported symbol, the dynamic linker updates the global offset table (GOT) upon its first invocation. This update provides the logical address of JNI_OnLoad relative to the shared object's memory layout within the process.  Therefore, we can easily access it using the enumerateExports() method in the Frida script.
  • Add the offset obtained to the JNI_Onload address for this process, to get the current ssl_crypto_x509_session_verify_cert_chain address (i.e. the address to which the function 

function hook_ssl_crypto_x509_session_verify_cert_chain(address){
Interceptor.attach(address, {
onEnter: function(args) { console.log("Disabling SSL certificate validation") },
onLeave: function(retval) { console.log("Retval: " + retval); retval.replace(0x1);}
function disable_certificate_validation(){
var m = Process.findModuleByName("libflutter.so");
console.log("libflutter.so loaded at ", m.base);
var jni_onload_addr = m.enumerateExports()[0].address;
console.log("jni_onload_address: ", jni_onload_addr);
// Adding the offset between
// ssl_crypto_x509_session_verify_cert_chain and JNI_Onload = 0x0027b624
let addr = ptr(jni_onload_addr).add(0x0027b624);
console.log("ssl_crypto_x509_session_verify_cert_chain_addr: ", addr);
let buf = Memory.readByteArray(addr, 12);
console.log(hexdump(buf, { offset: 0, length: 64, header: false, ansi: false}));

setTimeout(disable_certificate_validation, 1000)


At this point we are ready to execute Frida server via adb and run the above script with this command:
frida -U -f com.application -l script.js
We managed to bypass the certificate verification and intercepted HTTPS traffic:

Frida script launching 

Here follows the result of the bypass, showing the Burp Pro intercepting the request and its related response.


Request triggered by the Flutter App intercepted by Burp Pro