行動應用程式代碼安全:真正有效的實作模式

  1. 安全基礎:代碼中永遠不應該出現的內容
  2. 安全儲存實作
  3. 網路安全實作
  4. 代碼混淆實作
  5. UI 安全實作
  6. 執行時保護實作
  7. 生物識別身份驗證實作
  8. 一個真實的實作故事:測試拯救生產環境
  9. 實作檢查清單
  10. 結論

安全架構提供了藍圖,但實作決定了你的行動應用程式是否真正保護使用者資料。如果代碼包含漏洞、使用弱加密或錯誤處理敏感資料,即使是完美設計的安全模型也會失敗。安全理論與安全代碼之間的差距正是大多數資料外洩發生的地方。

行動平台提供了強大的安全 API,但正確使用它們需要理解其細微差別。iOS Keychain 和 Android Keystore 提供硬體支援的加密,但配置錯誤的可存取性設定可能會暴露資料。憑證固定加強了網路安全,但不當的實作會在憑證輪換期間破壞應用程式。Root 檢測可以識別受損裝置,但簡單的檢查很容易被繞過。

本文重點關注實作——保護行動應用程式的實際代碼。我們將研究安全儲存模式、網路安全實作、代碼混淆技術、執行時保護機制和身份驗證流程。每個部分都提供了可以調整的工作代碼,以及將安全功能變成漏洞的陷阱。

安全基礎:代碼中永遠不應該出現的內容

在實作安全功能之前,要了解什麼永遠不應該出現在你的代碼庫中。這些錯誤很常見、容易被利用,而且完全可以避免。

硬編碼密鑰:最大的罪過

在原始碼中硬編碼敏感資料是最常見也是最危險的安全錯誤。一旦提交到版本控制,密鑰就會永遠保留在儲存庫歷史中——即使在刪除之後。

🚫 永遠不要在代碼中硬編碼這些內容

憑證和密鑰

  • 密碼和口令
  • API 金鑰和密鑰
  • 私鑰和憑證
  • 資料庫憑證
  • OAuth 用戶端密鑰
  • 加密金鑰

敏感配置

  • 嵌入憑證的生產伺服器 URL
  • 第三方服務權杖
  • 簽章金鑰
  • Webhook 密鑰
  • 服務帳戶憑證

個人資訊

  • 測試代碼中的使用者資料
  • 電子郵件地址
  • 電話號碼
  • 任何用於測試的 PII

為什麼硬編碼密鑰很危險

原始碼不是安全儲存。開發人員共享儲存庫,CI/CD 系統存取代碼,反編譯器從二進位檔案中提取字串,版本控制無限期地保留歷史記錄。

// ❌ 永遠不要這樣做
class ApiClient {
    companion object {
        private const val API_KEY = "sk_live_51H7xK2eZvKYlo2C..." // 暴露了!
        private const val SECRET = "whsec_abc123..." // 在版本控制中!
        private const val DB_PASSWORD = "MyP@ssw0rd123" // 所有開發人員都能看到!
    }
}
// ❌ 永遠不要這樣做
class Configuration {
    static let apiKey = "AIzaSyD-9tSrke72PouQMnMX-a7eZSW0jkFMBWY" // 暴露了!
    static let privateKey = "-----BEGIN PRIVATE KEY-----\nMIIE..." // 災難!
}

任何有儲存庫存取權限的人都能看到這些密鑰。反編譯應用程式會暴露它們。攻擊者在 GitHub 上搜尋暴露的金鑰。一旦洩露,必須立即輪換密鑰——假設你發現了洩露。

正確的方法:基於環境的配置

密鑰屬於安全儲存,在執行時載入,永遠不要提交到版本控制。

// ✅ 從安全儲存載入
class ApiClient(context: Context) {
    private val secureStorage = SecureStorage(context)
    
    fun getApiKey(): String? {
        // 從加密儲存載入,而不是硬編碼
        return secureStorage.loadToken("api_key")
    }
}
// ✅ 從 Keychain 載入
class Configuration {
    static func getApiKey() -> String? {
        return SecureStorage.loadToken(forKey: "api_key")
    }
}

配置管理策略

不同類型的配置需要不同的方法:

🔧 配置最佳實踐

公共配置(可以安全提交)

  • 功能標誌
  • UI 配置
  • 非敏感 URL
  • 逾時值
  • 快取大小

特定環境(建置時注入)

  • 伺服器端點(不含憑證)
  • 環境識別碼
  • 除錯標誌
  • 分析 ID(非敏感)

密鑰(僅執行時載入)

  • API 金鑰和權杖
  • 加密金鑰
  • 憑證
  • 私鑰
  • 服務密鑰

建置時密鑰注入

對於建置時需要的密鑰,從環境變數或安全建置系統注入它們——永遠不要提交它們。

// Android: build.gradle
android {
    defaultConfig {
        // 從環境載入,而不是硬編碼
        buildConfigField "String", "API_KEY", "\"${System.getenv('API_KEY') ?: ''}\""
    }
}
# iOS: 使用 xcconfig 檔案(不提交)
# Config.xcconfig
API_KEY = ${API_KEY}

# .gitignore
Config.xcconfig

提供顯示結構但不包含實際密鑰的範本檔案:

// Config.template.xcconfig(已提交)
// 複製到 Config.xcconfig 並填寫實際值
API_KEY = YOUR_API_KEY_HERE

記錄敏感資料:無聲的暴露

記錄敏感資料會將其暴露給任何有日誌存取權限的人——開發人員、支援人員、當機報告服務以及獲得裝置存取權限的攻擊者。

// ❌ 永遠不要這樣做
fun login(username: String, password: String) {
    Log.d("Auth", "Login attempt: $username / $password") // 在日誌中暴露!
    // ...
}

fun processPayment(cardNumber: String, cvv: String) {
    Log.d("Payment", "Processing card: $cardNumber, CVV: $cvv") // 違反 PCI!
    // ...
}
// ❌ 永遠不要這樣做
func authenticate(token: String) {
    print("Auth token: \(token)") // 在主控台中可見!
    // ...
}

日誌保留在系統日誌、當機報告和分析平台中。從生產建置中刪除敏感日誌記錄:

# ProGuard: 在發布版本中刪除日誌記錄
-assumenosideeffects class android.util.Log {
    public static *** d(...);
    public static *** v(...);
    public static *** i(...);
}

版本控制衛生

一旦提交,密鑰就會保留在儲存庫歷史中。預防至關重要:

⚠️ 版本控制安全

提交前

  • 檢查差異中的密鑰
  • 使用預提交鉤子掃描密鑰
  • 維護配置檔案的 .gitignore
  • 使用密鑰掃描工具

如果密鑰已提交

  • 立即輪換受損密鑰
  • 不要只是刪除——歷史記錄會保留它們
  • 使用 BFG Repo-Cleaner 等工具清除歷史記錄
  • 通知安全團隊

預防工具

  • git-secrets (AWS)
  • detect-secrets (Yelp)
  • GitHub 密鑰掃描
  • GitGuardian

行動應用程式中的 API 金鑰:特殊考慮

行動應用程式分發給可以提取任何嵌入資料的使用者。即使是混淆或加密的金鑰也可以被有決心的攻擊者提取。

🔑 行動端 API 金鑰策略

用戶端 API 金鑰

  • 假設它們會被提取
  • 使用權限最小的金鑰
  • 在伺服器端實作速率限制
  • 監控濫用
  • 定期輪換

伺服器端代理模式

  • 將敏感金鑰保留在伺服器上
  • 行動應用程式呼叫你的 API
  • 你的伺服器呼叫第三方 API
  • 驗證行動請求
  • 在伺服器端控制存取

當用戶端金鑰必要時

  • 使用平台特定的限制(iOS bundle ID、Android 套件名稱)
  • 實作憑證固定
  • 新增請求簽章
  • 監控使用模式
  • 準備好輪換程序

伺服器端代理模式總是比在行動應用程式中嵌入金鑰更安全,即使使用混淆也是如此。

安全儲存實作

平台提供的安全儲存機制是你的第一道防線。當 iOS Keychain 或 Android Keystore 可用時,永遠不要實作自訂加密。

何時使用安全儲存

安全儲存用於執行時密鑰——通過身份驗證或 API 呼叫在應用程式安裝後獲得的資料。永遠不要使用它來隱藏不應該在應用程式中的硬編碼密鑰。

✅ 安全儲存的適當用途

執行時密鑰

  • 登入後收到的身份驗證權杖
  • 來自伺服器的工作階段金鑰
  • 使用者憑證(如果絕對必要)
  • 臨時加密金鑰
  • OAuth 權杖

不用於建置時密鑰

  • 不要在 Keychain 中儲存硬編碼的 API 金鑰
  • 不要加密硬編碼的密鑰並儲存它們
  • 不要使用安全儲存來隱藏不應該存在的內容
  • 伺服器端密鑰永遠不應該到達用戶端

iOS Keychain:完整實作

import Security

class SecureStorage {
    static func saveToken(_ token: String, forKey key: String) -> Bool {
        guard let data = token.data(using: .utf8) else { return false }
        
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: key,
            kSecValueData as String: data,
            kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
        ]
        
        SecItemDelete(query as CFDictionary)
        let status = SecItemAdd(query as CFDictionary, nil)
        return status == errSecSuccess
    }
    
    static func loadToken(forKey key: String) -> String? {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: key,
            kSecReturnData as String: true,
            kSecMatchLimit as String: kSecMatchLimitOne
        ]
        
        var result: AnyObject?
        let status = SecItemCopyMatching(query as CFDictionary, &result)
        
        guard status == errSecSuccess,
              let data = result as? Data,
              let token = String(data: data, encoding: .utf8) else {
            return nil
        }
        return token
    }
    
    static func deleteToken(forKey key: String) -> Bool {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: key
        ]
        let status = SecItemDelete(query as CFDictionary)
        return status == errSecSuccess
    }
}

🔑 Keychain 可存取性層級

kSecAttrAccessibleWhenUnlockedThisDeviceOnly

  • 對敏感資料最安全
  • 不備份到 iCloud
  • 僅在裝置解鎖時可存取

kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly

  • 用於背景工作
  • 首次解鎖後可用
  • 不備份

kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly

  • 需要裝置密碼
  • 如果刪除密碼則刪除
  • 最高安全性

Android 安全儲存:EncryptedSharedPreferences

import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey

class SecureStorage(private val context: Context) {
    
    private val masterKey = MasterKey.Builder(context)
        .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
        .build()
    
    private val encryptedPrefs = EncryptedSharedPreferences.create(
        context,
        "secure_prefs",
        masterKey,
        EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
        EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
    )
    
    fun saveToken(token: String) {
        encryptedPrefs.edit().putString("auth_token", token).apply()
    }
    
    fun loadToken(): String? {
        return encryptedPrefs.getString("auth_token", null)
    }
    
    fun deleteToken() {
        encryptedPrefs.edit().remove("auth_token").apply()
    }
}

Android Keystore 用於加密金鑰

import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import java.security.KeyStore
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey

class KeystoreManager {
    
    private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply {
        load(null)
    }
    
    fun generateKey(alias: String): SecretKey {
        val keyGenerator = KeyGenerator.getInstance(
            KeyProperties.KEY_ALGORITHM_AES,
            "AndroidKeyStore"
        )
        
        val keySpec = KeyGenParameterSpec.Builder(
            alias,
            KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
        )
            .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
            .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
            .setUserAuthenticationRequired(false)
            .build()
        
        keyGenerator.init(keySpec)
        return keyGenerator.generateKey()
    }
    
    fun getKey(alias: String): SecretKey? {
        return keyStore.getKey(alias, null) as? SecretKey
    }
}

⚠️ 儲存反模式

永遠不要這樣做

  • 在 UserDefaults/SharedPreferences 中儲存密碼
  • 在代碼中硬編碼加密金鑰
  • 使用弱演算法(DES、MD5、SHA1)
  • 將敏感資料記錄到主控台
  • 在版本控制中儲存 API 金鑰

網路安全實作

僅使用 HTTPS 是不夠的。適當的 TLS 配置、憑證驗證和請求簽章提供了縱深防禦。

iOS TLS 配置與憑證驗證

class SecureNetworking {
    
    private lazy var session: URLSession = {
        let config = URLSessionConfiguration.default
        config.tlsMinimumSupportedProtocolVersion = .TLSv12
        return URLSession(configuration: config, delegate: self, delegateQueue: nil)
    }()
    
    func makeRequest(url: URL, completion: @escaping (Data?, Error?) -> Void) {
        let task = session.dataTask(with: url) { data, response, error in
            completion(data, error)
        }
        task.resume()
    }
}

extension SecureNetworking: URLSessionDelegate {
    
    func urlSession(
        _ session: URLSession,
        didReceive challenge: URLAuthenticationChallenge,
        completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
    ) {
        guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
              let serverTrust = challenge.protectionSpace.serverTrust else {
            completionHandler(.cancelAuthenticationChallenge, nil)
            return
        }
        
        var secResult = SecTrustResultType.invalid
        let status = SecTrustEvaluate(serverTrust, &secResult)
        
        if status == errSecSuccess &&
           (secResult == .unspecified || secResult == .proceed) {
            let credential = URLCredential(trust: serverTrust)
            completionHandler(.useCredential, credential)
        } else {
            completionHandler(.cancelAuthenticationChallenge, nil)
        }
    }
}

Android 網路安全配置


<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
    <base-config cleartextTrafficPermitted="false">
        <trust-anchors>
            <certificates src="system" />
        </trust-anchors>
    </base-config>
    
    <debug-overrides>
        <trust-anchors>
            <certificates src="user" />
        </trust-anchors>
    </debug-overrides>
</network-security-config>

<application
    android:networkSecurityConfig="@xml/network_security_config">
</application>

請求簽章實作

import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
import java.util.Base64

class RequestSigner(private val secretKey: String) {
    
    fun signRequest(method: String, path: String, body: String, timestamp: Long): String {
        val payload = "$method\n$path\n$body\n$timestamp"
        
        val mac = Mac.getInstance("HmacSHA256")
        val keySpec = SecretKeySpec(secretKey.toByteArray(), "HmacSHA256")
        mac.init(keySpec)
        
        val signature = mac.doFinal(payload.toByteArray())
        return Base64.getEncoder().encodeToString(signature)
    }
    
    fun createHeaders(method: String, path: String, body: String): Map<String, String> {
        val timestamp = System.currentTimeMillis()
        val signature = signRequest(method, path, body, timestamp)
        
        return mapOf(
            "X-Timestamp" to timestamp.toString(),
            "X-Signature" to signature
        )
    }
}

伺服器端驗證:

import hmac
import hashlib
import time
import base64

def verify_signature(method, path, body, timestamp, signature, secret_key):
    current_time = int(time.time() * 1000)
    if abs(current_time - int(timestamp)) > 300000:  # 5 分鐘
        return False
    
    payload = f"{method}\n{path}\n{body}\n{timestamp}"
    expected_signature = hmac.new(
        secret_key.encode(),
        payload.encode(),
        hashlib.sha256
    ).digest()
    
    return hmac.compare_digest(
        expected_signature,
        base64.b64decode(signature)
    )

🚫 網路安全錯誤

永遠不要停用憑證驗證

  • 不要在生產環境中信任所有憑證
  • 不要忽略 SSL 錯誤
  • 不要允許明文 HTTP 流量

強制使用強 TLS

  • 最低 TLS 1.2
  • 避免弱密碼套件
  • 使用平台安全配置

代碼混淆實作

混淆提高了逆向工程的門檻,但並非萬無一失。與伺服器端驗證結合使用。

Android ProGuard 配置

// app/build.gradle
android {
    buildTypes {
        release {
            minifyEnabled true
            shrinkResources true
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'),
                         'proguard-rules.pro'
        }
    }
}
# proguard-rules.pro

-keep class com.example.app.Application { *; }

-repackageclasses ''
-allowaccessmodification

# 刪除日誌記錄
-assumenosideeffects class android.util.Log {
    public static *** d(...);
    public static *** v(...);
    public static *** i(...);
}

-keepclasseswithmembernames class * {
    native <methods>;
}

-keepattributes SourceFile,LineNumberTable
-renamesourcefileattribute SourceFile

字串混淆

object StringObfuscator {
    
    private const val KEY = 0x5A
    
    fun obfuscate(input: String): ByteArray {
        return input.toByteArray().map { (it.toInt() xor KEY).toByte() }.toByteArray()
    }
    
    fun deobfuscate(input: ByteArray): String {
        return input.map { (it.toInt() xor KEY).toByte() }.toByteArray().toString(Charsets.UTF_8)
    }
}

class ApiConfig {
    companion object {
        private val API_KEY_OBFUSCATED = byteArrayOf(
            0x3e, 0x2f, 0x3a, 0x2b, 0x3c, 0x2d, 0x3e, 0x2f
        )
        
        fun getApiKey(): String {
            return StringObfuscator.deobfuscate(API_KEY_OBFUSCATED)
        }
    }
}

🛡️ 混淆最佳實踐

要混淆的內容

  • 業務邏輯和演算法
  • API 端點和參數
  • 內部類別和方法名稱

要保留的內容

  • 公共 API 介面
  • 基於反射的類別
  • 原生方法宣告
  • 序列化類別

測試

  • 徹底測試發布建置
  • 驗證當機報告可讀
  • 使用映射檔案進行反混淆

UI 安全實作

通過截圖和應用程式切換器預覽保護敏感資料免受視覺捕獲。

Android 螢幕捕獲防護

import android.view.WindowManager
import androidx.appcompat.app.AppCompatActivity

class SecureActivity : AppCompatActivity() {
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        
        // 防止截圖和螢幕錄製
        window.setFlags(
            WindowManager.LayoutParams.FLAG_SECURE,
            WindowManager.LayoutParams.FLAG_SECURE
        )
        
        setContentView(R.layout.activity_secure)
    }
}

iOS 截圖檢測

import UIKit

class SecureViewController: UIViewController {
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        // 檢測何時截圖
        NotificationCenter.default.addObserver(
            self,
            selector: #selector(screenshotTaken),
            name: UIApplication.userDidTakeScreenshotNotification,
            object: nil
        )
    }
    
    @objc private func screenshotTaken() {
        // 記錄事件或警告使用者
        print("檢測到截圖")
        // 也可以暫時模糊敏感內容
    }
    
    deinit {
        NotificationCenter.default.removeObserver(self)
    }
}

應用程式切換器保護 - iOS

import UIKit

class AppDelegate: UIResponder, UIApplicationDelegate {
    
    var window: UIWindow?
    private var blurView: UIVisualEffectView?
    
    func applicationWillResignActive(_ application: UIApplication) {
        // 在快照前隱藏敏感內容
        addBlurEffect()
    }
    
    func applicationDidBecomeActive(_ application: UIApplication) {
        // 應用程式返回時恢復內容
        removeBlurEffect()
    }
    
    private func addBlurEffect() {
        guard let window = window, blurView == nil else { return }
        
        let blurEffect = UIBlurEffect(style: .light)
        let blurView = UIVisualEffectView(effect: blurEffect)
        blurView.frame = window.bounds
        blurView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        
        window.addSubview(blurView)
        self.blurView = blurView
    }
    
    private func removeBlurEffect() {
        blurView?.removeFromSuperview()
        blurView = nil
    }
}

使用佔位符視圖的替代方法:

class AppDelegate: UIResponder, UIApplicationDelegate {
    
    var window: UIWindow?
    private var coverView: UIView?
    
    func applicationWillResignActive(_ application: UIApplication) {
        addCoverView()
    }
    
    func applicationDidBecomeActive(_ application: UIApplication) {
        removeCoverView()
    }
    
    private func addCoverView() {
        guard let window = window, coverView == nil else { return }
        
        let cover = UIView(frame: window.bounds)
        cover.backgroundColor = .white
        
        // 可選:新增應用程式 logo
        let imageView = UIImageView(image: UIImage(named: "logo"))
        imageView.contentMode = .scaleAspectFit
        imageView.frame = CGRect(x: 0, y: 0, width: 100, height: 100)
        imageView.center = cover.center
        cover.addSubview(imageView)
        
        window.addSubview(cover)
        self.coverView = cover
    }
    
    private func removeCoverView() {
        coverView?.removeFromSuperview()
        coverView = nil
    }
}

應用程式切換器保護 - Android

import android.app.Activity
import android.os.Bundle
import android.view.View
import android.widget.ImageView

class MainActivity : AppCompatActivity() {
    
    private var coverView: View? = null
    
    override fun onPause() {
        super.onPause()
        // 在應用程式切換器快照前隱藏內容
        showCoverView()
    }
    
    override fun onResume() {
        super.onResume()
        // 應用程式返回時恢復內容
        hideCoverView()
    }
    
    private fun showCoverView() {
        if (coverView == null) {
            coverView = layoutInflater.inflate(R.layout.cover_screen, null)
            addContentView(
                coverView,
                ViewGroup.LayoutParams(
                    ViewGroup.LayoutParams.MATCH_PARENT,
                    ViewGroup.LayoutParams.MATCH_PARENT
                )
            )
        }
        coverView?.visibility = View.VISIBLE
    }
    
    private fun hideCoverView() {
        coverView?.visibility = View.GONE
    }
}

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@color/white">
    
    <ImageView
        android:layout_width="100dp"
        android:layout_height="100dp"
        android:layout_centerInParent="true"
        android:src="@drawable/logo"
        android:contentDescription="@string/app_logo" />
    
</RelativeLayout>

🛡️ UI 安全最佳實踐

螢幕捕獲防護

  • 僅對敏感活動套用 FLAG_SECURE
  • 不要在所有螢幕上防止捕獲
  • 考慮使用者對合法截圖的需求
  • 使用螢幕錄製應用程式進行測試

應用程式切換器保護

  • 在 onPause/willResignActive 中立即套用遮罩
  • 在 onResume/didBecomeActive 中移除遮罩
  • 使用簡單、快速載入的遮罩視圖
  • 測試快速應用程式切換場景

效能考量

  • 保持遮罩視圖輕量級
  • 避免複雜的版面配置或動畫
  • 快取遮罩視圖以供重複使用
  • 在低階裝置上測試

執行時保護實作

檢測受損環境並做出適當回應。

iOS 越獄檢測

class JailbreakDetector {
    
    static func isJailbroken() -> Bool {
        let jailbreakPaths = [
            "/Applications/Cydia.app",
            "/Library/MobileSubstrate/MobileSubstrate.dylib",
            "/bin/bash",
            "/usr/sbin/sshd",
            "/etc/apt",
            "/private/var/lib/apt/"
        ]
        
        for path in jailbreakPaths {
            if FileManager.default.fileExists(atPath: path) {
                return true
            }
        }
        
        let testPath = "/private/jailbreak_test.txt"
        do {
            try "test".write(toFile: testPath, atomically: true, encoding: .utf8)
            try FileManager.default.removeItem(atPath: testPath)
            return true
        } catch {
            // 無法在沙箱外寫入
        }
        
        if let url = URL(string: "cydia://package/com.example.package"),
           UIApplication.shared.canOpenURL(url) {
            return true
        }
        
        return false
    }
}

Android Root 檢測

class RootDetector(private val context: Context) {
    
    fun isRooted(): Boolean {
        return checkBuildTags() || 
               checkSuperuserApk() || 
               checkSuBinary() ||
               checkRootManagementApps()
    }
    
    private fun checkBuildTags(): Boolean {
        val buildTags = android.os.Build.TAGS
        return buildTags != null && buildTags.contains("test-keys")
    }
    
    private fun checkSuperuserApk(): Boolean {
        return try {
            context.packageManager.getPackageInfo("com.noshufou.android.su", 0)
            true
        } catch (e: Exception) {
            false
        }
    }
    
    private fun checkSuBinary(): Boolean {
        val paths = arrayOf(
            "/system/app/Superuser.apk",
            "/sbin/su",
            "/system/bin/su",
            "/system/xbin/su",
            "/data/local/xbin/su",
            "/data/local/bin/su",
            "/system/sd/xbin/su",
            "/system/bin/failsafe/su",
            "/data/local/su"
        )
        
        return paths.any { File(it).exists() }
    }
    
    private fun checkRootManagementApps(): Boolean {
        val packages = arrayOf(
            "com.topjohnwu.magisk",
            "eu.chainfire.supersu",
            "com.koushikdutta.superuser"
        )
        
        return packages.any { packageName ->
            try {
                context.packageManager.getPackageInfo(packageName, 0)
                true
            } catch (e: Exception) {
                false
            }
        }
    }
}

偵錯器檢測

// iOS
func isDebuggerAttached() -> Bool {
    var info = kinfo_proc()
    var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, getpid()]
    var size = MemoryLayout<kinfo_proc>.stride
    
    let result = sysctl(&mib, UInt32(mib.count), &info, &size, nil, 0)
    return (result == 0) && ((info.kp_proc.p_flag & P_TRACED) != 0)
}
// Android
fun isDebuggerConnected(): Boolean {
    return Debug.isDebuggerConnected() || Debug.waitingForDebugger()
}

fun isDebuggable(context: Context): Boolean {
    return (context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0
}

🔍 回應策略

優雅降級

  • 停用敏感功能
  • 向使用者顯示警告
  • 限制功能

靜默監控

  • 記錄到分析
  • 伺服器端風險評分
  • 模式檢測

硬阻止

  • 拒絕執行
  • 僅限高安全性應用程式
  • 向使用者清楚解釋

生物識別身份驗證實作

// iOS Face ID / Touch ID
import LocalAuthentication

class BiometricAuth {
    
    func authenticate(completion: @escaping (Bool, Error?) -> Void) {
        let context = LAContext()
        var error: NSError?
        
        guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else {
            completion(false, error)
            return
        }
        
        context.evaluatePolicy(
            .deviceOwnerAuthenticationWithBiometrics,
            localizedReason: "驗證以存取您的帳戶"
        ) { success, error in
            DispatchQueue.main.async {
                completion(success, error)
            }
        }
    }
}
// Android 生物識別
import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity

class BiometricAuth(private val activity: FragmentActivity) {
    
    fun authenticate(
        onSuccess: () -> Unit,
        onError: (String) -> Unit
    ) {
        val executor = ContextCompat.getMainExecutor(activity)
        
        val biometricPrompt = BiometricPrompt(
            activity,
            executor,
            object : BiometricPrompt.AuthenticationCallback() {
                override fun onAuthenticationSucceeded(
                    result: BiometricPrompt.AuthenticationResult
                ) {
                    onSuccess()
                }
                
                override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
                    onError(errString.toString())
                }
                
                override fun onAuthenticationFailed() {
                    onError("身份驗證失敗")
                }
            }
        )
        
        val promptInfo = BiometricPrompt.PromptInfo.Builder()
            .setTitle("生物識別身份驗證")
            .setSubtitle("驗證以存取您的帳戶")
            .setNegativeButtonText("取消")
            .build()
        
        biometricPrompt.authenticate(promptInfo)
    }
}

一個真實的實作故事:測試拯救生產環境

在憑證輪換期間,我們的測試團隊報告了失敗,但將其視為「憑證固定太難測試」而忽略了。他們在 UAT 中停用了固定,因為這使他們的工作流程變得複雜。在生產發布前兩小時,我親自進行了調查。

新憑證使用萬用字元通用名稱(*.example.com)而不是特定網域(api.example.com)。我們的固定邏輯將確切的 CN 對應到公鑰——萬用字元不符合。如果部署,每個行動使用者都會失去連線。

🚫 UAT 固定差距

他們為什麼停用固定

  • 「太難測試」
  • UAT 中頻繁的憑證變更
  • 自簽憑證
  • 「我們會在生產監控中發現問題」

危險的後果

  • UAT 沒有驗證任何關於固定的內容
  • 所有固定問題都會出現在生產環境中
  • 「成功」測試帶來的虛假信心
  • 使用者會遇到測試從未發現的失敗

我在剩餘一小時時叫停了發布。我們獲得了具有正確 CN 格式的憑證,進行了徹底測試,並在幾天後成功部署。

✅ 經驗教訓

永遠不要在 UAT 中停用固定

  • 如果生產環境有固定,UAT 也必須有
  • 「太難測試」意味著在生產環境中發現
  • UAT 必須鏡像生產行為
  • 接受營運負擔是必要的

永遠不要忽視測試失敗

  • 調查每個失敗的根本原因
  • 不要假設「在生產環境中會正常運作」
  • 憑證固定失敗是訊號

為彈性而設計

  • CN 到固定的對應允許金鑰輪換
  • 可以在不更新應用程式的情況下更新金鑰
  • 平衡安全性與營運現實

啟用固定測試的困難不是錯誤——它是你的早期警告系統。接受營運負擔作為適當安全驗證的代價。

實作檢查清單

在將安全代碼部署到生產環境之前:

✅ 部署前驗證

安全儲存

  • 使用平台 API(Keychain/Keystore)
  • 正確的可存取性設定
  • 日誌中沒有敏感資料
  • 配置了備份排除

網路安全

  • 強制使用 TLS 1.2+
  • 啟用憑證驗證
  • 不允許明文流量
  • 實作請求簽章

代碼保護

  • 為發布版本啟用 ProGuard/R8
  • 混淆敏感字串
  • 從發布建置中刪除日誌記錄
  • 歸檔映射檔案

執行時保護

  • 實作 Root/越獄檢測
  • 適當的回應策略
  • 偵錯器檢測就位
  • 測試優雅降級

測試

  • 在真實裝置上測試
  • 驗證多個作業系統版本
  • 測試失敗場景
  • 在所有環境中啟用安全功能

結論

安全實作是理論與現實相遇的地方。iOS Keychain 和 Android Keystore 等平台提供的 API 提供了優於任何自訂實作的硬體支援加密。網路安全需要適當的 TLS 配置、憑證驗證和請求簽章——僅使用 HTTPS 是不夠的。代碼混淆提高了逆向工程的門檻,但必須與伺服器端驗證結合使用。執行時保護檢測受損環境,允許適當的回應。

安全設計與安全代碼之間的差距是發生資料外洩的地方。配置錯誤的 Keychain 可存取性會暴露資料。在生產環境中停用憑證驗證允許中間人攻擊。弱混淆提供虛假信心。簡單的 root 檢測很容易被繞過。每個實作細節都很重要。

測試安全實作是不可協商的。在多個作業系統版本的真實裝置上測試。測試失敗場景——當 Keychain 存取失敗、憑證無效、裝置被 root 時會發生什麼?在理想條件下運作但在邊緣情況下失敗的安全性提供了虛假信心。永遠不要因為「太難」而在測試環境中停用安全功能——這種困難是你的早期警告系統。

憑證輪換事件證明了適當測試的重要性。因為使測試複雜化而在 UAT 中停用固定意味著第一次真正的測試將在生產環境中進行。只有維護一個啟用固定的單獨暫存環境才能發現問題。啟用安全功能進行測試的營運負擔遠小於生產中斷的成本。

平台安全 API 不斷演進,發現漏洞,攻擊技術不斷進步。隨時了解安全更新,監控你使用的函式庫的公告,並定期審查實作。昨天的最佳實踐可能是今天的漏洞。行動安全是一個持續的過程,而不是一次性的實作。

在部署安全代碼之前,了解你要防禦的威脅以及實作是否真正提供保護。並非每個應用程式都需要 root 檢測或代碼混淆。將安全投資與你的風險狀況相符合。在新增進階保護之前,專注於基礎——安全儲存、適當的 TLS、強身份驗證。

裝置可能在使用者的口袋裡,但安全責任仍然是你的。仔細實作,徹底測試,永遠不要為了方便而走捷徑損害安全性。

分享到