March 14, 2020

CVE-2020-10560 - OSSN Arbitrary File Read

Exploiting Arbitrary file read and poor crypto in OSSN.

CVE-2020-10560 - OSSN Arbitrary File Read

This is a fairly detailed blog post on the pain we went through to get Arbitrary File Read (CVE-2020-10560) in an open-source platform that involved writing a custom crypto cracking tool!. Before we get to that let's start at the beginning.

OSSN

The Open Source Social Network (OSSN) is, well just that, a host it yourself social media platform. It is reasonably easy to download and get running. Written in PHP, it uses a MySQL backend and has just shy of half a million downloads listed on its main site.

The Scanner

The source code for OSSN is free to download either from the main site or from the OSSN GitHub. It is a PHP application, so it is relatively easy to read and track what is happening, that being said there is a lot of code, so I decided to to use an Open Source static code analyser to run a quick pass over it.

The Results

It only takes a couple of minutes for progpilot to complete, and it kicks out a result that immediately looks interesting.

    {
        "source_name": [
            "$file_get_contents_return"
        ],
        "source_line": [
            316
        ],
        "source_column": [
            10492
        ],
        "source_file": [
            "\/tmp\/ossn\/components\/OssnComments\/ossn_com.php"
        ],
        "sink_name": "echo",
        "sink_line": 316,
        "sink_column": 10492,
        "sink_file": "\/tmp\/ossn\/components\/OssnComments\/ossn_com.php",
        "vuln_name": "xss",
        "vuln_cwe": "CWE_79",
        "vuln_id": "bd6478779f63431a516932bc0fe193e97ff73a84e6710cb644d88ba036f5bbbe",
        "vuln_type": "taint-style"
    },

The scanner believes there is potential XSS in a component that looks like it is related to comments. Swtiching over to the source code we jump to line 316 and see what we have

case 'staticimage':
		$image = base64_decode(input('image'));
		if(!empty($image)) {
				$file = ossn_string_decrypt(base64_decode($image));
				header('content-type: image/jpeg');
				$file = rtrim(ossn_validate_filepath($file), '/');
				if(is_file($file)) {
						echo file_get_contents($file);
				} else {
						ossn_error_page();
				}
		} else {
				ossn_error_page();
		}
		break;

It looks like this is not XSS, there is an echo, and it seems to be reading data from a file, but it is also setting image content types and seems to be validating filepaths. For the keen-eyed amongst you, there is also an encryption function, but we will get to that later :)

First, I wanted to see if it was possible to control the file path $file if that was possible then we could be looking at LFI or at the very least file read.

staticimage

Using a docker-compose template, I start a new instance with a clean database and some generic accounts. Once the application is up and running, I start looking to see if I can find where this function and case are called there are a few things I know that will help:

  • It is in a comment
  • It is image related
  • the path contains staticimage
  • there is a base64 string

Burp felt a bit heavy-handed at this early stage, so I just opened Chrome with Dev tools open and set a filter for staticimage. After creating my first post, I looked at the comment section and saw there was an option to add an image to a comment. I click the button, select an image to upload and . . .

That seems to hit all my requirements from the list above. It is also worth noting that at this point I have not yet actually posted a comment, this is just a preview that sent a request off to the server, stored the image and then rendered the preview in the front end.

Once it had been previewed, the image was stored on the server and could be accessed directly without being authenticated to the OSSN platform.

At this point I know I control the base64 string from the attacker's side and I know the base64 string is somehow used to construct a file path that is then echoed out to the page.

The Crypto

OK now I really have to see what that ossn_string_decrypt is doing, the application takes the B64 string from the URL image parameter, and base64 decodes it twice. The resulting output is then passed to this function

function ossn_string_decrypt($string = '', $key = '') {
    if (empty($string)) {
        return false;
    }
    if (empty($key)) {
        $key = ossn_site_settings('site_key');
    }
    $key = ossn_string_encrypt_key_cycled($key);
    
    $size    = openssl_cipher_iv_length('bf-ecb');
    $mcgetvi = openssl_random_pseudo_bytes($size);
    //note mcrypt and now this acting mcrpyt adds the spaces to make 16 bytes if its less then 16 bytes
    //you can use trim() to get orignal data without spaces
    return openssl_decrypt($string, "bf-ecb", $key, OPENSSL_RAW_DATA | OPENSSL_NO_PADDING, $mcgetvi);
}

That all looks reasonably standard, Let's see if we can grab the site key and see what is in that base64 encoded value. Getting the site_key is easy from our side as it is stored in the database.

mysql> select value from ossn_site_settings where name = "site_key";
+----------+
| value    |
+----------+
| 94bf7ac1 |
+----------+
1 row in set (0.00 sec)

mysql> 

That's not a very long key! but at the moment I am just interested in seeing what is in this blob to make it easier for myself I just copied these functions out to a standalone PHP script so I could play around with all the values and put some debug lines in there to see what is going on.

Once I had enough information I also created a couple of CyberChef recipes to replicate the functions All you need to do is replace the Key with your own site key (repeated to a length of 20)

OSSN Blowfish Decryption

OSSN Blowfish Encryption

Arbitrary File Read

Now I know how to generate the base64 encoded value it was time to see if it was possible to read files from the server that we should not be able to read. There was another function that was doing some file path validation it was attempting to replace directory traversal attempts by replacing any ../ in the strings, this was easy to ignore as we could just specify the full path we did not need to use a relative path.

To make things a bit more portable I created a python PoC script that would take a site_key, a file_path and a target URL and attempt to read the file.

You can get the full PoC code and the docker-compose images on my Github

Getting the site_key

This was all "easy" with knowledge of the site key but was there a way for an attacker to get hold of it? At this point Alex Seymour was helping me trace all the crypto bits and pieces and he jumped both feet in to the deep end of C and janky PHP blowfish implementations!

We knew the site_key that was used for the encryption was only 8 characters in length that's not a long key and we can predict some of the plain text so was there a way to brute-force the key from the outside in a reasonable timeframe?

8 characters, the key seemed to be lowercase hex so 16 possible options per character, that gives us around 281,474,976,710,656 possible site keys!!! OK, so that's a large number. Not deterred, we went to look at the function that actually created the site key to see if there was anything that could be used. Turns out yes, yes there is.

This is the function that creates a unique site_key

function ossn_generate_site_secret() {
        return substr(md5('ossn' . rand()), 3, 8);

What this does is:

  1. start with the string 'ossn'.
  2. calculate a random number with PHP rand().
  3. Append the number to the first string.
  4. calculate the md5 hash of this new string.
  5. take characters 3-11 as the site_key.

The really interesting thing here is the use of rand(). the rand function is not cryptographically secure and it warns you as such in the PHP Docs.

Using rand() in php7 or higher the maximum possible value is 2,147,483,647. In terms of cracking the key we just reduced the number of possible keys from around 282 trillion to a measly 2 billion and change.

Instead of trying every possible permutation of the 10 character key we could jsut calculate every md5sum for each of the 2billion possible values of rand.

so ossn1 to ossn2147483647

The rest of the Crypto was all Alex and I do not envy him for that task.

Breaking the Crypto

Following the identification of the weak key generation routine it was time to start writing a PoC to try to recover the site key using the encrypted blob containing an image's file path. First though we needed a way to identify a successful decryption attempt. Examining the source code and plaintext file path revealed the value tmp/photos to be a safe known plaintext value as it is hard-coded into the source code.

The initial PoC was just a quick Python script that spawned a handful of threads to distribute the generation of all the possible keys. The generation threads in turn spawned yet more threads to handle each decryption attempt, each of these threads simply executed a PHP subprocess and checked for the output for the known value tmp/photos. Unfortunately the performance of this approach wasn't anywhere near good enough, we saw average speeds of around 2000 attempts per second which would have taken around 2 weeks to work through all possibilities.

The next iteration of the PoC was written in C, which, unsurprisingly provided significant performance improvements but involved a bit of a learning curve having not worked with the language much in the past. The C version follows pretty much the same structure as the Python PoC; a fixed number of generator threads are spawned and the available keyspace is split across them. In our case we ran 4 generator threads so 2,147,483,647 was split into quarters and each quarter was passed to a different generator thread.

void start_generators(long max_value, int thread_count) {
    pthread_t threads[thread_count];
    struct generator_args args[thread_count];

    while (max_value % thread_count != 0) {
        max_value++;
    }

    for (int i = 0; i < thread_count; i++) {
        args[i].max = (max_value / thread_count) * (i + 1);
        args[i].min = (max_value / thread_count) * i;

        if (args[i].min > 0) {
            args[i].min++;
        }

        pthread_create(&threads[i], NULL, attempt_generator, &args[i]);
    }

    for (int i = 0; i < thread_count; i++) {
        pthread_join(threads[i], NULL);
    }
}

Each of the generator threads enumerates its assigned value range in order to construct the key for each decryption attempt according to the OSSN source code. Each value is appended to the string ossn, the result is then MD5 hashed and characters 3-11 of the resulting hash are extracted and passed to a new thread to handle further key preparation and the actual decryption attempt.

char *string = malloc(256);
unsigned char hash[MD5_DIGEST_LENGTH];
char *attempt = malloc(SUBSTR_LENGTH + 1);
char hash_hex = malloc(32);

memset(string, 0, 256);
memset(attempt, 0, SUBSTR_LENGTH + 1);
memset(hash_hex, 0, 32);
sprintf(string, "ossn%ld", i);
MD5(string, strlen(string), hash);

for (int j = 0; j < MD5_DIGEST_LENGTH; j++) {
    hash_hex += sprintf(hash_hex, "%02x", hash[j]);
}

hash_hex -= 32;
strncpy(attempt, hash_hex + SUBSTR_START, SUBSTR_LENGTH);
pthread_create(&threads[thread_index], NULL, test_attempt, attempt);

An interesting discovery during this process was the realisation that Blowfish's key expansion is not correctly implemented in PHP's OpenSSL extension. As detailed in this bug report keys less than 128 bits (16 bytes) are zero-padded up to 128 bits but should use key cycling according to the algorithm's inventor. The PHP developers added a new constant OPENSSL_DONT_ZERO_PAD_KEY which instructs calls to openssl_encrypt() to use key cycling instead of zero padding. The default implementation, however, still uses zero padding and, at the time of writing, the constant is undocumented. OSSN contains its own key cycling function which was used to cycle a key up to 20 characters in length so test1234 would become test1234test1234test.

All that was left was to implement key cycling and start decrypting the ciphertext block by block and checking to output for the known-plaintext tmp/photos.

The C implementation performed around 45,000 attempts per second on average, meaning it would take just over 13 hours to try every possible key. Cursory testing shows even faster speeds on higher-spec machines.

During the creation of this PoC, the OSSN developers released an update that changed their encryption process to use AES instead of Blowfish, so the PoC was modified to create an AES version as well. Both can be found on GitHub.

Disclosure

This was a fairly smooth process. I reached out to the OSSN team via email, rather than raising a GitHub issue that felt a bit too public. They were quick to respond and quick to push updates.

With some back and forth over a few days we got to a point where I was unable to read any more files :)

Timeline

  • First email: Mar 11th
  • First response: Mar 11th
  • Final patch: Mar 14th