Chromiumの新しい証明書有効期間要件下でのAndroid WebViewトラフィックのインターセプト
Chromiumの新しい証明書有効期間要件下でのAndroid WebViewトラフィックのインターセプト
原文: Intercepting Android WebView traffic under new certificate validity requirement by Chromium
著者: Jeroen Beckers (NVISO) | モバイルセキュリティ | 8分
最近のセキュリティ診断において、AndroidアプリのWebViewから発信されるトラフィックをインターセプトできない事象に遭遇しました。アプリは証明書のピニングを使用しておらず、通常のインターセプトを阻むような特別な設定もされていませんでした。同じアプリを別のデバイスでテストしたところ、トラフィックは直接インターセプトできたため、問題はアプリではなくデバイス側にあると判断しました。
TL;DR: Chromiumは現在、Burpが生成する証明書よりも短いリーフ証明書の有効期間を強制しています。これにより証明書検証が失敗します。PortSwiggerがBurpで証明書の有効期間を設定可能にするまで、最も簡単な解決策はデバイスからWebViewのアップデートを削除し、古い証明書検証ロジックに戻すことです。その他の方法は記事の最後に記載しています。
テスト用アプリの作成
まず、WebサイトにWebViewコンポーネントで読み込み、10秒ごとにリフレッシュする非常に基本的なテストアプリを作成しましょう。
public class MainActivity extends AppCompatActivity {
private final Handler handler = new Handler(Looper.getMainLooper());
private WebView webView;
private final Runnable refreshRunnable = new Runnable() {
@Override
public void run() {
if (webView != null) {
webView.loadUrl("https://nviso.eu");
}
handler.postDelayed(this, 10000);
}
};
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
webView = findViewById(R.id.webview);
WebSettings webSettings = webView.getSettings();
webSettings.setJavaScriptEnabled(true);
webView.setWebViewClient(new WebViewClient());
handler.post(refreshRunnable);
}
}
トラフィックをインターセプトしない場合、アプリは正常に動作し、NVISOのメインWebサイトを表示します。しかし、Burp Suiteをインターセプトプロキシとして設定すると、WebViewはページをロードできなくなります。
WebViewが黒くなるのに加え、Burp Suiteの典型的なプロキシエラーが表示され、TLSの問題であることが確認できます。
典型的な「クライアントがTLS接続をネゴシエートできなかった… unknown_ca」エラー
さらに、logcatにもエラーメッセージが出力されています(アプリ自体から出力されたものではありません):
07-07 16:49:14.759 3347 8552 E chromium: [0707/164914.759550:ERROR:net/socket/ssl_client_socket_impl.cc:944] handshake failed; returned -1, SSL error code 1, net_error -213
証明書の有効期間の問題
このエラーは、WebViewコンポーネントのレンダリングエンジンであるChromiumによってスローされます。エラー番号 -213 は CERT_VALIDITY_TOO_LONG です。
// The certificate claimed DNS names that are in violation of name constraints.
NET_ERROR(CERT_NAME_CONSTRAINT_VIOLATION, -212)
// The certificate's validity period is too long.
NET_ERROR(CERT_VALIDITY_TOO_LONG, -213)
// Certificate Transparency was required for this connection, but the server
// did not provide CT information that complied with the policy.
NET_ERROR(CERTIFICATE_TRANSPARENCY_REQUIRED, -214)
このエラーは、Chromiumが証明書を検証する際に cert_verify_proc でスローされます:
// Flag certificates using too long validity periods.
if (verify_result->is_issued_by_known_root && HasTooLongValidity(*cert)) {
verify_result->cert_status |= CERT_STATUS_VALIDITY_TOO_LONG;
if (rv == OK)
rv = MapCertStatusToNetError(verify_result->cert_status);
}
HasTooLongValidity の内部ロジックを見てみましょう:
bool CertVerifyProc::HasTooLongValidity(const X509Certificate& cert) {
base::Time start = cert.valid_start();
base::Time expiry = cert.valid_expiry();
if (start.is_max() || start.is_null() || expiry.is_max() ||
expiry.is_null() || start > expiry) {
return true;
}
// The maximum lifetime of publicly trusted certificates has reduced
// gradually over time.
// 現在の制限(CABF Baseline Requirements v2.1.7より)
static constexpr base::Time kTime_2029_03_15 = ...;
static constexpr base::Time kTime_2027_03_15 = ...;
static constexpr base::Time kTime_2026_03_15 = ...;
// 2029年3月15日以降に発行された証明書: 最大47日
if (start >= kTime_2029_03_15) {
return validity_duration > base::Days(47);
}
// 2027年3月15日以降に発行された証明書: 最大100日
if (start >= kTime_2027_03_15) {
return validity_duration > base::Days(100);
}
// 2026年3月15日以降に発行された証明書: 最大200日
if (start >= kTime_2026_03_15) {
return validity_duration > base::Days(200);
}
// 現在の制限(Chrome Root Certificate Policy)
// 2020年9月1日以降に発行された証明書: 最大398日
return validity_duration > base::Days(398);
}
証明書有効期間のロジックは、コード内のドキュメントで明確に説明されており、評価されるすべてのリーフ証明書に適用されます。
| 証明書の発行日 | 最大有効期間 |
|---|---|
| 2012-07-01〜 | 60ヶ月 |
| 2015-04-01〜 | 39ヶ月 |
| 2018-03-01〜 | 825日 |
| 2020-09-01〜 | 398日 |
| 2026-03-15〜 | 200日 |
| 2027-03-15〜 | 100日 |
| 2029-03-15〜 | 47日 |
つまり、2026年3月15日以降に発行された証明書について、Chromiumはリーフ証明書の有効期間を最大200日に制限するようになりました。 Burp Suiteが提供する証明書を簡単なスポットチェックで確認してみましょう。
$ openssl s_client \
-proxy 127.0.0.1:8080 \
-connect nviso.eu:443 \
-servername nviso.eu \
-showcerts 2>/dev/null | openssl x509 -noout -subject -issuer -startdate -enddate
subject=C = BE, O = NVISO, OU = SSA, CN = nviso.eu
issuer=C = BE, ST = Some-State, O = NVISO, OU = SSA, CN = NVISO.eu
notBefore=Jun 23 14:40:01 2026 GMT
notAfter=Jun 23 14:40:01 2027 GMT
証明書のデフォルト有効期間は1年で、新しいルールでは許可されなくなっています。一方、Burp Suiteの代わりにmitmproxyでプロキシすると、インターセプトは成功します。
接続が成功する理由は、mitmproxyが生成する証明書の有効期間がすでに短いためです:
$ openssl s_client \
-proxy 127.0.0.1:9090 \
-connect nviso.eu:443 \
-servername nviso.eu \
-showcerts </dev/null 2>/dev/null | openssl x509 -noout -subject -issuer -startdate -enddate
subject=CN = nviso.eu
issuer=C = BE, ST = Some-State, O = NVISO, OU = SSA, CN = NVISO.eu
notBefore=Jul 6 08:55:01 2026 GMT
notAfter=Jan 21 08:55:01 2027 GMT
問題の解決策
簡単な順に、いくつかの解決策があります:
- デバイスからChromiumのアップデートをアンインストールする
- mitmproxyとBurp Suiteを組み合わせる
- libwebviewchromiumライブラリにパッチを当てる
PortSwiggerにはすでに連絡済みで、証明書の有効期間を設定可能にすることを期待しています。meantime、各オプションを見ていきましょう。
デバイスからChromiumのアップデートをアンインストールする
最も簡単な方法です。Google Playストアで「Android System WebView」コンポーネントを検索するか、以下のadbコマンドで実行できます:
$ adb shell am start -a android.intent.action.VIEW \
-d "market://details?id=com.google.android.webview"
「アップデートを削除」をクリックするだけで元のバージョンにフォールバックできます。
WebViewコンポーネントのバージョンは dumpsys webviewupdate で確認できます:
# ダウングレード前: バージョン149
$ adb shell dumpsys webviewupdate
Current WebView package (name, version): (com.google.android.webview, 149.0.7827.159)
# ダウングレード後: バージョン113
$ adb shell dumpsys webviewupdate
Current WebView package (name, version): (com.google.android.webview, 113.0.5672.61)
mitmproxyとBurp Suiteを組み合わせる
mitmproxyはすでに正しい証明書を生成しているため、mitmproxyとBurp Suiteをチェーンさせて、デバイスからmitmproxyにトラフィックを送り、mitmproxyがBurp Suiteに転送するようにできます。これはアップストリームプロキシと呼ばれ、--mode upstream で設定できます:
./mitmproxy --listen-port 9090 \
--set confdir="$(pwd)/mitmproxy-conf" \
--mode upstream:http://localhost:8080 \
--ssl-insecure
注意: mitmproxyがBurp Suiteの自己署名証明書を受け入れるよう、--ssl-insecure が必要です。
このセットアップでは、Burp Suiteはポート8080、mitmproxyはポート9090でリッスンし、デバイスはmitmproxyをプロキシとして使用するように設定します。Burp Suiteとmitmproxyの両方がリクエストをインターセプトできます。
libwebviewchromiumライブラリにパッチを当てる
可能ですが、正しい cert_verify_proc 関数を見つけるのは難しいです。インストールされているバージョン(149.0.7827.159)では、HasTooLongValidity 関数が cert_verify_proc にインライン化されており、オフセット 0x3e015f8 にあります。
デフォルトではWebViewはマルチプロセスモードで実行されるため、アプリ自体ではなく webview_zygote にインジェクトする必要があります。これは少しトリッキーで、エラーが発生するとシステム上のすべてのWebViewが破損する可能性があります。代わりに、WebViewをマルチプロセスサポートなしで実行するように設定することで、アプリ自体にインジェクトして検証を無効化できます:
# マルチプロセスからシングルプロセスに切り替え
$ adb shell cmd webviewupdate disable-multiprocess
$ adb shell ps -A | grep -iE "nviso|webview"
u0_a263 8780 891 330974052 291592 do_epoll_wait 0 S eu.nviso.webview
これでアプリ内から cert_verify_proc をフックできます:
var base = Process.findModuleByName("libwebviewchromium.so").base;
var cert_verify_proc = base.add(0x3e015f8);
Interceptor.attach(cert_verify_proc, {
onEnter: function(args) {
console.log("CertVerifyProc called");
},
onLeave: function(retval) {
console.log("CertVerifyProc returned: " + retval.toInt32());
retval.replace(ptr(0));
}
});
ただし、ライブラリがTLS検証の結果を CachingCertVerifier::Verify で実際にキャッシュしているため、結果に一貫性がなくなります。チェックを完全に無効にするには、CachingCertVerifierもパッチする必要があります。この関数はオフセット 0x3e00068 にあります:
var verify_cache = base.add(0x3e00068);
Interceptor.attach(verify_cache, {
onEnter: function(args) {
console.log("verify_cache called");
},
onLeave: function(retval) {
console.log("verify_cache returned: " + retval.toInt32());
retval.replace(ptr(0));
}
});
これら2つのフックを組み合わせることで、バージョン 149.0.7827.159 で検証の無効化に成功します。
まとめ
Googleは、すべてを完全に自動化することを目的として、リーフ証明書の有効期間をどんどん短くする方向に進めています。レジリエンスの観点からは理にかなっていますが、テストツールがこれらの新しい要件に追いついていない場合、問題になることがあります。
幸い、上記の解決策はBurp Suiteが追いつくまで、または同様の状況で使用できます。