Bypass Certificate Pinning on a Flutter-based iOS App

Bypassing Flutter Certificate Pinning on iOS

ไม่กี่ปีที่ผ่านมาผมกับทีมได้มีโอกาสทดสอบความปลอดภัยให้กับแอปที่ถูกพัฒนาด้วยเฟรมเวิร์ค Flutter เราติดปัญหาเรื่องการ bypass certificate pinning เพราะตอนนั้นยังแทบไม่มีบทความหรือ research เกี่ยวกับการ penetration test แอปที่เขียนด้วย Flutter เลย แต่ยังโชคดีที่ project lead ของผมเค้าไปหาวิธีมาจนได้จาก blog ของ NVSIO (https://blog.nviso.eu/2020/06/12/intercepting-flutter-traffic-on-ios/). แต่พอมาเร็ว ๆ นี้ผมเริ่มรู้สึกกลับมาติดปัญหาเรื่องนี้อีกครั้ง วันนี้จะพามาดูวิธีที่ผมใช้เพื่อ bypass certificate pinning ของ iOS app ที่เขียนด้วย Flutter กัน

บทความโดย
Mr.Juttikhun Jirathanan
Cyber Security Researcher

Let’s build an app

ก่อนอื่นผมใช้โค้ดตัวอย่างของการทำ pinning จาก repository นี้ https://github.com/zionspike/tls_certificate_pinning_demo. เค้าแสดงการทำ certificate pinning โดยใช้ Dio package ใช้คลาส httpClientAdaptor กับเมธอด setTrustedCertificatesBytes ในการ pin ซึ่งตัวอย่างโค้ดที่ใช้ pin คือ

[...]
      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;
      };
[...]

หลังจากที่ build แล้วติดตั้งแอปบนเครื่องเทสที่เตรียมไว้เรียบร้อยคือ iPhone 7 plus กับ iOS 14.5 ถ้าแอปใช้งานได้ปกติ การ validate certificate ทำงานได้ปกติตัวแอปจะวิ่งไปคุยที่ https://pastebin.com และแสดง HTML code แบบนี้

ถ้าผมเอา Burp ไปดัก traffic (จะใช้ท่าไหนก็ได้ จะ VPN + iptables หรือ pfctl หรืออื่นๆ ก็สุดแล้วแต่) จะทำให้ certificate validation มัน fail และให้แสดงผลแบบนี้

Let’s be curious

จากความสงสัยทำให้ไปสืบมาแล้วพบว่า Dart SDK บน MacOS/iOS ใช้ SecTrustEvaluateWithError หรือ SecTrustEvaluate ในการทำ certificate validation (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);
  }
[...]

Writing Frida script

เพื่อจะ bypass certificate validation ผมเลยเขียน Frida มา hook SecTrustEvaluateWithError กับ SecTrustGetTrustResult ตามนี้

// 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"]));
	}

แต่ถ้าจะทำให้ Frida script ใช้กับ iOS อื่นด้วยก็ต้อง hook ที่ deprecate API SecTrustEvaluate ด้วย อันนี้ขอลอกมาจาก code share (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"]));
	}

รวมมันเข้าด้วยกันก็จะได้ตามนี้ kapi-frida-iOS.js

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!");
}