Researching VPN applications - part 1 VPN internals

Introduction

During the last holiday season, some of us were poking around some VPN clients. In particular we were interested in finding local privilege escalation issues. After testing some clients, we came to the conclusion that they pretty much work the same and most of them suffer from at least one vulnerability that could be used to elevate privileges.

Yet there are implementation differences and some clients appear to be more prone to these issues than others. Curious as we are, we decided to take a closer look at how these clients are implemented from a technical point of view, thus resulting in a new Securify research project. In this blog post we'll take a closer look at how these clients work and what types of vulnerabilities can be introduced that can eventually lead to local privilege escalation.

In our follow up blog post we will explain the methods used to find these issues. Using a few simple steps we will show that it is not that hard to find these issues.

App selection

For our research project, we've selected VPN applications that install a service/daemon that runs with elevated privileges. This service communicates with a client application running with the privileges of the logged on user. Also, it must invoke OpenVPN (or similar) to create a VPN connection. Although other VPN protocols may be supported we've limited our research to the OpenVPN implementations. In the end, the following VPN clients were selected (in alphabetic order):

  • CyberGhost
  • ExpressVPN
  • Mullvad
  • NordVPN
  • Perfect Privacy
  • Private Internet Access
  • ProtonVPN
  • PureVPN
  • SaferVPN
  • SurfShark

High level overview

The image below shows a high level diagram of how these VPN applications work (you could also consider this an initial threat model). There are some implementation differences, for example some clients don't use OpenVPN configuration files. These difference are often the places where issues occur.

consumervpns

At the core of the implementation is the service (daemon) process that runs with elevated privileges. These privileges are needed to make the necessary network changes when VPN connections are created or torn down. Also some 'killswitch' implementations depend on these privileges.

The service exposes an RPC interface (IPC) to which clients (frontend) can connect and send commands to the service. The client runs with the privileges of the authenticated user and doesn't require elevated privileges. The RPC interface can be exposed via a named pipe, TCP socket or a UNIX socket (macOS/Linux). The client can call whatever method is exposed by the service. At minimum there is a Connect and Disconnect method and generally some methods to changes settings.

Settings are normally stored on disk by the service. On Windows this is either in a folder under %ProgramData% or %ProgramFiles% (or the x86 one). On MacOS this is usually in a directory under /Library, ~/Library or /Applications/<appname>/Contents/. Most services if not all write log data to log files. OpenVPN writes to a separate log file in a number of cases or these logs are combined with the service logs. Some services expose a method to retrieve log data.

Most VPN applications launch OpenVPN with a configuration file, we've encountered only one instance where all settings are passed through the command line. This configuration file can be a fixed file where the dynamic options are passed through the command line. In other cases, a fixed configuration file is used as a template where certain values are replaced with user-supplied values. In some cases, the entire configuration file comes from the client.

Since OpenVPN configurations can potentially be abused to run arbitrary code, it can be that the VPN application validates the used configuration file. Validation can be done against a blacklist where known unsafe options are blocked or against a whitelist where options are blocked if they are not on the allowed list.

Finally, as said above, most VPN applications will pass OpenVPN options through the command line.

Security vulnerabilities

The following security vulnerabilities are the ones we encountered the most often. This is by no means an exhaustive overview.

Command line injection

Since OpenVPN is launched as a separate process, the service needs a way to pass information to OpenVPN so that it knows to which server it needs to connect and how to authenticate. There are two ways of doing this, either using a configuration file or through the command line. Most VPN applications tend to use a combination of these two.

As stated, some OpenVPN configuration options allow for the execution of arbitrary code. Most notable are the engine and plugin options. When present both options will cause OpenVPN to try and load a shared library (.dll, .dylib, .so) using the option value as the name of the shared library. There are also a number of options that accept a command that is executed when a certain event occurs, these options require that the script-security option is also set and has a value of 2 or higher. Finally, there are options that don't immediately result in code execution but could potentially be abused as well. These options include changing the current working directory or writing (logs) to arbitrary locations.

One of the issues that can be introduced by a VPN applications is when user-supplied input is used in the command line without proper validation and/or escaping. In this case, it would be possible to inject arbitrary command line arguments that are passed to OpenVPN. The most obvious attack would be that an unprivileged local attacker injects the engine or plugin option in the command line that is used to invoke OpenVPN. If successful, the attacker can force OpenVPN to load their shared library - resulting in code being executed with elevated privileges.

Consider the following code fragment, it is used to pass additional DNS settings to OpenVPN that will be used when the VPN connection is established.

internal class CustomDnsArguments : IEnumerable<string>
{
    private readonly IReadOnlyCollection<string> _dns;

    public CustomDnsArguments(IReadOnlyCollection<string> dns)
    {
        _dns = dns;
    }

    public IEnumerator<string> GetEnumerator()
    {
        if (_dns.Count == 0)
        {
            yield break;
        }

        yield return "--pull-filter ignore \"dhcp-option DNS\"";

        foreach (var dns in _dns)
        {
            yield return $"--dhcp-option DNS {dns}";
        }
    }

    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}

The values for the DNS server(s) are user-supplied. For example, if one DNS server is supplied with the value 1.1.1.1, the code will produce the following command line (part):

--pull-filter ignore "dhcp-option DNS" --dhcp-option DNS 1.1.1.1

While there is nothing wrong with this command line above, an attacker could try to pass something like 1.1.1.1 --engine foobar as the DNS server. If this value is used as-is, then it would result in the following command line:

--pull-filter ignore "dhcp-option DNS" --dhcp-option DNS 1.1.1.1 --engine foobar

OpenVPN will consider --engine as a new option, and thus it will try to find and load a shared library named foobar. If this is done from an attacker-controlled path, the attacker's shared library will be loaded and executed.

Arbitrary OpenVPN configuration

In some cases it is possible to cause the VPN service to launch OpenVPN with a user-supplied configuration. Obviously if this occurs we could perform the same type of attacks as we have seen with the command line injection. In general this means that the attacker creates a configuration file that contains an engine or plugin option, which loads a shared library from the local attacker with elevated privileges.

Take for example the following WCF model. The OpenVpnConfigTemplate field accepts an arbitrary OpenVPN configuration that is used as template when creating the actual VPN connection. The affected code doesn't validate the content and thus it is possible to provide a malicious template.

public class ServiceRuntimeSettings
{
  public string DnsServerIp { get; set; }

  public bool KillSwitchEnabledDefault { get; set; }

  public string OpenVpnConfigTemplate { get; set; }

  public int? IKEv2NetworkOutageSeconds { get; set; }
}

Configuration option injection

Similar to injecting OpenVPN options in the command line, it is also possible that the VPN application uses user-supplied input in configuration files. In this case there is a risk that a local attacker can inject arbitrary options in the newly created configuration file. In particular this can happen when the user-supplied values can contain newline characters.

Options in OpenVPN configuration files are separated by newline characters. Hence, injecting newlines through user input can be abused to inject arbitrary options in the configuration file. And as we've seen above, this can again result in arbitrary code execution with elevated privileges.

An example of this vulnerability can be seen in the following (slightly obfuscated) code fragment. In this case, specially crafted user input can be provided via the connection.Host field. The value of this field is used as-is in the configuration file - including any newlines it can contain.

   public class OpenVpnConfig
   {
      public string Generate(OpenVpnEntry connection)
      {
         return OpenVpnConfig.a(by.a(17781).Replace(by.a(20828), connection.Protocol.ToLower()).Replace(by.a(20817), connection.Host).Replace(by.a(20810), connection.Port.ToString()).Replace(by.a(20803), connection.DnsHosts.Any<string>() ? by.a(20655) : string.Empty));
      }
   
      private static string a(string w)
      {
         string path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData).EnsureDirectoryExists(), by.a(20612));
         using (StreamWriter streamWriter = new StreamWriter(path, false))
            streamWriter.WriteLine(w);
         return path;
      }
   }

In some cases the user input can be embedded in what is called an inline file. In this case the user input is included between HTML-like tags (eg, <ca>\n[...]\n<\ca>). If unvalidated input is used as an inline file, the attacker also needs to close the inline file tag (eg, </ca>). After the closing tag, new options can be added to the configuration file. Finally, the attacker injects a new open tag to satisfy the OpenVPN configuration parser.

Validation bypass

In cases where the VPN application does validate the configuration file, it may be worth investigating whether the validation can be bypassed. Since each line in a configuration file is essentially a new option, validation can be done by reading the file line by line and checking the option against a blacklist or whitelist. Special care must be taken for inline files, but apart from that, validation can be straightforward.

In addition, the VPN application also needs to decide what to do with invalid configuration files. The main options are to reject the file and not start a new VPN connection or to sanitize the configuration file. Naturally, the latter option provides more opportunity for a local attacker seeking to escalate privileges.

The first thing that we may want to check is which options are on the blacklist/whitelist and determine whether an option may have been forgotten (eg, not on the blacklist or on the whitelist). Of course we'll look at the usual suspects first. Take for example the following code we've seen in the wild.

internal class ConfigEntry
{
   private static readonly List<string> AllowedParams = new List<string>()
   {
      "client",
      "dev",
      "proto",
      "remote",
[...]
      "engine",
[...]

We've made it easy for you to spot the flaw. Obviously, the engine option should not be on the whitelist. Elevation of privileges seems trivial here.

Things become more (technically) challenging when you start looking at the implementation of the validation code. Take for example the following code fragment:

internal class OpenVpnConfigStreamReader : IDisposable
{
   private readonly StreamReader _reader;
   
[...]
   public Optional<ConfigEntry> ReadEntry()
   {
      string str = this.ReadLine();
      if (str == null)
         return new Optional<ConfigEntry>((ConfigEntry) null);
      if (!OpenVpnConfigStreamReader.IsInlineFileStart(str))
         return new Optional<ConfigEntry>(new ConfigEntry(str));
      string entry = str;
      string text;
      do
      {
         text = this.ReadLine();
         entry += text;
      }
      while (text != null && !OpenVpnConfigStreamReader.IsInlineFileEnd(text));
      return new Optional<ConfigEntry>((ConfigEntry) new InlineFileConfigEntry(entry));
   }

This code is used to process the inline file options. Can you spot the flaw here? The logic here is that when the configuration parser spots the start of an inline file - which is an HTML-like open tag - it will read up to and including the closing tag. Everything between the tags is not checked against the whitelist. Only the tag name is checked.

This approach seems reasonable. The issue here is that there is a difference between how this code processes newlines and how OpenVPN processes newlines. Let's have a look at the OpenVPN code.

static bool
in_src_get(const struct in_src *is, char *line, const int size)
{
    if (is->type == IS_TYPE_FP)
    {
        return BOOL_CAST(fgets(line, size, is->u.fp));
    }
[...]
}

static char *
read_inline_file(struct in_src *is, const char *close_tag, struct gc_arena *gc)
{
    char line[OPTION_LINE_SIZE];
    struct buffer buf = alloc_buf(8*OPTION_LINE_SIZE);
    char *ret;
    bool endtagfound = false;

    while (in_src_get(is, line, sizeof(line)))
    {
[...]

The problem exists because the VPN application uses the StreamReader.ReadLine() method to read the configuration file line by line. StreamReader.ReadLine() considers a line feed (\n), a carriage return (\r), or a carriage return immediately followed by a line feed (\r\n) as the end of a line. OpenVPN uses the fgets() function to read the configuration file line by line. In contrast to StreamReader.ReadLine(), fgets() will read a line up to the line feed character. Thus if a line only contains a carriage return, it will be seen as two lines by StreamReader.ReadLine(), but as one line by fgets().

Now consider a scenario where an attacker creates a configuration containing a line user \r<extra-certs>. The VPN application's parser will see this line as two lines, the second line is interpreted as an inline file. The parser will read up to the end tag and disregard everything in between.

When passed to OpenVPN, OpenVPN will see the line user \r<extra-certs> as one line. The user option is not supported on Windows, its value will be ignored. The lines following this line will be interpreted as regular configuration options instead of an inline file. Now any blacklisted/non-whitelisted option can be added by the attacker.

The last thing you should take into account when looking at configuration validators is that there may be a race condition. Since OpenVPN is launched as a new process, it could occur that the VPN application validates the configuration, and if found valid, starts OpenVPN with the same path as was used for validation. In the time between validation and starting of OpenVPN, it could be possible that the configuration is swapped with a different one that wouldn't pass the validation. Exploiting this issue on Windows is made easy with the BaitAndSwitch tool from James Forshaw. On Linux or macOS it is possible to exploit this race condition using named pipes. A pre-condition is of course that the configuration file is read from a user-writeable location.

File hijacking

The issues we've covered so far share the same attack pattern, which is passing unsafe options to OpenVPN. Other ways exists that can result in privilege escalation. One of these issues is when the VPN application tries to read a file from a user-writeable location. While this is not a vulnerability per se, there are cases where it does introduce a vulnerability.

Flaws can occur when VPN applications contain references to debug/test code. In some cases an application would check if a certain file exists and if so it would enable debugging or some other feature. In other situations, the application would try to load a plugin/module/extension from a user-writeable location. This can for example occur in applications build with Qt QML.

On Windows it can sometimes be observed that the application tries to look for a file that is normally only available on Linux/macOS. This usually happens when the application distributes an executable that supports multiple platforms - like the OpenVPN application.

For example, we've seen instances where OpenVPN is started, and an attempt is made by OpenVPN to read the file C:\etc\ssl\openssl.cnf. By default Windows allows any authenticated user to create new folders in the system root, meaning that a user can create a directory structure C:\etc\ssl\ containing a file openssl.cnf. When present, the file will be read and processed by the OpenSSL library (OpenVPN).

You may have guessed it, but when we can feed OpenVPN an arbitrary OpenSSL configuration, we can also define a malicious engine entry pointing to an attacker-controlled DLL. When the configuration is processed, an attempt is made to load this DLL by the OpenVPN binary.

C:\etc\ssl\openssl.cnf:

openssl_conf = init
[init]
engines = engines
[engines]
lpe = lpe
[lpe]
engine_id = lpe
dynamic_path = C:\\etc\\ssl\\lpe
default_algorithms = ALL
init = 1

Weak file permissions

The last vulnerability we like to cover is when the VPN application sets weak file permissions or forgets to explicitly set file permissions. On macOS this appears to be common practice. By default when you install an application on macOS, the owner of the installed files is the logged on user that performs the installation. This means that that user could also - at a later stage - overwrite any of the installed files.

So if for example, the openvpn binary is installed in the VPN application's installation folder and the owner is not changed, the binary can be overwritten by that owner. In case that user is infected with malware, the malware can overwrite openvpn. When a VPN connection is made, the malicious openvpn binary is started - generally with root privileges.

The following directory listing example we have seen in the wild illustrates weak file permissions. It allows the current authenticated user to modify scripts that will be passed from the client application to the service. The service will in turn run these scripts with elevated privileges. Forcing a service to run such scripts can be as simple as pressing the connect or disconnect button within the client.

-rwxr-xr-x@  1 user  admin    15K Apr 1  2020 client.down.sh
-rwxr-xr-x@  1 user  admin    62K Apr 1  2020 client.up.sh
-rw-r--r--@  1 user  admin     0B Apr 1  2020 config_current.ovpn
-rwxr-xr-x@  1 user  admin   2.0K Apr 1  2020 install-update.sh
-rwxr-xr-x@  1 user  admin   2.4M Apr 1  2020 openvpn
drwxr-xr-x@  3 user  admin    96B Apr 1  2020 tap.kext
drwxr-xr-x@  3 user  admin    96B Apr 1  2020 tun.kext

Similar issues can occur on Windows. Generally this happens when important (executable) files are written to %ProgramData% or when the application is installed outside of %ProgramFiles% (x86).

Observations

When testing these applications we quickly came to the conclusion that they are implemented in various ways. Some designs, in particular, appear to be more prone to security vulnerabilities and local privilege escalation than others. While on the surface they appear to be pretty much the same - even the UIs are similar. Under the hood they are quite different. The applications that appear to be more secure tend to do the following:

  • restrict user-supplied input as much as possible, in one instance the VPN application would only accept some basic settings and a numeric identifier referring to a particular location (server);
  • validate all user-supplied input;
  • write its configuration file to a folder with proper file permissions;
  • maintain an internal state to enforce the correct use of its (IPC) API;
  • verify a (codesigning) signature of the client invoking the IPC calls.

On the other end of the spectrum there are of course applications that don't do any validation at all and will happily pass anything you feed it to OpenVPN. The way OpenVPN tends to work also doesn't help here of course. We expect things to improve when VPN applications will migrate to different VPN protocols (eg, WireGuard) that are implemented differently, and are less prone to the issues covered in this blog.

Vragen of feedback?