Dark Mode Google Docs Was Clean. Then It Changed Hands.
A Chrome extension with 500,000 users shipped a clean build for years, got quietly acquired, and its next release added an ad banner beaconing to a throwaway Cloudflare Worker. It is one of eight extensions in the same ring. We pulled both builds and diffed them.
Dark Mode Google Docs Was Clean. Then It Changed Hands.
Dark Mode Google Docs (scorecard, marketplace id iabnclnclchijjckhdljmocghgmgnnii) has 500,000 installs. It does what it says: darkens Google Docs. It did only that for a long time. Version 4.9.67, shipped in early April, is a clean dark-mode extension with nothing interesting in it.
Version 4.9.68 is where it gets interesting, because 4.9.68 shipped a few days after the developer account behind the extension changed hands, and the new owner added an ad.
This is the acquisition play, and it's one of the cleaner examples I've pulled apart. Nobody wrote malware from scratch. Somebody bought an extension that 500,000 people already trusted, waited, and pushed one more update through the same auto-update channel that had only ever delivered bug fixes. That's the whole attack. The trust was already there. They just bought it.
Two builds, one diff
The nice thing about having every version archived is that you don't have to argue about what changed. You pull both builds and compare them.
4.9.67 is 624 KB. 4.9.68 is 654 KB. The 30 KB is not a mystery once you unzip both. The update touched nine files, and four of them are new:
+ banner.js the ad framework
+ banner.png the banner image (770x179)
+ ui/popup/banner-config.js per-extension campaign config
+ ui/popup/banner-init.js mounts the banner into the popup
~ manifest.json version string only, 4.9.67 -> 4.9.68
~ ui/popup/index.html three new <script> tags
~ ui/popup/index.js one new mount div
~ ui/popup/style.css banner styling
~ _metadata/verified_contents.json re-signature
Here's the part that matters for reading intent. The extension already had a content script that injects into every page you visit, because that's how a dark-mode extension works: it has to reach into the page to restyle it. If someone were turning this into something nasty, that injection code is where they'd do it. It's byte-for-byte identical between 4.9.67 and 4.9.68. So is the background service worker. The handoff didn't touch the dangerous surface the extension already had. It added a banner to the extension's own popup and nothing else.
That restraint is itself informative. Whoever did this wasn't trying to hide a keylogger. They were installing a monetization widget.
The payload, such as it is
banner.js is 5 KB and not obfuscated. The operative part:
var DEFAULT_CONFIG = {
apiBaseUrl: 'https://ext-ads-mvp.captain-products.workers.dev',
impressionPath: '/impression',
clickPath: '/click',
bannerId: 'darkmodegdocs_to_darkmodechrome1504_v1',
landingUrl:
'https://chromewebstore.google.com/detail/dark-mode-chrome/' +
'molkadplffcfnfaaapdlhionhggonekm?utm_medium=cpm&utm_campaign=1'
};
When the popup opens, the banner renders and fires a POST to /impression. When you click it, another POST to /click, then it opens the landing page. The landing page is another Chrome extension, "Dark Mode Chrome," which tells you what the campaign is: they're funneling users from the extension they bought toward another extension in the same orbit. The 1504 in the banner id is a campaign number. The cpm is how they're getting paid.
What the beacon sends home is the least alarming thing about this:
function getEventPayload(config) {
return { banner_id: config.bannerId, timestamp_ms: Date.now() };
}
A banner id and a timestamp. No browsing history. No page content. No cookies, no user id, no fingerprint. The Cloudflare Worker on the other end is counting impressions and clicks and nothing else. The banner image is bundled in the extension, not fetched from the server, and the destination is hardcoded. The server doesn't get to decide what the ad is or where it points. In its current form this is about as polite as adware gets.
I want to be precise about that, because it's tempting to call anything with a C2 domain "malware" and move on. This isn't that. It's undisclosed advertising with a metrics endpoint. Nobody consented to it, the store listing doesn't mention it, and it arrived through an update people didn't choose. That's a real policy violation and a real breach of the trust that 500,000 installs represent. It is not spyware, it does not inject into your pages, and it does not steal anything. Both of those statements are true at once, and a scoring system that can't hold both is going to be wrong a lot.
It's not one extension, it's a supply line
The reason I went looking in the first place is that the same beacon domain showed up on a smaller extension, AI Grammar Checker (scorecard), which had gone through its own ownership handoff before sprouting the identical ads-platform payload. One extension with an odd ad is a curiosity. Two with the same Cloudflare Worker is a pattern, so I pulled the thread.
Eight confirmed carriers, about 560,000 installs between them, all pinging ext-ads-mvp.captain-products.workers.dev, all using the same jivo affiliate id partner_id=49587:
| Extension | Users | Owner account |
|---|---|---|
| Dark Mode Google Docs | 500,000 | jimmy37823joe |
| Chatgpt PDF | 10,000 | capitan.ext.dev |
| VPN for Discord | 10,000 | jimmy.green568 |
| Spell Check | 10,000 | david834walker |
| HEIC to JPG Converter | 10,000 | rob384392black |
| Image Converter | 9,000 | david834walker |
| AI Grammar Checker | 7,000 | ronnyzfx |
| Screen recorder | 4,000 | diego.armando… |
The account named capitan.ext.dev is the tell. "Capitan" is "captain," as in captain-products, and that account owns one of the carriers directly. The rest are a spray of throwaway Gmail addresses, several of them holding multiple extensions, with a chain of ownership transfers clustered in March and April. Every banner in the network cross-promotes one extension, YouTube To Text, which has 200,000 users of its own. That one is clean. It carries no payload. It's the thing the whole network exists to pump traffic toward, which makes it the most valuable node and the one nobody would think to look at.
The payload itself has variants. Dark Mode Google Docs uses the popup banner.js I walked through above. Others ship an ads-platform/scripts/tracking.js form instead. Same domain, same affiliate id, different wrapper. That's not a coincidence of style, it's a product. Somebody built a reusable ad-injection kit with per-extension config files and drops it into whatever they've most recently acquired.
Why the acquisition route works
Writing a malicious extension and getting 500,000 people to install it is hard. The store review stands in the way, the extension has no reviews and no history, and organic growth is slow. Buying an extension that already has the installs skips all of it. The reviews are already there. The auto-update pipe is already trusted. The permissions were granted years ago by users who evaluated a dark-mode tool, not an ad network.
From the buyer's side the math is simple. A dark-mode extension with half a million users and a <all_urls> content script is worth more than its ad revenue, because the permissions and the install base are the asset. You're not buying a business, you're buying a foothold on 500,000 machines that update automatically.
The mild payload here is the part that should worry you most, not reassure you. Everything needed for something worse is already in place. The extension holds <all_urls> and scripting permissions for its legitimate feature. The ad framework's endpoint and landing url are config values, not constants. Pointing the banner somewhere hostile, or moving the logic from the popup into the page-injection path that already exists, is a small edit and another silent auto-update away. Today they chose to run a polite ad because a polite ad is lower risk to them. The capability sitting behind it is the actual finding.
What we changed on our end
Finding this exposed a gap in our own analyzer worth admitting. The captain-products domain only ever appeared inside a full URL in the extracted indicators, and our threat-intel check was matching bare domains, not the host portion of URLs. So a known-bad C2 referenced as https://ext-ads-mvp.captain-products.workers.dev/impression sailed through unrated while the same domain on its own would have been flagged. That's fixed now: URL indicators get their host checked against the intel cache like everything else, and a confirmed-malicious host upgrades the finding. We also wrote a detection rule for the payload family and seeded both captain-products domains as adware in the enrichment cache, so any future carrier that beacons there gets flagged on its next scan.
The scoring question is still open, and I'll leave it open honestly. Even after the C2 was marked malicious, Dark Mode Google Docs scores MEDIUM, not HIGH, because a single indicator of compromise is capped in how much it can move a composite score. For a confirmed adware beacon that feels low. Raising a floor for intel-confirmed indicators is the obvious fix and also the kind of change that quietly inflates half the catalog if you get the threshold wrong. I'd rather ship it deliberately than reflexively.
If you have any of these installed
Remove them. None of the eight are worth keeping, and "it's only an ad" assumes the next update is also only an ad, which is exactly the assumption that got 500,000 people here. If you build or maintain an extension, the lesson is narrower and older than this ring: an install base is a liability the day you stop wanting to own it, and the person who buys it inherits every permission you were ever granted. The users don't get a second consent prompt when that happens. They should.