Zero Hits, One Node: Hunting Browser Proxyware When Your Signatures Are Wrong
The last two posts named names (Grass, Hola, Bright Data) and showed the traffic. This one is about the machinery behind those finds, because in threat intel the method is the claim. If you can't say how you know, you don't. And the honest version of "how we know" includes the part where our own signatures came up empty and we found a node anyway.
Problem one: the store has no map
There is no official "list every extension" API for the Chrome Web Store. That's not a detail; it's the whole difficulty. You cannot audit what you cannot enumerate. So we stitch coverage from what is available (third-party indexes, category and search crawls, install-count metadata) and we report a floor, not a census: "of the N extensions we examined, X relay bandwidth," never "the store is X% infected." Honest scope beats a confident number you can't back.
And the corpus fights you. Of a batch of proxy/VPN/unblocker extensions we pulled, nearly three in four were already delisted by the time we went to fetch them. That's not noise. It's signal. The sketchiest carriers churn out of the store fast, which means any snapshot of the live store systematically undercounts the problem. The bodies are buried off-index.
Problem two: static analysis lies to you here
The instinct is to grab the extension, grep the JavaScript for known proxy infrastructure, and call it. It doesn't work, for two compounding reasons.
First, obfuscation. Modern extension bundles are minified into unreadable single lines; the interesting strings are concatenated, base64'd, or built at runtime. We ran a static scan across a set of live proxy/VPN extensions and got zero infrastructure matches, and when we grepped the raw unpacked bytes for our known hosts, they weren't there as strings either. Zero hits, but the corpus was minified enough that zero hits proved nothing. A clean static result on obfuscated code isn't "clean." It's "inconclusive."
Second, the strings that are there are often stale. Recall from the Grass post: the
extension's manifest still pointed at getgrass.io and wss://proxy2.wynd.network, while
the live traffic went to api.grass.io and director.grass.io. Static had the operator a
rebrand behind. You cannot map a moving network by the fossils in its manifest.
The conclusion writes itself: to know what one of these does, you have to run it. Which raises a problem of its own.
Problem three: you can't test proxyware by running it
Run this stuff normally and your connection genuinely becomes an exit node. You'd be relaying strangers' traffic through your own IP, which is the exact harm we're documenting. So the rig exists to observe without participating, and it's the part most worth stealing if you do this work:
- A disposable VM. The browser runs as a dedicated, unprivileged user. Snapshot before, roll back after. The blast radius is one throwaway machine.
- Network-layer containment, not an in-browser proxy. This is the subtle one. A proxy
setting inside the browser is not a boundary. The hostile extension can flip it off
(
chrome.proxy→ DIRECT) or punch out over WebRTC/UDP, which no HTTP proxy sees. So the boundary lives in the kernel: the browser user is allowed to reach exactly one thing, the local intercepting proxy, and every other egress, every protocol, is dropped. Try to go direct, you get dropped. Try WebRTC, dropped. We tested the bypasses; they die at the firewall. - Decryption with a trusted CA, so TLS and WebSockets are readable, not just countable.
- A hard sinkhole. The instant the extension is told to relay to a third party, the connection is blocked. We capture the tasking. We never carry the freight.
The nice property: the extension behaves as if it's on a normal home connection, we read every word, and no one else's traffic ever leaves the box. That's what "confirmed" means in these posts: not "the code looked bad," but "we watched it try, in cleartext, and stopped it."
The result that matters most: zero hits, one node
Here's the uncomfortable, useful part.
We ran a batch of proxy/VPN/unblocker extensions through the rig with our full catalog of known bandwidth-reseller infrastructure loaded as the allow-list. The catalogued-match count was zero. Not one extension reached for a host we already knew.
If we'd stopped there, we'd have reported "clean," and we'd have been wrong. Because the
rig doesn't just check for matches, it logs every host every extension touches, matched
or not. And in that unmatched residue, sitting there in plain sight, was perr.hola.org and
a cluster of rotating peer CDNs. That's Hola. That's the node from the last post. Our
signatures didn't know it; the traffic did.
The lesson generalizes past this project: signature detection only finds what you already catalogued. A zero-hit result isn't "nothing's here." It's "nothing I already knew about is here." The un-catalogued variant, the new rebrand, the thing worth finding, it lives in the part your allow-list ignored. Capture the residue, triage the anomalies, and feed what survives back into the catalog. We added Hola to ours the same afternoon.
Saying the quiet part: most of it was clean-ish
Threat intel has a failure mode where everything is a threat because threats are the product. So, plainly: in the same pass, the big-name "free VPNs" (VeePN, NordVPN's proxy extension, 1clickVPN, Browsec) did not, on a cold load, reach for a catalogued reseller SDK. They talked to their own infrastructure and went quiet.
That is not a clean bill of health, and we won't dress it up as one: we didn't log in or hit "connect," and an SDK that only wakes mid-session wouldn't have fired. It's a "didn't trigger under passive load," not an exoneration. But it's also not nothing. Not every free VPN is a proxy node. The ones that are deserve evidence (captured, decrypted, reproducible), and the ones that aren't deserve to not get swept up in the panic. Naming the guilty is only credible if you're willing to un-name the merely suspicious.
And here's the catch that makes dynamic confirmation non-negotiable. Look at what these things ask for:
# Sample fingerprints (this pass) — the capability surface is identical
name ver relay-relevant permissions sha256
Grass Lite Node 7.5.2 webRequest, 8ea2dd825e27e17a4a673bcd946d4af3…
Hola VPN 1.255.748 proxy, webRequest, *://*/* 05f9b2eaa86e16c989057c179b4deba4…
VeePN 4.0.2 proxy, tabs, webRequest, a3da24cc5a5b4ab8a33b2384818a2aa9…
NordVPN proxy 5.6.5 proxy, webRequest, declarativeNetReq*, all 50f14abdcce2d268fee189192e863ba4…
1clickVPN 2.1.0 proxy, webRequest, *://*/* 413dafaf45c1abc5c5d52296e011f9d9…
Browsec 3.93.7 proxy, webRequest, 6808984fb7ad893d3e2270d7c7e638ca…
Every one of them, the confirmed proxy nodes and the ones that stayed quiet, holds the
same keys: intercept web requests, across all URLs. Grass doesn't even bother with the
proxy permission; it relays over its own WebSocket. The manifest can't tell you who's
selling your bandwidth, because they all asked for the capability to. Capability is not
behavior, which is the whole reason you have to watch it run.
Where this goes
The catalog is the moat, and it's alive: every dynamic run corrects it (we fixed Grass's C2, we added Hola) so the next scan is sharper than the last. The browser is one surface. The same networks live in Android apps at far greater scale, in the smart TV in your living room, and on iOS, which is where other researchers have already been, and where this series is headed next.
The residential proxy economy runs on being un-mapped. We're mapping it, one confirmed node at a time, and telling you exactly how, so you can check our work.
Every indicator in this series is compiled in the defanged IOC appendix: hashes, live C2, ASNs, and TLS fingerprints for each confirmed family, formatted for detection and blocking (CSV).