androidkotlinvpnpacketandroid-vpn-service

Android vpnInterface causes ERR_NETWORK_CHANGED Error


I'm working on a Kotlin app in Android Studio that requires the user's web browsing to be monitored. I tried to use the android VPN API for this, and wrote the two functions below. The script compiles and runs fine showing logs as such

...
2024-08-07 13:52:02.492 18270-18311 WebFilterVpnService     com.example.pbapp                    D  
Packet captured from 10.0.0.2 to 142.250.68.227
2024-08-07 13:52:02.525 18270-18311 WebFilterVpnService     com.example.pbapp                    D  
Packet captured from 10.0.0.2 to 216.239.32.116
2024-08-07 13:52:02.589 18270-18311 WebFilterVpnService     com.example.pbapp                    D  
Packet captured from 10.0.0.2 to 216.239.32.116
2024-08-07 13:52:02.589 18270-18311 WebFilterVpnService     com.example.pbapp                    D  
Packet captured from 10.0.0.2 to 172.253.63.188
2024-08-07 13:52:02.589 18270-18311 WebFilterVpnService     com.example.pbapp                    D  
Packet captured from 10.0.0.2 to 216.239.32.116
...

Though, despite it running fine, it does not allow any websites to be loaded. When opening a site after running the VPN the page will load for about a minute and then throw this error "ERR_NETWORK_CHANGED".

function to initialize the VPN:

override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
    Log.d(TAG, "VPN Service started")

    val builder = Builder()
        .addAddress("10.0.0.2", 24)
        .addRoute("0.0.0.0", 0)
        .setSession("WebFilterVpnService")

    val configureIntent = PendingIntent.getActivity(
        this,
        0,
        Intent(this, MainActivity::class.java),
        PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
    )
    builder.setConfigureIntent(configureIntent)

    vpnInterface = builder.establish()
    if (vpnInterface == null) {
        Log.e(TAG, "Failed to establish VPN interface")
        stopSelf()
        return START_NOT_STICKY
    }
    Log.d(TAG, "VPN interface established")

    startCapture()

    return START_STICKY
}

function to sniff packets intercepted by the VPN:

 private fun startCapture() {
    vpnInterface?.let { vpnInterface ->
        executor.execute {
            Log.d(TAG, "Starting packet capture")
            val fileInputStream = FileInputStream(vpnInterface.fileDescriptor)
            val packet = ByteBuffer.allocate(32767)

            try {
                while (true) {
                    packet.clear()
                    val length = fileInputStream.read(packet.array())
                    if (length > 0) {
                        packet.limit(length)
                        if (length >= 20) {
                            val header = ByteArray(20)
                            packet.position(0)
                            packet.get(header, 0, 20)
                            val sourceIP = "${header[12].toInt() and 0xff}.${header[13].toInt() and 0xff}.${header[14].toInt() and 0xff}.${header[15].toInt() and 0xff}"
                            val destinationIP = "${header[16].toInt() and 0xff}.${header[17].toInt() and 0xff}.${header[18].toInt() and 0xff}.${header[19].toInt() and 0xff}"
                            Log.d(TAG, "Packet captured from $sourceIP to $destinationIP")
                        }
                    }
                }
            } catch (e: Exception) {
                Log.e(TAG, "Error during packet capture", e)
            } finally {
                fileInputStream.close()
            }
        }
    } ?: run {
        Log.e(TAG, "VPN interface is null, cannot start packet capture")
    }
}

I also tried to add a DNS along with the route and address as such

.builder.addDnsServer("8.8.8.8")
.builder.addDnsServer("1.1.1.1")

But that had no effect. Dose anyone know the cause for the ERR_NETWORK_CHANGED with the vpn enabled?


Solution

  • This is a copy of my answer to this post as it concerns the same general issue.

    I ended up figuring out a very obscure solution to the issue. It turns out that Android's Accessibility Service can track web traffic, not through packet sniffing or VPNs, but by utilizing the AccessibilityNodeInfo object. I do not fully understand it yet, but here is my best explanation.

    By monitoring the UI of browsers, specifically the android.widget.EditText view, you can figure out the site the user is currently on/loading. The android.widget.EditText view is used, at least by Chrome, to display the site's domain name in the search bar. So by checking this node and grabbing its value, we can find the site the user is on.

    I assume this code does have downsides though as it depends on what I assume is browser-specific code and UI, thus updates might break it.

    Here is the code I ended up using, though I did not filter for only the search bar view in this code instead scanning the whole screen.

    import android.accessibilityservice.AccessibilityService
    import android.accessibilityservice.AccessibilityServiceInfo
    import android.util.Log
    import android.view.accessibility.AccessibilityEvent
    import android.view.accessibility.AccessibilityNodeInfo
    
    class UrlLoggingService : AccessibilityService() {
    
        private val TAG = "UrlLoggingService"
    
        override fun onServiceConnected() {
            val info = AccessibilityServiceInfo().apply {
                eventTypes = AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED
                packageNames = arrayOf("com.android.chrome")
                feedbackType = AccessibilityServiceInfo.FEEDBACK_GENERIC
                notificationTimeout = 100
            }
            serviceInfo = info
            Log.d(TAG, "Accessibility Service connected")
        }
    
        override fun onAccessibilityEvent(event: AccessibilityEvent?) {
            event ?: return
    
            val rootNode = rootInActiveWindow ?: return
            Log.d(TAG, "Root node obtained, starting URL extraction")
    
            val url = extractUrl(rootNode)
            url?.let {
                Log.d(TAG, "Visited URL: $it")
            } ?: Log.d(TAG, "No URL found")
        }
    
        private fun extractUrl(node: AccessibilityNodeInfo?): String? {
            if (node == null) {
                Log.d(TAG, "Node is null, returning")
                return null
            }
    
            Log.d(TAG, "Processing node: ${node.className}, Text: ${node.text}")
    
            if (node.className == "android.widget.EditText" && node.text != null) {
                val text = node.text.toString()
                Log.d(TAG, "Found URL: $text")
                return text
    
            }
    
            for (i in 0 until node.childCount) {
                val childNode = node.getChild(i)
                val url = extractUrl(childNode)
                if (url != null) {
                    return url
                }
            }
            return null
        }
    
        override fun onInterrupt() {
            Log.d(TAG, "Accessibility Service interrupted")
        }
    }
    

    I should also note this code will need Accessibility permissions to run which can be requested with this code

    startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS))