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


