Some time ago I wrote some articles on how to Man-In-The-Middle Flutter on iOS, Android (ARM) and Android (ARM64). Those posts were quite popular and I often went back to copy those scripts myself.
Last week, however, we received a Flutter application where the script wouldn’t work anymore. As we had the source code, it was easy to figure out that the application was using the dio package to perform SSL Pinning.
While it would be possible to remove the pinning logic and recompile the app, it’s much nicer if we can just disable it at runtime, so that we don’t have to recompile ourselves. The result of this post is a Frida script that works both on Android and iOS, and disables the full TLS verification including the pinning logic.
As usual, we’ll create a test app to validate our script. I’ve created a basic Flutter app similar to the previous posts which has three buttons: HTTP, HTTPS and HTTPS (Pinned).
The app can be found on the GitHub page and an APK and IPA build are available. The Dio pinning logic is pretty straightforward:
ByteData data = await rootBundle.load('raw/certificate.crt'); Dio dio = Dio(); (dio.httpClientAdapter as DefaultHttpClientAdapter).onHttpClientCreate = (client) { SecurityContext sc = new SecurityContext(); sc.setTrustedCertificatesBytes(data.buffer.asUint8List()); HttpClient httpClient = new HttpClient(context: sc); return httpClient; }; try { Response response = await dio.get("https://www.nviso.eu/?dio"); _status = "HTTPS: SUCCESS (" + response.headers.value("date")! + ")" ; } catch (e) { print("Request via DIO failed"); print("Exception: $e"); _status = "DIO: ERROR"; }
Originally, we hooked the ssl_crypto_x509_session_verify_cert_chain
function, which can currently be found at line 361 of ssl_x509.cc. This method is responsible for validating the certificate chain, so if this method returns true, the certificate chain must be valid and the connection is accepted.
When performing a MitM on the test app on Android ARM64, the following error is printed in logcat:
3540 3585 I flutter : Request via DIO failed
3540 3585 I flutter : Exception: DioError [DioErrorType.other]: HandshakeException: Handshake error in client (OS Error:
3540 3585 I flutter : CERTIFICATE_VERIFY_FAILED: self signed certificate in certificate chain(handshake.cc:393))
3540 3585 I flutter : Source stack:
3540 3585 I flutter : #0 DioMixin.fetch (package:dio/src/dio_mixin.dart:488)
3540 3585 I flutter : #1 DioMixin.request (package:dio/src/dio_mixin.dart:483)
3540 3585 I flutter : #2 DioMixin.get (package:dio/src/dio_mixin.dart:61)
3540 3585 I flutter : #3 _MyHomePageState.callPinnedHTTPS (package:flutter_pinning_demo/main.dart:124)
3540 3585 I flutter : <asynchronous suspension>
3540 3585 I flutter : HandshakeException: Handshake error in client (OS Error:
3540 3585 I flutter : CERTIFICATE_VERIFY_FAILED: self signed certificate in certificate chain(handshake.cc:393))
Flutter gives us some nice information: there’s a self-signed certificate in the certificate chain, which it doesn’t like.
The original MitM script hooks session_verify_cert_chain, and for some reason the hooks were never triggered. The session_verify_cert_chain method is called from ssl_verify_peer_cert on line 386 and the error that is shown above results from OPENSSL_PUT_ERROR on line 393:
uint8_t alert = SSL_AD_CERTIFICATE_UNKNOWN; enum ssl_verify_result_t ret; if (hs->config->custom_verify_callback != nullptr) { ret = hs->config->custom_verify_callback(ssl, &alert); switch (ret) { case ssl_verify_ok: hs->new_session->verify_result = X509_V_OK; break; case ssl_verify_invalid: // If |SSL_VERIFY_NONE|, the error is non-fatal, but we keep the result. if (hs->config->verify_mode == SSL_VERIFY_NONE) { ERR_clear_error(); ret = ssl_verify_ok; } hs->new_session->verify_result = X509_V_ERR_APPLICATION_VERIFICATION; break; case ssl_verify_retry: break; } } else { ret = ssl->ctx->x509_method->session_verify_cert_chain( hs->new_session.get(), hs, &alert) ? ssl_verify_ok : ssl_verify_invalid; } if (ret == ssl_verify_invalid) { OPENSSL_PUT_ERROR(SSL, SSL_R_CERTIFICATE_VERIFY_FAILED); ssl_send_alert(ssl, SSL3_AL_FATAL, alert); }
The code path that is most likely taken, is that a custom_verify_callback
is registered, which makes line 368 return true, and the callback executed on line 369 returns ssl_verify_invalid
. The code then jumps to line 392 and the ret
variable does equal ssl_verify_invalid
so the alert is shown.
uint8_t alert = SSL_AD_CERTIFICATE_UNKNOWN; enum ssl_verify_result_t ret; if (hs->config->custom_verify_callback != nullptr) { ret = hs->config->custom_verify_callback(ssl, &alert); switch (ret) { case ssl_verify_ok: hs->new_session->verify_result = X509_V_OK; break; case ssl_verify_invalid: // If |SSL_VERIFY_NONE|, the error is non-fatal, but we keep the result. if (hs->config->verify_mode == SSL_VERIFY_NONE) { ERR_clear_error(); ret = ssl_verify_ok; } hs->new_session->verify_result = X509_V_ERR_APPLICATION_VERIFICATION; break; case ssl_verify_retry: break; } } else { ret = ssl->ctx->x509_method->session_verify_cert_chain( hs->new_session.get(), hs, &alert) ? ssl_verify_ok : ssl_verify_invalid; } if (ret == ssl_verify_invalid) { OPENSSL_PUT_ERROR(SSL, SSL_R_CERTIFICATE_VERIFY_FAILED); ssl_send_alert(ssl, SSL3_AL_FATAL, alert); }
The easiest approach would be to hook the ssl_verify_peer_cert function and modify the return value to be ssl_verify_ok, which is 0. By hooking this earlier method, both the default SSL validation and any custom validation is disabled. Unfortunately, the ssl_send_alert function already triggers an error and so modifying the return value of ssl_verify_peer_cert would be too late.
Fortunately, we can just throw out the entire function and replace it with a return 0
statement:
function hook_ssl_verify_peer_cert(address) { Interceptor.replace(address, new NativeCallback((pathPtr, flags) => { console.log("[+] Certificate validation disabled"); return 0; }, 'int', ['pointer', 'int'])); }
The only thing that’s left is finding the actual location of the ssl_verify_peer_cert function.
The approach which was explained in the previous blogposts can be followed to identify the ssl_verify_peer_cert function:
Both x509.cc and handshake.cc use the OPENSSL_PUT_ERROR macro which swaps in the file name and line number, which you can use to identify the correct functions.
Alternatively, we can use Frida’s pattern matching engine to search for functions that look very similar to the function from the demo app. The first bytes of a function are typically very stable, as long as the number of local variables and function arguments don’t change. Still, different compilers may generate different assembly code (e.g. usage of different registers or optimisations) so we do need to have some wildcards in our pattern.
After downloading and creating multiple Flutter apps with different Flutter versions, I came to the following list:
iOS x64: FF 83 01 D1 FA 67 01 A9 F8 5F 02 A9 F6 57 03 A9 F4 4F 04 A9 FD 7B 05 A9 FD 43 01 91 F? 03 00 AA 1? 00 40 F9 ?8 1A 40 F9 15 ?5 4? F9 B5 00 00 B4
Android x64: F? 0F 1C F8 F? 5? 01 A9 F? 5? 02 A9 F? ?? 03 A9 ?? ?? ?? ?? 68 1A 40 F9
Android x86: 2D E9 FE 43 D0 F8 00 80 81 46 D8 F8 18 00 D0 F8 ?? 71
These patterns should only result in one hit in the libFlutter library and all match to the start of the ssl_verify_peer_cert function.
Putting all of this together gives the following script. It’s one script that can be used on Android x86, Android x64 and iOS x64.
Check GitHub for the latest version
The script below may have been updated on the GitHub repo.
var TLSValidationDisabled = false; var secondRun = false; if (Java.available) { console.log("[+] Java environment detected"); Java.perform(hookSystemLoadLibrary); disableTLSValidationAndroid(); setTimeout(disableTLSValidationAndroid, 1000); } else if (ObjC.available) { console.log("[+] iOS environment detected"); disableTLSValidationiOS(); setTimeout(disableTLSValidationiOS, 1000); } function hookSystemLoadLibrary() { const System = Java.use('java.lang.System'); const Runtime = Java.use('java.lang.Runtime'); const SystemLoad_2 = System.loadLibrary.overload('java.lang.String'); const VMStack = Java.use('dalvik.system.VMStack'); SystemLoad_2.implementation = function(library) { try { const loaded = Runtime.getRuntime().loadLibrary0(VMStack.getCallingClassLoader(), library); if (library === 'flutter') { console.log("[+] libflutter.so loaded"); disableTLSValidationAndroid(); } return loaded; } catch (ex) { console.log(ex); } }; } function disableTLSValidationiOS() { if (TLSValidationDisabled) return; var m = Process.findModuleByName("Flutter"); // If there is no loaded Flutter module, the setTimeout may trigger a second time, but after that we give up if (m === null) { if (secondRun) console.log("[!] Flutter module not found."); secondRun = true; return; } var patterns = { "arm64": [ "FF 83 01 D1 FA 67 01 A9 F8 5F 02 A9 F6 57 03 A9 F4 4F 04 A9 FD 7B 05 A9 FD 43 01 91 F? 03 00 AA 1? 00 40 F9 ?8 1A 40 F9 15 ?5 4? F9 B5 00 00 B4 " ], }; findAndPatch(m, patterns[Process.arch], 0); } function disableTLSValidationAndroid() { if (TLSValidationDisabled) return; var m = Process.findModuleByName("libflutter.so"); // The System.loadLibrary doesn't always trigger, or sometimes the library isn't fully loaded yet, so this is a backup if (m === null) { if (secondRun) console.log("[!] Flutter module not found."); secondRun = true; return; } var patterns = { "arm64": [ "F? 0F 1C F8 F? 5? 01 A9 F? 5? 02 A9 F? ?? 03 A9 ?? ?? ?? ?? 68 1A 40 F9", ], "arm": [ "2D E9 FE 43 D0 F8 00 80 81 46 D8 F8 18 00 D0 F8 ?? 71" ] }; findAndPatch(m, patterns[Process.arch], Process.arch == "arm" ? 1 : 0); } function findAndPatch(m, patterns, thumb) { console.log("[+] Flutter library found"); var ranges = m.enumerateRanges('r-x'); ranges.forEach(range => { patterns.forEach(pattern => { Memory.scan(range.base, range.size, pattern, { onMatch: function(address, size) { console.log('[+] ssl_verify_peer_cert found at offset: 0x' + (address - m.base).toString(16)); TLSValidationDisabled = true; hook_ssl_verify_peer_cert(address.add(thumb)); } }); }); }); if (!TLSValidationDisabled) { if (secondRun) console.log('[!] ssl_verify_peer_cert not found. Please open an issue at https://github.com/NVISOsecurity/disable-flutter-tls-verification/issues'); else console.log('[!] ssl_verify_peer_cert not found. Trying again...'); } secondRun = true; } function hook_ssl_verify_peer_cert(address) { Interceptor.replace(address, new NativeCallback((pathPtr, flags) => { return 0; }, 'int', ['pointer', 'int'])); }
Jeroen Beckers
Jeroen Beckers is a mobile security expert working in the NVISO Software Security Assessment team. He is a SANS instructor and SANS lead author of the SEC575 course. Jeroen is also a co-author of OWASP Mobile Security Testing Guide (MSTG) and the OWASP Mobile Application Security Verification Standard (MASVS). He loves to both program and reverse engineer stuff.