thrunt.fyi

Inventorying VSCode Extensions

Issue

GlassWorm is targeting macOS via OpenVSX extensions... again1. This led me to conduct some threat hunting where I was quickly met with a blocker.

This post has been in my drafts ever since the initial GlassWorm campaigns and now even more OpenVSX extensions have been compromised following the Trivy breach; which affected Checkmarx and LiteLLM2. I will not discuss those intrusions here in detail. But the analysis of present extensions can help us identify signs of compromise.

I discovered that there was no centralised inventory of extensions in any of the tools available to me. A quick google search led me to a blog by another member of Bear discussing the approach to detection3.

I wanted to expand on that a bit more by using regex and DefenderXDR telemetry to assemble a list of known extensions that can be stored and added to periodically (every 30 days). So that in the event of an extension compromise we can quickly identify usage of affected versions.

I also wanted to explore longer term solutions that can provide automation and prevention capability.

RegEx magic using KQL

Knowing the extensions are stored in *\.vscode\extensions\* (Windows) or */.vscode/extensions/*3 (Unix) we can begin to extract them along with their associated version numbers.

We can start of by querying DeviceProcessEvents and matching against the above folder paths.

DeviceProcessEvents
| where (FolderPath has_any (@"\.vscode\extensions\", @"/.vscode/extensions/")) or (InitiatingProcessFolderPath has_any (@"\.vscode\extensions\", @"/.vscode/extensions/"))

This provides us with suitable starting point, but how do we create a nice neat list of the extensions? This is where the magic begins.

We start by using extend to create a new column where we can apply our RegEx.

| extend extension_windows = extract(@"\\extensions\\([^\\]+)\\", 1, FolderPath)
| extend extension_unix = extract(@"/extensions/([^/]+)/", 1, FolderPath)

Don't forget InitiatingProcess events!

| extend extension_windows_1 = extract(@"\\extensions\\([^\\]+)\\", 1, InitiatingProcessFolderPath)
| extend extension_unix_1 = extract(@"/extensions/([^/]+)/", 1, InitiatingProcessFolderPath)

Searching FolderPath and InitiatingProcessFolderPath ensures we don't miss any extensions that may not appear in one or the other. I considered extracting extensions from DeviceFileEvents but it ended up returning GUIDs rather than extensions names which exploded the results. If you know a way to map them to extension IDs, let me know!

Now we have created 4 new columns that should be returning values. Extracted values should look something like ms-python.python-2026.4.0-win32-x64. This is great, but lists of extensions provided as IOCs do not include the version4. Therefore, we need to apply some RegEx to our RegEx!

Technically not necessary, but we want to retain extension version numbers. You could just extract the IDs.

yo-dawg-regex

Using extend again we can create 4 more columns and apply our RegEx to extract just the extension IDs:

| extend extension_id_windows = extract(@"^(.+?)-\d+(?:\.\d+){1,3}(?:-.+)?$", 1, extension_windows)
| extend extension_id_unix = extract(@"^(.+?)-\d+(?:\.\d+){1,3}(?:-.+)?$", 1, extension_unix)
| extend extension_id_windows_1 = extract(@"^(.+?)-\d+(?:\.\d+){1,3}(?:-.+)?$", 1, extension_windows_1)
| extend extension_id_unix_1 = extract(@"^(.+?)-\d+(?:\.\d+){1,3}(?:-.+)?$", 1, extension_unix_1)

This leaves us with nice and clean extension IDs. Which should look something like ms-python.python. We can use coalesce to fuse the extracted extensions from both Windows and Unix into a single column.

| extend extension_id = coalesce(extension_id_windows, extension_id_unix, extension_id_windows_1, extension_id_unix_1)
| distinct extension_id

This allows us to keep a single deduplicated list of IDs which can be used to compare to compromised ones. I would recommend extracting IDs on a frequent basis and recording them somewhere for later use.

I would also recommend extracting extension versions and recording them in the event a specific version is reported as compromised.

Now that we have all the elements, we can tie them together and compare existing extensions to new ones.

let extensions = dynamic([
"example.extension"
]);
DeviceProcessEvents
| where (FolderPath has_any (@"\.vscode\extensions\", @"/.vscode/extensions/")) or (InitiatingProcessFolderPath has_any (@"\.vscode\extensions\", @"/.vscode/extensions/"))
| extend extension_windows = extract(@"\\extensions\\([^\\]+)\\", 1, FolderPath)
| extend extension_unix = extract(@"/extensions/([^/]+)/", 1, FolderPath)
| extend extension_windows_1 = extract(@"\\extensions\\([^\\]+)\\", 1, InitiatingProcessFolderPath)
| extend extension_unix_1 = extract(@"/extensions/([^/]+)/", 1, InitiatingProcessFolderPath)
| extend extension_id_windows = extract(@"^(.+?)-\d+(?:\.\d+){1,3}(?:-.+)?$", 1, extension_windows)
| extend extension_id_unix = extract(@"^(.+?)-\d+(?:\.\d+){1,3}(?:-.+)?$", 1, extension_unix)
| extend extension_id_windows_1 = extract(@"^(.+?)-\d+(?:\.\d+){1,3}(?:-.+)?$", 1, extension_windows_1)
| extend extension_id_unix_1 = extract(@"^(.+?)-\d+(?:\.\d+){1,3}(?:-.+)?$", 1, extension_unix_1)
| extend extension_id = coalesce(extension_id_windows, extension_id_unix, extension_id_windows_1, extension_id_unix_1)
| distinct extension_id
| where not (extension_id in~ (extensions))

Depending on your appetite, this can be configured to run as an alert or you can save it and come back to it at a later date.

Long-term Solutions

There are a few ways to approach extension control. We won't dive into too much detail but will cover the basics.

extensions.allowed3

VSCode allows organisations to manage extensions using a policy to configure extensions through extensions.allowed.

This would work but it would not catch compromises of existing extensions. It would also require a robust approval process for devs and engineers to request additional extensions. Over time this would become laborious to manage.

Hosting a Private Extension Marketplace5

Similarly, you could host your own private extension marketplace where you can vet and only allow specific extension versions.

This is probably overkill for most enterprises. You get full oversight, but it would require a lot of faff.

At the time of writing, private marketplace is only available to GitHub Enterprise customers.

JAMF Extension Attributes6

If you have a large macOS community and you have JAMF deployed, you can use extension attributes to run a bash script which checks for VSCode extension names and versions.

You can apply smart groups to monitor for devices with specific extensions installed. You can then deploy a policy that runs another bash script to remove specific extensions from hosts.

This is more straight forward but still requires a manual element to manage disallowed extensions.

You can find the full query on my GitHub :)

  1. https://www.bleepingcomputer.com/news/security/new-glassworm-attack-targets-macos-via-compromised-openvsx-extensions/

  2. https://www.wiz.io/blog/teampcp-attack-kics-github-action

  3. https://chelmzy.tech/detecting-new-vscode-extensions/

  4. https://socket.dev/blog/glassworm-loader-hits-open-vsx-via-suspected-developer-account-compromise

  5. https://code.visualstudio.com/docs/enterprise/extensions#_host-a-private-extension-marketplace

  6. https://tonyyo11.github.io/posts/Tracking-VSCode-Extensions-macOS/

#defenderxdr #detection engineering #extensions #hunting #kql #regex #vscode