Pinning'le ilgili iki tür olay görürüz. Birincisinde bir güvenlik denetimi "pinning yok" bulgusu yazar, ekip iki günde ekler, herkes rahatlar. İkincisinde aradan on dört ay geçer, altyapı ekibi sertifikayı yeniler, uygulamanın bütün sürümleri aynı dakikada sunucuya bağlanamaz hale gelir ve o gün kimse rahat değildir. İkinci olayın sebebi neredeyse her zaman birincisidir.
Pinning'in kendisi beş satır yapılandırmadır. Zor olan kısım o beş satırın sertifika takvimine, CDN sözleşmesine, sürüm benimseme eğrisine ve nöbet prosedürüne bağlanmasıdır. Bu yazı ikisini birlikte anlatıyor.
Telefonda güven nereden gelir, pinning buna ne ekler
TLS el sıkışmasında sunucu bir sertifika zinciri sunar. İstemci zincirin imzalarını, tarihlerini ve alan adını kontrol eder, sonra zincirin cihazın güven deposundaki bir köke bağlanıp bağlanmadığına bakar. Bu depoda yüzün üzerinde kök CA vardır ve her biri, teknik olarak, sizin alan adınız için geçerli sertifika üretebilir.
Bu modelin iki zayıf noktası var. Birincisi, o CA'lardan herhangi birinin hata yapması ya da ele geçirilmesi sizin alan adınızı da etkiler. İkincisi, cihazın güven deposuna sonradan bir kök eklenebilir: kurumsal bir MDM profili, kullanıcının kendi kurduğu bir proxy sertifikası, kötü niyetli bir yapılandırma profili.
Platformlar ikinci sorunun büyük kısmını zaten kapatıyor. iOS'ta App Transport Security, URL Loading System üzerinden giden bağlantılarda TLS 1.2 ve üstünü, forward secrecy'yi ve yeterli anahtar uzunluğunu zorunlu tutar. Android 7.0 (API 24) ve sonrasını hedefleyen uygulamalar kullanıcının eklediği CA'lara varsayılan olarak güvenmez; Android 9'dan itibaren şifresiz trafik de varsayılan olarak kapalıdır. iOS'ta kullanıcının elle kurduğu bir kök, Ayarlar'da ayrıca tam güven verilmedikçe TLS için geçerli sayılmaz.
Pinning bunun üzerine tek bir şey ekler: kabul edilen zincirin içinde sizin önceden bildiğiniz bir açık anahtarın bulunması şartı. Yani yüzün üzerinde kökten oluşan güven kümesini iki üç anahtara indirir. Yanlış sertifika üreten bir CA'ya ve cihaza eklenmiş bir köke karşı korur.
Eklemediği şeyler de aynı netlikte bilinmeli. Pinning, cihazın sahibi olan saldırgana karşı koruma sağlamaz; root'lu ya da jailbreak'li cihazda kontrol çalışma zamanında kapatılır. Sunucunuzun ele geçirilmesine karşı hiçbir şey yapmaz. API'nizi otomasyondan korumaz. Trafiğinizin incelenmesini geciktirir, engellemez.
Hangi uygulama, hangi uç nokta
OWASP MASVS-NETWORK-2, geliştiricinin kontrolündeki bütün uç noktalar için kimlik pinlemesi bekler. Aynı OWASP'ın Pinning Cheat Sheet'i ise pinning'i düşünmeniz gereken durumun neredeyse hiç olmadığını, kesinti riskinin çoğu zaman güvenlik kazancından büyük olduğunu söyler. Android dokümantasyonu daha da ileri gider ve pinning'i Android uygulamaları için önermediğini açıkça yazar. Apple'ın kendi makalesi de pinning'in isteğe bağlı olduğunu ve yalnızca gerektiğinde kullanılmasını söyler.
Bu çelişki gibi görünen tablo aslında tutarlıdır: pinning, bedelini ödeyebilecek ekipler için bir kontroldür. Bizim karar çerçevemiz şöyle:
| Durum | Karar |
|---|---|
| Ödeme, bankacılık, sağlık verisi, kimlik doğrulama uç noktaları; regülasyon ya da denetim MASVS-NETWORK-2 istiyor | Pinleyin, aşağıdaki runbook'la birlikte |
| Oturum token'ı taşıyan sıradan API, sertifika ve anahtar kontrolü sizde | Rotasyon sahibi belliyse pinleyin; değilse platform varsayılanları yeterli |
| CDN'in yönettiği, anahtarını görmediğiniz sertifikalar | Leaf pinlemeyin; ya kendi sertifikanızı yükleyin ya da pinlemeyin |
| Üçüncü taraf alan adları (analitik, harita, ödeme sağlayıcının SDK'sı) | Pinlemeyin; anahtarlar sizin değil, takvim de sizin değil |
| İçerik, görsel, pazarlama sayfaları | Pinlemeyin |
Operasyonel bedel üç kalemdir: sertifika ve CA değişikliklerinin uygulama sürümleriyle sıralanması, sahada yıllarca kalan eski sürümlerin hesaba katılması ve bir gece yarısı kilitlenme yaşandığında kimin ne yapacağının yazılı olması. Bu üçüne sahip çıkacak bir isim yoksa pinning eklemeyin; "geçici" diye eklenen pin seti, sertifika yenileme gününe kadar kimsenin hatırlamadığı bir saatli düzenek olur.
Neyi pinlemeli: leaf, ara sertifika, kök
Önce biçim. Sertifikanın tamamı yerine açık anahtarını (SubjectPublicKeyInfo, SPKI) pinleyin. Sertifika yenilenir, tarihleri ve seri numarası değişir; anahtar aynı kalabilir. Sertifika pinlemek her yenilemede uygulama güncellemesi demektir ve yenileme sıklığı artıyor: CA/Browser Forum'un kabul ettiği takvime göre azami sertifika ömrü Mart 2026'dan başlayarak kademeli kısalıyor ve Mart 2029'da 47 güne iniyor. Let's Encrypt varsayılan profilini Şubat 2027'de 64, Şubat 2028'de 45 güne çekiyor.
Sonra zincirdeki konum:
| Pinlenen | Kazanç | Bedel |
|---|---|---|
| Leaf anahtarı | En dar güven: yalnızca sizin anahtarınız | Her yenilemede anahtarın korunması gerekir; ACME istemcileri çoğu zaman varsayılan olarak yeni anahtar üretir |
| Ara sertifika (intermediate) | Leaf serbestçe yenilenir | CA ara sertifikayı siz sormadan değiştirebilir; Let's Encrypt bunun olacağını açıkça söyler |
| Kök | En az kırılgan | O CA'nın ürettiği her sertifikaya güvenirsiniz |
Bizim önerimiz iki duruma ayrılıyor. Anahtarı siz üretiyor ve yenilemeyi siz yönetiyorsanız (kendi yük dengeleyiciniz, CDN'e yüklediğiniz özel sertifika) leaf SPKI'sını pinleyin, yanına henüz hiçbir sunucuya konmamış, çevrimdışı saklanan bir yedek anahtarın hash'ini ekleyin ve yenilemede anahtarı yeniden kullanın (certbot'ta --reuse-key). Sertifikayı bir sağlayıcı yönetiyorsa CA seviyesinde pinleyin: kullandığınız CA'nın kökü ya da uzun ömürlü ara sertifikası, artı sözleşmeniz olan ikinci, bağımsız bir CA'nın kökü. Apple'ın makalesi de uzun vadeli strateji olarak CA pinlemeyi önerir; Android dokümantasyonu ise yedeklerden en az birinin tamamen sizin kontrolünüzde bir anahtar olmasını ister. İkisini birleştirmek mümkün ve mantıklı.
Hangisini seçerseniz seçin, pin seti tek elemanlı olamaz.
Yedek pini olmayan pinning, tarihi sizin belirlemediğiniz bir kesintidir.
Pin üretmek
Canlı bir sunucunun leaf anahtarından:
openssl s_client -connect api.example.com:443 -servername api.example.com </dev/null 2>/dev/null \
| openssl x509 -pubkey -noout \
| openssl pkey -pubin -outform der \
| openssl dgst -sha256 -binary \
| openssl enc -base64
Bu komut yalnızca zincirin ilk sertifikasını okur. Ara sertifika ya da kök için sertifika dosyasından üretin (CA'nın yayınladığı PEM dosyası ya da -showcerts çıktısından ayırdığınız blok):
openssl x509 -in intermediate.pem -pubkey -noout \
| openssl pkey -pubin -outform der \
| openssl dgst -sha256 -binary \
| openssl enc -base64
Yedek anahtar için sertifikaya gerek yok; hash açık anahtardan üretilir. Anahtarı üretin, özel kısmını HSM'e ya da çevrimdışı kasaya koyun, hash'ini uygulamaya gömün:
openssl ecparam -name prime256v1 -genkey -noout -out backup.key
openssl pkey -in backup.key -pubout -outform der \
| openssl dgst -sha256 -binary | openssl enc -base64
Çıkan değer 44 karakterlik bir Base64 dizesidir ve iOS, Android, OkHttp üçünde de aynıdır. Aşağıdaki örneklerde geçen hash'ler yer tutucudur; kendi zincirinizden üretin.
iOS: önce NSPinnedDomains, gerekiyorsa delegate
Info.plist ile bildirimsel pinning
iOS 14'ten beri pinning, ATS yapılandırmasının bir parçası olarak Info.plist'te tanımlanabiliyor:
<key>NSAppTransportSecurity</key>
<dict>
<key>NSPinnedDomains</key>
<dict>
<key>api.example.com</key>
<dict>
<key>NSIncludesSubdomains</key>
<false/>
<key>NSPinnedCAIdentities</key>
<array>
<dict>
<key>SPKI-SHA256-BASE64</key>
<string>r/mIkG3eEpVdm+u/ko/cwxzOMo1bk4TyHIlByibiA5E=</string>
</dict>
<dict>
<key>SPKI-SHA256-BASE64</key>
<string>i7WTqTvh0OioIruIfFR4kMPnBqrS2rdiVPl/s2uC/CY=</string>
</dict>
</array>
</dict>
</dict>
</dict>
Bilinmesi gereken ayrıntılar:
NSPinnedCAIdentities, zincirdeki ara ya da kök sertifikaların anahtarlarıyla eşleşir.NSPinnedLeafIdentitiesyalnızca sunucu sertifikasının anahtarıyla eşleşir. Bir dizide birden çok giriş varsa birinin eşleşmesi yeter.- İkisini birlikte tanımlarsanız ATS her iki kategoride de eşleşme ister. "Leaf ya da CA" anlamına gelmez; "leaf ve CA" anlamına gelir. Leaf pinini yedek olarak CA dizisinin yanına koyan ekipler bu yüzden kendilerini kilitler.
NSIncludesSubdomainsdoğrudan alt alan adlarını kapsar:example.comiçin açarsanızapi.example.comkapsanır, Apple'ın örneğine görev2.api.example.comkapsanmaz.- Pinlemek, sistemin o sertifikayı güven kökü olarak değerlendirme biçimini değiştirmez. Standart doğrulama yine yapılır; pin bunun üzerine eklenir. Kendi özel CA'nızı pinlemek onu güvenilir yapmaz.
- ATS yalnızca URL Loading System'i (
URLSession) kapsar. Network framework ya da kendi TLS yığınını getiren kütüphaneler bu ayardan etkilenmez.WKWebViewiçeriği için davranışı kendi sürümünüzde test edin; Apple'ın pinning dokümantasyonu bunu açıkça yazmıyor.
Bu yöntemin iki eksiği var: son kullanma tarihi yok ve hata durumunda size haber veren bir kanca yok. Bağlantı sıradan bir TLS hatasıyla kesilir. Pin setiniz sabitse ve telemetriyi sunucu tarafındaki trafik düşüşünden okuyabiliyorsanız yine de ilk tercihimiz budur, çünkü yanlış yazılabilecek kod içermez.
Kendi mantığınız gerekiyorsa: URLSessionDelegate
Pinleri uzaktan güncellemek, hataları raporlamak ya da debug build'de pinning'i kapatmak istiyorsanız kontrolü kendiniz yazarsınız. Burada sahada en sık gördüğümüz hata şudur:
// Önce: sistem doğrulaması yok, yalnızca leaf sertifikanın baytları karşılaştırılıyor
func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
let trust = challenge.protectionSpace.serverTrust!
let chain = SecTrustCopyCertificateChain(trust) as! [SecCertificate]
let serverData = SecCertificateCopyData(chain[0]) as Data
if serverData == bundledCertificateData {
completionHandler(.useCredential, URLCredential(trust: trust))
} else {
completionHandler(.cancelAuthenticationChallenge, nil)
}
}
Üç sorun var. SecTrustEvaluateWithError hiç çağrılmıyor, yani süre ve alan adı kontrolü pin eşleşmesine emanet edilmiş. Sertifikanın tamamı karşılaştırıldığı için ilk yenilemede uygulama kilitlenir. Delegate bütün host'lar için aynı kontrolü uyguluyor; uygulamanın konuştuğu diğer her alan adı da reddedilir.
Doğrusu iki adımlıdır: önce sistemin standart değerlendirmesi, sonra zincirdeki anahtarların SPKI hash'leri. İkinci adımda bir tuzak var. SecKeyCopyExternalRepresentation SubjectPublicKeyInfo yapısını döndürmez: RSA için PKCS #1 biçimini, eliptik eğri anahtarları için ANSI X9.63 biçimini (04 || X || Y) verir. openssl ve Android ise SPKI'nın tamamını hash'ler. Aynı değeri elde etmek için anahtar tipine ve uzunluğuna göre sabit ASN.1 başlığını öne eklemeniz gerekir:
import CryptoKit
import Foundation
import Security
enum SPKIHash {
// AlgorithmIdentifier + BIT STRING başlıkları; SecKey'in vermediği kısım
private static let rsa2048: [UInt8] = [
0x30, 0x82, 0x01, 0x22, 0x30, 0x0d, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86,
0xf7, 0x0d, 0x01, 0x01, 0x01, 0x05, 0x00, 0x03, 0x82, 0x01, 0x0f, 0x00,
]
private static let rsa4096: [UInt8] = [
0x30, 0x82, 0x02, 0x22, 0x30, 0x0d, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86,
0xf7, 0x0d, 0x01, 0x01, 0x01, 0x05, 0x00, 0x03, 0x82, 0x02, 0x0f, 0x00,
]
private static let ecP256: [UInt8] = [
0x30, 0x59, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02,
0x01, 0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03, 0x01, 0x07, 0x03,
0x42, 0x00,
]
private static let ecP384: [UInt8] = [
0x30, 0x76, 0x30, 0x10, 0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02,
0x01, 0x06, 0x05, 0x2b, 0x81, 0x04, 0x00, 0x22, 0x03, 0x62, 0x00,
]
static func base64(of key: SecKey) -> String? {
guard let attributes = SecKeyCopyAttributes(key) as? [CFString: Any],
let type = attributes[kSecAttrKeyType] as? String,
let bits = attributes[kSecAttrKeySizeInBits] as? Int,
let raw = SecKeyCopyExternalRepresentation(key, nil) as Data?
else { return nil }
let rsa = String(kSecAttrKeyTypeRSA)
let ec = String(kSecAttrKeyTypeECSECPrimeRandom)
let header: [UInt8]
switch (type, bits) {
case (rsa, 2048): header = rsa2048
case (rsa, 4096): header = rsa4096
case (ec, 256): header = ecP256
case (ec, 384): header = ecP384
default: return nil // tanımadığımız anahtar tipi eşleşme üretmez
}
var hasher = SHA256()
hasher.update(data: Data(header))
hasher.update(data: raw)
return Data(hasher.finalize()).base64EncodedString()
}
}
RSA başlıkları, genel üssün yaygın değer olan 65537 olduğunu varsayar. Zincirinizde 3072 bit RSA ya da P-521 varsa başlığını aynı yöntemle ekleyin: openssl pkey -pubout -outform der çıktısının başındaki, ham anahtardan önce gelen baytlar. Bu kodu yazdıktan sonra ilk işiniz, ürettiği hash'i yukarıdaki openssl komutunun çıktısıyla aynı sunucu için karşılaştırmak olsun.
Delegate'in kendisi:
struct PinFailure: Sendable {
let host: String
let seenHashes: [String]
let systemTrustPassed: Bool
}
final class PinningDelegate: NSObject, URLSessionDelegate {
private let pins: [String: Set<String>] // host -> SPKI hash'leri
private let onFailure: @Sendable (PinFailure) -> Void
init(pins: [String: Set<String>], onFailure: @escaping @Sendable (PinFailure) -> Void) {
self.pins = pins
self.onFailure = onFailure
}
func urlSession(
_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge
) async -> (URLSession.AuthChallengeDisposition, URLCredential?) {
let space = challenge.protectionSpace
guard space.authenticationMethod == NSURLAuthenticationMethodServerTrust,
let trust = space.serverTrust,
let expected = pins[space.host]
else { return (.performDefaultHandling, nil) } // pinlenmemiş host'lar sisteme kalır
// 1. Standart değerlendirme: zincir, tarih, alan adı, ATS kuralları
var error: CFError?
guard SecTrustEvaluateWithError(trust, &error) else {
onFailure(PinFailure(host: space.host, seenHashes: [], systemTrustPassed: false))
return (.cancelAuthenticationChallenge, nil)
}
// 2. Değerlendirilmiş zincirdeki herhangi bir anahtar pin setinde mi
let chain = SecTrustCopyCertificateChain(trust) as? [SecCertificate] ?? []
let seen = chain
.compactMap { SecCertificateCopyKey($0) }
.compactMap { SPKIHash.base64(of: $0) }
guard !expected.isDisjoint(with: seen) else {
onFailure(PinFailure(host: space.host, seenHashes: seen, systemTrustPassed: true))
return (.cancelAuthenticationChallenge, nil)
}
return (.useCredential, URLCredential(trust: trust))
}
}
Apple, SecTrustEvaluateWithError'ın ağ erişimi gerektirebileceği için ana run loop'ta çağrılmamasını ister; delegateQueue parametresine nil verdiğinizde oturum delegate'i kendi arka plan kuyruğunda çalıştırır. Reddedilen istek çağıran tarafa NSURLErrorCancelled (-999) olarak döner; kullanıcıya "iptal edildi" yerine anlamlı bir mesaj göstermek istiyorsanız bunu ağ katmanınızda eşleyin. Debug build'de proxy kullanmak için pin sözlüğünü boş geçmeniz yeter, kod aynı kalır.
Bu delegate yalnızca onu verdiğiniz URLSession'ı kapsar. Kendi oturumunu açan üçüncü taraf SDK'lar ve WKWebView dışarıda kalır; web görünümü için aynı kontrolü WKNavigationDelegate'in kimlik doğrulama geri çağrısında yapmanız gerekir.
Hazır kütüphane olarak TrustKit hâlâ makul bir seçenek: yazının tarihinde depo arşivlenmemiş, son sürümü 3.0.7 (Haziran 2025) ve 2026 içinde commit alıyor. Hata raporlama mekanizması hazır gelir. Delegate'leri swizzle eden otomatik modu yerine açık doğrulama API'sini kullanmanızı öneririz; ağ katmanında görünmeyen davranış, bir yıl sonra kimsenin hatırlamadığı davranıştır.
Android: Network Security Config ve OkHttp
Network Security Config
<!-- res/xml/network_security_config.xml -->
<network-security-config>
<base-config cleartextTrafficPermitted="false" />
<domain-config>
<domain includeSubdomains="false">api.example.com</domain>
<domain includeSubdomains="false">auth.example.com</domain>
<pin-set expiration="2027-10-01">
<pin digest="SHA-256">r/mIkG3eEpVdm+u/ko/cwxzOMo1bk4TyHIlByibiA5E=</pin>
<!-- yedek: ikinci CA'nın kökü -->
<pin digest="SHA-256">i7WTqTvh0OioIruIfFR4kMPnBqrS2rdiVPl/s2uC/CY=</pin>
<!-- yedek: çevrimdışı saklanan kendi anahtarımız -->
<pin digest="SHA-256">fwza0LRMXouZHRC8Ei+4PyuldPDcf3UKgO/04cDM1oE=</pin>
</pin-set>
</domain-config>
<debug-overrides>
<trust-anchors>
<certificates src="user" />
</trust-anchors>
</debug-overrides>
</network-security-config>
Dosya manifest'te android:networkSecurityConfig="@xml/network_security_config" ile bağlanır. Davranışın ayrıntıları:
- Zincir, pinlenen anahtarlardan en az birini içeriyorsa geçerlidir. Leaf, ara ve kök ayrımı yoktur; hangisinin hash'ini yazdıysanız o seviyede pinlemiş olursunuz.
expiration(yyyy-MM-dd) geçtikten sonra pinler uygulanmaz ve bağlantı yalnızca standart doğrulamayla devam eder. Güncelleme almayan eski sürümler bu sayede sonsuza kadar kilitli kalmaz. Aynı dokümantasyon bedelini de yazar: son kullanma tarihi, saldırganın pinlerinizi atlatmasına imkân verebilir. Biz bir yıl civarı bir tarih koyup her sürümde ileri almayı tercih ediyoruz; tarihi ileri almayı unutan bir ekip, pinning'i sessizce kaybeder ama kullanıcılarını kaybetmez.debug-overridesyalnızcaandroid:debuggable="true"olan build'lerde uygulanır. Buradaki güven köklerindeoverridePinsvarsayılan olaraktrue'dur: zincir debug köküne bağlanıyorsa pin kontrolü yapılmaz. Release build'de bölümün tamamı yok sayılır, yani debug ve release için ayrı dosya tutmanıza gerek yoktur.domain-configiç içe yazılabilir ve tanımlanmayan değerleri üstteki yapılandırmadan devralır.includeSubdomains="true"bütün alt seviyeleri kapsar (iOS'taki tek seviye davranışından farklı).- Android 16 ile gelen
certificateTransparencyöğesi pinning'in tamamlayıcısıdır: hatalı üretilmiş sertifikaları pin operasyonu olmadan yakalamaya yarar ve pinlemediğiniz alan adlarında da çalışır.
OkHttp CertificatePinner
val pinner = CertificatePinner.Builder()
.add("api.example.com",
"sha256/r/mIkG3eEpVdm+u/ko/cwxzOMo1bk4TyHIlByibiA5E=",
"sha256/i7WTqTvh0OioIruIfFR4kMPnBqrS2rdiVPl/s2uC/CY=",
"sha256/fwza0LRMXouZHRC8Ei+4PyuldPDcf3UKgO/04cDM1oE=")
.build()
// Pin hatasını raporlayıp aynen yeniden fırlatan application interceptor
class PinFailureReporter(
private val report: (host: String, detail: String) -> Unit,
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response =
try {
chain.proceed(chain.request())
} catch (e: SSLPeerUnverifiedException) {
report(chain.request().url.host, e.message.orEmpty())
throw e
}
}
val client = OkHttpClient.Builder()
.certificatePinner(pinner)
.addInterceptor(PinFailureReporter(telemetry::pinFailure))
.build()
Desen sözdizimi: api.example.com tam eşleşme, *.example.com tek seviye alt alan adı, **.example.com her derinlikteki alt alan adı. Kontrol, TLS el sıkışması ve standart doğrulama başarıyla bittikten sonra, temizlenmiş zincir üzerinde yapılır. Başarısız olursa SSLPeerUnverifiedException fırlatılır ve mesajında sunucunun sunduğu zincirin hash'leri ile beklenen pinler listelenir; telemetri için ihtiyacınız olan bilgi tam olarak budur.
NSC ile OkHttp arasında seçim yaparken: NSC bildirimseldir, son kullanma tarihi ve debug davranışı hazırdır, ama hata anında kanca vermez ve derleme zamanında sabitlenir. CertificatePinner yalnızca kurduğunuz OkHttpClient'ı kapsar ama pinleri çalışma zamanında değiştirmenize ve hatayı görmenize izin verir. Sabit pin seti için NSC, uzaktan güncellenen pinler ya da telemetri gerekiyorsa OkHttp tercih ederiz. İkisini aynı alan adı için birlikte kullanırsanız rotasyonda iki listeyi de güncellemeyi unutmayın.
Neler kapsanır, neler kapsanmaz
NSC, platformun TrustManager'ını kullanan yığınları kapsar: HttpsURLConnection ve OWASP MASTG'ye göre WebView. OkHttp de Android'de platformun güven yöneticisine dayandığı için NSC kurallarına tabidir; yine de kendi sürümünüzde aşağıdaki testle doğrulayın. Kapsanmayanlar: kendi X509TrustManager'ını yazan kod (Android dokümantasyonu bunu başlı başına bir risk olarak sınıflandırır), native kodda kendi TLS kütüphanesini getiren SDK'lar ve kendi ağ yığınıyla gelen çapraz platform çerçeveleri. Bağımlılık listenizdeki her SDK için "bu, ağa hangi yığınla çıkıyor" sorusunun cevabını bir kez yazın; pinning'in gerçek kapsamı o listeden okunur.
Rotasyon: zaman çizelgeli bir runbook
Pinning'in asıl maliyeti buradadır. Kural tek cümle: yeni anahtarın hash'i, o anahtar sunucuya konmadan önce sahadaki sürümlerin içinde olmalıdır.
T-0 Yeni anahtarı üret (ya da yeni CA'yı seç). Hash'ini pin setine EKLE,
eskileri çıkarma. Android expiration tarihini ileri al.
T-0 Bu pin setiyle uygulama sürümünü yayınla (v5.4).
T+? Bekle: v5.4 öncesi sürümlerin aktif kullanıcı oranı, kabul ettiğin
eşiğin altına inene kadar. Eşik bir iş kararıdır, yazılı olsun.
Gerekirse eski sürümlere zorunlu güncelleme uygula.
T+N Sunucuda yeni anahtara/CA'ya geç. Pin hatası telemetrisini izle.
T+N+1 gün Telemetri sakinse geçiş tamam. Eski anahtarı hemen imha etme.
Sonraki Eski pini çıkar, yerine YENİ bir yedek ekle. Set hiçbir zaman
sürüm tek elemanlı kalmaz.
Bekleme süresini tahmin etmeyin, ölçün: analitiğinizdeki sürüm dağılımı, son altı sürümün ilk iki ve dört haftadaki benimsenme oranını verir. Kurumsal dağıtımlar ve eski işletim sistemlerinde kalan cihazlar bu eğrinin uzun kuyruğudur.
Takvime dışarıdan giren olaylar da var. CA ara sertifikasını değiştirebilir; kök ya da kendi anahtarınızı pinlediyseniz etkilenmezsiniz, ara sertifikayı pinlediyseniz o sabah öğrenirsiniz. Sertifika ömrü 47 güne indiğinde yenileme tamamen otomatik olur; leaf pinliyorsanız ACME istemcinizin anahtarı yeniden kullandığından emin olun ve bunu yenilemeden sonra hash karşılaştıran bir izleme kontrolüyle doğrulayın.
CDN ayrı bir başlıktır. CDN'in sizin adınıza yönettiği sertifikalarda anahtar ve CA seçimi sağlayıcıdadır ve habersiz değişebilir. Cloudflare dokümantasyonu bunu açıkça söyler: yönettikleri sertifikalar düzenli döner, pinning gerekiyorsa tek geçerli yol sizin yüklediğiniz özel sertifikadır ve yenilemede aynı anahtarı kullanmanız gerekir. Sağlayıcınız farklıysa aynı soruyu sözleşme aşamasında sorun.
Zaten kilitlendiyseniz
Sıra önemli, çünkü ilk seçenekler dakikalar, sonuncusu günler sürer.
- Eski anahtar hâlâ elinizdeyse sunucuya geri koyun. Sertifikanın süresi dolduysa aynı anahtar için yeni sertifika alın; SPKI pinlediğiniz için uygulama bunu kabul eder. Kilitlenmelerin çoğu bu adımla biter, yeter ki eski anahtar "temizlik" adına silinmemiş olsun.
- Yedek pinin anahtarı kasadaysa onunla sertifika çıkarıp sunucuya koyun. Yedek pinin var olma sebebi budur.
- Uzaktan güncellenen pin setiniz varsa yenisini yayınlayın (aşağıdaki bölüm).
- Android'de
expirationtarihi yakınsa o tarihten sonra eski sürümler kendiliğinden bağlanır. iOS'taNSPinnedDomainsiçin böyle bir çıkış yok. - Hiçbiri yoksa acil sürüm çıkarırsınız. Kullanıcıya güncelleme mesajını gösteren uç nokta da pinli alan adındaysa mesajı gösteremezsiniz; zorunlu güncelleme ve durum bilgisi için pinlenmemiş, ayrı bir alan adı bulundurmak bu yüzden runbook'un parçasıdır.
Uzaktan güncellenen pinler ve kill switch
Pin setini sunucudan güncelleyebilmek, kilitlenmeyi sürüm döngüsünden kurtarır. Karşılığında bir önyükleme sorunu doğar: yeni pin setini hangi kanaldan, neye güvenerek indireceksiniz? Pinli kanaldan indirirseniz kilitlendiğiniz anda güncelleme de kilitlenir. Pinsiz kanaldan indirirseniz araya giren biri kendi pinini yollayabilir.
Çıkış, taşıma güvenliğiyle içerik güvenliğini ayırmaktır. Pin setini pinlenmemiş bir alan adından indirin ama uygulamaya gömülü bir açık anahtarla imzasını doğrulayın. İmza anahtarı TLS anahtarlarınızdan bağımsızdır ve çevrimdışı tutulur.
import CryptoKit
import Foundation
struct SignedPinSet: Decodable {
let payload: Data // PinSet'in JSON hali
let signature: Data // payload üzerinde ECDSA P-256, DER
}
struct PinSet: Decodable {
let version: Int
let notAfter: Date
let pins: [String: Set<String>]
}
enum PinSetVerifier {
static func verify(
_ signed: SignedPinSet,
currentVersion: Int,
publicKeyX963: Data // uygulamaya gömülü imza açık anahtarı
) throws -> PinSet? {
let key = try P256.Signing.PublicKey(x963Representation: publicKeyX963)
let signature = try P256.Signing.ECDSASignature(derRepresentation: signed.signature)
guard key.isValidSignature(signature, for: signed.payload) else { return nil }
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
let set = try decoder.decode(PinSet.self, from: signed.payload)
// Eski bir seti yeniden oynatmayı ve süresi geçmiş seti reddet
guard set.version > currentVersion, set.notAfter > .now else { return nil }
return set
}
}
Kurallar: uygulamaya gömülü pin seti her zaman başlangıç değeridir, uzaktaki set onu yalnızca geçerli imzayla ve daha yüksek sürüm numarasıyla değiştirir. İndirme başarısızsa eldeki sette kalınır. Saldırgan güncellemeyi engelleyebilir ama sahte set kabul ettiremez. Android'de aynı tasarım, doğrulanan setten CertificatePinner kurup OkHttpClient.newBuilder() ile istemciyi yenilemek demektir; NSC bu senaryoya uymaz.
Kill switch, yani pinning'i uzaktan tamamen kapatan bayrak, aynı imzalı yapının pins alanını boş göndermekten ibarettir. Faydası kilitlenmede dakikalar içinde çıkış sağlamasıdır. Riski de tam olarak budur: imza anahtarını ele geçiren, pinning'i herkes için kapatabilir. İmzasız bir remote config bayrağıyla pinning kapatmak ise pinning'i hiç eklememekle aynı güvenlik seviyesidir, yalnızca daha fazla kodla.
Telemetri: hata var mı, saldırı mı, kendi rotasyonunuz mu
Pin hatasından haberdar olmayan ekip, kilitlenmeyi mağaza yorumlarından öğrenir. Her pin hatasında şunu raporlayın: host, uygulama sürümü, işletim sistemi, sistem doğrulamasının geçip geçmediği ve sunucunun sunduğu zincirin hash'leri. Kullanıcı verisi göndermeyin, gerek yok. Raporu pinlenmemiş ayrı bir alan adına gönderin; pinli kanal o an çalışmıyor.
NSPinnedDomains ve NSC size bu kancayı vermez; bağlantı genel bir TLS hatasıyla kesilir. Bu iki yöntemde elinizdeki sinyal, ağ katmanınızdaki TLS hata oranı ve sunucu tarafındaki trafik düşüşüdür. Zincir hash'lerini görmek istiyorsanız delegate, CertificatePinner ya da TrustKit gerekir.
Gelen veriyi okumak zor değildir:
| Gözlem | Büyük olasılıkla |
|---|---|
| Bütün sürümlerde, bütün coğrafyalarda, bir sunucu değişikliğinden hemen sonra ani sıçrama | Kendi rotasyonunuz; runbook'un "kilitlendiyseniz" bölümü |
| Yalnızca belirli bir sürümün altındaki sürümlerde hata | Rotasyon doğru, bekleme süresi kısa tutulmuş |
| Düşük ve sabit oran, az sayıda cihaz, görülen hash'ler bilinen proxy araçlarına ait | Güvenlik araştırmacıları, meraklı kullanıcılar, kurumsal TLS denetimi yapan ağlar |
| Sistem doğrulaması geçmiş, zincirde tanımadığınız bir CA, birbiriyle ilgisiz çok sayıda cihaz ve ağ | Ciddiye alın: hatalı üretilmiş sertifika ya da ağ seviyesinde müdahale |
İlk satır için alarm kurun: pin hata oranı belirli bir eşiği aştığında nöbetçiye gitsin.
Test: debug'da proxy, release'de ret
İki şeyi ayrı ayrı doğrulamanız gerekir: geliştiricilerin debug build'de trafiği inceleyebildiğini ve release build'in güvenilmeyen bir sertifikayı gerçekten reddettiğini. İkincisini doğrulamayan ekiplerde pinning'in aylarca kapalı kaldığını, çünkü debug ayarının release'e sızdığını ya da pinli alan adının yanlış yazıldığını görüyoruz.
Android'de debug tarafı debug-overrides ile hazırdır. iOS'ta NSPinnedDomains için debug istisnası yoktur; Debug yapılandırmasında pin içermeyen ayrı bir Info.plist kullanın ve CI'da release paketinin pinleri içerdiğini kontrol edin:
# iOS: release .ipa içindeki Info.plist pin setini içeriyor mu
plutil -extract NSAppTransportSecurity.NSPinnedDomains json -o - Payload/App.app/Info.plist
# Android: release APK'daki derlenmiş NSC (kaynak yolu kısaltılmışsa önce
# `aapt2 dump resources` ile dosyanın APK içindeki yolunu bulun)
aapt2 dump xmltree --file res/xml/network_security_config.xml app-release.apk
Release build'in davranış testi elle yapılır ve her sürüm adayında tekrarlanır: cihaza mitmproxy, Charles ya da Proxyman'in kök sertifikasını kurun, iOS'ta Ayarlar'dan tam güven verin, release build'i proxy üzerinden çalıştırın. Pinli alan adlarına giden her istek başarısız olmalı, proxy'de yalnızca başarısız el sıkışmaları görünmelidir. Android'de bunu, proxy kökünü sistem deposuna eklediğiniz root'lu bir emülatörde de tekrarlayın; kullanıcı deposundaki bir kök zaten pinning olmadan da reddedilir, yani o test pinning'i ölçmez.
İstemci kurulumunu birim testle de kilitleyebilirsiniz. OkHttp 5'in mockwebserver3 ve okhttp-tls modülleriyle:
@Test
fun `pin eşleşmezse bağlantı reddedilir`() {
val localhost = InetAddress.getByName("localhost").canonicalHostName
val held = HeldCertificate.Builder().addSubjectAlternativeName(localhost).build()
val serverCerts = HandshakeCertificates.Builder().heldCertificate(held).build()
// Sertifikaya güveniyoruz: standart doğrulama geçsin, yalnızca pin başarısız olsun
val clientCerts = HandshakeCertificates.Builder()
.addTrustedCertificate(held.certificate)
.build()
val server = MockWebServer()
server.useHttps(serverCerts.sslSocketFactory())
server.enqueue(MockResponse())
server.start()
val client = OkHttpClient.Builder()
.sslSocketFactory(clientCerts.sslSocketFactory(), clientCerts.trustManager)
.certificatePinner(
CertificatePinner.Builder()
.add(server.hostName, "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
.build()
)
.build()
assertFailsWith<SSLPeerUnverifiedException> {
client.newCall(Request.Builder().url(server.url("/")).build()).execute()
}
server.close()
}
Aynı testin ikinci yarısı, CertificatePinner.pin(held.certificate) ile doğru pini verip isteğin başarılı olduğunu göstermelidir. iOS'ta karşılığı, PinningDelegate'i yanlış bir pinle kurup gerçek bir host'a istek atan ve NSURLErrorCancelled bekleyen bir entegrasyon testidir.
Pentest'te pinning nasıl atlatılır
OWASP MASTG'nin kendi ifadesiyle, çoğu uygulamada pinning saniyeler içinde atlatılır; yeter ki uygulama araçların tanıdığı API'leri kullansın. Savunan tarafın bilmesi gereken üç yol var.
Birincisi çalışma zamanı kancalamasıdır. Root'lu ya da jailbreak'li cihazda Frida, uygulamanın sürecine girer ve doğrulama fonksiyonlarını değiştirir. objection bunu tek komuta indirir: android sslpinning disable ve ios sslpinning disable. Hazır betikler SecTrustEvaluateWithError, X509TrustManager, OkHttp'nin CertificatePinner.check metodu ve TrustKit gibi bilinen noktaları hedefler. Platformun standart API'lerini kullandığınız ölçüde bu betikler sizde de çalışır; bu, standart API'leri bırakmak için bir sebep değildir.
İkincisi yeniden paketlemedir. APK açılır, network_security_config.xml içindeki pin-set silinir ya da kullanıcı CA'larına güven eklenir, uygulama yeniden imzalanır. MASTG ayrıca statik yolu tarif eder: kod içindeki hash'leri bulup proxy'nin hash'iyle değiştirmek. Jailbreak'siz iOS cihazlarda da benzer şekilde Frida kütüphanesi uygulamaya gömülüp yeniden imzalanır.
Üçüncüsü özel implementasyonların tersine mühendisliğidir. Pin kontrolünü native koda taşımak, karartmak, birden çok yere dağıtmak hazır betikleri boşa çıkarır ve saldırganın harcadığı zamanı saatlerden günlere çıkarır. Günlerden sonsuza çıkarmaz.
Buradan çıkan sonuç şu: pinning, cihazı kontrol eden birine karşı sır saklamaz. Protokolünüzü gizli tutarak güvenlik sağlıyorsanız sorununuz pinning'den önce başlıyor. İşe yarayan katmanlar sunucu tarafındadır:
- İsteğin değiştirilmemiş bir uygulamadan ve gerçek bir cihazdan geldiğini sunucuda doğrulamak. Yeniden paketlenmiş bir uygulama pinning'i atlatır ama geçerli bir attestation üretemez; ayrıntısı App Attest ve Play Integrity yazısında.
- İş kurallarını, fiyatı, limiti, yetkiyi sunucuda tutmak. İstemcinin gönderdiği hiçbir değer, trafiği gören birinin değiştiremeyeceği varsayımıyla işlenmemeli.
- Root, jailbreak ve hooking tespitini sunucuya giden bir risk sinyali olarak kullanmak; istemcide engel olarak kullanıldığında aynı Frida betiğiyle kapatılır.
- Hız sınırlama ve anomali tespiti.
Pentest raporunda "pinning Frida ile atlatıldı" yazması tek başına bir bulgu değildir. Bulgu, atlatıldıktan sonra neler yapılabildiğidir.
Sık yapılan hatalar
- Standart doğrulamayı atlayıp yalnızca pin karşılaştırmak, ya da debug için yazılmış "her sertifikaya güven" kodunu release'e taşımak.
- Tek pin. Yedeği olmayan set, ilk anahtar değişiminde kilitlenir.
- Sertifikanın kendisini ya da CDN'in yönettiği leaf anahtarını pinlemek.
- iOS'ta
NSPinnedLeafIdentitiesileNSPinnedCAIdentities'i "ya o ya bu" sanarak birlikte yazmak. - iOS'ta ham
SecKeyCopyExternalRepresentationçıktısını hash'leyipopensslçıktısıyla eşleşmemesine şaşırmak. - Android'de
expirationtarihini bir kez yazıp unutmak; pinning sessizce devre dışı kalır. - Sunucudaki anahtarı, yeni pini taşıyan sürüm yayılmadan değiştirmek.
- Eski anahtarı rotasyondan hemen sonra imha etmek.
- Üçüncü taraf alan adlarını pinlemek.
- Zorunlu güncelleme ve durum uç noktalarını pinli alan adında tutmak.
- Pinning'i imzasız bir remote config bayrağına bağlamak.
- Release build'in gerçekten reddettiğini hiç test etmemek.
Kontrol listesi
- Pinlenen her alan adının yazılı bir gerekçesi ve rotasyondan sorumlu bir sahibi var.
- SPKI pinleniyor; set en az iki eleman içeriyor ve biri çevrimdışı saklanan ya da ikinci bir CA'ya ait yedek.
- Pin seviyesi (leaf ya da CA) sertifikayı kimin yönettiğine göre seçildi; CDN'in yönettiği anahtarlar pinlenmiyor.
- iOS'ta
NSPinnedDomainsya da önceSecTrustEvaluateWithErrorçağıran bir delegate kullanılıyor; SPKI hash'iopensslçıktısıyla karşılaştırıldı. - Android'de
pin-setiçinexpirationtanımlı ve her sürümde gözden geçiriliyor;debug-overridesdışında kullanıcı CA'larına güven yok. - WebView ve kendi ağ yığınını getiren SDK'ların kapsam dışında kalıp kalmadığı listelendi.
- Rotasyon runbook'u yazılı: önce uygulama sürümü, benimsenme eşiği, sonra sunucu; eski anahtar saklanıyor.
- Kilitlenme senaryosu için sıra belli ve zorunlu güncelleme uç noktası pinlenmemiş bir alan adında.
- Uzaktan pin güncellemesi varsa imzalı, sürüm numaralı ve gömülü sete geri düşüyor.
- Pin hataları raporlanıyor ve oran için alarm var.
- Her sürüm adayında release build, güvenilen bir proxy köküyle test ediliyor ve pinli istekler başarısız oluyor.
- Sunucu tarafında attestation ve iş kuralı kontrolleri var; güvenlik pinning'in atlatılamayacağı varsayımına dayanmıyor.
Bu konuda desteğe mi ihtiyacınız var?
Uygulamanızda bu kontrollerin doğru kurulup kurulmadığını birlikte inceleyelim. Güvenlik denetimi ya da danışmanlıkla buradayız.
Kaynaklar
- Apple · NSPinnedDomains
- Apple · Identity Pinning: How to configure server certificates for your app
- Apple · SecKeyCopyExternalRepresentation
- Android · Network security configuration
- Android · Security with network protocols
- OkHttp · CertificatePinner (kaynak ve KDoc)
- OWASP MASVS · MASVS-NETWORK
- OWASP MASTG
- OWASP · Pinning Cheat Sheet
- Let's Encrypt · Decreasing Certificate Lifetimes to 45 Days
Yorumlar ve sorular
Katılmadığınız bir nokta ya da projenizde takıldığınız bir yer mi var? Yazın, konuşalım.