1. Background

The Steam Chat client is a particularly interesting system to attack because it's built using a modern set of technologies with strong security characteristics.

It's built on React, which has some of the strongest security characteristics of any modern Javascript application framework, and avoids use of the unsafe dangerously family of functions well.

Content Security Policy is deployed, although with unsafe-inline. This is a minor inconvenience but an interesting step forward.

The Chat client, unlike most desktop applications using web technologies runs in a custom, highly locked down build of Chrome Embedded Framework. In most Electron-like systems, privileged access is granted to the Javascript VM via the window object. The chat client takes the interesting and potentially much more secure approach of running at, in essence the privilege of a regular webpage allowing privileged actions only through PostMessage and a loopback WebSocket that communicates with the parent process.

The WebSocket carries a custom binary protocol that is very difficult to dissect over the wire and it's leveraged such that through some common bug I have yet to find root cause on Chrome Dev Tools crashes if breakpoints fire as the page loads (I think it's some kind of race that happens when WebWorkers are active and the Javascript assets are heavy).

The trend toward DOM heavy applications is interesting to me, as many in the security industry still rely heavily on HTTP proxies that aren't able to accurately reflect application state in these cases.

2. Techniques

2a. React Security Gotchas

Since the Steam Chat client is built on React, there's far fewer ways XSS is possible. There are a few ways I look for particularly:

React does not special-case encode the attributes of any tags. Attributes with DOM manipulation properties are dangerous.

It's very common to see <a href> attributes generated from user input where javascript: input URIs, when clicked will result in XSS. Hand-rolled countermeasures to javascript: URIs are still poor, and often employ URL parsers that are not intended to be used defensively.

It's not uncommon to see style tags generated by string concatenation that include user input where an image, for example as a background URL can be injected to IP address information and tokens from the URL via Referer header. CSS sanitization isn't really a thing, even in the best contextually-aware XSS libraries. CSS-based attacks on the DOM that use selector[value=string] or that define fonts that make HTTP requests for each character to conditionally load resources and exfiltrate data are almost entirely unknown outside infosec circles.

React doesn't attempt to provide hardened versions of other unsafe Javacript functionality, or disable them. It's common to see React applications use document.location = xxx to change the location of the browser which is also vulnerable to Javascript URI injection.

In the same vein, requests to HTTP APIs don't gain enhanced security from React. It's still common to request data using user input spliced into a URL that's not encoded properly. React developers love to use fancy REST syntax to generate request paths like "/user/" + encodeURIComponent(username) + "profile", even though to my knowledge there's no safe way to encode a URL path in vanilla Javascript. Even where ../ is encoded to ..%2F, virtually all web servers ignore the lexical difference between %2F and /.

Some protocols, like OEMBED are just unsafe by design by returning HTML. To use these APIs, React apps including this one have to use dangerouslySetInnerHTML. It's also not uncommon to see React refs used to get a handle on the generated element and innerHTML called directly, which can evade testers grepping for 'unsafe'.

Protocols that return HTML, and even those that don't still routinely return Content-Type: text/html, which means that if a victim is navigated to an API result for example by submitting an HTML form even if the client would handle the output safely, the browser won't if an XSS exists.

2b. Advanced DevTools Features

I wanted to throw in a few DevTools features that my infosec friends don't use enough that I found instrumental to finding this bug. Hackerone doesn't support uploading images to summaries, so I'll link them instead.

2b I. The Console Drawer

https://zemn.me/misc/devtools_pullout.png
When you press escape with devtools open, a drawer pulls up from the bottom. From here, you can get access to some insanely powerful features while still browsing source code or network logs.

https://zemn.me/misc/devtools_pullout_console.png
The most insane feature is the pull-out console. When a breakpoint is fired and execution is paused, you can execute any code you want here and it will execute in the context of the current line the debugger is on. This is absolutely indispensable for modifying and inspecting code that operates at many levels of abstraction.

2b II. Code Search, Pretty Print

https://zemn.me/misc/devtools_search_steam.png
DevTools contains an extremely powerful search feature that searches every asset loaded into the current window. It can be accessed by the bottom drawer (click the three dots). If you find an element in the DOM and wonder how it's generated, you can search for it here and jump to where it's mentioned.

Once there, you'll likely want to hit the pretty print button {} in the bottom left and ctrl-f for anything in that file you might find interesting.

2b IV. Breaking on Events

It's super common that you have some event you expect to happen, like an XHR or a postMessage but you don't know where the handler is defined. Do not worry! If you scroll to the bottom of the rightmost panel of Sources you can set breakpoints for XHR / fetch and event listeners.

2b V. The Call Stack

Let's say you know a user action calls an XHR eventually, but you want to find the high-level construction of the XHR request. If you set an XHR / fetch breakpoint you'll end up breaking deep in a library of some kind usually that provides little context.

You can actually now step back through the call stack by clicking on each call, viewing code, variables in scope and which file it's in as you go until you find something that looks specially written for this application. I find this indispensable for escaping library call hell in modern minified applications.

2b VI. Injecting Code

https://zemn.me/misc/devtools_conditional_breakpoint.png
While chrome devtools does have the ability to edit the code of Javascript in webpages on the fly, this doesn't work for minified code, because you cannot do this on pretty-printed files. You can, however use a special trick.

If you right-click on a line number you can use 'insert conditional breakpoint'. Conditional breakpoints are fully featured javascript that breaks when the statement is true. console.log always returns undefined, so if you want to inspect several values as a program runs, you can inject console.log calls to have their values printed to console, something that isn't possible with the similar but less effective 'watch' feature.

In systems that have a heartbeat, a breakpoint will often cause a disconnection. Using conditional breakpoints can allow you to add code without stopping execution and using the console.

2b VII. Network Search

https://zemn.me/misc/network_search.png
Sure you're getting some data from somewhere, but have no idea where? You can click the magnifying glass icon in the network panel to search full requests, responses and their headers.

2b VIII. Network Filter Expressions

In applications that send a lot of XHR requests, especially those that regularly poll it can quickly become impossible to navigate all the requests in the network panel. You can use filter expressions to narrow down requests to those that you might find important.

2b IX. Copy as Curl

There's a ton of things you can do with devtools, but you generally can't bypass web security primitives like same-origin policy, and it's not at all easy to remake and customize requests. You can click on any request in the network panel and go to 'copy as curl' to get an exact replication of the request you can iterate on to mess with the request form.

3. Approach

In a typical, last-generation chat application, security issues are most likely to surface where HTML is generated from input text. After all, not only is parsing language a super hard problem but it's especially hard to provide the power of HTML features to the user without inadvertently allowing them control over the browser by manipulating these features.

3a. Recon

My first realisation was that the app deployed inside the Steam desktop app was the same as the online one at https://steamcommunity.com/chat, which made it much easier to inject DevTools into the testing flow.

After that, I spent a little time using chat with DevTools' Network panel open and observed that we weren't getting bombarded with XHR polling. This likely meant we were using a WebSocket. Refreshing the page with the Network panel open (you only see WebSocket connections if you see them open), I browsed to the WS panel and noted that the client was communicating over completely unintelligible binary frames.

Noticing that the chat system supported embedded content which is unbelievably difficult to implement securely, I started doing code searches for 'OEMBED' and other generic embedding systems that are super easy to get XSS on.

At this point, I discovered the application was a React app, and switched to, at least partially using the React Chrome Extension to inspect the DOM. The extension makes it super easy to jump to the code which generates elements, and since React components express all the information they depend on as props (which can be inspected in the extension) I found it much easier to get a handle on application structure.

I did a few searches for dangerously, innerHTML etc, and set breakpoints on all those. I tried to then get those functions to fire by tracing the call stack up. dangerously was only really called for certain 'oembed elements' that could be sent by the chat server.

I set up breakpoints on XHRs & other events that were fired by loading OEMBED content, like YouTube. When these fired, I stepped back through the call stack to reach the function which sanitised user input and sent it to the chat servers via binary WebSocket.

3b. Reaching XSS

This proved to have surprising results. I'd end up with the breakpoints firing twice for each sent message: once when the user makes a send request, and the client writes an assumed server response to the DOM – and a second time when that was overwritten by the actual response the server sent back. These renders could differ significantly under certain circumstances, in particular when OEMBED was used.

If I send a Vimeo link in chat, the client renders that initially as an HTML link when the message is sent. Then, when the server updates the chat room, it'll replace the link with a BBCode (yes, bbcode!) [OEMBED] tag that is essentially just raw HTML.

I immediately jumped at this and, using a breakpoint after the call to sanitize user input of BBCode I tried sending OEMBED tags containing malicious HTML, hoping they would be reflected back by the server. However, the server would completely strip these tags before responding.

After some scanning of the code, I located the table that matched BBCode tags with their corresponding React components and sent every BBCode tag I saw with mixed results. Most tags were stripped out by the server, and many tags clearly needed attribute parameters [imgur alt=] etc I didn't know how to use.

I pivoted to trying to log the BBCode representation of all the rich text commands like rolling a random number or showing an image to little success. I discovered a small handful of tags I could send that would otherwise be stripped including [url=xxx], [code], and [image]. Image was heavily locked down and I found it nearly impossible to use it in a malicious way. [code] just safely generated [code] tags, but [url=xxx]was legitimately making links to anywhere, includingjavascript: URI links.

I immediately opened this report, saying that I'd reached XSS and this usually results in RCE in such clients. It turned out to be a lot more complex than that.

Sure, I had reached XSS and I could really mess with web browser users but the javascript: URI was found to actually be smartly stripped or not honoured by the custom Steam client browser. I started trying other approaches, knowing that I could form any URL.

3c. The Steam URI

Based on prior art, I started by using steam:// URIs, which are unique to the steam client and can do a lot of bad stuff. Many years ago, in-browser steam:// URIs were used to reach Remote Code Execution by installing, and then running a game and piping its carefully crafted logs to the startup folder.

I had minor success. In the Chat client, steam:// URIs were executing in a privileged context browsers could not normally access. After the steam:// security issues, valve added a prompt for if the URL was opened by an external system – if I send you steam://open/440 in a web browser, it'll cause steam to confirm that you really want to open that game, but these links in the Steam chat client would cause no such confirmation.

I played around with making links that variously opened games on people's computers, reset their configurations, closed steam or opened systems on it like the steam console by running steam://-console or something. I don't remember what the actual URL is :p

3d. Abusing OEMBED

After much trying and headdesking I changed tack again and tried to target OEMBED specifically. Secure OEMBED systems are hard as hell to implement, so people usually just use a service like Embedly. Embedly's security comes from good use of iframe sandboxing, and whitelists. However, since we're not in a normal browser, being embedded gives you different privileges. If you're embedded in an Electron desktop app browser and the iframe isn't in a special <WebView>, one can still access all the dangerous electron APIs through our iframe's window object even if the iframe would be safe in a browser.

To abuse this, though I'd need to either (1) get whitelisted by embedly (impossible) or (2) find a javascript injection in a whitelisted embedly embed. In a stroke of luck, I remembered that codepen.io is whitelisted by embedly and codepen is, well literally Javascript injection as a service.

In the past, working in such contexts was something I literally did by injecting the script for FireBug, but this is usually a pain because stuff doesn't work quite right. @mandatory recommended I use a remote chrome console. He recommended me some software i completely forget the name of that lets you use a chrome dev tools remote console by injecting some scripts.

3e. Remote Console

Once I had loaded in Steam my codepen.io app with my remote console, I started looking for idiosyncrasies of the Steam Web Helper context. I started by dumping Object.keys(window) and running a diff off it against a normal Chrome browser. This came up with a few things, most of which were useless. I could hook an event for when some styles loaded on the page and other stuff that's not usually possible in the browser, but not really a security issue.

Since the Chat client communicates with a parent window to perform privileged operations like pulling the friends list, I tried doing window.top.postMessage() with the postMessage commands it used to try to coax the client into doing something bad. It seems like the sandboxed context produced by the OEMBED system prevented access to window.top.

At this point, I'd started using the remote console to rapidly test the effects of steam:// URIs by issuing open("steam://xxx"). I didn't find out much more than I had before, but it pushed me to start dissecting the Steam Web Helper Binary a little bit more. I started by running a binary grep in the Steam folder for Steam protocol URIs I knew existed, then I used vim to search for string tables containing these. These led me to a couple of interesting undocumented URIs particular to the Steam Web Helper.

3f. Through an Open Window

Two particularly interesting URIs included a Chrome Dev Tools URI I hoped might have some level of privilege and the URI steam://openexternalforpid which appeared as steam://openexternalforpid/%s/%s in the application binary. The Chrome Dev Tools URI did weird stuff. When I opened it, it opened a single pixel wide black window as many times as I wanted. From the string of openexternalforpid it was clear that it required two parameters, but I was at a total loss as to how to work out what they were.

After much guessing myself, I passed the openexternalforpid stuff onto my friend @XMPPWocky an extremely capable binary reverse-engineer whom I've found serious Steam bugs with before, but he found it difficult to make much of it with the little time he could spare from saving the world or whatever at Symantec.

I had a thought about the context in which this Javascript was executed. Opening a window is often specially implemented for an embedded browser, and it's pretty frequent that an opened window has different privileges to the opener window. I tried grabbing open('steam-chrome-dev-tools://something').contentWindow or whatever the URL was to see if I could grab a privilaged devtools window. With interesting results.

New windows did actually have special functions not normally accessible. I could read where the user's cursor was, maximise the window, minimise it and a bunch of other junk none of which brought me closer to remote code execution after hours of testing.

3g. Beyond Protocol

In my testing of the Steam:// protocol I noticed something interesting: whenever I made a typo, Windows would open up a dialog saying, for example that it did not know how to open the file type sream: or something. That was interesting to me.

Custom protocols like Steam are implemented by a ton of different pieces of software. They're usually Very Bad, and their security relies on the browser prompting to open this application. Back when everyone used Skype, I had some amusing fun sending people skype://call urls which opened calls to the loopback skype number that just echoes what you say.

But like, people don't actually have Skype these days so I was wondering what other custom protocols might be implemented on my system I could leverage. This turned out to be an absolutely fascinating rabbit hole into windows internals. I spent hours trawling forums and reference documents describing how to add protocols and what protocols were registered by windows. Turns out, there's quite a few. Windows even has custom protocols for opening maps to places for the user to look at.

Then I went deeper. The custom protocols are actually implemented in the Windows Registry in HKEY_CURRENT_CLASSES or something. It's truly fascinating how telling the structure of this system is. Not only is in this directory every protocol (like, for example, what opens when you open an http:// link on Windows), but this folder actually contains the file type associations for every filetype in windows, like how Notepad opens if you open a .txt file.

The folders for http: protocols and others are sitting right by the folders for .png, and they follow the same syntax, describing how arguments get turned into a program invocation. In complete disbelief, I pressed win+R and typed .txt:hello ... and it opened Notepad. Custom protocols and filetype associations are the same thing.

After that, I scoured the entire class for stuff I might find useful in smarter ways each time. I made a beeline for the .bat filetype which runs arbitrary Windows commands and tried it in Run. It crashed Windows Explorer.

I got smarter and started searching for filetypes that would take the 0th argument and open it with the program, because from reading how protocols opened it was clear that if the 0th argument was called $0, protocols like http:// are essentially open_webbrowser.exe $0 where $0 would be the URL.

I came across some truly bizarre sights I wish to share with you aall. There's a calculator protocol. I don't know why, but there is. If you wanna be fancy and show popping a calculator, you can literally make a link to calculator: like <a href="calculator:">click me!</a>, which, when clicked will open the calculator on the victim's computer.

I spent absolutely hours trawling this damn database and found a few potentially exploitable protocols. One is jarfile:, which executes the jarfile you give it. It's actually the binding for the .jar filetype. Another one, JSEFile: is a windows xp-era system that let you run an HTML page with VB script like a program. Like prehistoric electron or something.

It... wasn't working. The problem is that $0 included the full URI. If I make a link to jarfile:c:/windows/whatever.exe, the actual invocation is like c:/Program Files/Java/Java.exe jarfile:c:/windows/whatever.exe, and well... it tries to find a directory called 'jarfile:c:', which obviously doesn't exist.

I took a break and submitted another ticket, stating that I'd found another interesting method via this means, and I could open any program on their computer, though not with the arguments I might want. I was so sure this was a way to RCE. At the very least I could submit an example that launched calculator.exe, which is what all the cool kids do, right?

Almost immediately I had an epiphany. Directory traversal. If it's looking for a directory called 'jarfile:c:' that doesn't exist, we can inject a ../ to say 'go one directory back' and negate the directory that doesn't exist. This was actually a pretty great success. I could send like jarfile:..\..\..\..\..\..\..\..\Users\Username\Downloads\drive-by-download.jar and actually legitimately run a jar file on the victim's computer. This was simultaneously exciting and disappointing as it meant I couldn't load a jar file remotely. I'd need to get the user to donwload it.

3h. openexternalforpid

This whole time, i had the Steam Console open (opened with steam://console, I think). I wasn't reading it, but it was printing a lot of useful information, like, in particular what steam:// invocations it was running. I tabbed onto the console accidentally and by absolute sheer luck, I saw something. When I sent jarfile: something, the Steam Web Helper was internally sending steam://openexternalforpid/10400/jarfile: something. This is huge.

I immediately switched from all these nonsense custom protocols to invoking openexternalforpid with the magic number 10400 and cmd.exe and guess what? Remote. Code. Execution. Job done.

Because this link form isn't a javascript:// link, it's still honoured by Steam Chat. Either I could send my codepen.io embed or I could send my [link] tag to get remote code execution :)

4. Conclusion

This was fun as all hell and I learned a lot. I always look for bugs where a set of simple mistakes of low severity cascade into one huge bug with critical severity and this is the perfect example of that.

The Steam chat client both sends and receives bbcode format chat messages. These map to HTML elements, and notably the [url] bbcode tag is supported for arbitrary URLs. React has strong XSS mitigations but does not mitigate javascript: URI based XSS.

This is rather difficult to exploit as the client transmits sanitised messages and receives over a binary WebSocket. I've attached a video of executing this XSS, which is persistent.

Impact

I strongly believe an attacker could get remote code execution in Steam via this method. The Steam chat client uses the same codebase as the steam web chat client, and, I imagine does so using electron or some other webview system. These systems all expose functions which allow arbitrary calls to system to allow them to be competitive with e.g. windows forms.

OK. so there's a trivial way to reach RCE using this bug. From my tests, because the Steam Chat Client is run inside the Steam CEF context, any steam:// commands it issues (through uri syntax) are immediately evaluated and trusted (whereas if they're issued from a browser there's a confirmation window) as though they were issued from e.g. the game listing inside the steam client.
You can issue, therefore steam://run/[GAMEID] and it will run any installed game, without confirmation, and with command line parameters

Thanks for the additional details — we will consider the additional impact of `steam://` URLs when determining final report severity. However, I do want to note that launching an installed app isn't enough to qualify as generalized RCE unless you can demonstrate a way to use that functionality to run arbitrary code.
Thanks again for your report. We should have more information soon.

as per my research in https://hackerone.com/reports/411329#activity-3375819 you can use the openexternalforpid protocol i.e. `steam://openexternalforpid/10400/file:///C:/Windows/cmd.exe` to remotely initiate process calls on the victim's PC. I can provide another PoC video of this if needed, but the TL;DR is you can use the previously mentioned bug to send `[url=steam://openexternalforpid/10400/file:///C:/Windows/cmd.exe]click me[/url]` and you win :)