Bypassing Certificate Pinning on a Flutter-based iOS App

Few years ago, I and my teammate had to perform penetration test on a Flutter-based app. During that time, a blogpost from NVISO (https://blog.nviso.eu/2020/06/12/intercepting-flutter-traffic-on-ios/) lab helped us to understand and bypass the certificate pinning on the app. Now, a bit of the Flutter engine has been changed and the previous method is not sufficient to bypass the pinning on iOS app. Today I will demonstrate how to deal with it.

Let’s build an app

For building the app, I use the example code from the Git repository https://github.com/zionspike/tls_certificate_pinning_demo. It demonstrates how to pin a certificate using simple HTTP client, Dio package. The app uses Dio class with httpClientAdaptor in order to connect to the website (https://httpbin.org). The app pins the site’s leaf certificate using setTrustedCertificatesBytes method. The excerpt of pinning certificate code are as follows:

[...]
      Dio dio = Dio(options);

      // https://httpbin.org/ - leaf cert. 
      String certificate = '''
-----BEGIN CERTIFICATE-----
MIIF3DCCBMSgAwIBAgIQAVjzCtWdYq8gnxXuT8K7MzANBgkqhkiG9w0BAQsFADBG
MQswCQYDVQQGEwJVUzEPMA0GA1UEChMGQW1hem9uMRUwEwYDVQQLEwxTZXJ2ZXIg
Q0EgMUIxDzANBgNVBAMTBkFtYXpvbjAeFw0yMTExMjEwMDAwMDBaFw0yMjEyMTky
MzU5NTlaMBYxFDASBgNVBAMTC2h0dHBiaW4ub3JnMIIBIjANBgkqhkiG9w0BAQEF
AAOCAQ8AMIIBCgKCAQEAhOQnpezrwA0vHzf47Pa+O84fWue/562TqQrVirtf+3fs
GQd3MmwnId+ksAGQvWN4M1/hSelYJb246pFqGB7t+ZI+vjBYH4/J6CiFsKwzusqk
SF63ftQh8Ox0OasB9HvRlOPHT/B5Dskh8HNiJ+1lExSZEaO9zsQ9wO62bsGHsMX/
UP3VQByXLVBZu0DMKsl2hGaUNy9+LgZv4/iVpWDPQ1+khpfxP9x1H+mMlUWBgYPq
7jG5ceTbltIoF/sUQPNR+yKIBSnuiISXFHO9HEnk5ph610hWmVQKIrCAPsAUMM9m
6+iDb64NjrMjWV/bkm36r+FBMz9L8HfEB4hxlwwg5QIDAQABo4IC9DCCAvAwHwYD
VR0jBBgwFoAUWaRmBlKge5WSPKOUByeWdFv5PdAwHQYDVR0OBBYEFM8HhLgDSKzJ
rNsRZQp9Kf/Wl0uzMCUGA1UdEQQeMByCC2h0dHBiaW4ub3Jngg0qLmh0dHBiaW4u
b3JnMA4GA1UdDwEB/wQEAwIFoDAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUH
AwIwPQYDVR0fBDYwNDAyoDCgLoYsaHR0cDovL2NybC5zY2ExYi5hbWF6b250cnVz
dC5jb20vc2NhMWItMS5jcmwwEwYDVR0gBAwwCjAIBgZngQwBAgEwdQYIKwYBBQUH
AQEEaTBnMC0GCCsGAQUFBzABhiFodHRwOi8vb2NzcC5zY2ExYi5hbWF6b250cnVz
dC5jb20wNgYIKwYBBQUHMAKGKmh0dHA6Ly9jcnQuc2NhMWIuYW1hem9udHJ1c3Qu
Y29tL3NjYTFiLmNydDAMBgNVHRMBAf8EAjAAMIIBfQYKKwYBBAHWeQIEAgSCAW0E
ggFpAWcAdgApeb7wnjk5IfBWc59jpXflvld9nGAK+PlNXSZcJV3HhAAAAX1Acwi1
AAAEAwBHMEUCIF3Y8cwUmZ9cmYSclNrSYOhYDeCzSTAYHwpIhp6oAHKBAiEA/8nK
wN0G3SwUiS3/NdzlVuuakr4a7oviAzN7zXiSmzcAdgBRo7D1/QF5nFZtuDd4jwyk
eswbJ8v3nohCmg3+1IsF5QAAAX1AcwjJAAAEAwBHMEUCIH4pZ7551jQOJ/lV20sD
KNCWOLdt+cS2pjUIFEpI8nwlAiEAj2hxpivIPtX9tReEcJCaAuC5Gh90mZz9lWQy
usID0VQAdQDfpV6raIJPH2yt7rhfTj5a6s2iEqRqXo47EsAgRFwqcwAAAX1Acwii
AAAEAwBGMEQCIEyeaMOsy5LSsKkIod2CfBML3J/+CwjvJekdMBI4QYI2AiAYKdpD
ptDftXG7GSOz8SgpqRtUoWIHs1woSj7uwEJwuzANBgkqhkiG9w0BAQsFAAOCAQEA
L2Qd0308BkF7ahyUYJkxkrfr4WyyrO7SW/TsNpSmxqPF+D/QqQcBt8tPHWg1oNEc
UYinl5qtA4kyHqpAlgzYl04FUpShkNDjcwd1GikgmNMIhSGx3EHaQeyHvrKIgCRe
TK1fPPxDvFU2ao9nnEfiQ0OossRVC6EaJsQ+/CEnTir0BEPjWRjW2C/g9YOHRyP9
PO4R/58KOy8pdJZwWkOyGKylZemsLy6sR8h3UE0KW0TawMwGO+sjWU2eB/uOJ6Yc
aAS0og1S7NrLDqT3HUWSf81g7qlNeC3hNgI8fMxFLPTkhn8+v220SJipi6ignkJR
VFoC1aTFolXMq/oMqRqUHA==
-----END CERTIFICATE-----
''';

[...]
(dio.httpClientAdapter as DefaultHttpClientAdapter).onHttpClientCreate  = (client) {
          SecurityContext sc = new SecurityContext();
          sc.setTrustedCertificatesBytes(certificate.codeUnits);
          HttpClient httpClient = new HttpClient(context: sc);
          return httpClient;
      };
[...]

After building the app and installing it on the target device, iPhone 7 plus with jailbroken iOS 14.5., if the certificate validation works fine, the app will show the responded HTML code just like the following figure:

If we put a proxy tool like Burp suite in the middle of its communication, the certificate validation will fail and yield the following result:

I used L2PT VPN and iptables for redirecting traffic from the app to Burp. You can use other ways and can find out how at NVISO’s blog.

Let’s be curious

Based on my curiosity, I find out that the Dart SDK uses SecTrustEvaluateWithError or SecTrustEvaluate for validating a server’s certificate based on the running OS version (ref: https://github.com/dart-lang/sdk/blob/e995cb5f7cd67d39c1ee4bdbe95c8241db36725f/runtime/bin/security_context_macos.cc).

[...]
if (__builtin_available(iOS 12.0, macOS 10.14, *)) {
    // SecTrustEvaluateWithError available as of OSX 10.14 and iOS 12.
    // The result is ignored as we get more information from the following call
    // to SecTrustGetTrustResult which also happens to match the information we
    // get from calling SecTrustEvaluate.
    bool res = SecTrustEvaluateWithError(trust.get(), NULL);
    USE(res);
    status = SecTrustGetTrustResult(trust.get(), &trust_result);
  } else {
    // SecTrustEvaluate is deprecated as of OSX 10.15 and iOS 13.
    status = SecTrustEvaluate(trust.get(), &trust_result);
  }
[...]

So I know how does Dart validate the certificate so just write Frida script to hook it.

Writing Frida script

In order to bypass the certificate validation, I need to hook on SecTrustEvaluateWithError and SecTrustGetTrustResult method. The following Frida scripts show the hooks:

// Bypass SecTrustEvaluateWithError
	var SecTrustEvaluateWithErrorHandle = Module.findExportByName('Security', 'SecTrustEvaluateWithError');
	if (SecTrustEvaluateWithErrorHandle) {
		var SecTrustEvaluateWithError = new NativeFunction(SecTrustEvaluateWithErrorHandle, 'int', ['pointer', 'pointer']);
		// Hooking SecTrustEvaluateWithError
		Interceptor.replace(SecTrustEvaluateWithErrorHandle,
			new NativeCallback(function(trust, error) {
				console.log('[!] Hooking SecTrustEvaluateWithError()');
				SecTrustEvaluateWithError(trust, NULL);
				if (error != 0) {
					Memory.writeU8(error, 0); 
				} 
				return 1;
			}, 'int', ['pointer', 'pointer']));
	}

	// Bypass SecTrustGetTrustResult
	var SecTrustGetTrustResultHandle = Module.findExportByName("Security", "SecTrustGetTrustResult");
	if (SecTrustGetTrustResultHandle) {
		// Hooking SecTrustGetTrustResult
		Interceptor.replace(SecTrustGetTrustResultHandle, new NativeCallback(function(trust, result) {
			console.log("[!] Hooking SecTrustGetTrustResult");
			// Change the result to kSecTrustResultProceed
			Memory.writeU8(result, 1);
			// Return errSecSuccess
			return 0;
		}, "int", ["pointer", "pointer"]));
	}

In order to make my Frida scripts work with other iOS version, I need to put hooking on SecTrustEvaluate method also (credit to https://codeshare.frida.re/@snooze6/ios-pinning-disable/):

// Bypass SecTrustEveluate
	var SecTrustEvaluateHandle = Module.findExportByName("Security", "SecTrustEvaluate");
	if (SecTrustEvaluateHandle) {
		var SecTrustEvaluate = new NativeFunction(SecTrustEvaluateHandle, "int", ["pointer", "pointer"]);
		// Hooking SecTrustEvaluate
		Interceptor.replace(SecTrustEvaluateHandle, new NativeCallback(function(trust, result) {
			console.log("[!] Hooking SecTrustEvaluate");
			var osstatus = SecTrustEvaluate(trust, result);
			// Change the result to kSecTrustResultProceed
			Memory.writeU8(result, 1);
			// Return errSecSuccess
			return 0;
		}, "int", ["pointer", "pointer"]));
	}

Now put them together and test.

function bypass_SecTrustEvaluates() {
	// Bypass SecTrustEvaluateWithError
	var SecTrustEvaluateWithErrorHandle = Module.findExportByName('Security', 'SecTrustEvaluateWithError');
	if (SecTrustEvaluateWithErrorHandle) {
		var SecTrustEvaluateWithError = new NativeFunction(SecTrustEvaluateWithErrorHandle, 'int', ['pointer', 'pointer']);
		// Hooking SecTrustEvaluateWithError
		Interceptor.replace(SecTrustEvaluateWithErrorHandle,
			new NativeCallback(function(trust, error) {
				console.log('[!] Hooking SecTrustEvaluateWithError()');
				SecTrustEvaluateWithError(trust, NULL);
				if (error != 0) {
					Memory.writeU8(error, 0); 
				} 
				return 1;
			}, 'int', ['pointer', 'pointer']));
	}

	// Bypass SecTrustGetTrustResult
	var SecTrustGetTrustResultHandle = Module.findExportByName("Security", "SecTrustGetTrustResult");
	if (SecTrustGetTrustResultHandle) {
		// Hooking SecTrustGetTrustResult
		Interceptor.replace(SecTrustGetTrustResultHandle, new NativeCallback(function(trust, result) {
			console.log("[!] Hooking SecTrustGetTrustResult");
			// Change the result to kSecTrustResultProceed
			Memory.writeU8(result, 1);
			// Return errSecSuccess
			return 0;
		}, "int", ["pointer", "pointer"]));
	}

	// Bypass SecTrustEveluate
	var SecTrustEvaluateHandle = Module.findExportByName("Security", "SecTrustEvaluate");
	if (SecTrustEvaluateHandle) {
		var SecTrustEvaluate = new NativeFunction(SecTrustEvaluateHandle, "int", ["pointer", "pointer"]);
		// Hooking SecTrustEvaluate
		Interceptor.replace(SecTrustEvaluateHandle, new NativeCallback(function(trust, result) {
			console.log("[!] Hooking SecTrustEvaluate");
			var osstatus = SecTrustEvaluate(trust, result);
			// Change the result to kSecTrustResultProceed
			Memory.writeU8(result, 1);
			// Return errSecSuccess
			return 0;
		}, "int", ["pointer", "pointer"]));
	}
}

// Main
if (ObjC.available) {

	bypass_SecTrustEvaluates();

} else {
	send("error: Objective-C Runtime is not available!");
}