Certificate pinningの実装におけるPrivate trust anchorの判定について調べてみる
Lenovoのコンシューマ向けPCにプリインストールされたSuperfishと呼ばれるアプリケーションが、システムに独自のルート証明書をインストールしSSL MITM(Man-in-the-Middle)を行っていたことが発覚し、話題になっている(Lenovoによるプレスリリース)。 この件に関して、「Certificate pinningでは防げない」といった内容が書かれた記事がある。 この記事の中で参照されているChromiumのSecurity FAQでは、デバッグ用プロクシやファイアウォールなどのセキュリティ機器によるSSL MITMを許すため、「private trust anchor」の場合はpinningが無効になると書かれている。
- Security FAQ - The Chromium Projects # How does key pinning interact with local proxies and filters?
Chrome does not perform pin validation when the certificate chain chains up to a private trust anchor. A key result of this policy is that private trust anchors can be used to proxy (or MITM) connections, even to pinned sites. “Data loss prevention” appliances, firewalls, content filters, and malware can use this feature to defeat the protections of key pinning.
そこで、Certificate pinningの実装において「private trust anchor」の判定がどのようになっているか、実際にOSSのWebブラウザのソースコードを確認してみる。
Chromium
Chromiumでは、TransportSecurityState::CheckPublicKeyPins
関数にて、システムインストールのtrust anchor以外の場合はチェックをパスするようになっている。
bool TransportSecurityState::CheckPublicKeyPins( const std::string& host, bool is_issued_by_known_root, const HashValueVector& public_key_hashes, std::string* pinning_failure_log) { // Perform pin validation if, and only if, all these conditions obtain: // // * the server's certificate chain chains up to a known root (i.e. not a // user-installed trust anchor); and // * the server actually has public key pins. if (!is_issued_by_known_root || !HasPublicKeyPins(host)) { return true; } ... }
ソースコードを追うと、is_issued_by_known_root
変数にはCertVerifyResult
クラスのis_issued_by_known_root
メンバが対応していることがわかる。
// The result of certificate verification. class NET_EXPORT CertVerifyResult { public: ... // is_issued_by_known_root is true if we recognise the root CA as a standard // root. If it isn't then it's probably the case that this certificate was // generated by a MITM proxy whose root has been installed locally. This is // meaningless if the certificate was not trusted. bool is_issued_by_known_root; ... };
このis_issued_by_known_root
メンバは証明書の検証処理の中でセットされる。
この処理は、Windowsの場合はOSが提供する証明書ストア、Linuxの場合はMozilla NSS(Network Security Services)あるいはOpenSSLといったように、環境によって異なる方法で行われる。
// static CertVerifyProc* CertVerifyProc::CreateDefault() { #if defined(USE_NSS) || defined(OS_IOS) return new CertVerifyProcNSS(); #elif defined(USE_OPENSSL_CERTS) && !defined(OS_ANDROID) return new CertVerifyProcOpenSSL(); #elif defined(OS_ANDROID) return new CertVerifyProcAndroid(); #elif defined(OS_MACOSX) return new CertVerifyProcMac(); #elif defined(OS_WIN) return new CertVerifyProcWin(); #else return NULL; #endif }
ここからは、それぞれの方法についてis_issued_by_known_root
メンバに関連する箇所を示す。
NSS
NSSの場合は証明書のスロット名が「NSS Builtin Objects」となっているものを、システムインストールによる証明書として扱う。
// IsKnownRoot returns true if the given certificate is one that we believe // is a standard (as opposed to user-installed) root. bool IsKnownRoot(CERTCertificate* root) { if (!root || !root->slot) return false; // This magic name is taken from // http://bonsai.mozilla.org/cvsblame.cgi?file=mozilla/security/nss/lib/ckfw/builtins/constants.c&rev=1.13&mark=86,89#79 return 0 == strcmp(PK11_GetSlotName(root->slot), "NSS Builtin Objects"); }
OpenSSL
OpenSSLの場合は、ユニットテスト用の証明書以外はすべてシステムインストールによる証明書として扱われる。 つまり、ユーザが手動でインストールしたものもすべてシステムインストール扱いとなる(ただしOS側でパッチが当てられている場合は除く)。
void GetCertChainInfo(X509_STORE_CTX* store_ctx, CertVerifyResult* verify_result) { ... // Set verify_result->verified_cert and // verify_result->is_issued_by_known_root. if (verified_cert) { verify_result->verified_cert = X509Certificate::CreateFromHandle(verified_cert, verified_chain); // For OpenSSL builds, only certificates used for unit tests are treated // as not issued by known roots. The only way to determine whether a // certificate is issued by a known root using OpenSSL is to examine // distro-and-release specific hardcoded lists. verify_result->is_issued_by_known_root = true; if (TestRootCerts::HasInstance()) { X509* root = NULL; if (verified_chain.empty()) { root = verified_cert; } else { root = verified_chain.back(); } TestRootCerts* root_certs = TestRootCerts::GetInstance(); if (root_certs->Contains(root)) verify_result->is_issued_by_known_root = false; } } }
Android
Androidの場合はAndroidが提供するシステムキーストアを参照することで、システムインストールによる証明書かどうか判定している。
/** * Utility functions for verifying X.509 certificates. */ @JNINamespace("net") public class X509Util { ... private static boolean isKnownRoot(X509Certificate root) throws NoSuchAlgorithmException, KeyStoreException { // Could not find the system key store. Conservatively report false. if (sSystemKeyStore == null) return false; // Check the in-memory cache first; avoid decoding the anchor from disk // if it has been seen before. Pair<X500Principal, PublicKey> key = new Pair<X500Principal, PublicKey>( root.getSubjectX500Principal(), root.getPublicKey()); if (sSystemTrustAnchorCache.contains(key)) return true; // Note: It is not sufficient to call sSystemKeyStore.getCertificiateAlias. If the server // supplies a copy of a trust anchor, X509TrustManagerExtensions returns the server's // version rather than the system one. getCertificiateAlias will then fail to find an anchor // name. This is fixed upstream in https://android-review.googlesource.com/#/c/91605/ // // TODO(davidben): When the change trickles into an Android release, query sSystemKeyStore // directly. // System trust anchors are stored under a hash of the principal. In case of collisions, // a number is appended. String hash = hashPrincipal(root.getSubjectX500Principal()); for (int i = 0; true; i++) { String alias = hash + '.' + i; if (!new File(sSystemCertificateDirectory, alias).exists()) break; Certificate anchor = sSystemKeyStore.getCertificate("system:" + alias); // It is possible for this to return null if the user deleted a trust anchor. In // that case, the certificate remains in the system directory but is also added to // another file. Continue iterating as there may be further collisions after the // deleted anchor. if (anchor == null) continue; if (!(anchor instanceof X509Certificate)) { // This should never happen. String className = anchor.getClass().getName(); Log.e(TAG, "Anchor " + alias + " not an X509Certificate: " + className); continue; } // If the subject and public key match, this is a system root. X509Certificate anchorX509 = (X509Certificate) anchor; if (root.getSubjectX500Principal().equals(anchorX509.getSubjectX500Principal()) && root.getPublicKey().equals(anchorX509.getPublicKey())) { sSystemTrustAnchorCache.add(key); return true; } } return false; } ... }
Mac
Macの場合は、ハードコードされたfingerprintのリストと比較することでシステムインストールによる証明書かどうかの判定を行う。
- net/cert/cert_verify_proc_mac.cc - chromium/src.git - Git at Google
- net/cert/x509_certificate_known_roots_mac.h - chromium/src.git - Git at Google
// IsIssuedByKnownRoot returns true if the given chain is rooted at a root CA // that we recognise as a standard root. // static bool IsIssuedByKnownRoot(CFArrayRef chain) { int n = CFArrayGetCount(chain); if (n < 1) return false; SecCertificateRef root_ref = reinterpret_cast<SecCertificateRef>( const_cast<void*>(CFArrayGetValueAtIndex(chain, n - 1))); SHA1HashValue hash = X509Certificate::CalculateFingerprint(root_ref); return IsSHA1HashInSortedArray( hash, &kKnownRootCertSHA1Hashes[0][0], sizeof(kKnownRootCertSHA1Hashes)); }
Windows
Windowsの場合も、Macの場合と同様にハードコードされたfingerprintのリストと比較することでシステムインストールによる証明書かどうかの判定を行う。
- net/cert/cert_verify_proc_win.cc - chromium/src.git - Git at Google
- net/cert/x509_certificate_known_roots_win.h - chromium/src.git - Git at Google
// IsIssuedByKnownRoot returns true if the given chain is rooted at a root CA // which we recognise as a standard root. // static bool IsIssuedByKnownRoot(PCCERT_CHAIN_CONTEXT chain_context) { PCERT_SIMPLE_CHAIN first_chain = chain_context->rgpChain[0]; int num_elements = first_chain->cElement; if (num_elements < 1) return false; PCERT_CHAIN_ELEMENT* element = first_chain->rgpElement; PCCERT_CONTEXT cert = element[num_elements - 1]->pCertContext; SHA1HashValue hash = X509Certificate::CalculateFingerprint(cert); return IsSHA1HashInSortedArray( hash, &kKnownRootCertSHA1Hashes[0][0], sizeof(kKnownRootCertSHA1Hashes)); }
Firefox
Firefoxでは、CertListContainsExpectedKeys
関数にて、システムインストールのtrust anchor以外の場合はチェックをパスするようになっている。
ただし、これはコンフィグ設定においてsecurity.cert_pinning.enforcement_level
が1となっている場合のみである。
Result CertListContainsExpectedKeys(const CERTCertList* certList, const char* hostname, Time time, CertVerifier::PinningMode pinningMode) { ... CERTCertListNode* rootNode = CERT_LIST_TAIL(certList); if (CERT_LIST_END(rootNode, certList)) { return Result::FATAL_ERROR_INVALID_ARGS; } bool isBuiltInRoot = false; SECStatus srv = IsCertBuiltInRoot(rootNode->cert, isBuiltInRoot); if (srv != SECSuccess) { return MapPRErrorCodeToResult(PR_GetError()); } // If desired, the user can enable "allow user CA MITM mode", in which // case key pinning is not enforced for certificates that chain to trust // anchors that are not in Mozilla's root program if (!isBuiltInRoot && pinningMode == CertVerifier::pinningAllowUserCAMITM) { return Success; } ... }
FirefoxはNSSを利用しているため、Chromiumの場合とほぼ同様に、証明書のスロット名が「Builtin Object Token」となっているものをシステムインストールによる証明書として扱う。
SECStatus IsCertBuiltInRoot(CERTCertificate* cert, bool& result) { result = false; ScopedPK11SlotList slots; slots = PK11_GetAllSlotsForCert(cert, nullptr); if (!slots) { if (PORT_GetError() == SEC_ERROR_NO_TOKEN) { // no list return SECSuccess; } return SECFailure; } for (PK11SlotListElement* le = slots->head; le; le = le->next) { char* token = PK11_GetTokenName(le->slot); PR_LOG(gCertVerifierLog, PR_LOG_DEBUG, ("BuiltInRoot? subject=%s token=%s",cert->subjectName, token)); if (strcmp("Builtin Object Token", token) == 0) { result = true; return SECSuccess; } } return SECSuccess; }
スロット名は、GUIの設定ダイアログにおける「セキュリティデバイス」の欄に対応する。
まとめ
現時点では、マルウェアなどが独自のルート証明書をインストールしSSL MITMを行うといった脅威をCertificate pinningで防ぐことはできない。 ただし、OpenSSLを利用するChromiumにおいては、実装の制約上例外的に防げる状態になっている可能性がある。