SSL Pinning 概述
SSL Pinning(憑證綁定)是一種安全機制,用於確保應用程式只與預期的伺服器建立安全連線。透過在應用程式中預先嵌入伺服器的憑證或公鑰資訊,可以有效防止中間人攻擊(Man-in-the-Middle, MITM)。
當應用程式進行 HTTPS 連線時,除了驗證憑證鏈的有效性外,還會額外檢查伺服器憑證是否與預先嵌入的資訊相符。如果不符,連線將被拒絕。
為什麼需要憑證綁定
傳統的 SSL/TLS 驗證依賴於憑證授權機構(CA)系統,但這存在以下風險:
- CA 被入侵:如果 CA 被攻擊者控制,可能會簽發偽造憑證
- 惡意 CA:使用者可能被誘騙安裝惡意根憑證
- 企業代理:某些企業環境會安裝自己的 CA 以進行流量檢查
- 政府監控:部分國家可能強制安裝監控憑證
SSL Pinning 可以防止這些攻擊,確保應用程式只信任特定的憑證。
Pinning 類型
1. 憑證綁定(Certificate Pinning)
直接綁定完整的伺服器憑證。
優點:實作簡單,安全性最高
缺點:憑證更新時需要同步更新應用程式
2. 公鑰綁定(Public Key Pinning)
只綁定憑證中的公鑰部分。
優點:憑證更新時若保持相同公鑰,無需更新應用程式
缺點:需要從憑證中提取公鑰
3. SPKI 綁定(Subject Public Key Info)
綁定公鑰資訊的雜湊值,這是目前最推薦的方式。
1
2
3
4
5
6
| # 提取憑證的 SPKI 雜湊值
openssl s_client -connect example.com:443 | \
openssl x509 -pubkey -noout | \
openssl pkey -pubin -outform der | \
openssl dgst -sha256 -binary | \
openssl enc -base64
|
Android 實作範例
使用 Network Security Config(Android 7.0+)
在 res/xml/network_security_config.xml 中配置:
1
2
3
4
5
6
7
8
9
10
11
| <?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<domain-config>
<domain includeSubdomains="true">api.example.com</domain>
<pin-set expiration="2025-01-01">
<pin digest="SHA-256">AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=</pin>
<!-- 備用 pin,用於憑證輪換 -->
<pin digest="SHA-256">BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=</pin>
</pin-set>
</domain-config>
</network-security-config>
|
在 AndroidManifest.xml 中引用:
1
2
3
| <application
android:networkSecurityConfig="@xml/network_security_config"
...>
|
使用 OkHttp
1
2
3
4
5
6
7
8
| val certificatePinner = CertificatePinner.Builder()
.add("api.example.com", "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
.add("api.example.com", "sha256/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=")
.build()
val client = OkHttpClient.Builder()
.certificatePinner(certificatePinner)
.build()
|
iOS 實作範例
使用 URLSession
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
| class PinningDelegate: NSObject, URLSessionDelegate {
let pinnedPublicKeyHash = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
func urlSession(_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
guard let serverTrust = challenge.protectionSpace.serverTrust,
let certificate = SecTrustGetCertificateAtIndex(serverTrust, 0) else {
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
// 提取公鑰並計算雜湊
let publicKey = SecCertificateCopyKey(certificate)
let publicKeyData = SecKeyCopyExternalRepresentation(publicKey!, nil)! as Data
let hash = sha256(data: publicKeyData).base64EncodedString()
if hash == pinnedPublicKeyHash {
completionHandler(.useCredential, URLCredential(trust: serverTrust))
} else {
completionHandler(.cancelAuthenticationChallenge, nil)
}
}
}
|
使用 Alamofire
1
2
3
4
5
6
7
| let evaluators: [String: ServerTrustEvaluating] = [
"api.example.com": PublicKeysTrustEvaluator()
]
let manager = Session(
serverTrustManager: ServerTrustManager(evaluators: evaluators)
)
|
伺服器端 HPKP(已廢棄)
HTTP Public Key Pinning(HPKP)曾是一種伺服器端的 Pinning 機制,透過 HTTP 標頭告知瀏覽器應該信任的公鑰。
1
2
3
4
5
| Public-Key-Pins:
pin-sha256="base64+primary==";
pin-sha256="base64+backup==";
max-age=5184000;
includeSubDomains
|
為何被廢棄:
- 配置錯誤可能導致網站完全無法訪問
- 可能被攻擊者濫用進行「HPKP 勒索攻擊」
- 憑證輪換管理複雜
Chrome 從版本 72 開始移除 HPKP 支援。建議改用 Certificate Transparency(CT)機制。
更新與輪換策略
憑證輪換最佳實踐
- 始終配置備用 Pin:至少配置兩個 Pin,包含當前和備用憑證
- 提前規劃:在憑證到期前 30-60 天開始輪換程序
- 階段性部署:
- 第一階段:新增新憑證 Pin 到應用程式
- 第二階段:等待應用程式更新普及
- 第三階段:伺服器切換到新憑證
- 第四階段:移除舊憑證 Pin
版本控制策略
1
2
3
4
5
| // 根據應用程式版本動態載入 Pin
val pins = when {
BuildConfig.VERSION_CODE >= 100 -> listOf(PIN_V2, PIN_V3)
else -> listOf(PIN_V1, PIN_V2)
}
|
繞過 SSL Pinning(測試用途)
在開發和安全測試時,可能需要暫時繞過 SSL Pinning。
Frida Script
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| // frida -U -f com.example.app -l bypass.js
Java.perform(function() {
var TrustManager = Java.use('javax.net.ssl.X509TrustManager');
var SSLContext = Java.use('javax.net.ssl.SSLContext');
var TrustManagerImpl = Java.registerClass({
name: 'com.example.TrustManager',
implements: [TrustManager],
methods: {
checkClientTrusted: function(chain, authType) {},
checkServerTrusted: function(chain, authType) {},
getAcceptedIssuers: function() { return []; }
}
});
var TrustManagers = [TrustManagerImpl.$new()];
var sslContext = SSLContext.getInstance("TLS");
sslContext.init(null, TrustManagers, null);
});
|
使用 objection
1
2
3
| objection -g com.example.app explore
# 進入後執行
android sslpinning disable
|
注意:這些技術僅供合法的安全測試使用,未經授權的使用可能違法。
最佳實踐
- 選擇正確的 Pinning 層級:優先使用 SPKI Pinning,平衡安全性與維護性
- 實作備用機制:配置多個 Pin 以支援憑證輪換
- 設定合理的過期時間:使用
expiration 屬性避免 Pin 過期導致應用程式無法使用 - 監控與告警:實作 Pin 驗證失敗的日誌和告警機制
- 優雅降級:考慮在 Pin 驗證失敗時的使用者體驗
- 定期審查:定期檢查 Pin 配置是否需要更新
- 測試環境隔離:開發環境可使用不同的配置或禁用 Pinning
1
2
3
4
5
6
7
8
| // 根據環境切換配置
val client = if (BuildConfig.DEBUG) {
OkHttpClient.Builder().build()
} else {
OkHttpClient.Builder()
.certificatePinner(certificatePinner)
.build()
}
|
參考資料