This is the story of how I found a universal cross-site scripting vector in a browser extension used by over 10 million users.

Introduction

I could immediately tell I would find something as soon as my school rolled out the extension, and once I saw the permissions it requested, I knew I would find something extra spicy.

ClassLink OneClick Extension permissions

the inability to disable the extension and high privileges were motivating factors in this research

What Is This Extension?

This extension is designed to work in tandem with ClassLink LaunchPad which enables a student to select and automatically sign into educational websites. School IT staff load their students' credentials into LaunchPad's admin console; later, when a student clicks a website on LaunchPad, it sends their credentials to the extension which signs them in.

Suspicious Function

While Scrolling through a pretty-printed version of injected.js, part of the code immediately attracted my attention.

suspicious code section

Confusion

Why would this extension need to inject scripts into the DOM? It turns out that this is actually the primary pattern behind the whole extension. There is a bit of JavaScript for each website they integrate with that is responsible for logging a user in. ClassLink LaunchPad connects to over 6,000 websites, so I can understand why the flexibility of running unique JavaScript for each site becomes appealing.

How Can We Exploit This?

It would be possible to exploit the e function if we can control appResponse.pre_auth_script and the window location. This would enable an attacker to inject any script into any page, in other words, universal cross-site scripting.

Backtracking

Following the code backwards, I found that the e function is called from an event listener.

message listener which calls suspicious code

At first this looks trivially exploitable by sending a handle-sso message from a webpage. Unfortunately for an attacker, chrome.runtime.onMessage has protections against this. Here is a section from Message Passing (Chrome Developers).

article section from Chrome Developers

In order to send a message to this listener, the extension would need to have an attacker-controlled domain in their manifest.json, which effectively closes off this route of exploitation.

Sending "handle-sso"

Because externally_connectable is not in manifest.json, we know that handle-sso messages can be only be sent from background.js. Searching for handle-sso in background.js yields this event listener:

sending "handle-sso" message when a new tab is opened

A handle-sso message is sent when a new tab is opened, but the listener is created only after background.js receives an initiate-sso message. This message is sent by the following function in injected.js:

function that sends "initiate-sso" message

This s function takes in a parameter that's passed into the initiate-sso message, so calling s with user-controlled data is equivalent to universal cross-site scripting.

s is called here, but it's behind a regex that validates if the URL is controlled by ClassLink. However, this is not the only place that calls s

regex check before "initiate-sso" send function

The Fundamental Flaw

The s function is also called here:

duplicated from previous code but without the regex check

For whatever reason, there existed a mostly copied and pasted version of the previous code snippet which omits the regex check. This code registers click handler, but only for elements with two magic classes: bg-info and js-uc.

The failure to run the regex in this case is the root cause of this vulnerability. Putting carefully crafted data into the head of the document will allow us to control the parameters of s and, by proxy, get universal cross-site scripting.

Creating a Proof of Concept

Here is the code for a proof of concept:

<html>
	<head>
		<title>ClassLink OneClick UXSS</title>
		<script>
			//appResponse: JSON.parse('{"userauth": [""], "pre_auth_script": "alert(window.location)"}'),
			//gwstokenMd5:{}

			setTimeout(function() {
				const button = document.getElementById("button");

				button.click();
				window.location.href = "https://example.com";
			}, 200);
		</script>
	</head>
	<body>
		<button id="button" class="bg-info js-uc" data-index="0">button of doom</button>
	</body>
</html>

In the body, there's a button with the two magic classes the click handler checks. The button also has data-index="0" and appResponse has userauth set as [""] to prevent an out of bounds array index. The pre_auth_script property of appResponse functions as the payload.

Reporting

I was initially frustrated by the lack of official avenue to report vulnerabilities. I reported this vulnerability through the help desk, which made me a bit anxious me due to the possibility that details of my report could be intercepted by a third party. I advise that ClassLink adopts RFC 9116 (security.txt) to make it more clear to researchers where and how they should report vulnerabilities in the future.

Timeline

  • September 15th, 2022: vulnerability reported to ClassLink.
  • September 19th, 2022: I received an acknowledgement from ClassLink.
  • October 25th, 2022: I inquired about the status of a fix.
  • November 8th, 2022: I received notice that a patch would roll out on December 1st.
  • December 1st, 2022: The vulnerability was patched.

Acknowledging Previous Research

I stumbled upon research done in 2018 that was published on the Full Disclosure mailing list. They made eerily similar findings to me. The research from 2018 exploits the same function which injects scripts into the DOM, but with a different way of triggering it. I assume this research is responsible for the implementation of the URL regex check mentioned earlier in the article.

Analysis of The Patch

The patch adds the regex guard to the s function. I tested my proof of concept code against the latest version and confirmed that it is sufficient to prevent exploitation.

patched function that sends "initiate-sso" function