I don't know since when VPN extensions have become popular, but VPN extensions should actually be called proxy extensions. The underlying involves no VPN but proxy, yet they claim they are as secure and private as a regular VPN.

After several pentests and personal researches on VPN extensions, I can conclude that almost all VPN extensions are vulnerable to different levels of IP leaks and DNS leaks. Ironically, although most of them are results of extensions' misconfigurations, browsers are also responsible as there are a lot of pitfalls and misleading documentations on proxy configurations.

PAC Script

Chrome and Firefox both provide an API for extensions to register a PAC (Proxy Auto-Configuration) script. It is a JavaScript file that exposes a function FindProxyForURL(url, host) which instructs browsers whether or not a request should be forwarded to a proxy server. Helper functions are also provided to build conditions. I am going to cover the most identified issues regarding misuses of PAC scripts in the following paragraphs.

Split tunneling

It is quite common to see VPN extensions try to resolve the hostname of a request, and allow private addresses to bypass the proxy. This allows a user to access an Intranet and proxied Internet at the same time.

function FindProxyForURL(url, host) { let ip = dnsResolve(host); if (isInNet(ip, "172.16.0.0", "255.240.0.0")) return "DIRECT"; }

However, it is simply impossible to achieve this without introducing DNS leaks. Since dnsResolve is called, a DNS query will be made for every request using local DNS servers, which are ISP-provided by default. This allows:

a website to identify what ISP a user is using

an on-path eavesdropper (e.g. ISP) to see what websites a user is visiting

Incorrect Use of Helper Functions

Another very common issue is extensions misunderstanding how helper functions work.

function FindProxyForURL(url, host) { if (shExpMatch(url, "*://api.vpn.com/*") || shExpMatch(host, "192.168.*.*") || dnsDomainIs(host, "vpn.com") || isPlainHostName(host) ) return 'DIRECT'; }

In Chrome, there is something called a match pattern that defines what URLs an extension is allowed to touch. It uses URL format and a wildcard character.

Naturally, developers think shExpMatch should work the same way probably because it also supports the same wildcard character. However, its expression is very different from a match pattern as it is not URL-aware. For example, http://evil.com/ ://api.vpn.com/ bypasses the proxy because it matches the expression *://api.vpn.com/* . Similarly, 192.168 .evil.com bypasses the proxy because its hostname matches 192.168.*.* . A website can leak a user's IP address by making their browser issue a request to those URLs.

dnsDomainIs has a confusing description.

Returns true if and only if the domain of hostname matches.

It sounds like the function compares if the two arguments are equal. In fact, some examples also suggest that this is the case. What the description actually refers to as hostname is just the subdomain part. For example, dnsDomainIs("api.vpn.com", "vpn.com") returns true since api.vpn.com is a subdomain of vpn.com. This alone does not introduce any security issues but Chrome has an intended implementation bug where it only matches the tail. This allows an attacker to register the domain evil vpn.com to pass dnsDomainIs(host, "vpn.com") and leak a user's IP address.

isPlainHostName is an interesting one. It returns true when the hostname does not contain a dot. A hostname without a dot indicates it belongs to an intranet, so letting it bypass the proxy seems reasonable. Except it is not always the case. Some TLDs like http://ai are accessible through the Internet and therefore can bypass the proxy. Fortunately, it is rather infeasible to exploit this becaues an attacker needs to own a TLD. It is worth mentioning that Chrome takes a step further to exclude IPv6 addresses since they are also dot-less (e.g. [::1] ) which would have introduced another bypass.

Loose Matching

Yet another frequent issue is extensions do not use the provided helper functions. This might be as a result of developers not being aware of the provided helper functions or Firefox not supporting them.

The global helper functions usually available for PAC files (isPlainHostName(), dnsDomainIs(), and so on) are not available.

In many cases, native JavaScript functions are used directly or as polyfills instead.

function FindProxyForURL(url, host) { if (host.indexOf("localhost") !== -1 || /^127\./.test(host) || isPlainHostName(host) || url.substring(0, 4) !== 'http' ) return 'DIRECT'; } function isPlainHostName(host) { return host.search('\\.') === -1; }

Extensions trying to whitelist certain hostnames are a common occurrence, but in an inaccurate manner. For example, they only look for substring in the host or the beginning of the host ( 127 . localhost .evil.com passes both host.indexOf("localhost") !== -1 and /^127\./.test(host) ). Occasionally, RegExp mistakes are involved (e.g. not escaping . ).

As said earlier, Firefox does not support helper functions. Therefore Firefox extensions have to implement a polyfill for functions like isPlainHostName . Seemingly, it only needs to check if a hostname is dot-less according to the documentation. What they oversee is the aforementioned IPv6 issue. Here an attacker can leak a user's IPv6 address by making their browser issue a request to an IPv6 host.

Sometimes, extensions don't want to handle non-HTTP traffic so they allow URLs not starting with http to bypass the proxy ( url.substring(0, 4) !== 'http' ). This simply opens up an opportunity for a website to leak a user's IP address by forcing their browser to issue non-HTTP requests. They can be FTP ( ftp:// ) and WebSocket ws:// & wss:// ).

Whitelised Hostnames

A couple extensions have a whitelist for proxy bypass. They are usually the company's domains ( *.vpn.com ), DNS loopback services (e.g. http://lvh.me), Google services, and bandwidth intensive services (e.g. CDN and streaming sites). A user visiting a whitelisted website will have their IP leaked.

Unencrypted Proxy Protocols

Some extensions use protocols that are considered not secure.

function FindProxyForURL(url, host) { return "PROXY http.vpn.com; HTTP http.vpn.com; SOCKS socks4.vpn.com; SOCKS4 socks4.vpn.com; SOCKS5 socks5.vpn.com;"; }

PAC script supports four proxy protocols. HTTP ( PROXY & HTTP ), HTTPS ( HTTPS ), SOCKS4 ( SOCKS & SOCKS4 ) and SOCKS5 ( SOCKS5 ). While HTTPS tunnels are secure thanks to TLS, HTTP and SOCKS do not support encryption. This means an on-path eavesdropper can easily intercept the traffic as if there is no VPN, or proxy rather.

DNS Prefetching

Chrome has a feature called DNS Prefetching.

DNS prefetching is an attempt to resolve domain names before a user tries to follow a link. This is done using the computer's normal DNS resolution mechanism; no connection to Google is used.

Chrome automatically prefetches DNS for:

suggested items in the Omnibox (address bar)

hyperlinks in a HTTP page or a site optting in DNS prefetching

Most importantly, this feature is enabled by default even when a proxy is turned on as demonstrated below.

This affects all extensions that use a PAC script (but not fixed servers) and essentially results in DNS leaks. Opera's built-in VPN is also affected.

Updated: All Chrome VPN extensions are affected

The only mitigation is for users to manually disable this feature:

Navigate to chrome://settings/ Type "predict" in "Search settings" Disable the option "Use a prediction service to help complete searches and URLs typed in the address bar" and "Use a prediction service to load pages more quickly"

Fixed Servers

In addition to PAC scripts, Chrome allows extensions to set up fixed proxy servers. This acts like a PAC script with only the return statement. It does support a simple bypass list using match patterns.

Incorrect Documentation

The documentation for the bypass list states that:

<local> Match local addresses. An address is local if the host is "127.0.0.1", "::1", or "localhost".

Example: "<local>"

So this is pretty straightforward that this entry makes loopback addresses bypass the proxy. Wrong. Looking through the source code of Chromium reveals a different story:

class BypassLocalRule : public ProxyBypassRules::Rule { public: bool Matches(const GURL& url) const override { const std::string& host = url.host(); if (host == "127.0.0.1" || host == "[::1]") return true; return host.find('.') == std::string::npos; } std::string ToString() const override { return "<local>"; } std::unique_ptr<Rule> Clone() const override { return std::make_unique<BypassLocalRule>(); } };

The Matches method returns true not only for loopback addresses (127.0.0.1 and [::1]), but also any hostnames without a dot. This is exactly the same issue with isPlainHostName . This again enables IPv6 leaks.

Check yourself

In Chrome, you can go to chrome://net-internals#proxy to see the effective proxy settings. To extract the PAC script, copy everything after base64, and run atob("PASTE_HERE") in the DevTools' console. There is no easy way for Firefox other than extracting the source code.

Conclusion

The whole thing is a complicated mess involving VPN vendors and browsers. I've reported some of the issues to the affected parties but there doesn't seem to be much progress. Some of the edge cases I have not included can be more severe (e.g injection in PAC script to control proxy settings).

In my opinion, VPN extensions are great for bypassing geoblocking, but a big no-no for anonymity and privacy.