安全架構提供了藍圖,但實作決定了你的行動應用程式是否真正保護使用者資料。如果代碼包含漏洞、使用弱加密或錯誤處理敏感資料,即使是完美設計的安全模型也會失敗。安全理論與安全代碼之間的差距正是大多數資料外洩發生的地方。
行動平台提供了強大的安全 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、強身份驗證。
裝置可能在使用者的口袋裡,但安全責任仍然是你的。仔細實作,徹底測試,永遠不要為了方便而走捷徑損害安全性。