On November 21th 2023, Owncloud released a new version patching two vulnerabilities (1 and 2) we reported a few weeks before. The vulnerabilities were assigned CVE-2023-49103 and CVE-2023-49105.
Note: We performed the security assessment on dockerized Owncloud 10.12.2.
One of the advisories mentioned a PHPinfo bug which boasted a CVSS of 10 out of 10. Indeed, on docker installs, if one could reach the PHPinfo page, they'd get access to every environment variable, and juicy secrets such as the username and password of the ownCloud administrator. As soon as it came up, it received lots of attention ~and an equal number of bad takes~.
Although reaching the file directly yields a redirect, appending anything that looks like a resource allow access:
http://docker.local/apps/graphapi/vendor/microsoft/microsoft-graph/tests/GetPhpInfo.php/a.css
With this horror out of the way, let's talk about the vulnerability that should have gotten the attention instead.
This bug, which is not related to docker, got a CVSS of 9.8. It affects every single ownCloud from version 10.6.0 to version 10.13.0. Regrettably, ownCloud's advisory is not precise enough, and only describes part of the impact for unauthenticated users.
The real impact is twofold:
Let's first start with the unauthenticated attack.
When issuing requests to some parts of the website, including the WEBDAV and CALDAV, users can authenticate by providing a username and a signature. The signature is computed from a user-specific key and elements from the HTTP request, such as the GET parameters, the HTTP method, etc. Sadly, by default, users do not have a key set. Their signing key, in this case, defaults to a blank string. As a result, an unauthenticated attacker can impersonate any user if they know their username.
Accessing WEBDAV as an attacker has incredible potential: one could read, create, modify, or delete any file a user possesses on the server. Someone said ransomware?
Even scarier: whenever you upload files of certain types (images, for instance), a preview gets generated. For specific file formats, ownCloud uses ImageMagick to generate said preview. If the library is not up to date, an attacker will get remote code execution.
However, if Imagemagick is not vulnerable, and you already have an account, there is another way to get RCE: escalating your privileges to full admin.
Sadly for attackers, user settings and the administration panel cannot be accessed using signed URLs. To understand why, let's check the code that handles the authentication through this mean:
# /apps/dav/lib/Connector/Sabre/Auth.php
$verifier = new Verifier($request, $this->config);
if ($verifier->isSignedRequest()) {
if (!$verifier->signedRequestIsValid()) {
return [false, 'Invalid url signature'];
}
// TODO: setup session ???
$urlCredential = $verifier->getUrlCredential();
$user = \OC::$server->getUserManager()->get($urlCredential);
if ($user === null) {
$message = \OC::$server->getL10N('dav')->t('User unknown');
throw new LoginException($message);
}
if (!$user->isEnabled()) {
$message = \OC::$server->getL10N('dav')->t('User disabled');
throw new LoginException($message);
}
$this->userSession->setUser($user); // <--- here
\OC_Util::setupFS($urlCredential);
$this->session->close();
return [true, $this->principalPrefix . $urlCredential];
}
If the signature is valid, Session::setUser()
gets called:
public function setUser($user) {
if ($user === null) {
$this->session->remove('user_id');
} else {
$this->session->set('user_id', $user->getUID());
}
$this->activeUser = $user;
}
user_id
is the only session variable that gets set when authenticating using a signed URL. It stores the name of the logged-in user.
Now, if we try to reach a "standard" page of the website, for instance user settings, the authentication is checked like so:
if (\OC::$server->getUserSession()) {
$request = \OC::$server->getRequest();
$session = \OC::$server->getUserSession();
$davUser = \OC::$server->getUserSession()->getSession()->get('AUTHENTICATED_TO_DAV_BACKEND');
if ($davUser === null) {
$session->validateSession();
} else {
...
}
}
Since AUTHENTICATED_TO_DAV_BACKEND
is not set in our case, we end up in validateSession()
, which checks that the session has a valid token. Since no token is set, we are logged out: we can't access "standard pages" using only this bug.
However, we could look to elevate our privileges by logging in as a standard user, and then spoofing our user_id
to admin
: we'd keep the old session variables (such as AUTHENTICATED_TO_DAV_BACKEND
), and would just change our ID.
Sadly, further down the authentication process, verifyAuthHeaders()
gets called. It checks that the user is properly authenticated.
class Session implements IUserSession, Emitter {
...
public function verifyAuthHeaders($request) {
$shallLogout = false;
try {
...
foreach ($this->getAuthModules(true) as $module) {
$user = $module->auth($request); # [1]
if ($user !== null) {
if ($this->isLoggedIn() && $this->getUser()->getUID() !== $user->getUID()) { // [2]
$shallLogout = true;
break;
}
...
}
}
} catch (Exception $ex) {
$shallLogout = true;
}
if ($shallLogout) { # [3]
// the session is bad -> kill it
$this->logout();
return false;
}
return true;
}
}
If an authentication module is able to authenticate a user [1], ownCloud makes sure that the user returned by the module matches the one stored in the user_id
session key [2]. If it does not, we're logged out [3].
The only way to bypass this check is to make the auth()
method of the module that authenticates us, TokenAuthModule
, return null
, despite having a valid session.
class TokenAuthModule implements IAuthModule {
...
public function auth(IRequest $request) {
...
$dbToken = $this->getToken($request, $token); # [1]
if ($dbToken === null) {
return null;
}
...
$uid = $dbToken->getUID();
return $this->manager->get($uid);
}
private function getToken(IRequest $request, &$token) {
$authHeader = $request->getHeader('Authorization'); # [2]
if ($authHeader === null || \strpos($authHeader, 'token ') === false) {
// No auth header, let's try session id
try {
$token = $this->session->getId();
} catch (SessionNotAvailableException $ex) {
return null;
}
} else {
$token = \substr($authHeader, 6);
}
try {
return $this->tokenProvider->getToken($token);
} catch (InvalidTokenException $ex) {
$token = null;
return null;
}
}
}
We therefore need to make getToken()
return NULL [1]. Luckily, the latter checks whether a token is present in the Authorization
header before checking if a valid session is present [2]. By providing an incorrect token through this header, we can make TokenAuthModule::auth()
return NULL despite being logged in, and thus bypass the username check.
The resulting privilege escalation procedure is as so:
user_id
Authorization: token thisisnotavalidtoken
This allows you to get from a normal user to the admin account. From there, there are various ways to get remote code execution. These are left as an exercise to the reader.
CVE-2023-49105 allows you to either get complete access to the files of any user (and potentially, get RCE), or if you already have an account, escalate your privileges to admin, paving the way for remote code execution. The other, CVE-2023-49103, is a PHPinfo.
The exploits are available here.
This video shows how an unauthenticated attacker gets access to the files of any user.
Ambionics is an entity of Lexfo, and we're hiring! To learn more about job opportunities, do not hesitate to contact us at [email protected]. We're a french-speaking company, so we expect candidates to be fluent in our beautiful language.