Seasons In The Abyss - Diving into OpenVPN Access Server

Here at watchTowr, we like to proactively audit security-critical codebases which we notice our clients rely on. This feeds our ability to keep external attack surfaces secure, as we can find and fix vulnerabilities before exploitation can affect our clients.

Typically, during these audits, we are concerned with high-impact, 'world-ending' vulnerabilities, but often we notice smaller bugs which have limited impact, or even those which have no impact at all but severely weaken the general security posture of a codebase. Usually in these case, we work with the software vendor to have these bugs fixed to support strength within a codebase.

These audits are always a "journey", in some sense of the word, progressing from a state of little knowledge about the target codebase into a state of heightened awareness of its foibles. Today I'd like to share one such journey, which (spoilers!) didn't yield anything high-impact, but exposed a few weird eccentricities into the codebase which some technical readers might find interesting. This audit also highlighted a few oddities that could have become world-ending bugs in the future, had we not found them and brought them to the attention of the vendor.

A word of warning - this isn't going to be the usual 'actionable' blogpost, but represents more of a 'stream of consciousness' as I recollect this audit. Those interested in auditing such codebases may find it interesting, as may those interested in the gory details of the bug-hunting process, but if you're just here to find the latest 0day you may want to skip this post!

About the target

So, in this audit, we're looking at "OpenVPN Access Server", or "OpenVPN AS". Not to be confused with "OpenVPN" itself, which is a VPN daemon, "OpenVPN Access Server" is a tool to manage your installation of OpenVPN. For the sake of brevity I'll refer to "OpenVPN Access Server" simply as AS from now on.

Those who have managed an OpenVPN installation will be aware that it usually uses a proper PKI-based authentication flow, with a CA certificate which signs a certificate for each client. Setting up this PKI can be fiddly, so AS will take care of this for you, setting up it's own CA cert on installation. You can then add or delete users, with AS taking care of signing and revoking certificates.

The users themselves can also log into OpenVPN AS, at which point they are presented with the option to download a configuration file which they can use to connect to OpenVPN itself. AS will even serve a customised version of the OpenVPN client installer, with the necessary configuration and certificates injected, so that your users can easily connect to the VPN without administrative oversight. Neat!

As a closed-source application frequently exposed to untrusted users (by necessity), this seems like a good place to hunt for critical bugs. There's also an evaluation version of the server available for download, so let's install it and take a look around!

First impressions

After installing, we are presented with a nice login page via HTTPS on port 943.

Great, but what's the default password? I can't remember setting one during the installation. Let's delve into the filesystem and look for it -

root@box# grep pass /usr/local/openvpn_as/*
To login please use the "openvpn" account with "vpsRK3ishLxi" password.
Finding the default login

OK, great, so the default password is put in this init.log file, which logs a load of information about the installation process. It's also in the 'tmp/initial_ovpn_pass' file, in a format better suited for scripting. This reveals our first bug - who can spot it?

root@box# ls -lah /usr/local/openvpn_as
drwxr-xr-x  9 root root 4.0K May 30 22:29 .
drwxr-xr-x 11 root root 4.0K May 25 18:36 ..
drwxr-xr-x  2 root root 4.0K May 25 18:36 bin
drwxr-xr-x 10 root root 4.0K May 31 01:48 etc
-rw-r--r--  1 root root  516 May  2 14:52 exports
drwxr-xr-x  2 root root 4.0K May  2 14:52 include
-rw-r--r--  1 root root  15K May 25 18:37 init.log
drwxr-xr-x  3 root root 4.0K May 25 18:36 lib
-rwxr-xr-x  1 root root  60K May  2 14:52 license.txt
drwxr-xr-x  2 root root 4.0K Jun  1 08:12 sbin
drwxr-xr-x  2 root root 4.0K May 30 22:30 scripts
drwxr-xr-x  2 root root 4.0K May 25 18:36 tmp
ls, lah!

Ooops, that init.log file is world-readable! Anyone on the system can read it and find the initial password. Hopefully, of course, any self-respecting system administrator would change this password, and ideally users would be unable to log into the system anyway. Nevertheless, we reported this to OpenVPN who issued CVE-2022-33737 and fixed the behaviour as of AS 2.11.0. OpenVPN have stated they intend to further refine the fix to clean up permissions on previously-installed systems.

As I say, not earth-shattering, but a good start - onward to better bugs!

Decompilation

Unfortunately for us, while most of OpenVPN's offerings are open-source, AS is not. This means we can't simply peruse the source to see what's going on. However, since it is written in Python, there's a lot of metadata available, and decompilation results in pretty readable output.

Since we've installed via a debian package, as suggested by OpenVPN, it's easy enough to see what has been installed.

root@openvpn:~# dpkg --listfiles openvpn-as
<lots of lines omitted>
/usr/local/openvpn_as/lib/python/pyovpn-2.0-py3.8.egg
/usr/local/openvpn_as/lib/python/pyovpnc.cpython-38-x86_64-linux-gnu.so

These are the compiled Python files. They're just zipfiles, so we can unpack the installed version easily.

root@openvpn:~# cp /usr/local/openvpn_as/lib/python/pyovpn-2.0-py3.8.egg .
root@openvpn:~# mkdir unzipped && cd unzipped
root@openvpn:~/unzipped# unzip ../pyovpn-2.0-py3.8.egg
Archive:  ../pyovpn-2.0-py3.8.egg
  inflating: EGG-INFO/PKG-INFO
  inflating: EGG-INFO/SOURCES.txt
  inflating: EGG-INFO/dependency_links.txt
  inflating: EGG-INFO/top_level.txt
  inflating: EGG-INFO/zip-safe
  inflating: common/__init__.pyc
  inflating: common/depinfo.pyc
  inflating: common/patch.pyc
  inflating: common/utils.pyc
  inflating: pyovpn/__init__.pyc
  inflating: pyovpn/plugin.pyc
  inflating: pyovpn/production.pyc
<lots of lines omitted>

If we take a look in the pyovpn directory, we can see a load of compiled pyc files. As I mentioned before, pyc files are rich with metadata, so we can run them through a decompiler (I used uncompyle6) to generate human-readable Python. Most of this ends up being very readable, as the metadata includes the names of local variables, line numbers, and oodles of other information:

def gen_sso_url(url_template, client_info):
    ci = {}
    for key in ('node_id', 'user_id', 'tenant_id', 'sso_id'):
        if key in client_info:
            ci[key] = client_info[key]
        else:
            ci[key] = '(unset)'
    else:
Example decompiled output

However, there are a number of files which uncompyle6 fails to decompile. Even then, though, the output is pretty readable in most cases:

def get_gauth_info--- This code section failed: ---

 L. 237         0  LOAD_FAST                'access'
                2  LOAD_METHOD              is_superuser
                4  CALL_METHOD_0         0  ''
                6  STORE_FAST               'su'

I didn't even need to look up what the opcodes mean for most of the operations, as they are pretty self-explanatory. The code snippet above loads the 'access' module, invokes is_superuser, and stores the result in the su variable:

def get_gauth_info():
	su = access.is_superuser()

As a side note, I was mildly surprised that the venerable IDA Pro lacks a Processor module to read Python bytecode. I was very tempted to write one, but managed to talk myself out of it, and so it is left as an exercise for the motivated reader!

Exposed attack surface

So, let's make a quick list of the exposed functionality from AS. Somewhat unusually, there are a number of 'endpoints' which all listen on the same TCP port, 943, presumably in an attempt to aid in administration.. We've got:

  • A webserver, allowing non-admin user logins on the root '/'
  • A second 'administrative' webserver, found under the '/admin' path
  • An XML-based RPC interface under the '/RPC2' path
  • A REST-based RPC interface under the '/rest' path.

Interestingly, the two API interfaces expose slightly different functionality. By default, however, both interfaces operate in 'limited' mode, exposing only a fraction of methods to clients. This leaves a very small amount of attack surface, a very wise design decision and perhaps the reason we didn't find any default-access critical bugs during this audit.

Before we look at the two API interfaces, a quick word about this 'limited' mode. The 'full' API can be enabled by an administrator via the admin web UI, although it is unclear why anyone would want to do so. If you want to make sure you haven't enabled the feature by accident, it is possible to determine if the 'full' API is enabled without even authenticating by querying the XML API:

curl  --insecure -X POST https://192.168.182.132:943/RPC2 			\
	-d "<?xml version=\"1.0\"?> <methodCall> 						\
    	<methodName>GetVPNStatus</methodName> <params> </params> 	\
        </methodCall>"

While a server operating in 'limited' mode will respond XML-RPC function 'GetVPNStatus' with 0 arguments may be not be called at the currently configured relay level, a server with the 'full' mode enabled will respond with XMLRPCRelay: error. Here's the full response for each scenario:

<?xml version='1.0'?>
<methodResponse>
	<fault>
		<value><struct>
			<member>
				<name>faultCode</name>
				<value><int>9000</int></value>
			</member>
			<member>
				<name>faultString</name>
				<value><string>XML-RPC function 'GetVPNStatus' with 0
                    arguments may be not be called at the currently
                    configured relay level</string></value>
			</member>
		</struct></value>
	</fault>
</methodResponse>
Response from the default, 'limited' API
<?xml version='1.0'?>
<methodResponse>
	<fault>
		<value><struct>
			<member>
				<name>faultCode</name>
				<value><int>8002</int></value>
			</member>
			<member>
				<name>faultString</name>
				<value><string>XMLRPCRelay: error</string></value>
			</member>
		</struct></value>
	</fault>
</methodResponse>
Response from the 'complete' API

Webservers

AS deploys two webservers - one for unprivileged logins and one for administrative operations, with both responding to different URIs on the same domain.

The unprivileged interface is the interesting one for us, and examining the cmain class which we decompile previously reveals supported endpoints, rendered by the decompiler as a large if-elif block.

            elif segs[0] == 'create_profile':
                <omitted>
            elif segs[0] == "delete_profile":
                self.allow_access(req, cws_proto_ver)
                serial = req.arg_get('serial')
                res = session.proxy.callRemote('RevokeCertsSN', (int(serial)))
                res(finish)
            elif segs[0] == "change_password":
                <omitted>
A cleaned-up excerpt from cmain, with irrelevant code removed to show routing

I'm going to gloss over the endpoints themselves, as they reveal nothing of real substance that we can use, and focus next on the APIs themselves.

REST interface

The REST interface is extremely minimal, providing only three functions, all concerned with generating a login profile for OpenVPN. Authentication is via HTTP basic authentication.

Here's an example invokation and response.

$ curl -u aliz:test --insecure https://192.168.182.132:943/rest/GetGeneric
# Automatically generated OpenVPN client config file
# Generated on Thu Jul  7 18:40:14 2022 by openvpn
# Note: this config file contains inline private keys
#       and therefore should be kept confidential!
# Define the profile name of this particular configuration file
# OVPN_ACCESS_SERVER_PROFILE=192.168.182.132/NoClientCert

This session token seems to be generated securely, as it takes ten bytes from urandom, appends the current system time, and applies a HMAC. The HMAC key is a 32byte subsection of /usr/local/openvpn_as/etc/tmp/auth_token_key which was again generated securely, so there's no way in here that I can see!

Interestingly, requesting the GetUserlogin endpoint causes it to add two HTTP headers - Vpn-Session-User and Vpn-Session-Token. The first is a base64-encoded username, and the second is an authentication token which OpenVPN itself can use to authenticate to the VPN without requiring the user to authenticate separately (see the OpenVPN documentation). Here's what I mean (I've removed irrelevant lines from the output):

$ curl -u aliz:test --insecure https://192.168.182.132:943/rest/GetUserlogin -v
> GET /rest/GetUserlogin HTTP/1.1
> Authorization: Basic YWxpejp0ZXN0
> User-Agent: curl/7.83.1
> Accept: */*
>
< HTTP/1.1 200 OK
< Vpn-Session-User: YWxpeg==
< Vpn-Session-Token: U0VTU19<truncated for brevity>zEySDkwNA==

XML interface

The XML interface exposes slightly more functionality, allowing a user to change their password and a few other things. For example, to fetch the Windows-platform installer to set up the VPN client, you can:

$ curl -u aliz:test --insecure -X POST https://192.168.182.132:943/RPC2 \
	-d "<?xml version=\"1.0\"?> <methodCall>							\
    <methodName>GetGenericInstaller</methodName> 						\
    <params> <param><value><string>win</string></value></param></params>\
    </methodCall>"

There's not really much in the way of attack surface here. Session management is fairly simple, using a python dict to store a securely-generated session ID:

session_id = (b'AS_' + random_bytes_strong(16)).decode()
self.sessions[session_id] = session
print('AuthRPCSessionServer: NEW sess_id=%s exp=%s len=%s sess=%s' % (session_id, self._show_exp(session), len(self.sessions), session))

Again, no fun tricks here, unfortunately.

Misc

There are also session.json and session2.json files, which are requested via an HTTP GET request and notify the UI of the currently logged-in user's username, and whether they hold administrative privilege:

$ curl 'https://192.168.182.132:943/session2.json' 	\
  -H 'Cookie: <session cookie omitted>' 			\
  -H 'X-CWS-Proto-Ver: 2' 							\
  -H 'X-OpenVPN: 1' 								\
  --insecure
)]}'
{"user": "aliz", "is_admin": false}

Note the malformed JSON in the response, which I was unable to get to the bottom of!

Auth flow

Initially I was somewhat perplexed by the complexity of the authentication flow that AS uses. However, as the audit continued and I became aware of more features that AS (and OpenVPN) supports, I began to understand why it was there.

There are a number of different authentication tokens flying around, issued by different parts of the code for different reasons, and it's quite easy to confuse them. Let's take a look at them individually.

Auth flow: Web auth

This is the most straightforward of authentication flows. When a user requests the login page, a cookie is generated by the SessionWrapper class:

def _sessionCookie(self):
	return bytes(sha256(b'%s_%s' % (str(random.random()).encode('utf-8'), str(time.time()).encode('utf-8'))).hexdigest(), 'utf-8')

If authentication is successful, the session is added to the SessionWrapper.sessions class. Subsequent access is checked against this class.

This is pretty much as simple as it gets, but at the same time is very interesting to us. Can you spot why?

Indeed! the sessionCookie is generated using Python's random.random, which is not 'cryptographically secure'. The term 'not cryptographically secure', in this context, means that we can predict what comes out of the random number generator, given some information about its history. In contrast, a 'cryptographically secure' random number generator would provide numbers that are (almost) impossible to predict, even if you knew every number that generator had ever produced. RNGs are an interesting subject, and while the advanced analysis is best left to those with a better head for mathematics than I, an understanding of the basics is really helpful to spot bugs like this.
Returning to the topic at hand, what we've discovered a weak source of entropy being used for session ID tokens. While this might be enough to get a CVE, everything is better with a PoC, so let's dive in and see if we can exploit this in practical terms!

Weak PRNG

Python uses a common algorithm, the 'Mersenne Twister', for generating pseudo-random numbers. There's a very interesting four-part blogpost I found online - here's part 3 - at  which speaks about cracking PRNGs, and talks about the Python implementation specifically (recommended reading for those interested). The important part, for us, is that if we can extract some 624 sequential values from the PRNG, we can reconstruct the internal state of the PRNG, and thus predict subsequent values.

This requirement seems easy at first glance, since we can just request 624 different session cookies. Unfortunately though, it is harder than it appears. Remember how session cookies are generated:

def _sessionCookie(self):
	return bytes(sha256(b'%s_%s' % (str(random.random()).encode('utf-8'), str(time.time()).encode('utf-8'))).hexdigest(), 'utf-8')
Weak session ID generation

So the session cookie which we get doesn't actually contain the random data itself, but rather a sha256 hash of the random data, concatenated with the time.time() value. The resolution of time.time() varies by platform, but on my Ubuntu box, values were reported with a precision of six or seven decimal places. While we can leak the value of time.time() from the server via other means, bruteforcing this hash is infeasible - the random number itself contains 53 bits of entropy, and even given an optimistic estimate for the timestamp, we're still at ~60 bits which is way too much for us to bruteforce.

It's worth noting here that concatenating the system time prevents us from precomputing hashes before we attack. It's clear that the authors of this code have a firm grasp on the cryptography, as would be expected from the team behind the OpenVPN daemon.

Anyway - this means that we can't get PRNG output that way. Maybe there's another way? After all, we only need a method that tells us the output of the random.random() function, which is shared in the Python runtime.

Some searching yields the following inside the OpenSSL code (rewritten for clarity):

def random_string(length, alphabet='ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789') -> str:
	return ''.join([random.choice(alphabet) for count in range(length)])
		
ctx = SSL.Context(SSL.SSLv23_METHOD)
ctx.set_session_id(random_string(16).encode('utf-8'))

Here we're generating a 16-character string using the insecure RNG, and using it as the TLS session ID. This is exposed to the user via the TLS connection, which would be great, but when we check it ourselves we don't see the format we expect:

$ openssl s_client -connect 192.168.182.132:943 -reconnect
SSL-Session:
    Protocol  : TLSv1.3
    Cipher    : TLS_AES_256_GCM_SHA384
    Session-ID: 5DBAE4A9794BD41E98B22FDE3498E2A8915C80D670C9D1301449904FE7EC1277

What's going on here? Well, it turns out that OpenSSL encrypts the session tokens itself via AES, so we can't extract PRNG data there either. D'oh!

Unfortunately (or fortunately, depending on your perspective), I found no other way to expose PRNG state to a client, and thus no practical attack. However, since this was the only thing preventing a practical attack, watchTowr notified OpenVPN of the situation, noting mitigating factors. OpenVPN felt the issue was serious enough to warrant CVE-2022-33738, and corrected the behaviour in AS 2.11.0, using Python's secrets module.

Auth flow: OpenVPN itself

As I mentioned before, AS runs on a server alongside the OpenVPN daemon itself, which handles VPN connections. I initially imagined some kind of shared database holding user authentication tokens, but the reality is more involved owing to the flexibility of authentication that AS provides.

Instead of some kind of shared database, holding user credentials, OpenVPN validates login requests by communicating with AS over a named socket. AS itself will then decide if the user should be granted access or not. This may seem overkill but allows for sessions to be re-used. For example, consider the following "user journey":

  1. User submits username and password to AS
  2. AS validates username/password, but is configured to use time-based OTP to authenticate the user, and so requests OTP token
  3. User provides OTP token, is authenticated OK
  4. User downloads OpenVPN configuration
  5. User connects to OpenVPN, presents username and valid certificate
  6. User is prompted for OTP password
  7. User is finally connected to the VPN

By storing sessions in AS itself, step six can be omitted, since the user has already authenticated. Similarly, other tokens can be required (or not required) depending on configuration.

The protocol that OpenVPN speaks via this named socket is called 'OMI' - OpenVPN Management Interface - and is documented by OpenVPN. It's worth noting that this protocol is not designed to be exposed to malicious inputs, and so I started trying to inject into it by adding various tokens, but these attempts were stymied by OpenVPN itself, which whitelists usernames aggressively, allowing only alphanumeric characters and underscores.

Autologin

Since AS performs authentication on behalf of OpenVPN itself, it is also able to add new features to the authentication flow. One example is 'autologin' functionality, which (if enabled) will allow a user to generate a certificate which will permit them to log into OpenVPN without needing to supply their password. The way that this is implemented, however, is a little shaky - check out this simple function to check if a given user should be logged-in automatically:

def is_autologin(cn):
	return cn.endswith('_AUTOLOGIN')

'cn', in this case, refers to the 'common name' field of the certificate. Here's an excerpt from an autologin certificate:

$ openssl.exe x509 -text < cert.pem
Certificate:
    Data:
        Version: 3 (0x2)
        Serial Number: 29 (0x1d)
        Signature Algorithm: sha256WithRSAEncryption
        Issuer: CN = "myovpninstall"
        Validity
            Not Before: May 29 16:59:44 2022 GMT
            Not After : May 27 16:59:44 2032 GMT
        Subject: CN = aliz_AUTOLOGIN

The prudent information here is the last line, showing the subject. Note that the username has the token _AUTOLOGIN appended to it. This has been generated for a user who has the username 'aliz', and when presented to the server, will log in 'aliz' without prompting for further credentials.

This kind of in-band signalling has historically ended in disaster, from the blue-boxes of the 80s through SQLi and XSS, so let's take a look at see if we can cause any mayhem with it.

The obvious thing to try is to create a user whose username ends with the _AUTOLOGIN token, such as aliz_AUTOLOGIN. Perhaps the server will be confused enough to log in aliz instead of aliz_AUTOLOGIN. Let's do this, log in as aliz_AUTOLOGIN, and see what happens in the system logs:

2022-07-07T17:19:27+0000 [stdout#info] [OVPN 0]FROM OMI: '>CLIENT:ENV,username=aliz_AUTOLOGIN'
2022-07-07T17:19:27+0000 [stdout#info] [OVPN 0]FROM OMI: '>CLIENT:ENV,X509_0_CN=aliz_AUTOLOGIN'

Here we can see that OpenVPN itself has the correct username, aliz_AUTOLOGIN. 'OMI' is the 'OpenVPN Management Interface' I mentioned earlier, which AS uses to talk to OpenVPN. Authentication is then performed by AS itself:

2022-07-07T17:19:27+0000 [stdout#info] AuthRPCSessionServer: UPDATED 	\
sid='PG_OFXAFaZD9mJukCem' exp=[expire=+86100 max_expire=+86100]		\
sess={'authret': {'status': 0, 'user': 'aliz_AUTOLOGIN',		\
'reason': 'local auth succeeded', 'proplist': 				\
{'prop_autogenerate': 'true', 'type': 'user_connect', 			\
'pvt_password_digest': '<omitted>'}, 					\
'common_name': 'aliz_AUTOLOGIN', 'serial': '81', 'serial_list': ()	\
}, 'max_expire': 1657300467, 'expire': 1657300467}

The username is passed through correctly here, and unfortunately for us, everything works as expected, with no confusion between the aliz and aliz_AUTOLOGIN accounts. The only oddity is in the web UI itself, which will notice the _AUTOLOGIN suffix and remove it, incorrectly showing aliz as logged in if we actually log in as aliz_AUTOLOGIN:

Who is logged in? It's not 'aliz'!

SESS_ID oddities and a brief segue into 70's fone preaking

I allude to 'in-band signalling' in the previous section, and how it has historically caused so many problems. By this, I mean the mixing of control and user data in the same space. The term comes from antiquated telephone equipment. Let me digress a moment to explain what I mean. For this we need to take a trip back in time, to the 1970s, and imagine their telephony system.

In the early 1970s, of course, TCP/IP wasn't yet invented. Neither was Frame Relay, and much of the other protocol groundwork we take for granted. However, the telephone system did exist, and had a need for functionality that we'd typically use a modern protocol for. The telephone system used a number of 'exchanges', each serving a physical location, connected together using so-called 'trunk' lines (those familiar with modern network protocols may recognise the terminology). An exchange can be thought of as similar to a network switch, providing access to a number of users, and a 'trunk' can be thought of as a high-speed uplink carrying data for multiple users (similar to a modern VLAN trunk carrying tagged data). Trunks were effectively bundles of many data lines in parallel.

Imagine a call from a subscriber on the first exchange to the second exchange in the above diagram. First, the user picks up their phone and dials. The first exchange would receive the number, store it in some buffer memory, and identify that the receiver was in the vicinity of the second exchange. At this point, it would look at its collection of 'trunk lines', and allocate one for this call. It would then instruct the second exchange, via this trunk line, that it had a call for the destination subscriber, which would ring the telephone to that user, and connect the user to the trunk line.

There are a few abstractions that aren't present here which I'd like to state explicitly - there's no multiplexing, so each trunk line is taken up by one user when it is in use. There's no other means of communication between exchanges, since wires are expensive, and when the two users are connected, there's no 'protocol' running anywhere - the analogue voltages generated by the first user's microphone are simply amplified by the local exchange, put on to the trunk, and then amplified again at the destination exchange (as an aside, this is where the belief that you can 'hear' a line being snooped on comes from - if you connect a second speaker to the trunk line, there'll be an audible click as the contacts touch the wire, and slightly lower voltage levels).

There does, of course, need to be some form of 'protocol', in the loosest sense of the word, operating between the two exchanges, to signal (among other things) the start and end of calls. This was signalled using special tones which were played by each exchange. For example, if an exchange wanted a trunk line to be 'idle', it would play a constant 2600Hz drone down it. Once this trunk was allocated to a user, different frequencies would be used to signal this information. Telecom people refer to this kind of arrangement as 'in-band signalling', in which the 'signalling data', the metadata which is used by exchanges, is transmitted in the same 'band' (in the signal term, the 'band' in 'bandwidth') as the voice data.

Can you spot how a user might abuse this system to cause mayhem yet?

Well, various enterprising individuals found that they could hijack the trunk lines easily. They could start a telephone call to a user serviced by a remote exchange, and then play a tone of 2600Hz down their phone. The local exchange would simply forward this electrical signal down the line, where the remote exchange would notice it and assume it was sent by the first exchange as an indication the call had terminated. The originating caller would then play a series of tones and instruct the exchange to do their bidding - for example, they could call internationally (while being billed at a local rate), call special lines not intended to be accessible to the public, or tie up resources. This is a fascinating area of 'Hacker History', and one I'm sadly too young to have experienced first-hand, but it is perhaps the biggest example of in-band signalling being dangerous! If you're keen on learning more, the device in question was known as a 'blue box', and the internet is full of tales of varying truth from these who used them, the 'fone phreaks'.

Another simpler example is the 'red box' - a device that could be once be used to make free calls from payphones. While most users of payphones would dial using the keypad on the phone itself, there is always the possibility that a user can call the operator and request a call to a number. Since operator calls are free, some mechanism to prevent this from being exploited to gain free calls was necessary, and this was done by marking the line as a payphone so that the operator would know not to forward calls. However, the ability to charge for calls was required, and so the operator could request that the user inserts coins before they connect the call. The connection from the phone to the operator, however, had no out-of-band signalling ability, and so the coin-reading mechanism would simply play a set tone when a coin was inserted. Enterprising fone phreaks would record these tones and play them back to fool the operator and get free calls.

The Phantom Phreak demonstrates usage of a red box

While going to all this trouble for free (or cheap) phone calls may seem silly in today's connected world, remember this was before the Internet, and talking internationally was an amazing thing not yet taken for granted by the world!

The fundamental problem here is that control data (such as 'the user has inserted a coin') has been mixed together with the payload data (the voice signal itself), in a way that makes it impossible for them to be separated securely. One could draw a parallel with SQL injection, where control data - the SQL statement - is mixed with payload data, or XSS, in which HTML elements are mixed with user input. In the previous section, we saw how control data - the _AUTOLOGIN token - was mixed with user data, albeit in a roundabout kind of way. These sections of a system require careful design and care.

Anyway! That's the end of the segue. Back to OpenVPN AS now, I promise!

The way that session identification is implemented in AS's web interface is unusual, in that the session ID is used as a password when authenticating. Here's some (abridged) code showing how new sessions are authenticated:

def authenticate_new_session(self, request, authcred, attributes=None):
	pw = authcred.get('password')
	if pw.startswith('SESS_ID_'):
		sid = pw[8:]
		ret = self.session_authenticate(sid)
		return ret
	return self.auth_module.authenticate(authcred, attributes)

We can see there's a strange SESS_ID_ token here. If the supplied password begins with that token, it will be assumed that it contains a session ID, not an actual password, and will be authenticated as such. It's a totally useless bug, except in the unusual case that you would like to be locked out of AS and can't persuade your administrator to do so - simply change your actual password to something starting with SESS_ID_ and you will be unable to login until an administrator resets it!

There's some crossover between such 'in-band signalling' and the lack of type data that some database backends (or storage mechanisms) can provide. There's an old joke about an unfortunate person whose surname was "null", who was unable to sign up for all sorts of computer systems since they would confuse the string "null" with the special value of null. AS uses SQLite as a backend by default, which by default is untyped, which can yield some elements of confusion. Trying to log in as a user named 'True', for example, provides the unusual response 'Your account has been suspended'. Let's hope no-one has this name!

Another harmless use of in-band signalling, this time in the password field, is in the implementation of TOTP. The TOTP mechanism expects to receive OTP tokens via the username field, encoded like this:

class PasswordStaticResponse(object):
	prefix = 'SCRV1'
	re_encoding = 
    	re.compile('^%s:([A-Za-z0-9+/=]*):([A-Za-z0-9+/=]*)$' % (prefix,))

	@classmethod
	def split(C, encoded_str):
		if encoded_str:
			m = re.match(C.re_encoding, encoded_str)
			if m:
				pw64, resp64 = m.groups()
				return (
                	mydec(base64.b64decode(pw64)),
                    mydec(base64.b64decode(resp64))
                )

That is, the string 'SCRV1:', a base64-encoded username, a colon, and then the OTP token itself. This still works if you don't have OTP enabled. For example, if you didn't like your current password of 'test', and fancied a change, you could supply the following as your password:

SCRV1:dGVzdA==:

Again, totally useless, but hey, that's how all good bugs start out!

Username woes

I must admit, I did a lot of auditing and research of the AS codebase before I thought to try the 'simple things'. It was near the end of the audit when I tried inserting some HTML control characters into the username field, to be rewarded with a simple XSS. Here's the result of creating the a user with the username openvpn"); alert('watchTowr rules OK'); return false; showHideControl(" , browsing to the user list in the admin UI, and expanding the relevant entry:

The 'add user' dialog, but with a an alert messagebox displaying 'watchTowr rules OK'
oops! Is that a username or is that a DOM element?!

While there's exploitation potential here, the realistic impact is low, as it would require that a user can sign up to the system with a username containing such control characters. watchTowr notified the OpenVPN maintainers of this issue, noting the mitigating factors, who declined to treat the bug as a security issue. They explained further that work is planned to replace the whole web frontend with a more modern framework, which would fix the issue (along with other issues) - fair enough.

Conclusions

I hope you enjoyed this look behind the curtain of a typical enterprise software codebase! The audit resulted in three bugs being reported to the vendor, and two CVEs being assigned from them.

One of the key advantages that the 'defenders' have over offensive teams in this area is that we are able to fix severe weaknesses - such as the use of the weak PRNG - before they become real security bugs. It is plausible to consider that an update to AS could leak PRNG state and enable a full attack in the future, had watchTowr not discovered the issue and reported it to OpenVPN. While less glamourous than providing PoC, it keeps our clients safest when we can fix bugs before they are even exploitable.

The other point I'd like to stress is more philosophical, regarding my segue about 'in-band signalling'. It sounds so easy in theory to keep user input and code itself separate, but in practice, not so much! We've seen historically that this is a difficult problem, and new technologies such as ARM's Pointer Authentication or Intel's CET-based Shadow Stack could be seen as attempts to further separate the two.

Finally, I'd like to stress that nothing I found in the AS codebase is particularly out of the ordinary in terms of code quality, for a project of this size, and my criticisms are not directed at the OpenVPN team, who were very responsive and helpful in addressing the issues we found. Since the code itself is distributed as compiled Python, I didn't have access to the 'real' source code, and so the code snippets may differ from the actual, human-readable codebase that OpenVPN keeps. The OpenVPN team deserve some commendation for proactively fixing the weak PRNG issue and taking it seriously despite the mitigating factors.

At watchTowr, we believe continuous security testing is the future, enabling the rapid identification of holistic high-impact vulnerabilities that affect your organisation.

If you'd like to learn more about how the watchTowr Platform, our Attack Surface Management and Continuous Automated Red Teaming solution, can support your organisation, please get in touch.