대부분의 Android 앱이 VPN을 감지할 때 쓰는 검사는 인터페이스 이름이며, 그것은 결코 VPN 검사가 아니었다. 개발자가 기대한 문자열을 가진 인터페이스가 존재한다고 알려줄 뿐이고, 어떤 기기에서는 OpenVPN을, 다른 기기에서는 전혀 무관한 것을 뜻한다. ping도 같은 종류의 증거다. 하나의 목적지가 응답했다는 것을 증명할 뿐, 터널이 패킷을 실어 날랐다는 것은 아니다. 플랫폼이 실제로 노출하는 것은 네트워크의 집합과 각 네트워크가 선언하는 전송 방식이며, 이는 다른 답을 가진 다른 질문이다.

신화: tun0가 있으면 VPN이 켜져 있다

Android VPN 앱은 시스템에 tun 파일 디스크립터를 넘기는 VpnService 구현이다. 인터페이스 이름은 그것을 만든 쪽에서 온다. 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를 반환했다고 하는 보고서는 라우팅이나 제외 목록을 직접 가리키며, 그것은 누군가 대응할 수 있는 수정이다.

관련 글