Intercepting Flutter traffic on iOS

My previous blogposts explained how to intercept Flutter traffic on Android ARMv8, with a detailed follow along guide for ARMv7. This blogpost does the same for iOS.

⚠️ 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. To set up the initial MitM via WiFi/VPN, you can still use this article.

Testing apps

The beauty of a cross-platform application is of course that I can use my previous Android test app for iOS so it has the same functionality. You can find an IPA version of the test file on our github, and you can install the app by copying it over to your jailbroken device and using appinst:

appinst proxyme.ipa 
2020-06-12 10:54:57.722 appinst[2454:755332] appinst (App Installer)
2020-06-12 10:54:57.724 appinst[2454:755332] Copyright (C) 2014-2019 Linus Yang, Karen/あけみ
2020-06-12 10:54:57.724 appinst[2454:755332] ** PLEASE DO NOT USE APPINST FOR PIRACY **
2020-06-12 10:54:57.731 appinst[2454:755332] appinst: main:58 Cleaning up temporary files...
2020-06-12 10:54:57.751 appinst[2454:755332] appinst: main:133 Installing be.nviso.flutterApp ...
2020-06-12 10:55:02.500 appinst[2454:755332] appinst: main:183 Successfully installed be.nviso.flutterApp

The app contains two buttons, one to send an HTTP request and one to send an HTTPS one:

void callHTTP(){
  client = HttpClient();
  _status = "Calling...";
  client
      .getUrl(Uri.parse('http://neverssl.com'))
      .then((request) => request.close())
      .then((response) => setState((){_status = "HTTP: SUCCESS (" + response.headers.value("date") + ")" ;}))
      .catchError((e) =>
          setState(() {
            _status = "HTTP: ERROR";
            print(e.toString());
          })
      );
}
void callHTTPS(){
  client = HttpClient();
  _status = "Calling...";
  client
      .getUrl(Uri.parse('https://www.nviso.eu')) // produces a request object
      .then((request) => request.close()) // sends the request
      .then((response) => setState((){
                                        _status = "HTTPS: SUCCESS (" + response.headers.value("date") + ")" ;
                                    }))
      .catchError((e) =>
                      setState(() {
                        _status = "HTTPS: ERROR";
                        print(e.toString());
                      })
                );
}

Let’s get started

On iOS, the story is exactly the same as on Android. The app is proxy unaware and uses its own certificate store. Setting a proxy in your WIFI settings won’t have any effect, and trusting your certificate in the system settings won’t validate any HTTPS certificates. The first idea to fix the proxy issue would be to SSH into your iOS device and use iptables to redirect the traffic, just like ProxyDroid does on Android. Unfortunately, iptables requires kernel support, and the iOS kernel does not have any support for it. The next obvious step is to recompile the iOS kernel to implement this support. So download your copy of the iOS kernel and let’s get started!

All jokes aside, adding kernel support is not an option, so we will have to look elsewhere. The easiest approach is to create a WIFI hotspot using a second WIFI adapter and use iptables to redirect all traffic to Burp. However, if you don’t have an extra WIFI adapter, you can also set up an OpenVPN server and have your device connect to it. Both possibilities are explained below.

Setting up a WIFI hotspot

The steps are rather straightforward, though depending on your OS and network setup it might require a bit of troubleshooting. I’ll run through the steps I took, starting from a clean Kali image, but if something goes wrong, you’ll have to troubleshoot yourself ;). Note that you can also set one up through the Kali ‘Advanced Network Configuration’ panel (type: hotspot), but where’s the fun in that?

Setting up kali

Download the latest Kali (2020.1b in my case) and spin up a VM instance. First, we need hostapd for a wireless network, and dnsmasq for the DHCP server:

sudo apt-get update && sudo apt-get install hostapd dnsmasq

I’m using a small WIFI dongle with a Ralink 5370 chipset, so I have two adapters: eth0 and wlan0.

Setting up the WIFI network

We need to create a hostapd configuration for our network. Create the mitmwifi.conf file and add the data as seen below. This will create a WIFI network with SSID MobileTestbed and Password123 as a password.

sudo nano /etc/hostapd/mitmwifi.conf
# Enter the following configuration:
interface=wlan0
driver=nl80211
ssid=MobileTestbed
hw_mode=g
channel=6
macaddr_acl=0
ignore_broadcast_ssid=0
auth_algs=1
wpa=2
wpa_key_mgmt=WPA-PSK
rsn_pairwise=TKIP
wpa_passphrase=Password123

Next, we update the hostapd init script to reference to the mitmwifi.conf file:

sudo nano /etc/default/hostapd
# Update the DAEMON_CONF line:
DAEMON_CONF="/etc/hostapd/mitmwifi.conf"

Set up dnsmasq by modifying /etc/dnsmasq.conf

sudo nano /etc/dnsmasq.conf
# Add the following configuration to the end of the file:
# The interface to listen on
interface=wlan0
# The range to distribute (192.168.10.100-250)
dhcp-range=192.168.10.100,192.168.10.250,255.255.255.0,12h
# The gateway (this ip)
dhcp-option=3,192.168.10.1
# The DNS server
dhcp-option=6,1.1.1.1
# Another DNS server
server=1.1.1.1
# Some logging
log-queries
log-dhcp
# Listen on the localhost address
listen-address=127.0.0.1

Assign the correct IP address to the wlan0 interface, enable IP forwarding and route the traffic to eth0 so that the subnet has internet access.

sudo ifconfig wlan0 up 192.168.10.1 netmask 255.255.255.0
sudo sysctl -w net.ipv4.ip_forward=1
sudo iptables -t nat -A  POSTROUTING -o eth0 -j MASQUERADE 

Finally, start dnsmasq and restart hostapd. The WIFI network should show up on your device:

sudo systemctl unmask hostapd
sudo service hostapd start
sudo service dnsmasq start

At this point, you have a working WIFI hotspot with outgoing internet access. Skip to ‘Setting up the MITM’ in case these steps were successful. Otherwise, you can try the OpenVPN approach explained below.

Setting up OpenVPN

In case you don’t have access to a WIFI adapter, or there are other reasons why the hotspot approach doesn’t work, we need a second option. Fortunately, there is a good way to have all the traffic from an iOS device go over an intermediate node: Through a VPN. This functionality has already been used by some corporations to spy on iOS users, so if they can do it, we can too!

Installing OpenVPN is pretty straightforward with the help of this setup script. The script detects our Kali as a Debian OS, but fails to determine the version and will therefore exit. That’s why I added a step to delete the exit statement in the Debian version check. I used a bit of bash hacking, but if it doesn’t work anymore, just remove the line manually.

wget https://git.io/vpn -O openvpn-install.sh
sed -i "$(($(grep -ni "debian is too old" openvpn-install.sh | cut  -d : -f 1)+1))d" ./openvpn-install.sh
chmod +x openvpn-install.sh 
sudo ./openvpn-install.sh

Choose the following options:

# Choose the following options:
Public IPv4 address / hostname [xx.xx.xx.xx]: 192.168.10.1     <<< Change with your public IP address.
Protocol [1]: 1         (UDP)
Port [1194]: 1194 
DNS server [1]: 3              (1.1.1.1)
Name [client]: nviso

The script will set everything up and create an OpenVPN configuration located in the /root/ home directory. If you run sudo ifconfig now, you can see that a tun0 interface has been added.

Finally, start the OpenVPN service:

sudo service openvpn start

Install the OpenVPN client on your iPhone and start a python HTTP server to host the OpenVPN configuration:

sudo python3 -m http.server 8080 --directory /root/

Navigate to <yourip>:8080 on your iPhone in Safari and download the ovpn file. Open the file and follow the steps to add it to the OpenVPN app.

At this point, you should have internet access on the device and see a VPN icon on the top of your screen.

Setting up the MITM

Finally, we need to intercept the traffic when it leaves either the WIFI interface or the OpenVPN interface and before it goes to the eth0 interface. We can do this by using iptables. Modify 192.168.10.0 with the actual IP address where your traffic enters the network.

# For WIFI: -i wlan0
sudo iptables -t nat -A PREROUTING -i wlan0 -p tcp --dport 80 -j REDIRECT --to-port 8080
sudo iptables -t nat -A PREROUTING -i wlan0 -p tcp --dport 443 -j REDIRECT --to-port 8080
sudo iptables -t nat -A POSTROUTING -s 192.168.10.0/24 -o eth0 -j MASQUERADE
# For OpenVPN: -i tun0
sudo iptables -t nat -A PREROUTING -i tun0 -p tcp --dport 80 -j REDIRECT --to-port 8080
sudo iptables -t nat -A PREROUTING -i tun0 -p tcp --dport 443 -j REDIRECT --to-port 8080
sudo iptables -t nat -A POSTROUTING -s 192.168.10.0/24 -o eth0 -j MASQUERADE

Next, start up Burp, enable a listener on port 8080 on either 10.8.0.1 or 192.168.10.1 (or ‘all interfaces’) and enable ‘Invisible proxy’ mode:

At this point, the HTTP traffic is intercepted, from both Safari and the Flutter test app.

Disable SSL verification and intercept HTTPS traffic

Now that we have a MITM on the HTTP traffic, it’s time to do the same for HTTPS. Unfortunately, Flutter doesn’t use any of iOS’s default libraries so the standard approach of Objection or SSLKillSwitch won’t work. Flutter apps use the BoringSSL library to create TLS connections, and those are the methods we need to hook or modify in order to modify the certificate validation logic. Which method we want to change is explained in more detail in a previous blogpost, so be sure to read that for background information. To be able to intercept HTTPS we will need to:

  • Get the Flutter binary in a decrypted form
  • Find the correct method to hook
  • Write a Frida script to modify behavior.

Let’s get started!

Acquire the Flutter binary

First, we need the Flutter framework file from the target app. Depending on how the IPA is installed, you will need to take a different approach, as the IPA may or may not be encrypted. The test app in this case is installed through appinst with a development certificate and is not encrypted. We can therefore extract it using ipainstaller:

ipainstaller -b be.nviso.flutterApp

Alternatively, if the app was downloaded from the App Store, you should use Clutch. Clutch needs to be built on MacOS and pushed to the device through SCP. The exact instructions can be found on the GitHub page. Once it has been installed, you can use it to create a decrypted IPA file:

./Clutch -d <packagename>

After that, you end up with an IPA file that you can copy to your host with SCP and get to the Flutter binary which is located at <app>/Payload/Runner.app/Frameworks/Flutter.framework/Flutter:

# Copy the ipa to the host
scp root@192.168.2.4:"/private/var/mobile/Documents/flutter_app\ \(be.nviso.flutterApp\)\ v1.0.0.ipa" ./flutterapp.ipa
# Unzip the ipa file
unzip flutterapp.ipa 
# Find the Flutter binary
file Payload/Runner.app/Frameworks/Flutter.framework/Flutter 
# Result: Payload/Runner.app/Frameworks/Flutter.framework/Flutter: Mach-O universal binary with 2 architectures: [armv7:Mach-O armv7 dynamically linked shared library, flags:<NOUNDEFS|DYLDLINK|TWOLEVEL|WEAK_DEFINES|BINDS_TO_WEAK|NO_REEXPORTED_DYLIBS>] [arm64]

For the test app, you end up with a fat binary, as it still contains 2 architectures. You can extract the arm64 version with lipo on MacOS:

lipo -thin arm64 Flutter -output FlutterThin
file FlutterThin
# Result: FlutterThin: Mach-O 64-bit dynamically linked shared library arm64

Find the session_verify_cert_chain method

Now that we have the binary, we can identify and patch the method performing the SSL verification in order to for the binary to accept our certificate. As explained in-depth in my previous blogpost, the ‘session_verify_cert_chain method’ is the one we are looking for. There are two approaches to locate that method in the binary: Search for the magic number of 0x186, or for the x509.cc string. Since there are less references to the magic number than there are to the x509.cc string, let’s take the first approach. Select Search > For Scalars and enter 0x186 in the Specific Scalar field.

Searching for the magic number 0x186

The correct reference is in FUN_004068C8. The decompiled version of this function is very similar to the one identified on Android ARM64, and the x509.cc string is also referenced from here, so we can be pretty sure this is the right function. If you’ve read the other blog posts, you know I’m a fan of searching for the correct method using a bunch of bytes rather than take the offset directly, so that’s what we’ll do.

The session_verify_cert_chain method and the method signature

The first bytes of this method are ff 03 05 d1 fc 6f 0f a9 f8 5f 10 a9 f6 57 11 a9 f4 4f 12 a9 fd 7b 13 a9 fd c3 04 91 08 0a 80 52 and we can use binwalk to get the correct offset:

binwalk -R "\xff\x03\x05\xd1\xfc\x6f\x0f\xa9\xf8\x5f\x10\xa9\xf6\x57\x11\xa9\xf4\x4f\x12\xa9\xfd\x7b\x13\xa9\xfd\xc3\x04\x91\x08\x0a\x80\x52" FlutterThin
DECIMAL       HEXADECIMAL     DESCRIPTION
--------------------------------------------------------------------------------
4221128       0x4068C8        Raw signature (\xff\x03\x05\xd1\xfc\x6f\x0f\xa9\xf8\x5f\x10\xa9\xf6\x57\x11\xa9\xf4\x4f\x12\xa9\xfd\x7b\x13\xa9\xfd\xc3\x04\x91\x08\x0a\x80\x52)

To make sure this is a repeatable process, I farmed some Flutter apps and ran the signature over all of them:

binwalk -R "\xff\x03\x05\xd1\xfc\x6f\x0f\xa9\xf8\x5f\x10\xa9\xf6\x57\x11\xa9\xf4\x4f\x12\xa9\xfd\x7b\x13\xa9\xfd\xc3\x04\x91\x08\x0a\x80\x52" *
Target File:   /home/flutter/testapps/anon/Flutter1
DECIMAL       HEXADECIMAL     DESCRIPTION
--------------------------------------------------------------------------------
4221128       0x4068C8        Raw signature (\xff\x03\x05\xd1\xfc\x6f\x0f\xa9\xf8\x5f\x10\xa9\xf6\x57\x11\xa9\xf4\x4f\x12\xa9\xfd\x7b\x13\xa9\xfd\xc3\x04\x91\x08\x0a\x80\x52)
Target File:   /home/flutter/testapps/anon/Flutter2
DECIMAL       HEXADECIMAL     DESCRIPTION
--------------------------------------------------------------------------------
Target File:   /home/flutter/testapps/anon/Flutter3
DECIMAL       HEXADECIMAL     DESCRIPTION
--------------------------------------------------------------------------------
4247284       0x40CEF4        Raw signature (\xff\x03\x05\xd1\xfc\x6f\x0f\xa9\xf8\x5f\x10\xa9\xf6\x57\x11\xa9\xf4\x4f\x12\xa9\xfd\x7b\x13\xa9\xfd\xc3\x04\x91\x08\x0a\x80\x52)
Target File:   /home/flutter/testapps/anon/Flutter4
DECIMAL       HEXADECIMAL     DESCRIPTION
--------------------------------------------------------------------------------
4370908       0x42B1DC        Raw signature (\xff\x03\x05\xd1\xfc\x6f\x0f\xa9\xf8\x5f\x10\xa9\xf6\x57\x11\xa9\xf4\x4f\x12\xa9\xfd\x7b\x13\xa9\xfd\xc3\x04\x91\x08\x0a\x80\x52)
Target File:   /home/flutter/testapps/anon/Flutter5
DECIMAL       HEXADECIMAL     DESCRIPTION
--------------------------------------------------------------------------------
4221128       0x4068C8        Raw signature (\xff\x03\x05\xd1\xfc\x6f\x0f\xa9\xf8\x5f\x10\xa9\xf6\x57\x11\xa9\xf4\x4f\x12\xa9\xfd\x7b\x13\xa9\xfd\xc3\x04\x91\x08\x0a\x80\x52)

Four versions are playing nice, but one version doesn’t have a match. For this version, most likely an older one, the last four bytes don’t match. In this case, you can shorten the signature and use trial & error, or open up Ghidra and find the correct offset manually.

Hook it with Frida

With the correct signature, you can now let Frida search for the correct function to hook. The script is very similar to the one for Android:

function hook_ssl_verify_result(address)
{
    Interceptor.attach(address, {
        onEnter: function(args) {
            console.log("Disabling SSL validation")
        },
        onLeave: function(retval)
        {
            retval.replace(0x1);
        }
    });
}
function disablePinning()
{
    var pattern = "ff 03 05 d1 fc 6f 0f a9 f8 5f 10 a9 f6 57 11 a9 f4 4f 12 a9 fd 7b 13 a9 fd c3 04 91 08 0a 80 52"
    Process.enumerateRangesSync('r-x').filter(function (m) 
    {
        if (m.file) return m.file.path.indexOf('Flutter') > -1;
        return false;
    }).forEach(function (r) 
    {
        Memory.scanSync(r.base, r.size, pattern).forEach(function (match) {
        console.log('[+] ssl_verify_result found at: ' + match.address.toString());
        hook_ssl_verify_result(match.address);
        });
    });
}
setTimeout(disablePinning, 1000) 

Alternatively, if you know the offset, the following script can be used:

function hook_ssl_verify_result(address)
{
    Interceptor.attach(address, {
        onEnter: function(args) {
            console.log("Disabling SSL validation")
        },
        onLeave: function(retval)
        {
            retval.replace(0x1);
        }
    });
}
function disablePinning()
{
    var m = Process.findModuleByName("Flutter"); 
    hook_ssl_verify_result(m.base.add(0x4068C8))
}
setTimeout(disablePinning, 1000) 

Run it with Frida:

frida -Uf be.nviso.flutterApp -l disable.js --no-pause
...
   . . . .   More info at https://www.frida.re/docs/home/
Spawned `be.nviso.flutterApp`. Resuming main thread!                    
[iOS Device::be.nviso.flutterApp]-> [+] ssl_verify_result found at: 0x1013928c8
Disabling SSL validation

And finally, even the HTTPS traffic is intercepted.

Final thoughts

Because much of the reverse-engineering work was already done in my Android blogposts, it was fairly easy to find the correct method in Ghidra. This is one of the rare cases where having a cross-platform framework is actually beneficial to the reverse-engineering process, which is usually not the case. Usually, it’s not possible to reuse techniques between platforms; take for example Xamarin, which is interpreted code on Android but native code on iOS, or hybrid applications where the webview communicates with a native layer in either Java/Kotlin or ObjectiveC/Swift.

Flutter seems to be gaining traction, so the development of tools and scripts to aid in security assessments will be very necessary. Hopefully this blogpost was a push in the right direction for you 🙂

8 thoughts on “Intercepting Flutter traffic on iOS

  1. Hi! Very very good article 🙂 Thanks. Related to intercepting the traffic. I wasn’t able to listen Firebase Cloud Firestore requests of my native iOS app, with mitmproxy. Do you know why is that?

Leave a Reply