Malicious homeserver can trick Element/Schildichat into revealing links in E2EE Rooms

The Desktop and Web versions of the popular Matrix client Element, as well as its fork Schildichat, support link previews. These can also be activated inside of end-to-end encrypted chats.
The problem arises from its implementation: The setting is stored insecurely on the homeserver and URLs are sent to the homeserver for link preview generation.
This article is the a full disclosure on this issue.

What does this mean to me, an Element/Schildichat user?

Update your clients! Should you be stuck on an older version however (due to using a debian-based distribution for example), read on.

If you need to share a link that can’t be disclosed to unrelated third parties (such as links to personal info, trade secrets, intimate photos, what have you), you probably want to use a file sharing service with narrow permissions. Many cloud hosters let you share files with individual other users or groups, as an alternative to only requiring a link to be known.

Failing that, other file hosters like proton drive or mega store a cryptographic key in the part of the link that I verified is not transferred to the server.

Failing that yet again, you may want to avoid using element web or desktop on homeservers you do not trust. This applies equally to other room participants.

To be clear, this issue only affects element web and element desktop, and any of their forks. I have however not verified if Element Android, Element iOS, or Element X are affected by similar issues.

Timeline

  • 2022-08-17: I discovered this issue.
  • 2022-08-18: I constructed a first PoC and ran it against myself. I then sent an encrypted report to security@matrix.org reporting this issue.
  • around 2024-05-15: I took a look again at security issues known to me in matrix. I have not received any response from the security team at this point, however this may have been an e-mail delivery failure.
  • 2024-05-18: I sent security@matrix.org a second report
  • 2024-07-13: I announced the disclosure date to security@matrix.org. I also contacted the developers of Schildichat about this issue. They promptly responded, although said it was “out of scope”.
  • 2024-07-16: The matrix.org security team has responded to an earlier version of this blog post and are working on implementing the hotfix I have mentioned below.
  • 2024-07-31: The security team announced a fix for this issue.
  • 2024-08-06: The fix goes live.
  • 2024-08-18: This article goes live.

The Discovery

At the time, I was actively using the Element client (or rather, a slightly modified version of it) to chat with others online. One thing that annoyed me was the lack of link previews in end-to-end encrypted chats.
A quick look in the room settings did reveal to me that you could, in fact, enable link previews inside of end-to-end encrypted rooms. Enabling them for all rooms manually would have been a gigantic chore however, so I decided to look deeper.

How Element stores user settings

Element has a complex multi-layered system for determining user settings. The important takeaway here is that settings can be stored locally, in the account data stored on the server, or in a room’s state.[1]

The local settings are stored in the LocalStorage property mx_local_settings. For server-stored settings, it uses the key im.vector.web.settings. In either case it’s a loosely typed json blob where a key not being present corresponds to the setting not being set at that level. Here is a formatted example of such a settings blob:

{
 "language":"en",
 "theme":"custom-Catppuccin Mocha",
 "deviceNotificationsEnabled":true,
 "showHiddenEventsInTimeline":true
}

Certain settings only work in specific configuration layers. All of the keys here for example only work on the device settings layer[2], not on any server-stored layer.

To get a feel for which settings exist and on which layers they work, I’d look at settings/Settings.tsx of matrix-react-sdk which, while not a complete list, is all we need to care about for this article.

How to not store security settings

The perceptive among you may have noticed a curious setting in the Settings.tsx I have linked, named urlPreviewsEnabled_e2ee. Indeed it is the setting we are looking for. The settings is defined in the same way as any other setting in the list, and only supported on the “room device” and “room account” layers. We also know the setting applies to multiple devices after being set on one. As such it stands to reason that the setting is only set on the “room account” layer.

Given what we know about the settings mechanism so far, enabling link previews in an e2ee room should create or update an account data entry for the current room with the state key im.vector.web.settings.
When I enable the setting in an end to end encrypted room and check the room account data with the built-in devtools I see the following entry appear:

{
  "type": "im.vector.web.settings",
  "content": {
    "urlPreviewsEnabled_e2ee": true
  }
}

Bingo!

Sidetrack: How is the preview generated?

The settings description reads:

When someone puts a URL in their message, a URL preview can be shown to give more information about that link such as the title, description, and an image from the website.
In encrypted rooms, like this one, URL previews are disabled by default to ensure that your homeserver (where the previews are generated) cannot gather information about links you see in this room.
Enable URL previews for this room (only affects you) ☑️

Of course we don’t have to take the description’s word for it, you can also see for yourself in the network tab of the browser devtools:
A screenshot of chrome’s devtools, showing a successful request to a “preview_url” route with a censored ink as the argument

Charlotte Raccoon sitting in front of a laptop wearing sunglasses, typing furiously.

Analyzing the censor bars with the “undo” tool, I discovered that this is a preview request for this article by Soatok: Issues in Matrix’s Olm Library — Dhole Moments. It even happens to be about bad matrix security as well! What a coincidence!

Thankfully not the whole URL is revealed. Specifically it doesn’t reveal the part after the # symbol, which in some file hosts is used as an encryption key for the file. Also it cannot circumvent authentication the site requires.

The proof of concept

Alice and Bob are communicating on Matrix in an end-to-end encrypted chat. Alice uses a homeserver that Mallory has privileged access to. Mallory extracts a valid access token out of the account database, or by monitoring the traffic to the server.
Mallory then uses this access token to send a PUT request to https://mallory.example/_matrix/client/v3/user/%40eve%3Amallory.example/rooms/!e2eechat%3Abob.example/account_data/im.vector.web.settings with the body

{"urlPreviewsEnabled_e2ee": true}

Alice then accesses the chat room with Bob. Bob has sent a private link that shouldn’t be shared with any third party, including Mallory. Alice’s client receives information about how this settings value has changed, and then transmits the link to the media preview endpoint that is controlled by Mallory.

Original PoC

I only discovered this far simpler and elegant PoC while writing this writeup, the original PoC that I sent to developers involved this SQL query:

INSERT INTO room_account_data (user_id, room_id, account_data_type,  
stream_id, content) VALUES ('@alice:mallory.example',  
'!e2eeroom:bob.example', 'im.vector.web.settings', 0,  
'{"urlPreviewsEnabled_e2ee":true}');  

Which I ran on my server to globally enable URL previews in all chatrooms without needing to do this. There’s several downsides to this, namely that the cache needs to be cleared as existing clients never get notified about the state change. But since I discovered this far simpler PoC this actually made this issue more problematic.

Question of Privilege

On a typical cloud-hosted homeserver that routes their traffic through Cloudflare, there’s several parties that can perform this exact exploit:

  1. The admins of the server (duh)
  2. The cloud platform provider, especially without encryption at rest[3]
  3. Cloudflare
  4. The government compelling 1-3 to extract or intercept the data.

The government compelling cloud platform providers to add TLS interception of chat providers has been discovered and reported by jabber.ru administrators last year. It is therefore not out of the question that governments would attempt to get matrix servers wiretapped in a similar manner.

Scope of Matrix’s security commitments

The thread model of Matrix is a bit unclear on whether a malicious own homeserver is part of the threat model. While it does anticipate malicious homeservers, many of the threat model items are not addressed in the case that the own homeserver is malicious.

I believe it should be part of the threat model, as the use of end-to-end encryption implies a lack of trust in the intermediates between your device and your recipient’s device, especially in the future.

Matrix itself indirectly addresses the malicious homeserver case in its multi-device support, since as part of the verification process you have to perform an out-of-band verification procedure (through a list of emoji or by scaninng a qrcode with a logged-in device), or by entering a trusted “security key”. In either case, a malicious homeserver would not be able to insert itself in the middle of a new session to decrypt messages.[4]

In an email, the security team has clarified that this falls under the Matrix threat model.

Hotfix

diff --git a/src/components/views/room_settings/UrlPreviewSettings.tsx b/src/components/views/room_settings/UrlPreviewSettings.tsx
index b2b4c553f0..e2b563510e 100644
--- a/src/components/views/room_settings/UrlPreviewSettings.tsx
+++ b/src/components/views/room_settings/UrlPreviewSettings.tsx
@@ -101,7 +101,7 @@ export default class UrlPreviewSettings extends React.Component<IProps> {
             (
                 <SettingsFlag
                     name={isEncrypted ? "urlPreviewsEnabled_e2ee" : "urlPreviewsEnabled"}
-                    level={SettingLevel.ROOM_ACCOUNT}
+                    level={isEncrypted ? SettingsLevel.ROOM_DEVICE : SettingLevel.ROOM_ACCOUNT}
                     roomId={roomId}
                 />
             );
diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx
index 360cd7ab69..cf31aa1896 100644
--- a/src/settings/Settings.tsx
+++ b/src/settings/Settings.tsx
@@ -900,7 +900,7 @@ export const SETTINGS: { [setting: string]: ISetting } = {
         controller: new UIFeatureController(UIFeature.URLPreviews),
     },
     "urlPreviewsEnabled_e2ee": {
-        supportedLevels: [SettingLevel.ROOM_DEVICE, SettingLevel.ROOM_ACCOUNT],
+        supportedLevels: [SettingLevel.ROOM_DEVICE],
         displayName: {
             "room-account": _td("settings|inline_url_previews_room_account"),
         },

This prevents matrix-react-sdk from reading the settings value from the room account data, and instead only uses the on-device value. It also makes it so that the setting is stored on the device instead of the account data.

Of course this only narrowly changes this one setting. I have not checked other settings if they have the same problem (affecting security while being manipulable by the server)

An Amateur’s Attempt at actually fixing the Issue

A plush of Charlotte Raccoon with a clueless look looking at the reader with tongue sticking out.

This hasn’t been vetted by a professional cryptographer! If you implement this, prod will set on fire.
If you are looking to implement this, please have this design reviewed by a cryptographer first. I was also informed that cryptographers might be more willing to do this if you pay them for their expertise and time.

The security requirements for secure settings synchronization are, in my opinion, as follows:

  1. Integrity of the settings data. The settings blob must have originated from a trusted device, signed by a key that is signed with a trusted device key.
  2. Rollback protection. Existing sessions can’t accept settings updates that constitute a rollback to a previous settings state.
  3. Confidentiality. The server can’t be allowed to access the unencrypted settings data.

With these requirements i thought of the following:

Settings Sync private key

This X25519 private key will be used for decrypting synchronized settings values. It is shared by all authorized clients and stored as an SSSS Secret in the account data key rs.chir.matrix.settingssync.v1.private-key.[kid].
kid is a k4.pid PASERK Key ID, corresponding to the public key of this encrypted private key.
An example key name is rs.chir.matrix.settingssync.v1.private-key.k4.pid.9ShR3xc8-qVJ_di0tc9nx0IDIqbatdeM2mqLFBJsKRHs.

Settings Sync public key

The public key is a v4.public PASETO Token stored in the account data key rs.chir.matrix.settingssync.v1.public-key.
The account data entry is a JSON Object containing the only key content with the PASETO token as its value.

The token contains the following claims:

Claim NameDescriptionExample
wpkThe public key for settings synchronization, serialized as a k4.public PASERK keyk4.public.cHFyc3R1dnd4eXp7fH1-f4CBgoOEhYaHiImKi4yNjo8
iatThe device time at which this token got generated.2024-07-15T08:41:24+00:00
nbfThe time at which this token becomes valid. In doubt this should be the same as iat2024-07-15T08:41:24+00:00
expThe time at which this token becomes invalid. This value is NOT RECOMMENDED to be later than 90 days past nbf2024-10-13T08:41:24+00:00
trustList of trusted k4.public PASERK keys["k4.public.cHFyc3R1dnd4eXp7fH1-f4CBgoOEhYaHiImKi4yNjo8"]
decomThe decommissioned k4.public PASERK key. It is still trusted, but will be removed when the settings blob is next changed.null
The token SHALL have an implicit assertion that is the k4.pid PASERK key ID of the currently trusted master key.
The client SHALL reject public key tokens that are signed by an unknown, outdated, or untrusted master signing key.

Public Key Updates

The client SHALL remember the most recent version of the rs.chir.matrix.settingssync.v1.public-key token. Upon modification, the following checks MUST be performed:

  1. The new value MUST be a valid PASETO Token, following all verification procedures specified.
  2. The iat claim MUST be strictly greater than the iat claim of the previous value.
  3. The iat claim MAY NOT be greater than 1 hour ahead of the current system time.

In case these checks fail, the client MUST NOT accept the update and in case of iat decreasing MAY reset it to the previous value.

Settings File

Big hole here, I have thought about it quite a bit and I can’t cleanly fit them together with the parts I have set up so far.

The idea is that the settings entry is encrypted using authenticated asymmetric encryption, basically like libsodium’s crypto_box apis. PASETO and PASERK don’t offer a mode like this right now, and I’m not sure if there are plans to include them.

As a poor substitute, I was thinking about just nesting v4.local and v4.public tokens in each other, but this is definitely the incorrect way to do this.

The encryption would involve the secret key of a device, as listed in the trust list of the synchronization public key, and the public synchronization key. Another client would be able to verify that a trusted client signed this settings structure.

The settings file would be structured something like this:

{
 "iat": "2024-07-15T08:41:24+00:00",
 "im.vector": {
  "global": {
   "language": "en"
  },
  "!e2eechat:bob.example": {
   "urlPreviewsEnabled_e2ee": true
  }
 }
}

The important thing to note here is that this structure would be shared across clients and also across rooms to avoid profiling by the server.

Updating the Settings Blob

There is no way to atomically modify this blob. As such an application SHOULD NOT make automated changes to the settings blob. Intentional changes by the user ought to be rare and only made from one device at a time, as such it is unlikely that many collisions will occur.

When updating the settings blob, the application MAY remove entries for rooms that the user is no longer part of. it MUST update the iat field to the current timestamp.

When notified about a change to the settings blob, the same checks to the iat value MUST be performed.

Key Rotation

The synchronization key has a built-in expiry date. It might be worthwhile to rotate it once in a while and you are able to do so freely. Applications are RECOMMENDED to rotate the key within 7 days of expiry, and are REQUIRED to do so, if they before terminating a session.

Login procedure

After cross-signing is set up, the application receives access to the Master Signing Key (MSK). At this point it can create a new public key blob with the same public key and a freshly generated Ed25519 key added to the list of trusted keys. After that the client application is able to verify the settings file and apply any settings stored within.

Logout procedure

Create a new public key with the device key removed from the trusted list. If the current settings blob is signed by the device key, the device key is added as the decom claim of the public key.

The problem of clock synchronization

The client’s clock may not be 100% accurate. This shouldn’t be an issue if the clock is only off by a few minutes, but in my experience it can be off by far more than that (specifically on Windows for Linux users). To avoid spurious errors when synchronizing settings, clients are RECOMMENDED to verify the local system clock with server event timestamps and turn off settings synchronization temporarily if they differ by more than ~10 minutes.

Why did I not publish this sooner?

I did mention that I found and reported this issue in 2022, but why did I resend the report two years later and only create a writeup about this just now?

Simple: GPG

The first report I sent was encrypted using GPG, but something went wrong and I couldn’t decrypt my own email. Additionally I did not receive a response from the security team. As such I just assumed that they never received it and gave the team the benefit of the doubt.

In fact there does seem to be delivery issues from matrix.org to my zoho mailbox, as I did not receive a confirmation until I sent them a link to this writeup.

Closing Remarks

This is my first long-form vulnerability writeup, and there might be more coming in the future.

The blog currently has no comment functionality, so in the meantime consider leaving a content on the fediverse.

Special thanks to Soatok, without whom I would not have created this writeup.

If you liked this article and want to buy me a coffee, I have a Ko-Fi.


  1. 1.There is actually 8 of these layers, and the order in which they apply can vary per setting
  2. 2.The language setting also works as a config.json entry, which an admin can deploy on a hosted copy of element-web.
  3. 3.There have actually been reports of Linode directly modifying the file system of a VM automatically, leading to corruption in certain nixos configurations.
  4. 4.Well, assuming you follow the steps shown on screen. You do that, right?