Skip to content

Instantly share code, notes, and snippets.

@meithecatte
Created February 18, 2026 19:00
Show Gist options
  • Select an option

  • Save meithecatte/1472ee7f06b87fce2768e22b39205680 to your computer and use it in GitHub Desktop.

Select an option

Save meithecatte/1472ee7f06b87fce2768e22b39205680 to your computer and use it in GitHub Desktop.
On Soatok's alleged high-severity Vodozemac vulnerability

When building cryptographic systems, you assume attackers have certain capabilities without needing to figure out all the ways they can attain those. "I set my PK to 0, group admin can read the history" is an attack. QED

to which I say: what capabilities does one typically assume then? what about "I set my PK to the group generator, server admin can read the history"?

No, because setting your public key to the generator doesn't result in an all-zero shared secret, and will therefore depend on the secret key of the other participant in the ECDH protocol.

Zero is literally the only failure mode you have to avoid.

well, on the public key of the other participant.

you know, the one that the attacker trivially knows.

Nope!

Clamping, motherfucker.

  1. https://github.com/dalek-cryptography/x25519-dalek/blob/2ddda07333e5028698d8618428c783264ae38b40/src/x25519.rs#L205
  2. https://github.com/dalek-cryptography/curve25519-dalek/blob/61533d75cfc5df3452cbee20181c89ab2e40bccd/curve25519-dalek/src/montgomery.rs#L150-L162
  3. https://github.com/dalek-cryptography/curve25519-dalek/blob/61533d75cfc5df3452cbee20181c89ab2e40bccd/curve25519-dalek/src/scalar.rs#L1425

Well... I don't think that really matters much?

So, here's a PoC. Paralleling Soatok's PoC for the vulnerability being discussed.

diff --git a/src/olm/account/mod.rs b/src/olm/account/mod.rs
index d93e5ba..95a682f 100644
--- a/src/olm/account/mod.rs
+++ b/src/olm/account/mod.rs
@@ -1613,4 +1613,117 @@ mod test {

         assert_eq!(alice.identity_keys(), account.identity_keys());
     }
+
+    /// PoC: Megolm session key exfiltration via X25519 group generator
+    ///
+    /// Attack chain:
+    ///  1. Attacker replaces Bob's published Curve25519 keys with
+    ///     the group generator [9] + [0u8; 31]. Since, as Soatok claims,
+    ///     we should give the attacker this ability to do a proper
+    ///     cryptanalysis.
+    ///  2. Alice creates an outbound Olm session to these keys.
+    ///  3. Alice encrypts the Megolm SessionKey over this Olm session.
+    ///  4. Because Bob's published keys have been replaced, the attacker
+    ///     can decrypt the message with the Megolm key, that was
+    ///     actually meant for Bob.
+    ///  5. And wouldn't you know it, the attacker can now decrypt ALL
+    ///     group messages in the room. zomg.
+    ///
+    /// Just like before, this escalates a single poisoned Olm session
+    /// into full room compromise: every message from every participant
+    /// encrypted under that Megolm session is readable by the attacker.
+    #[cfg(feature = "low-level-api")]
+    #[test]
+    fn if_attacker_magically_sets_pk_to_the_generator_then_shit_breaks() {
+        use crate::cipher::{Cipher, MessageMac};
+        use crate::megolm::{
+            GroupSession, InboundGroupSession,
+            SessionConfig as MegolmSessionConfig, SessionKey,
+        };
+
+        // ── Step 1: Sender creates a Megolm group session ──
+        let mut group_session =
+            GroupSession::new(MegolmSessionConfig::default());
+        let session_key = group_session.session_key();
+        let session_key_bytes = session_key.to_bytes();
+
+        // Encrypt several group messages.
+        let room_msg_1 = group_session.encrypt(
+            "Quarterly earnings: revenue up 40%",
+        );
+        let room_msg_2 = group_session.encrypt(
+            "Confidential: merger target identified",
+        );
+
+        // ── Step 2: Alice encrypts the SessionKey over a poisoned
+        //    Olm channel (attacker replaced Bob's keys with the generator) ──
+        let alice = Account::new();
+        let mut funny_key = [0u8; 32];
+        funny_key[0] = 9;
+        let funny_key = PublicKey::from(funny_key);
+        let mut poisoned_session = alice.create_outbound_session(
+            SessionConfig::version_2(),
+            funny_key,
+            funny_key,
+        );
+        let olm_message = poisoned_session.encrypt(&session_key_bytes);
+        let OlmMessage::PreKey(pre_key) = &olm_message else {
+            unreachable!()
+        };
+
+        // and, let's not forget, Alice has sent the keys out into the world
+        let alice_pubkeys = alice.identity_keys();
+
+        // ── Step 3: Attacker derives the session key ──
+        let attacker = Account::new();
+        let inner = pre_key.message();
+        let identity_key = alice_pubkeys.curve25519;
+        let base_key = pre_key.base_key();
+        let mut merged_secret = Box::new([0u8; 96]);
+        merged_secret[0..32].copy_from_slice(identity_key.as_bytes());
+        merged_secret[32..64].copy_from_slice(base_key.as_bytes());
+        merged_secret[64..96].copy_from_slice(base_key.as_bytes());
+
+        use crate::olm::Session;
+        use crate::olm::shared_secret::RemoteShared3DHSecret;
+        let mut attacker_session = Session::new_remote(
+            SessionConfig::version_2(),
+            RemoteShared3DHSecret(merged_secret),
+            pre_key.message().ratchet_key(),
+            pre_key.session_keys(),
+        );
+
+        // ── Step 4: Attacker decrypts the Olm-encrypted payload ──
+        let decrypted_bytes = attacker_session.decrypt_decoded(&pre_key.message()).unwrap();
+
+        // ── Step 5: Parse stolen Megolm SessionKey ──
+        let stolen_key =
+            SessionKey::from_bytes(&decrypted_bytes).expect(
+                "Decrypted bytes are a valid Megolm SessionKey",
+            );
+
+        // ── Step 6: Decrypt group messages ──
+        let mut inbound = InboundGroupSession::new(
+            &stolen_key,
+            MegolmSessionConfig::default(),
+        );
+
+        let d1 = inbound
+            .decrypt(&room_msg_1)
+            .expect("Attacker decrypts group message 1");
+        let d2 = inbound
+            .decrypt(&room_msg_2)
+            .expect("Attacker decrypts group message 2");
+
+        assert_eq!(
+            String::from_utf8_lossy(&d1.plaintext),
+            "Quarterly earnings: revenue up 40%",
+            "Attacker reads first group message"
+        );
+        assert_eq!(
+            String::from_utf8_lossy(&d2.plaintext),
+            "Confidential: merger target identified",
+            "Attacker reads second group message"
+        );
+    }
 }
diff --git a/src/olm/shared_secret.rs b/src/olm/shared_secret.rs
index ebad928..3aa267c 100644
--- a/src/olm/shared_secret.rs
+++ b/src/olm/shared_secret.rs
@@ -42,7 +42,7 @@ use crate::{Curve25519PublicKey as PublicKey, types::Curve25519SecretKey as Stat
 pub struct Shared3DHSecret(Box<[u8; 96]>);

 #[derive(Zeroize, ZeroizeOnDrop)]
-pub struct RemoteShared3DHSecret(Box<[u8; 96]>);
+pub struct RemoteShared3DHSecret(pub Box<[u8; 96]>);

 fn expand(shared_secret: &[u8; 96]) -> (Box<[u8; 32]>, Box<[u8; 32]>) {
     let hkdf: Hkdf<Sha256> = Hkdf::new(Some(&[0]), shared_secret);

Apply this to vodozemac at commit a4807ce7f8e69e0a512bf6c6904b0d589d06b993 (latest main at the time of writing). Then run the following:

cargo test --features "low-level-api" if_attacker_magically_sets_pk_to_the_generator_then_shit_breaks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment