İçeriğe geç
Muhammed Ali
Kocabey

Android'de Certificate Pinning'i Doğru Yapmak

Android'de pinning, Network Security Config'te ya da OkHttp'nin CertificatePinner'ında birkaç satırlık bir ayar. İşin kolay kısmı bu satırlar. Zor olan, hangi anahtarı sabitleyeceğinize karar vermek, her zaman bir yedek tutmak ve anahtar rotasyonunu eski sürümdeki kullanıcıları dışarıda bırakmadan yapmak. Bu yazıda iki mekanizmayı, bu operasyonel kararları ve pinning'in neye karşı koruma sağlamadığını anlatıyorum.

Pinning'le, Android'de eID çipi okuma yazısındakilere benzer NFC/eID akışları da olan bir bankacılık uygulamasında uğraştım.

Pinning neye karşı korur?

Android varsayılan olarak sunucu sertifikasını sistemin güven deposuna göre doğrular. Android 7.0'dan beri API 24 ve üstünü hedefleyen uygulamalar, açıkça izin vermedikçe kullanıcının yüklediği CA'lara güvenmez. Pinning buna ikinci bir koşul ekler: doğrulanan zincirde önceden seçtiğiniz bir açık anahtar da bulunmalı.

Bu, sistem deposundaki bir CA'dan domain'iniz için geçerli sertifika almış bir saldırgana karşı korur; örneğin hatalı sertifika veren ya da ele geçirilmiş bir CA durumunda. Bedeli erişilebilirlik. Pin uyuşmazlığında bağlantı doğrudan kesilir, düşülecek başka bir yol yoktur. Yanlış bir pin, eksik bir yedek ya da planlamadığınız bir sunucu anahtarı değişikliği yeter; kullanıcılar güncelleme yükleyene kadar uygulama backend'e ulaşamaz. Android'in Network Security Config dokümantasyonu da bu yüzden pinning'e temkinli yaklaşır ve yedek pin'de ısrar eder. Pinning bilinçli yapılan bir tercihtir, her uygulamada varsayılan olarak açılacak bir şey değildir.

Network Security Config

Deklaratif yöntem, manifest'ten referans verilen bir XML dosyası (Android 7.0, API 24 ile geldi). Platformun varsayılan trust manager'ından geçen bağlantılara uygulanır: HttpsURLConnection ve varsayılan TrustManager'ı değiştirmediğiniz sürece OkHttp/Retrofit. WebView Chromium'un kendi ağ katmanını kullanır, kendi TLS kütüphanesini kullanan native kod da bu yoldan geçmez; pin'lerin bunları da kapsadığını varsaymayın.

<!-- res/xml/network_security_config.xml -->
<network-security-config>
    <domain-config>
        <domain includeSubdomains="true">api.example.com</domain>
        <pin-set expiration="2027-10-01">
            <!-- SPKI'nin base64 SHA-256 hash'i: şu an kullanılan anahtar -->
            <pin digest="SHA-256">k3hBDZ8m0cJ8yq1Yd0pPtQx2cHjWn7p9vF4sL1aXuGk=</pin>
            <!-- Yedek anahtar: önceden hazırlandı, henüz trafik sunmuyor -->
            <pin digest="SHA-256">YwFp2nL8qR6tZ3vK1mD9xS0cJ7hG4bN5aU8eW2oP1rM=</pin>
        </pin-set>
    </domain-config>
</network-security-config>
<application android:networkSecurityConfig="@xml/network_security_config">

Buradaki pin değerleri örnek; kendi değerlerinizi kendi anahtarlarınızdan üretin (aşağıda nasıl yapılacağı var). Birkaç detay:

  • Desteklenen tek digest digest="SHA-256". Değer, sertifikanın tamamının değil, SubjectPublicKeyInfo'sunun (SPKI) base64 SHA-256 hash'idir.
  • Doğrulanan zincirdeki sertifikalardan (leaf, intermediate veya trust anchor) en az birinin anahtarı sabitlenmişse kontrol geçer.
  • includeSubdomains="true" ile aynı pin set api.example.com'un bütün subdomain'lerine uygulanır.
  • expiration yyyy-MM-dd formatındadır. O tarihten itibaren pin'ler uygulanmaz ve bağlantı normal CA doğrulamasına döner. Bu, hiç güncellenmemiş eski bir uygulamanın bozulmasını önler ama koruma da sessizce kalkar. Tarihi yayın sürecinin bir parçası olarak görün ve her sürümde ileri taşıyın.

OkHttp CertificatePinner

Pinning'i kodda, test etmesi ve ortama göre yönetmesi daha kolay bir yerde istiyorsanız CertificatePinner'ı kullanın. Değerler aynı base64 SHA-256 SPKI hash'leri, başlarına sha256/ öneki geliyor:

val certificatePinner = CertificatePinner.Builder()
    .add(
        "api.example.com",
        "sha256/k3hBDZ8m0cJ8yq1Yd0pPtQx2cHjWn7p9vF4sL1aXuGk=", // current
        "sha256/YwFp2nL8qR6tZ3vK1mD9xS0cJ7hG4bN5aU8eW2oP1rM=", // backup
    )
    .build()

val client = OkHttpClient.Builder()
    .certificatePinner(certificatePinner)
    .build()

Hostname pattern'leri şu kurallarla eşleşir:

  • api.example.com yalnız tam olarak o host'la eşleşir; www.api.example.com için ayrı bir girdi gerekir.
  • *.example.com tam olarak bir label'la eşleşir: api.example.com evet, a.b.example.com hayır.
  • **.example.com her derinlikteki subdomain'le ve example.com'un kendisiyle eşleşir.
  • Bir host hem tam hem wildcard pattern'le eşleşiyorsa ikisinin pin'leri de kabul edilir.

OkHttp pin'leri sunucunun gönderdiği ham zincirle karşılaştırmaz. Önce zinciri "temizler": trust manager'ı kullanarak leaf'ten bir trust anchor'a giden güvenilir yolu kurar, ilgisiz sertifikaları atar. Pin'ler bu temizlenmiş zincirle karşılaştırılır. Bu sayede sunucu root sertifikayı hiç göndermese bile root CA anahtarını sabitleyebilirsiniz; saldırgan da handshake'e fazladan bir sertifika ekleyerek pin'i geçemez.

Sabitlenmiş client'ı tek bir yerde, örneğin :core:network modülünde tutun ki başka bir modül fark ettirmeden pinsiz bir OkHttpClient oluşturmasın. Bu tür sınırları Android modül sınırları yazısında anlattım. Network Security Config'i ve CertificatePinner'ı birlikte kullanırsanız iki kontrol de çalışır; pin'leri tek bir kaynaktan üretin, yoksa iki liste zamanla ayrışır ve bir kontrolden geçip diğerinde takılan bağlantılarla uğraşırsınız.

Hangi anahtarı sabitlemeli: leaf, intermediate, CA

İki API de sertifikanın tamamını değil, açık anahtarı (SPKI) sabitler; yani sertifikanın yenilenmesi tek başına pin'i bozmaz. Pin ancak anahtar değişince bozulur. Asıl karar, zincirdeki hangi anahtarı sabitleyeceğiniz:

  • Leaf anahtarı: en sıkı seçenek, çünkü yalnız sizin anahtarınız eşleşir. Yenilemeye ancak her yeni sertifika aynı anahtar çiftiyle alınıyorsa dayanır. Birçok ACME istemcisi varsayılan olarak her yenilemede yeni anahtar üretir (örneğin certbot'ta --reuse-key gerekir); CDN ya da yönetilen bir load balancer kullanıyorsanız anahtar hiç sizin kontrolünüzde olmayabilir.
  • Intermediate CA anahtarı: leaf anahtarı değişse de çalışır, ama o intermediate'ın host'unuz için verdiği her sertifikayı kabul eder. CA'lar intermediate'larını değiştirir, bazıları aynı anda birkaç tanesinden sertifika verir; CA'nızın duyurularını takip etmeniz gerekir.
  • Root CA anahtarı: en seyrek değişen ve en az koruma sağlayan seçenek; güveni bütün sistem deposundan o tek CA'ya daraltır, o kadar.

Anahtar yönetimi sizdeyse kendi leaf anahtarınızı ve bir yedek anahtarı sabitleyin. Değilse intermediate ya da root anahtarlarını sabitleyin, gerektiğinde geçebilmek için ikinci bir CA'nın anahtarını da yedek pin olarak ekleyin.

Canlı sunucudan hash'i şöyle hesaplayabilirsiniz:

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 leaf anahtarının hash'ini verir; intermediate'lar için -showcerts ekleyip aynı adımları her sertifikaya uygulayın. Komutu güvendiğiniz bir ağda çalıştırın, TLS inspection yapan bir proxy'nin arkasında proxy'nin anahtarını alırsınız. Henüz deploy edilmemiş bir yedek anahtarın pin'ini anahtar dosyasından hesaplayın:

openssl pkey -in backup.key -pubout -outform der \
  | openssl dgst -sha256 -binary \
  | openssl enc -base64

OkHttp'de yerel bir test build'ine bilerek yanlış bir pin de koyabilirsiniz: SSLPeerUnverifiedException mesajı temizlenmiş zincirin sha256/ değerlerini listeler. Bunu yalnız güvendiğiniz bir ağda yapın, debugging proxy'sinin arkasında asla.

Her zaman yedek pin olsun

Tek pin varken sabitlenen anahtarın değişmesi gereken gün (anahtarın ele geçirilmesi, CA değişimi, altyapı değişikliği) uygulamanın eşleştirebileceği bir şey kalmaz. Tek çözüm acil bir sürüm çıkarmaktır; güncellemeyen kullanıcılar da güncelleyene kadar bağlanamaz.

Yedek pin bunu önler. İkinci bir anahtarı önceden hazırlarsınız (sizin üretip güvenle sakladığınız bir anahtar çifti ya da ikinci bir CA'nın anahtarı) ve hash'ini aktif anahtarın yanında dağıtırsınız. Uygulama bu anahtarı zaten kabul ettiği için sunucuyu yeni bir sürüm çıkarmadan ona geçirebilirsiniz. Her pin set'te en az iki pin olmalı.

Kullanıcıları dışarıda bırakmadan rotasyon

Yedek ancak kullanıldığında yerine yenisi konursa işe yarar. Rotasyon kayan bir pencere gibi işler:

  1. İki pin'le yayınlayın: A anahtarı (kullanımda) ve B anahtarı (yedek).
  2. Rotasyon zamanı geldiğinde B anahtarı için sertifika alıp sunucuya deploy edin. Uygulama B'yi zaten sabitlediği için yeni sürüm gerekmez.
  3. Bir sonraki uygulama sürümünde yeni bir C anahtarı hazırlayın, pin'ini ekleyin ve A'yı çıkarın. Pin set yine "aktif + yedek" düzenine döner.

Sunucu tarafındaki her geçişten önce tek bir şeyi kontrol edin: hâlâ desteklediğiniz bütün uygulama sürümleri yeni anahtarı sabitliyor mu? C'ye geçmek üzereyken yalnız A ve B'yi sabitleyen sürümde hatırı sayılır sayıda kullanıcı varsa ya bekleyin ya da force-update mekanizmanızı kullanın. expiration da burada işe yarar (yalnız Network Security Config'te var; CertificatePinner'ın böyle bir süresi yok): güncelleyemediğiniz çok eski sürümlerin sonunda bağlantı hatasıyla kalmak yerine normal CA doğrulamasına dönmesini sağlar. Sertifika ve anahtar tarihlerini diğer üretim bağımlılıkları gibi takip edin; rotasyonu CA'nın değil kendi takviminize göre yapın.

Pin hataları uygulamada OkHttp'den SSLPeerUnverifiedException, platformdan SSLHandshakeException olarak gelir. Bunları crash veya analytics aracınıza diğer ağ hatalarından ayrı raporlayın; yanlış bir pin kullanıcı şikayetlerinden önce izlemede görünsün.

Debug build'ler ve kullanıcı CA'ları

Release'teki pinning, geliştirme sırasında kendi trafiğinizi Proxyman ya da Charles'la incelemenizi engellememeli. Network Security Config için bir debug-overrides bloğu yeterli:

<network-security-config>
    <domain-config>
        <!-- domain ve pin-set yukarıdaki gibi -->
    </domain-config>
    <debug-overrides>
        <trust-anchors>
            <certificates src="user" />
        </trust-anchors>
    </debug-overrides>
</network-security-config>

Bu blok yalnız build debuggable olduğunda (android:debuggable="true") geçerlidir, release build'lerde yok sayılır. Bu debug'a özel anchor'larla biten zincirlerde pin'ler uygulanmaz; böylece proxy'nin kullanıcı CA'sı debug'da çalışır, release'teki pin set olduğu gibi kalır.

OkHttp'nin CertificatePinner'ı debug-overrides'ı bilmez. Temizlenmiş zincir artık proxy'nin CA'sında biter, pin'ler eşleşmez ve istek başarısız olur. Bu yüzden debug build'de pinner'ı eklemeyin:

// BuildConfig, AGP 8+ ile buildFeatures { buildConfig = true } gerektirir
val certificatePinner = if (BuildConfig.DEBUG) {
    CertificatePinner.DEFAULT
} else {
    CertificatePinner.Builder()
        .add("api.example.com", PRIMARY_PIN, BACKUP_PIN)
        .build()
}

PRIMARY_PIN ve BACKUP_PIN yukarıdaki sha256/ string'leri. Release build'de kullanıcı CA'larına asla güvenmeyin; API 24+ hedefleyen uygulamalar onlara varsayılan olarak güvenmiyor, geri eklemeyin.

Certificate Transparency

Certificate Transparency (CT) aynı soruna başka bir açıdan bakar. "Yalnız bu anahtarlar" demek yerine sertifikanın herkese açık log'lara yazılmış olmasını şart koşar. Bir CA yine domain'iniz için hatalı sertifika verebilir, ama CT'yi zorunlu tutan istemciler log'a yazılmamış sertifikayı reddeder; yani o sertifikayı bu istemcilere karşı kullanmak herkese açık bir iz bırakır. Domain'leriniz için verilen sertifikaları CT log'larından izleyebilirsiniz. Chrome ve Apple platformları, herkese açık güvenilen sertifikalar için CT'yi zorunlu tutuyor. Android'de uygulama trafiği geleneksel olarak platform tarafından CT kontrolünden geçirilmiyordu; yeni sürümler CT desteği ekliyor, hedef SDK'nızda ne olduğunu Network Security Config dokümantasyonundan kontrol edin. OkHttp'de yerleşik CT kontrolü yok; bunu ekleyen üçüncü parti kütüphaneler var.

CT, hatalı verilmiş bir sertifikanın biri fark etmeden kullanılmasını engellemez ama kullanıcıları dışarıda bırakma riski de taşımaz. Birçok uygulama için CT izleme ile domain'iniz adına hangi CA'ların sertifika verebileceğini sınırlayan CAA DNS kayıtları makul bir alternatif; pinning'i de en riskli host'lara saklayabilirsiniz.

Pinning neyi engellemez?

Pinning, ele geçirilmemiş bir cihazdaki kullanıcıyı ağdaki bir saldırgana karşı korur. Uygulamanızı kendi kontrolündeki bir cihazda çalıştıran birine karşı korumaz. Root'lu bir cihazda ya da yeniden paketlenmiş bir APK'da Frida ve objection gibi araçlar uygulamanın içindeki TLS kontrollerine (platform trust manager'ı, OkHttp'nin CertificatePinner'ı) çalışma zamanında hook atıp onları her zaman geçecek hale getirir. Yeniden paketlenen bir uygulamanın Network Security Config'i de doğrudan değiştirilebilir.

Yani pinning bir API koruması değil. Yetkilendirme ve kötüye kullanım kontrollerini sunucuya koyun, uygulamaya secret gömmeyin; Play Integrity ve SecureCheck'te topladığım türden root/hook tespiti gibi sinyalleri saldırının maliyetini artıran katmanlar olarak kullanın; onların da aşılabileceğini bilerek.

Sık sorulan sorular

Leaf sertifikanın anahtarını mı, intermediate'ı mı, yoksa root CA'yı mı sabitlemeliyim?
Network Security Config de OkHttp de SPKI (açık anahtar) hash'i sabitler; asıl seçim zincirdeki hangi anahtarın sabitleneceğidir. Leaf anahtarı en sıkı seçenektir ama ancak anahtar sizin kontrolünüzdeyse ve yenilemelerde aynı kalıyorsa işe yarar. Intermediate ve root CA anahtarları leaf değişimlerinden etkilenmez ama o CA'nın host'unuz için verdiği her sertifikayı kabul eder; CA'lar da intermediate'larını değiştirir.
Pin set'te neden yedek pin olmalı?
Tek pin varken sabitlenen anahtar değiştiğinde (ele geçirilme, CA değişimi, her yenilemede yeni anahtar üreten bir ACME istemcisi) uygulamanın eşleştirebileceği bir şey kalmaz ve bunu ancak uygulama güncellemesi düzeltir. Önceden hazırlanmış bir anahtarın yedek pin'i, uygulamaya dokunmadan sunucuyu o anahtara geçirmenizi sağlar.
pin-set expiration tarihi geçince ne olur?
O tarihten itibaren Network Security Config pin'leri uygulamayı bırakır ve normal CA doğrulamasına döner. Uygulama çalışmaya devam eder ama pinning koruması görünür bir işaret vermeden ortadan kalkar; bu yüzden tarihin her sürümde ileri taşınması gerekir.
Debug build'de pinning'in Proxyman veya Charles'ı engellemesini nasıl önlerim?
Network Security Config için kullanıcı sertifikalarına güvenen bir debug-overrides bloğu ekleyin; yalnız debuggable build'lerde geçerlidir ve bu anchor'larla biten zincirlerde pin'ler uygulanmaz. OkHttp'nin CertificatePinner'ı bu bloğu dikkate almaz, o yüzden debug build'de pinner'ı hiç eklemeyin.

BİRLİKTE ÇALIŞALIM

İyi bir problemle başlayalım.

Ekibiniz için bir Android mühendisi arıyorsanız veya bir teknik problemi birlikte değerlendirmek istiyorsanız konuşalım.

30 dakika görüşelim[email protected]Tüm iletişim ve podcast bağlantıları