Abusing VSCode: From Malicious Extensions to Stolen Credentials (Part 2)
73% of developers use VSCode to work on hobby projects and enterprise software alike. At the same time, a disproportional amount of independent security research has been performed on VSCode to enable the community to make informed, risk-based decisions when it comes to VSCode adoption and hardening. Both Check Point and Aqua show how easily malicious extensions steal personal identifiable information (PII) and other sensitive data by impersonating popular extensions in the VSCode Marketplace. Cycode unveiled that stealing tokens from other extensions, even though protected through VSCode’s internal vscode-encrypt
rust module, is trivial. Nothing keeps the same extension from sending this critical information off to a nefarious command & control (C2) server either.
Since Cycode published these findings, VSCode migrated away from their internal rust module and instead use Electron’s safeStorage API to enable their SecretStorage API. In this blog post we dive deep into the implementations of VSCode, Electron & Chromium, to demonstrate that the same limitations as initially laid out by Cycode still exist today: any malicious extension can steal other extension’s credentials, even though they are managed and namespaced by the VSCode SecretStorage API.
High Level Architecture
VSCode relies on Electron, a popular open-source framework to create desktop applications using web technologies, which run on any platform. The rendering stack in turn utilizes the Chromium browser engine, which also provides APIs to securely store secrets backed by the operating system. On Linux this functionality is implemented by libsecret.
flowchart EXTENSION["VSCode Extension"] VSCODEAPI["VSCode API SecretStorage"] ELECTRON["Electron safeStorage"] CHROMIUM["Chromium OSCrypt"] LIBSECRET["libsecret"] KEYRING["OS keyring"] SQLITE["sqlite3"] DB["globalStorage/state.vscdb"] EXTENSION --> VSCODEAPI VSCODEAPI --> ELECTRON ELECTRON --> CHROMIUM CHROMIUM --> LIBSECRET LIBSECRET --> KEYRING VSCODEAPI --> SQLITE SQLITE --> DB
Since
libsecret
is a Linux specific implementation, some details may be different for MacOS (keychain) and Windows (DPAPI), but the same attack vector should work on any OS.
VSCode API
The VSCode API includes several security-relevant functionalities, most notably the SecretStorage
class. It allows extensions to store and retrieve secrets beyond a single session and workspace. Its documentation states that this is a “storage utility for secrets [and] information that [are] sensitive”.
It also takes care of persisting the secrets between sessions in a shared SQLite3 database, which also holds the user’s configuration and common VSCode settings. It is stored in the user’s home directory: ${HOME}/.config/Code/User/globalStorage/state.vscdb
.
The database holds a single table, ItemTable
, with a very simple layout that features only two columns: key
and value
.
Secrets always have a key that is prefixed with secret://
, followed by a simple JSON object that tracks the ID of the extension for which this secret is stored (this enables namespaced secrets), as well as an arbitrary key
which is used by the extension to look up its secrets. A full key value looks like this:
secret://{"extensionId":"my.extension","key":"secret-token"}
The value column holds another JSON object that mainly stores the encrypted secret using a byte slice representation:
{"type":"Buffer","data":[118,49,49,79,119,...]}
Electron safeStorage
The Electron safeStorage API is a very thin wrapper, only ~100 lines of C++, to call down to the Cromium OSCrypt package. Its API documentation promises to protect “data stored on disk from being accessed by other applications or users with full disk access”.
Chromium OSCrypt
This is the library where the actual cryptographic operations are implemented. Let’s step through each major operation performed.
Encryption / Decryption
The OSCryptImpl::EncryptString and OSCryptImpl::DecryptString is what is called by the Electron safeStorage API.
The core encryption mechanism relies on AES-128 in cipher-block-chaining (CBC) mode. The initialization vector (IV) is hard-coded to the string of 16 spaces (0x20 hex). No modifications are made to the user-provided plain text.
Once the cipher-text is produced, a prefix is added. The prefix v11
means encrypted with a randomly generated password. The value v10
means that the hard-coded key peanuts
was used to encrypt the string, which is used as a fallback on Linux systems where the keyring or other secure storage is not available.
flowchart LR KEY["key"] PT["Plaintext = 'hello'"] IV["IV = ' ' * 16"] CT["Ciphertext"] AES{"AES-128-CBC"} KEY --> AES PT --> AES IV --> AES AES --> CT CAT{"concatenate"} PREFIX["'v11'"] PREFIX --> CAT CT --> CAT CAT --> OUTPUT["Output"]
Key Management
The key is derived from a password, which in turn is stored in the user’s local keyring, the implementation of this is backed by libsecret. The salt and iteration count are hard-coded.
flowchart LR PASSWORD["password"] PBKDF2{"PBKDF2"} KEY["key"] ITERATION["iteration = 1"] SALT["salt = 'saltysalt'"] KEYRING["keyring"] KEYRING --> PASSWORD ITERATION --> PBKDF2 PASSWORD --> PBKDF2 SALT --> PBKDF2 PBKDF2 --> KEY
Key Generation
In case there is not yet a password stored in the keyring for this application a new random password is base64 encoded and stored in the keyring.
flowchart LR PASSWORD["password"] KEYRING["keyring"] RAND["16 random bytes"] BASE64["base64"] RAND --> BASE64 BASE64 --> PASSWORD PASSWORD --> KEYRING
Even though the password is base64 encoded, it is never decoded and simply fed as-is into the key derivation function! This may or may not have a been a frustrating culprit when developing the PoC 😉
Analysis
Equipped with this knowledge, we can dive into the problems that arise from the chosen implementations.
Limited Documentation
The attentive reader may have picked up on the changing promises made by the documentation about the capabilities to store secrets. Let’s repeat them side by side:
- VSCode SecretStorage API: “storage utility for secrets, information that is sensitive”
- Electron safeStorage API: “This module protects data stored on disk from being accessed by other applications or users with full disk access.”
- Chromium OSCrypt API: “This directory contains
OSCrypt
implementations that support cryptographic primitives that allow binding data to the OS user.” - Libsecret itself provides no documentation about it’s security assumptions, but a SO post puts it’s quite succinctly: “allows a user to store, e.g., passwords in a way that they are easily accessible for the logged on user, but very hard to access by someone else.”
Even though there is no evidence to support it, the Electron API claims to keep secrets safe from other applications. Even worse, the VSCode documentation makes no further claims and declares the storage secure for sensitive information.
When API documentation provides too little information about their capabilities and assumptions, developers are put at a disadvantage to make good decisions.
We have started conversations with the VSCode and Electron projects to improve the documentation and enable developers to make informed decisions.
No Sandboxing
As there is no sandboxing implemented in VSCode, any extension has the same permissions as any other user process. Extensions are free to read and write files on disk, make network requests, or call other available APIs and libraries.
Therefore, an evil extension is able to directly call out to libsecret
and sqlite3
to read encrypted secrets from other extensions and decrypt them using the OS-managed key.
flowchart EXTENSION["VSCode Extension"] style EXTENSION fill:#47A347 VSCODEAPI["VSCode API SecretStorage"] ELECTRON["Electron safeStorage"] CHROMIUM["Chromium OSCrypt"] LIBSECRET["libsecret"] KEYRING["OS keyring"] EVIL_EXTENSION["Evil Extension"] style EVIL_EXTENSION fill:#FF5151 SQLITE["sqlite3"] DB["globalStorage/state.vscdb"] EXTENSION --> VSCODEAPI VSCODEAPI --> ELECTRON ELECTRON --> CHROMIUM CHROMIUM --> LIBSECRET LIBSECRET --> KEYRING VSCODEAPI --> SQLITE SQLITE --> DB EVIL_EXTENSION --> LIBSECRET EVIL_EXTENSION --> SQLITE
We have released an example extension that does exactly this. It has some built-in safeguards, but when you run the “Steal Secrets” function, it will happily decrypt and print all VSCode-managed secrets from other extensions.
As there is no plan to support extension sandboxing, enterprises need to take on the challenge of discovering, classifying and limiting access to malicious extensions before they can harm their business.
Hardcoded Chromium Password
As Linux is a more fragmented ecosystem than MacOS and Windows, when there is no keyring available, Chromium falls back to using a static password to encrypt secrets. This level of security is equal to not using a password at all, as there is no secret knowledge required to decrypt the stored information. Therefore, even other users on a shared system are able to decrypt data if they get access to an encrypted string.
This opens up new avenues for an attacker. Breaking availability, e.g., via denial of service or misconfiguration, is usually an easier target than breaking state-of-the-art encryption.
This limitation should be communicated more clearly, as the fallback is a silent one.
Disclosure
We followed VSCode’s security policy and disclosed our findings to Microsoft using the Microsoft Security Response Center (MSRC).
Microsoft reviewed our findings and concluded that “users should only install extensions they trust” as publicly documented.
We will continue our efforts with the VSCode & Electron teams to enable users and developers to make better security decisions in future.