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:
- It's open source
- It has a bug bounty program
- Mature cloud environment = many features = many bugs
- PHP :)
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:
- thirdparty.com sends the user to the ownCloud login page with a
client_id
and aredirect_uri
in the GET parameters - The user logs in and is redirected to the
redirect_uri
with anauth token
in the GET parameters - thirdparty.com redeems the
auth token
for anaccess token
and can now act on behalf of the user
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:
- client_id : the client id that ownCloud generated. This is not considered a secret and servers as an identifier for thirdparty.com
- redirect_uri : the uri the user will be redirected to upon successful authorization
- state : optional, not important in this scenario.
The authorize endpoint is responsible for verifying the following:
- the client_id exists
- the redirect_uri is valid
- the user actually clicks accept
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.
- The function receives our supplied URI, the expected URI stored in the back end and a bool indicating whether wildcard subdomains are allowed
- By default, the port portion should be validated
- If the given URI starts with
http://localhost:*
, we do not validate the port - Parse the expected and actual URL via phps
URL
into URL parts -
if subdomains are allowed:
-
if the hostnames of given and expected do not match
- return
false
ifstrcmp($expectedUrl->hostname, \str_replace(\explode('.', $actualUrl->hostname)[0] . '.', '', $actualUrl->hostname)) !== 0
- return
-
if the hostnames of given and expected do not match
- else compare hostnames
- compare ports if we need to
- compare pathname
- compare parameters
- return true if all comparisons were true
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:
-
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
-
Click "Authorize"
-
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. Withclient 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:
- Don't give up. I spent weeks looking at the codebase without finding anything. I was about to give up but then I found this bug.
- Understand the Application. Don't fixate on finding bugs but try to understand the application. Bugs will naturally follow.
- Follow the user input. Can't break what you can't reach.
- Get comfortable. Spending some extra time for nice logging setups and scripts will pay off when working longer on a target.
Timeline
Everything went really smoothly, shoutout to the awesome ownCloud team!
- August 15, 2023 : I submit the report to Hackerone
- August 30, 2023 : A fix gets supplied from the vendor
- August 31, 2023 : New public release of the OAuth 2.0 module
- September 12, 2023 : I receive a bounty