anon@tnl ~/blog/Owncloud OAuth Token Steal leading to CRUD Filestore Access>

OwnCloud OAuth Token Steal leading to CRUD Filestore Access

In this blog post we are going to look at a bug I found in the ownCloud OAuth 2 extension that allows an unauthenticated attacker to gain CRUD access to the users cloud content by making them click a link under certain conditions. The bug has since been fixed.

From April to August this year I worked as a security researcher over at AVOLENS, which I really enjoyed since I could pick targets myself. One of them ended up being the ownCloud server, which is an open source self hosted cloud server written in PHP which I picked because:

The OAuth 2.0 Plugin

I began looking at various parts of the codebase and some day decided to transition to one of the many plugins: OAuth2. I'm still not sure why I picked this plugin in particular since there don't seem to be many opportunities regarding RCE/LFI. I guess after weeks of not finding anything I just needed a break - which turned out to be the right decision.

So, what does OAuth2 provide? Simply put, It allows third party applications to request an access token for a specific user and act on the their behalf on successful user login and verification. This is useful if you want to connect an application to ownCloud without sharing your password with the third party. In fact, this is exactly what happens when you do "Login with Google" (although the ownCloud process is much simpler).

Lets dive into more detail. In the following, the third party thirdparty.com would like to request a token for user1 at owncloudserver.com.

The general OAuth process is roughly as follows:

This is a very simplified version of the OAuth flow. In reality, there are many more steps and possibly more endpoints involved, have a look.

For this to be possible in ownCloud, an administrator has to 1) install the OAuth2.0 Plugin and 2) add thirdparty.com to the list of trusted clients in the admin interface. Lets do that.

We had to choose a redirection URL. This will be the sink that receives the auth token from ownCloud if everything goes well. The user will be redirected to this URL with the auth token added in the get parameters so thirdparty.com can redeem it for an access token. and maybe show the user some custom text. The client identifier and the secret were randomly generated by ownCloud.

OAuth 2.0 flow in detail

Now lets look at what happens when thirdparty.com wants to receive an access token in detail:

thirdparty.com will send the user to the /index.php/apps/oauth2/authorize endpoint to first receive an authentication token with the following GET parameters:

The authorize endpoint is responsible for verifying the following:

Some excerpts of the code in owncloud/apps/oauth2/lib/Controller/PageController.php

<?php
public function authorize(
    $response_type,
    $client_id,
    $redirect_uri,
    $state = null,
    $user = null,
    $code_challenge = null,
    $code_challenge_method = null
) {
    if (!\is_string($response_type) || !\is_string($client_id)
        || !\is_string($redirect_uri) || ($state !== null && !\is_string($state))
    ) {
        $this->logger->error('Invalid OAuth request - one of the mandatory query parameters is missing');
        return new TemplateResponse(
            $this->appName,
            'authorize-error',
            ['client_name' => null],
            'guest'
        );
    }.

....

try {
        /** @var \OCA\OAuth2\Db\Client $client */
        $client = $this->clientMapper->findByIdentifier($client_id);
    } catch (DoesNotExistException $exception) {
        $this->logger->error("Invalid OAuth request with client-id $client_id");
        return new TemplateResponse(
            $this->appName,
            'authorize-error',
            ['client_name' => null],
            'guest'
        );
    }

    if (!Utilities::validateRedirectUri($client->getRedirectUri(), \urldecode($redirect_uri), $client->getAllowSubdomains())) {
        $this->logger->error("Invalid OAuth request with invalid redirect_uri: $redirect_uri !== {$client->getRedirectUri()}");
        return new TemplateResponse(
            $this->appName,
            'authorize-error',
            ['client_name' => $client->getName()],
            'guest'
        );
    }

...

// trusted clients get their auth code back directly
if ($client->getTrusted()) {
    return $this->generateAuthorizationCode($response_type, $client_id, $redirect_uri, $state, $code_challenge, $code_challenge_method);
}

$logoutUrl = $this->urlGenerator->linkToRouteAbsolute(
    'oauth2.page.logout',
    [
        'user' => $user,
        'requesttoken' => Util::callRegister(),
        'response_type' => $response_type,
        'client_id' => $client_id,
        'redirect_uri' => \urlencode($redirect_uri),
        'state' => $state,
        'code_challenge' => $code_challenge,
        'code_challenge_method' => $code_challenge_method
    ]
);
$currentUser = $this->userSession->getUser();
$currentUser = $this->buildDisplayForUser($currentUser);

return new TemplateResponse($this->appName, 'authorize', [
    'client_name' => $client->getName(),
    'current_user' => $currentUser,
    'logout_url' => $logoutUrl
], 'guest');
}
?>

We can see that client_id is checked and that redirect_uri is verified via validateRedirectUri(). Additionally, a client marked as "trusted" in the admin UI will skip the verification by the user via a click (we will see that in the next screenshot)

Lets visit /index.php/apps/oauth2/authorize?response_type=code&client_id=3gZi1dLJvqPHwcr88WcnB0x9BjMwKF61U3zF3kLG5NFeCIatSRsfupJNwSiKufXW&redirect_uri=https://thirdparty.com/sink

if we click "Authorize", we will be redirected to https://thirdparty.com/sink?code=<AUTHCODE>

The Bug

I love function names starting with validate! (Why not start your next codebase assessment with rg . -e "function (verify|validate)" ?)

If we manage to somehow control the redirect URI, we could receive the auth token. The validation should prevent us from supplying a URI that is not registered for the specific client. Lets look at the source. See if you can find the bug here!

<?php

/**
 * Validates a redirection URI.
 *
 * @param string $expected The expected redirection URI.
 * @param string $actual The actual redirection URI.
 * @param boolean $allowSubdomains Whether to allow subdomains.
 *
 * @return boolean True if the redirection URI is valid, false otherwise.
 */
public static function validateRedirectUri($expected, $actual, $allowSubdomains) {
    $validatePort = true;
    if (\strpos($expected, 'http://localhost:*') === 0) {
        $expected = 'http://localhost' . \substr($expected, 18);
        $validatePort = false;
    }
    try {
        $expectedUrl = new URL($expected);
        $actualUrl = new URL($actual);
        if (\strcmp($expectedUrl->protocol, $actualUrl->protocol) !== 0) {
            return false;
        }

        if ($allowSubdomains) {
            if (\strcmp($expectedUrl->hostname, $actualUrl->hostname) !== 0
                && \strcmp($expectedUrl->hostname, \str_replace(\explode('.', $actualUrl->hostname)[0] . '.', '', $actualUrl->hostname)) !== 0
            ) {
                return false;
            }
        } elseif (\strcmp($expectedUrl->hostname, $actualUrl->hostname) !== 0) {
            return false;
        }

        if ($validatePort && $expectedUrl->port !== $actualUrl->port) {
            return false;
        }

        if ($expectedUrl->pathname !== $actualUrl->pathname) {
            return false;
        }

        if ($expectedUrl->search !== $actualUrl->search) {
            return false;
        }

        return true;
    } catch (TypeError $ex) {
        return false;
    }
}

What a cute function! Lets go over it.

Mhhh.. the subdomain processing looks interesting. Lets transform that oneliner:

<?php
if (strcmp($expectedUrl->hostname, $actualUrl->hostname) !== 0){

    $exploded = explode('.', $actualUrl->hostname)[0] . '.';

    $newhostname = str_replace($exploded, '', $actualUrl->hostname);

    return strcmp($expectedUrl->hostname, $newhostname) !== 0
}

Now it should be more obvious what the devs were trying to do here. If the hostnames do not match but we allow subdomains (phps URL will include subdomains in the hostname attribute), we extract the highest subdomain by splitting the $actualUrl by dots, taking the first index and appending a dot to it. (e.g for a.b.com we extract a.).

Then we form the $newhostname by replacing the extracted subdomain portion with "" in the $actualUrl, effectively removing every occurence.

If the "cut down" $newhostname now equals the expected hostname, we pass the check.

So subdomain.thirdparty.com becomes thirdparty.com

And co.thirdparty.com becomes thirdparty.m - WAIT WHAT?

Yup, str_replace of course replaces every occurrence of the substring. We can do evil stuff here. If we now supply a domain like de.thirdparty.de.com and register it, ownCloud would process the domain like ~de.~thirdparty.~de.~com, compare it to thirdparty.com and deem it just fine! But in the end the user will be redirected to de.thirdparty.de.com, which we control. Thus we receive the auth token that gets appended to it :)

Remember, we know everything to generate an authorize URL (client_id,our evil redirect_uri and the expected hostname for the client). We just need subdomains to be allowed for our target client.

We have an OAuth token

Don't get too excited yet. We are now in possession of an OAuth token. What can we do with it? Lets look at the ownCloud OAuth docs. Okay, we can now request access tokens for a specific user and client!

But look close at the docs:

... Client authentication is done using basic authentication with the client identifier as username and the client secret as a password.

But we don't know the client secret! 😭 Well, maybe we can find a way :)

Client Secret bypass

Let's just try to make a POST request to the access token endpoint and see what we get:

import requests
client_secret = 'we dont know :c'
client_ident = '3gZi1dLJvqPHwcr88WcnB0x9BjMwKF61U3zF3kLG5NFeCIatSRsfupJNwSiKufXW'

def get_access_token(authtoken):

    endpoint = 'http://127.0.0.1/index.php/apps/oauth2/api/v1/token'

    data = {
        'grant_type': 'authorization_code',
        'code': authtoken,
        'redirect_uri': 'http://thirdparty.com/sink',
    }

    return requests.post(endpoint, params=data,auth=(client_ident,client_secret)).text

print(get_access_token(input('authtoken pls>')))

When running this we get {"error":"invalid_client"} from the server. Lets search for that string in the codebase and look at the code that is responsible for handeling access token requests:

<?php

public function generateToken(
    $grant_type,
    $code = null,
    $redirect_uri = null,
    $refresh_token = null,
    $code_verifier = null,
    $client_id = null
) {
    if (!\is_string($grant_type)) {
        return new JSONResponse(['error' => 'invalid_request'], Http::STATUS_BAD_REQUEST);
    }

    if (\is_string($client_id) && \is_string($code_verifier)) {
        // The authorization code flow doesn't require a client secret in case of a public client.
        // Instead, the client needs to use the PKCE extension and send a code challenge / code verifier.
        // That is why we don't compare the client secret when the client id and code verifier are set in the
        // query parameters.
        try {
            /** @var \OCA\OAuth2\Db\Client $client */
            $client = $this->clientMapper->findByIdentifier($client_id);
        } catch (DoesNotExistException $exception) {
            return new JSONResponse(['error' => 'invalid_client'], Http::STATUS_BAD_REQUEST);
        }
    } else {
        if (!isset($_SERVER['PHP_AUTH_USER']) || !isset($_SERVER['PHP_AUTH_PW'])) {
            return new JSONResponse(['error' => 'invalid_request'], Http::STATUS_BAD_REQUEST);
        }

        try {
            /** @var \OCA\OAuth2\Db\Client $client */
            $client = $this->clientMapper->findByIdentifier($_SERVER['PHP_AUTH_USER']);
        } catch (DoesNotExistException $exception) {
            return new JSONResponse(['error' => 'invalid_client'], Http::STATUS_BAD_REQUEST);
        }

        if (\strcmp($client->getSecret(), $_SERVER['PHP_AUTH_PW']) !== 0) {
            return new JSONResponse(['error' => 'invalid_client'], Http::STATUS_BAD_REQUEST);
        }
    }

    switch ($grant_type) {
        case 'authorization_code':

This is the function that processes token requests. First of all, one can notice that the function takes much more arguments than we previously supplied via the GET request. Also note the comment: The function actually ignores the client secret when we supply a client_id and code_verifier - using the PKCE extension?

Interesting.. Lets keep on looking what happens with code_verifier.

<?php
switch ($grant_type) {
    case 'authorization_code':
        if (!\is_string($code) || !\is_string($redirect_uri)) {
            return new JSONResponse(['error' => 'invalid_request'], Http::STATUS_BAD_REQUEST);
        }

        try {
            /** @var \OCA\OAuth2\Db\AuthorizationCode $authorizationCode */
            $authorizationCode = $this->authorizationCodeMapper->findByCode($code);
            VRLOG("got authcode");
        } catch (DoesNotExistException $exception) {
            // could be that authorization code has been already cleaned up or client sends wrong authorization code
            $this->logger->debug("authorization code does not exist: {$exception}", ['app'=>__CLASS__]);
            return new JSONResponse(['error' => 'invalid_grant', 'error_description' => 'authorization code does not exist'], Http::STATUS_BAD_REQUEST);
        }
        
        if (\strcmp((string)$authorizationCode->getClientId(), (string)$client->getId()) !== 0) {
            // ERROR: client ids mismatch
        }

        if ($authorizationCode->hasExpired()) {
            // ERROR: authcode expired
        }

        if (!Utilities::validateRedirectUri($client->getRedirectUri(), \urldecode($redirect_uri), $client->getAllowSubdomains())) {
            // ERROR: redirect uri bad
        }

        try {
            if (!$authorizationCode->isCodeVerifierValid($code_verifier)) {
                $this->logger->debug("code verifier invalid: {$code_verifier}", ['app' => __CLASS__]);
                return new JSONResponse(['error' => 'invalid_grant', 'error_description' => 'code verifier invalid'], Http::STATUS_BAD_REQUEST);
            }
        } catch (UnsupportedPkceTransformException $e) {
            $this->logger->debug("code challenge method invalid: {$e}", ['app' => __CLASS__]);
            return new JSONResponse(['error' => 'invalid_request', 'error_description' => $e->getMessage()], HTTP::STATUS_BAD_REQUEST);
        }

        $this->logger->info('An authorization code has been used by the client "' . $client->getName() . '" to request an access token.', ['app' => $this->appName]);

I cut out unimportant code pieces. It becomes obvious that we have to pass the check in isCodeVerifierValid(). We dont even know what the code verifier is (we only know its a string) but lets keep digging and look at isCodeVerifierValid():

<?php

public function isCodeVerifierValid($codeVerifier) {
        if ($this->codeChallengeMethod === 'S256') {
			// See https://tools.ietf.org/pdf/rfc7636.pdf#57
			$h = \hash('sha256', $codeVerifier, true);
			$encoded = Utilities::base64url_encode($h);
            return $encoded === $this->codeChallenge;
		} elseif ($this->codeChallengeMethod === 'plain' ||
				   $this->codeChallengeMethod === '' ||
                   $this->codeChallengeMethod === null) {
            // print debug version of codechallengemethod
			return $codeVerifier === $this->codeChallenge;
		}
		throw new UnsupportedPkceTransformException("Code challenge method {$this->codeChallengeMethod} not supported");
	}

Quite simple. There are 3 cases. Either the codeChallengeMethod is "S256", in which case our codeVerifier is sha256 hashed, base64 encoded and compared to the internal codeChallenge. Else if the codeChallengeMethod is "plain" or empty, we just compare the codeVerifier to the internal codeChallenge. If the codeChallengeMethod is anything else, the program throws an exception.

If we could influence the codeChallengeMethod and the codeChallenge, we could easily pass the check. Lets look at the code that sets the codeChallengeMethod and codeChallenge. For this I did some string grepping in the codebase and ended up with a very familiar looking function:

<?php
 public function generateAuthorizationCode($response_type, $client_id, $redirect_uri, $state = null, $code_challenge = null, $code_challenge_method = null) {
		if (!\is_string($response_type) || !\is_string($client_id)
			|| !\is_string($redirect_uri) || ($state !== null && !\is_string($state))
		) {
			return new RedirectResponse(OC_Util::getDefaultPageUrl());
		}

		$userUID  = $this->userSession->getUser()->getUID();

...

This function gets called by the auth() view function. Seems like we can supply a code_challenge and code_challenge_method via GET parameters. Lets see how they are processed:

<?php
...
switch ($response_type) {
    case 'code':
        try {
        ...
        } catch (DoesNotExistException $exception) {
            return new RedirectResponse(OC_Util::getDefaultPageUrl());
        }

        if (!Utilities::validateRedirectUri($client->getRedirectUri(), \urldecode($redirect_uri), $client->getAllowSubdomains())) {
            return new RedirectResponse(OC_Util::getDefaultPageUrl());
        }

        $code = Utilities::generateRandom();
        $authorizationCode = new AuthorizationCode();
        $authorizationCode->setCode($code);
        $authorizationCode->setClientId($client->getId());
        $authorizationCode->setUserId($userUID);
        $authorizationCode->resetExpires();
        $authorizationCode->setCodeChallenge($code_challenge);
        $authorizationCode->setCodeChallengeMethod($code_challenge_method);
        $this->authorizationCodeMapper->insert($authorizationCode);

        ...

        case 'token':
...

Well... that's it. The code_challenge and code_challenge_method are directly passed to the AuthorizationCode object. Does this really affect the variables used in the access token controller? Lets not look into code and just try it out:

  1. Make a request to /index.php/apps/oauth2/authorize?response_type=code&client_id=<ID>&redirect_uri=<EVIL_REDIR>&code_challenge=123&code_challenge_method=plain

  2. Click "Authorize"

  3. Make a request to /index.php/apps/oauth2/api/v1/token?grant_type=authorization_code&code=<AUTHCODE>&redirect_uri=<REDIR_URI>&code_verifier=123&client_id=<ID>

Note the code_verifier and code_challenge parameters

And we receive:

{"access_token":"...","token_type":"Bearer","expires_in":3600,"refresh_token":"...","user_id":"admin","message_url":"http:\/\/127.0.0.1\/apps\/oauth2\/authorization-successful"}

BINGO! Why is this allowed? Is this a second bug??

OAuth 2.0 PKCE

Turns out, it is not a bug!

The OAuth 2.0 PKCE extension is working as intended right here. The extensions actual purpose is to prevent CSRF and authcode injection attacks. Its funny that this feature actually allowed us to escalate an auth token to a full access token but this is just due to the fact that we can use fake redirect URIs.

PKCE is actually very simple. The two new parameters, code challenge and verifier, are used to verify that the client that requested the auth code is the same that requests the access token. The client generates a random string (the code_verifier), hashes it and sends the hash along with the auth code request. The back end stores the hash (the code_challenge) and the method used to hash it. When the client requests the access token, it sends the original random string. The back end hashes it and compares it to the stored hash. If they match, the client is verified. So even if an attacker gets a hold of an auth token, they cannot redeem it for an access token because they don't know the original random string.

Here is a simple overview of the intended flow:

Here is a nice quote from a blog I found while researching:

With PKCE, you prove that the same application is swapping the code as the one who requested it. With client authentication, you prove that the application is even allowed to swap the code.

And in this case, we are the same application that requested the auth code. We just used the redirect steal bug to get the auth code from any registered client.

So it is always a good idea to require client authentication through a client secret even when using PKCE as this would have prevented the attack and make the bug just have the impact of stealing OAuth tokens without being able to redeem them.

Impact

If the attacker knows one of the client ids from the registered OAuth clients and the admin has allowed subdomains for that client, the attacker can get auth tokens for any user that clicks an attacker supplied link and allows the authorization request. The attacker can then redeem the auth token for an access token, along with a refresh token, meaning unlimited access tokens for a client and user of the attackers choice.

It took me a while to figure out how the access token can actually be used. I looked at the Owncloud Desktop Client because it can use OAuth to connect to an ownCloud server.

Turns out we get full WebDAV access. This means full CRUD access to the users files. Yay! 🎉

Learnings from Bug Bounty Hunting

I learned a lot during the 4 months, here are some key takeaways:

Timeline

Everything went really smoothly, shoutout to the awesome ownCloud team!