ほとんどの Android アプリが VPN の検出に使う検査はインターフェース名であり、それは VPN の検査ではなかった。開発者が期待した文字列を持つインターフェースが存在すると報告するだけで、ある端末では OpenVPN を意味し、別の端末ではまったく無関係なものを意味する。ping も同じ種類の証拠で、一つの宛先が応答したことを示すにすぎず、トンネルがパケットを運んだことまでは示さない。プラットフォームが実際に公開しているのはネットワークの集合と、それぞれが宣言するトランスポートであり、これは別の答えを持つ別の問いである。

神話:tun0 が存在すれば VPN は起動している

Android の VPN アプリは VpnService の実装で、tun ファイルディスクリプタをシステムに渡す。インターフェース名はそれを作った側に由来する。OpenVPN は通常 tun0 を生成し、PPTP と L2TP は ppp0 を生成し、WireGuard はトンネルにちなんでインターフェースを命名し、IPsec トンネルは従来の名前をまったく示さないこともある。したがって /proc/net/dev を読むことや NetworkInterface オブジェクトを列挙することは、事実ではなく慣習を探している。

誤りの両方向が現実にある。端末には利用者の VPN と無関係なローカルトンネルのために tun0 が存在し得るし、まったく健全な WireGuard トンネルが、tun0 という文字列しか知らない検査には見えないこともある。同じ関数の中で偽陽性と偽陰性が起きるため、診断としては使えない。なぜなら「VPN が検出されない」というサポートチケットはどちらの方向にも信頼できないからだ。

アクセスも狭まった。新しい Android では通常のアプリが /proc から読める内容が制限されるため、Android 8 で機能した名前ベースの検査が、コードを変えていないのに新しい端末では空のリストを返すことがある。検査が静かに開いた側へ失敗すると、アプリはトンネルが正常なハードウェアで VPN がないと結論する。

神話:ping の成功はトンネルが通信を運んでいることを意味する

内部ホストへの ping は、そのホストへの経路が存在する限り成功する。分割トンネルでは、トンネルが起動して別のことをしている間に、ホストへ Wi-Fi 経由で到達できることがある。アプリ単位の VPN では、ping を行うアプリがトンネルから除外されていることがあり、その場合 ICMP エコーはトンネルの外に出て戻ってくる。これは検査が前提とする内容の正反対を証明している。

ICMP と TCP も別々にフィルタリングされる。エコー要求を許可しながらサービスが必要とする TCP ポートを落とすトンネルは、ping テストには合格しアプリでは失敗する。これは端末の性質ではなく相手側のファイアウォール規則である。成功した ping の唯一の誠実な読み方は、この宛先がこの瞬間にこのプロトコルへ応答したということだ。

プラットフォームが実際に報告するもの

API 21 以降、ConnectivityManager.getAllNetworks() はシステムが知るすべてのネットワークを返し、getNetworkCapabilities(network) はそのネットワークが何を宣言しているかを報告する。VPN ネットワークは TRANSPORT_VPN を持つ。すべてのネットワークを巡回すれば 一つの問いに答える。この端末で現在なんらかの VPN が起動しているか。

第二の、より狭い問いには別の呼び出しが要る。getActiveNetwork() はアプリの通信が今使うネットワークを返し、それに対して同じ機能を調べれば次の問いに答える:自分の通信は VPN を通るのか? 両者は食い違うことがあり、その食い違いは有益である。起動しているが現在のネットワークではない VPN は、アプリ単位のルーティングか分割トンネルの設定を示しており、トンネルの欠落を示すものではない。

Network active = cm.getActiveNetwork();
NetworkCapabilities caps = cm.getNetworkCapabilities(active);
boolean viaVpn = caps != null
    && caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN);

両方の呼び出しを保護すること。列挙と問い合わせの間に消えたネットワークでは機能が null になり得るし、引き継ぎの間は現在のネットワークが null になり得る。NET_CAPABILITY_NOT_VPN は逆方向に役立ち、VPN トランスポートのフラグが単に欠けているのではなく、そのネットワークが確実にトンネルでないことを確認できる。

NetworkInfo は API を伴う同じ神話だった

NetworkInfo を伴う ConnectivityManager.TYPE_VPN はこの検査の第二世代であり、理由があって非推奨である。現代のリリースでは、トンネルが接続していても VPN に対して返されるオブジェクトが null になり得るため、null を「VPN なし」と扱うコードは、答えが最も重要な端末で静かに偽陰性を報告する。

通常それを伴うブロードキャストも良くはない。CONNECTIVITY_CHANGE には VPN の遷移を取りこぼすという文書化された歴史があり、すべてのネットワーク変化ではなく VPN の接続や切断のときだけ発火するビルドもあった。それに基づくリスナーは、次の完全な再接続までプラットフォームの見解から離れていく状態変数を生む。

ポーリングではなくコールバックに反応する

TRANSPORT_VPN を要求し NET_CAPABILITY_NOT_VPN を除く NetworkRequest を登録し、その要求で registerNetworkCallback を呼ぶ。すると onAvailable と onLost が宣言の変化を教えてくれ、それが本当に関心のある出来事である。コンポーネントが停止したらコールバックの登録を解除すること。さもなければアプリは死んだ画面への参照を保持し、コールバックは発火し続ける。

NetworkRequest req = new NetworkRequest.Builder()
    .addTransportType(NetworkCapabilities.TRANSPORT_VPN)
    .removeCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
    .build();
cm.registerNetworkCallback(req, callback);

これには ACCESS_NETWORK_STATE が必要で、これは通常の権限である。root も、利用者の同意ダイアログも、VPN 権限も要らない。タイマーによるポーリングは人が頼る代替手段だが、バッテリーを消費し、しかも間隔より短い遷移を取りこぼす。それはまさに利用者が報告する遷移である。

答えがまだ伝えられないこと

これらの API はいずれも、トンネルが健全か、どのサーバーで終端するか、特定のアプリの通信がその中にあるかを報告しない。VPN ネットワークは使用可能な経路なしで存在し得るし、アプリ単位のトンネルは問いを立てているアプリを除外し得る。

だから検査が見たものを記録すること。真偽値だけでは足りない。ネットワーク識別子、宣言されたトランスポート、そのネットワークが現在だったかを記録する。「VPN が検出されない」という報告は使いものにならないが、あるネットワークが TRANSPORT_VPN を宣言したのに getActiveNetwork() が Wi-Fi を返したという報告は、ルーティングか除外リストを直接指しており、それは誰かが対処できる修正である。

関連記事