Is Spotify Ad Blocker - Blockify safe?
Blockify is high risk. Every 60 minutes, Blockify fetches a config from blockify.b-cdn.net controlling ad-blocking features, exempt sites, and filtering rules. custom_dynamic_dnr injects rules that allow/block requests; forced_exclusions adds exempt domains.…
Who publishes itValueFoundry - 2 other listings from the same operator, none carrying a finding
ValueFoundry - 2 other listings from the same operator, none carrying a finding
What this publisher told the store about itself, and the other listings that told it the same thing.
Same store account
2 other listings published from this account, 4k+ users between them, none of them carrying a finding.
Shared hosts - 1 hostname
Hostnames hardcoded in this extension that few other listings call. That can mean one operator behind both, and it can equally mean a small shared vendor, so it is context rather than a conclusion. Hosts that many listings call are left out: they are services, not connections.
AI-generated. Findings may contain errors. Those marked Verified have been manually reviewed.
Publishers can request a review.
Findings
Remote Configuration Controls Ad Blocking Behavior via blockify.b-cdn.net
Every 60 minutes, Blockify fetches a config from blockify.b-cdn.net controlling ad-blocking features, exempt sites, and filtering rules. custom_dynamic_dnr injects rules that allow/block requests; forced_exclusions adds exempt domains.
The extension starts up, then every 60 minutes thereafter, a background alarm fires.
The extension fetches a JSON configuration file from blockify.b-cdn.net and applies it, potentially changing which sites are blocked or exempt from ad blocking.
The server can add any domain to the ad-blocking exception list (forced_exclusions) or inject arbitrary declarativeNetRequest rules (custom_dynamic_dnr) without any user interaction.
| Field | Value | Why it matters | |
|---|---|---|---|
forced_exclusions | ["example-ad-network.com", "partner-site.com"] | A list of domains added to your ad-blocking exception list, bypassing the ad blocker for those sites. Controlled entirely by the server. | |
custom_dynamic_dnr | [{"id":9001,"priority":1000,"action":{"type":"allow"},"condition":{"urlFilter":"||ads.example.com*"}}] | An array of network filtering rules the server can inject into the extension. These rules can allow or block any request from your browser. | |
rules_json | "disabled" | Enables or disables the extension's built-in ad blocking ruleset for all users simultaneously. | |
cssjs | "enabled" | Enables or disables CSS-based ad element hiding across all sites. | |
spotify/yt/hulu_injection flags | "enabled" | Toggle per-site ad blocking for Spotify, YouTube, and Hulu for all users. |
Server-controlled DNR rule injection and exception list update in bk_modules.js
// bk_modules.js: A background alarm fires every 60 minutes (or on startup)
// and fetches the 'switches' config from blockify.b-cdn.net.
// The server response directly controls:
// - forced_exclusions: domains exempt from ad blocking (merged into user's exception list)
// - custom_dynamic_dnr: arbitrary declarativeNetRequest rules injected into the extension
// - rules_json: enables/disables the main ad blocking ruleset
// - cssjs, spotify_injection, yt_injection, hulu_injection: per-feature toggles
//
// There is no UI notification when any of these values change.
async function setSwitches() {
const response = await fetch('https://blockify.b-cdn.net/switches190.json', { cache: 'no-cache' });
const config = await response.json();
// Store config and apply immediately:
chrome.storage.local.set({ switches: config });
self.switches = config;
applyForcedExclusions(config.forced_exclusions); // updates your exception list
applyCustomDNRRules(config.custom_dynamic_dnr); // injects server-side network rules
setAdBlockingToggles(config); // enables/disables features
}// The custom_dynamic_dnr array from the server is passed directly to
// chrome.declarativeNetRequest.updateDynamicRules().
// Rules can have any action (allow, block, redirect, modifyHeaders) and
// any condition (urlFilter, resourceTypes, domains, etc.).
// There is no validation of rule contents before they are applied.
async function applyServerDNRRules(rules) {
const existing = await chrome.declarativeNetRequest.getDynamicRules();
const existingIds = new Set(existing.map(r => r.id));
const newRules = rules.filter(r => !existingIds.has(r.id)); // skip duplicates by ID
if (newRules.length > 0) {
await chrome.declarativeNetRequest.updateDynamicRules({ addRules: newRules });
}
}- blockify.b-cdn.net
BunnyCDN distribution serving switches190.json. Controlled by the Blockify developer. Configuration changes here affect all ~300K users simultaneously.
Browsing History Transmitted to insights.getblockify.com on Every Navigation
Every time you navigate, Blockify sends the full URL, referring URL, install ID, OS, browser name/version, timezone, and user-agent to insights.getblockify.com in 5 encrypted POSTs; the hardcoded key lets anyone decrypt them.
You navigate to any page in any browser tab.
The extension sends the full URL, the page you came from, and a persistent device ID to insights.getblockify.com.
This fires on every completed navigation across all sites, not just Spotify. No action or opt-in is required.
| Content-Type | application/json;charset=utf-8 |
{
"eventType": 1,
"request": {
"enRequest": "Xk9mR2FhQkxPdUZvN1ZhT3ZMdEprQ2FvQ0VscXZUSmVPb2ZJNFFwUW1PZlVyT3FKS1hIck1tVlN0WkV4SHdFWkFud3VhWTZzdmE0djlhZzNaM0tLMGdkQzBkQjNaZFdlR2Y2amhxMVBLUnVxbzFHT1JyMlpFaHl5UW56ekdlNW5GWDZGVG5nYUxEY3dvcXVaUERheVZBZ3VETnJveHpVNkFPbGRhU2drcVhvUzZOak9ib0RqYjZxNlFMeVFJaEdEeDU3VG5DazVpV2FyYThsblBjQkVOZ2V3UnlSUmd4ZzRGR3plczJIN1g0Q0hXNndRZ0Ux"
}
}The request body is AES-GCM encrypted before sending. The 16-byte encryption key is hardcoded in analytics.js at line 535, so the payload can be fully decrypted by anyone with the extension source.
[
{
"fileDate": "2026-04-15T03:05:12.441Z",
"deviceTimestamp": 1744682712441,
"userId": "e3359683-1ef2-4a0e-b57e-602aec57a0b1",
"referrerUrl": null,
"targetUrl": "https://www.google.com/",
"requestType": "GET",
"scheme": "https:",
"host": "www.google.com",
"user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
"accept_language": "en-US,en;q=0.9",
"os_name": "Linux",
"browser_name": "Not-A.Brand",
"os_version": "N/A",
"browser_version": "8",
"timeZone": "UTC"
}
]| Field | Value | Why it matters | |
|---|---|---|---|
The URL you visited | https://mail.google.com/mail/u/0/#inbox | The exact address of every page you load, including any query parameters. | |
The page you came from | https://www.google.com/search?q=gmail | The URL you were on before this page, builds a chain of your browsing session. | |
Your persistent device ID | e3359683-1ef2-4a0e-b57e-602aec57a0b1 | A UUID generated once and stored in chrome.storage.sync. It is the same across all sites you visit and survives browser restarts. | |
Your operating system | macOS 10.15.7 | The OS name and version detected from your browser environment. | |
Your browser name and version | Chrome 130 | The specific browser and version you are using. | |
Your timezone | America/New_York | Your local timezone as reported by the browser, narrows your geographic location. | |
Timestamp | 2026-04-15T03:05:12.441Z | When you visited the page, to the millisecond. |
The navigation listener and payload assembly code in analytics.js
// The extension creates a PageStatistics tracker with three hardcoded values:
// - API key: "Iengi0kiEi3eicae"
// - Encryption key: "ej0hie1MThoo8Ri2" ← anyone with the source can decrypt traffic
// - Server: "https://insights.getblockify.com"
self.stat = new PageStatistics("Iengi0kiEi3eicae", "ej0hie1MThoo8Ri2", "https://insights.getblockify.com");
stat.init(); // registers all navigation event listeners// Called by chrome.tabs.onUpdated for every completed navigation.
// Assembles a visit record with: the visited URL, referrer, persistent UUID,
// OS name/version, browser name/version, timezone, user-agent, and timestamp.
// The record is AES-128-GCM encrypted (key hardcoded above) and POSTed to /process.
async function reportAction(url, referrer) {
const payload = [{
fileDate: new Date().toISOString(), // ISO timestamp
deviceTimestamp: Date.now(), // ms precision
userId: this.data.uuid, // persistent UUID from chrome.storage.sync
referrerUrl: referrer, // previous page URL
targetUrl: url, // visited page URL
requestType: 'GET',
scheme: new URL(url).protocol,
host: new URL(url).hostname,
user_agent: navigator.userAgent,
accept_language: navigator.language,
os_name: getOSName(),
browser_name: getBrowserName(),
os_version: getOSVersion(),
browser_version: getBrowserVersion(),
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone
}];
const encrypted = await encryptData(JSON.stringify(payload));
await fetch('https://insights.getblockify.com/process', {
method: 'POST',
headers: { 'Content-Type': 'application/json;charset=utf-8' },
body: JSON.stringify({ eventType: 1, request: { enRequest: encrypted } })
});
}- insights.getblockify.com
Primary telemetry endpoint. Receives every navigation event from every user. Operated by the Blockify developer.
Decrypts the AES-128-GCM encrypted payloads that Blockify sends to insights.getblockify.com/process. The hardcoded key from analytics.js:535 is used to decrypt any captured enRequest value. Run with a base64 enRequest as the argument, or without arguments for a self-contained demo.
/**
* PoC: Decrypt Blockify (nfmlkliedggdodlbgghmmchhgckjoaml) analytics payloads
*
* analytics.js:533-536 hardcodes:
* api_key = "Iengi0kiEi3eicae"
* encryption_key = "ej0hie1MThoo8Ri2" (16-byte AES-GCM key)
* server_url = "https://insights.getblockify.com"
*
* Encryption scheme (analytics.js:373-398):
* key = AES-GCM import of raw UTF-8 bytes of encryption_key
* iv = 16 random bytes prepended to ciphertext
* payload = base64(iv || AES-GCM-encrypt(JSON.stringify([...event objects...])))
*
* The decrypted payload is the JSON array sent to /process as enRequest.
* Usage:
* node blockify-decrypt.js [base64_enRequest]
*
* If no argument is given, the script demonstrates encryption+decryption with
* a synthetic payload matching the fields collected in analytics.js:233-264.
*/
const crypto = require('crypto');
const ENCRYPTION_KEY = "ej0hie1MThoo8Ri2"; // analytics.js:535
/**
* Decrypt a base64-encoded AES-GCM payload (16-byte IV prepended).
*/
function decrypt(base64Payload) {
const raw = Buffer.from(base64Payload, 'base64');
const iv = raw.slice(0, 16);
const ciphertext = raw.slice(16);
const keyBuf = Buffer.from(ENCRYPTION_KEY, 'utf8'); // 16 bytes = AES-128
const decipher = crypto.createDecipheriv('aes-128-gcm', keyBuf, iv);
// AES-GCM auth tag is the last 16 bytes of ciphertext
const authTag = ciphertext.slice(ciphertext.length - 16);
const actualCiphertext = ciphertext.slice(0, ciphertext.length - 16);
decipher.setAuthTag(authTag);
const decrypted = Buffer.concat([decipher.update(actualCiphertext), decipher.final()]);
return decrypted.toString('utf8');
}
/**
* Encrypt a payload the same way the extension does.
* Returns the base64 string that would appear as enRequest.
*/
function encrypt(plaintext) {
const keyBuf = Buffer.from(ENCRYPTION_KEY, 'utf8');
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv('aes-128-gcm', keyBuf, iv);
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
const combined = Buffer.concat([iv, encrypted, tag]);
return combined.toString('base64');
}
// --- Demonstration: round-trip a synthetic event matching analytics.js payload shape ---
const syntheticEvent = JSON.stringify([{
fileDate: new Date().toISOString(),
deviceTimestamp: Date.now(),
userId: "e3359683-1ef2-4a0e-b57e-602aec57a0b1",
referrerUrl: null,
targetUrl: "https://mail.google.com/mail/u/0/#inbox",
requestType: "GET",
scheme: "https:",
host: "mail.google.com",
user_agent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
accept_language: "en-US,en;q=0.9",
os_name: "macOS",
browser_name: "Chrome",
os_version: "10.15.7",
browser_version: "130",
timeZone: "America/New_York"
}]);
console.log("=== Blockify AES-GCM Key Extraction PoC ===");
console.log("Source: analytics.js:535 (hardcoded encryption_key)");
console.log("Key: ej0hie1MThoo8Ri2 (16 bytes = AES-128-GCM)");
console.log("");
const encoded = encrypt(syntheticEvent);
console.log("Synthetic enRequest (as sent to /process):");
console.log(encoded);
console.log("");
const decoded = decrypt(encoded);
console.log("Decrypted payload:");
console.log(JSON.stringify(JSON.parse(decoded), null, 2));
console.log("");
console.log("Fields transmitted: userId (persistent UUID), targetUrl (visited URL),");
console.log("referrerUrl, os_name, browser_name, os_version, browser_version, timeZone.");
// If a captured base64 payload is passed as argument, also decrypt it
if (process.argv[2]) {
console.log("\n=== Decrypting provided enRequest ===");
try {
const result = decrypt(process.argv[2]);
console.log(JSON.stringify(JSON.parse(result), null, 2));
} catch (e) {
console.error("Decryption failed:", e.message);
}
}- 1Save as blockify-decrypt.js.
- 2Run: node blockify-decrypt.js to confirm the key on a synthetic payload.
- 3For a real request: DevTools Network, filter insights.getblockify.com/process, copy enRequest, run: blockify-decrypt.js '<value>'
Remote Blockify config can change active blocking behavior
We observed Blockify request https://blockify.b-cdn.net/switches190.json and apply the JSON as feature config.
It stores the response, enables/disables behaviors, and can apply blocking rules without a Store update.
The extension starts its background service worker.
The same service worker also sets an hourly alarm for later refreshes.
The extension downloads remote feature switches and applies them to its blocking behavior.
The observed response populated the extension's stored switches.
| Field | Value | Why it matters | |
|---|---|---|---|
Dynamic blocking rules | [] | This field can provide new request-blocking rules for the extension to add at runtime. | |
Forced exclusions | ["nil"] | This field can add sites that the extension allows through its blocking rules. | |
YouTube injection toggle | enabled | This decides whether the extension registers its YouTube blocking script in your browser. | |
CSS and script toggle | enabled | This controls whether the extension applies its CSS and JavaScript behavior on pages. |
The extension fetches switches when the service worker loads, then schedules a refresh every 60 minutes.
The service worker fetches and applies remote switches
chrome.runtime.onStartup.addListener(() => {
setupAlarm();
updateYouTubeBlockingScript();
//createContextMenu();
//updateRules();
});
chrome.alarms.onAlarm.addListener((alarm) => {
if (alarm.name == 'getSwitches') {
setSwitches();
}
});
setSwitches();
setupAlarm();
//check_opt_eli();
updateYouTubeBlockingScript();
//createContextMenu();
//updateRules();async function setSwitches() {
var url = 'https://blockify.b-cdn.net/switches190.json';
fetch(url, { cache: 'no-cache' })
.then(response => {
// Check if the response is ok (status in the range 200-299)
if (!response.ok) {
console.error(response); //throw new Error(`HTTP error! Status: ${response.status}`);
}
// Parse the JSON data
return response.json();
})
.then(data => {
// Handle successful data retrieval
if (data && data["spotify_injection"] && data["cssjs"]) {
chrome.storage.local.set({ "switches": data });
self.switches = JSON.parse(JSON.stringify(data));
recheck_exclusions();
cssjs_set();
check_ruleset();
updateYouTubeBlockingScript();
}
})
.catch(error => {
// Handle any errors that occurred during fetch
console.error('Fetch error:', error.message);
reportErrorToSentry(6, false, error, "bk_modules.js", "setSwitches");
});
};function recheck_customDNR() {
if (self.switches && self.switches["custom_dynamic_dnr"] && typeof self.switches["custom_dynamic_dnr"] == 'object' && self.switches["custom_dynamic_dnr"] != "" && self.switches["custom_dynamic_dnr"].length > 0) {
//valid
var rules = self.switches["custom_dynamic_dnr"];
addRulesIfNotPresent(rules);
}
};
async function addRulesIfNotPresent(rulesToAdd) {
try {
// 1. Retrieve the existing dynamic rules
var existingRules = await chrome.declarativeNetRequest.getDynamicRules();
var existingRuleIds = new Set(existingRules.map(rule => rule.id));
// 2. Filter out any rules that have the same ID as an existing rule
var filteredRules = rulesToAdd.filter(rule => !existingRuleIds.has(rule.id));
// 3. If there are new, non-duplicate rules, add them
if (filteredRules.length > 0) {
await chrome.declarativeNetRequest.updateDynamicRules({ addRules: filteredRules });
}
}
catch (err) {
console.error('Error updating dynamic rules:', err);
}
};async function setupAlarm() {
var existingAlarm = await chrome.alarms.get('getSwitches');
if (!existingAlarm) {
// Create an alarm that fires every 60 minutes
chrome.alarms.create('getSwitches', {
periodInMinutes: 60
}); // Repeats every 60 minutes
}
};- blockify.b-cdn.net
Serves switches190.json, the configuration file that controls feature toggles and dynamic request rules.
+3 more findings not shown