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が無効になると書かれている。

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のリストと比較することでシステムインストールによる証明書かどうかの判定を行う。

// 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のリストと比較することでシステムインストールによる証明書かどうかの判定を行う。

// 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の設定ダイアログにおける「セキュリティデバイス」の欄に対応する。

f:id:inaz2:20150225112741p:plain

まとめ

現時点では、マルウェアなどが独自のルート証明書をインストールしSSL MITMを行うといった脅威をCertificate pinningで防ぐことはできない。 ただし、OpenSSLを利用するChromiumにおいては、実装の制約上例外的に防げる状態になっている可能性がある。

関連リンク