Is SetupVPN - Lifetime Free VPN safe?
SetupVPN encrypts all its API traffic with a per-request XOR key, making it impossible to inspect what data is being sent.
Code analysis found that all communication with the extension's backend is XOR-encrypted using a key included in each request header, rendering standard traffic inspection ineffective. The extension also contains a Go-compiled WebAssembly module with a hardcoded AES key. The content of the transmitted data is technically recoverable but has not yet been analyzed.
Who publishes itSetupVPN Inc - 2 other listings from the same operator, none carrying a finding
SetupVPN Inc - 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 operator - 2 listings
Published under a different store account, but sharing the registered address, contact or declared legal entity this one gave the store.
AI-generated. Findings may contain errors. Those marked Verified have been manually reviewed.
Publishers can request a review.
Findings
Remote Config: 7-Mirror CDN Rotates All API Infrastructure Every 6 Hours
SetupVPN doesn't hardcode servers.
It fetches tierssv.json from one of 7 CDN mirrors every 6h, listing domains to use next, letting operators swap endpoints with no update.
Mirrors: DigitalOcean, S3, R2, Vultr, Linode, GitHub, Bitbucket.
You install SetupVPN or open Chrome after installing it.
The config fetch fires once at install and then every 6 hours via a Chrome alarm, no user interaction needed.
The extension downloads a server config file from one of 7 CDN mirrors and replaces all API endpoints.
The new mainbase and tierbase domain lists immediately take effect for all future requests. The operator can push completely different infrastructure to 9 million users within 6 hours.
| Origin | chrome-extension://oofgbpoabipfcfjapgnbbjjaenockbdp |
| Sec-Fetch-Mode | cors |
| Sec-Fetch-Site | cross-site |
The live server config from CDN. mainbase lists the 12 domains the extension sends data to; the operator can push a new list any time.
chrome.storage.local key 'ext_serversjson'{
"retcode": 200,
"data": {
"version": 4,
"update_interval_hours": 6,
"settings": {
"tierbase_search_timeout": 15000,
"mainbase_search_timeout": 20000,
"tierbase_api_timeout": 35000,
"mainbase_api_timeout": 30000,
"proxy_search_timeout": 30000,
"mirror_timeout": 20000,
"base_ttl_days": 90,
"bs_consequtive_fails": 42
},
"mainbase": [
"https://lllm.scanners.fun",
"https://uabh.talked.run",
"https://mjgu.figure.run",
"https://xcxx.pointed.cc",
"https://uaia.scanners.fun",
"https://icax.figure.run",
"https://1.foreground.work",
"https://1.awakened.work",
"https://1.6912044.cc",
"https://1.default2024.uk",
"https://1.sahi.uk",
"https://1.area9.uk"
],
"tierbase": [
"https://sxkk.pointed.cc",
"https://api.keepthisdomain.com",
"https://1.allnine.uk",
"https://ksho.uk",
"https://3245.uk"
],
"uibase": [
"https://user1.setupvpn.com",
"https://user2.setupvpn.com",
"https://user3.setupvpn.com",
"https://user4.setupvpn.com",
"https://user5.setupvpn.com",
"https://user6.setupvpn.com",
"https://user7.setupvpn.com",
"https://user8.setupvpn.com",
"https://user9.setupvpn.com",
"https://user10.setupvpn.com",
"https://user11.setupvpn.com",
"https://user13.setupvpn.com",
"https://user14.setupvpn.com",
"https://user15.setupvpn.com"
],
"mirrors": [
"https://tierbase3.fra1.cdn.digitaloceanspaces.com/tierssv.json",
"https://tierbase4.s3.amazonaws.com/tierssv.json",
"https://pub-8029ed10cf4e4db0b3757e6b82ef7a40.r2.dev/tierssv.json",
"https://ams1.vultrobjects.com/tierupdate2/tierssv.json",
"https://mirror4.es-mad-1.linodeobjects.com/tierssv.json",
"https://raw.githubusercontent.com/the7c/update/master/master/ui/data.json",
"https://bitbucket.org/the7c/update/raw/master/edge/pub/data.json"
],
"t": 1736093012935
}
}Mirror fetch and server config dispatch (background.bundle.js)
// UPDATE_SERVERSJSON is the Redux action type that replaces all API endpoints.
const UPDATE_SERVERSJSON = 'UPDATE_SERVERSJSON';
// Hardcoded fallback config — used only if no CDN mirror is reachable.
// In practice, getTiers() fires at startup and CDN response overwrites this immediately.
const DEFAULT_SERVER_CONFIG = {
retcode: 200,
data: {
version: 4,
update_interval_hours: 6, // server can change this
mainbase: [ // 12 API endpoints — all fingerprinted requests go here
'https://lllm.scanners.fun',
'https://uabh.talked.run',
'https://mjgu.figure.run',
'https://xcxx.pointed.cc',
'https://uaia.scanners.fun',
'https://icax.figure.run',
'https://1.foreground.work',
'https://1.awakened.work',
'https://1.6912044.cc',
'https://1.default2024.uk',
'https://1.sahi.uk',
'https://1.area9.uk'
],
tierbase: [ // 5 secondary endpoints
'https://sxkk.pointed.cc',
'https://api.keepthisdomain.com',
'https://1.allnine.uk',
'https://ksho.uk',
'https://3245.uk'
],
mirrors: [ // 7 CDN mirrors tried in order
'https://tierbase3.fra1.cdn.digitaloceanspaces.com/tierssv.json',
'https://tierbase4.s3.amazonaws.com/tierssv.json',
'https://pub-8029ed10cf4e4db0b3757e6b82ef7a40.r2.dev/tierssv.json',
'https://ams1.vultrobjects.com/tierupdate2/tierssv.json',
'https://mirror4.es-mad-1.linodeobjects.com/tierssv.json',
'https://raw.githubusercontent.com/the7c/update/master/master/ui/data.json',
'https://bitbucket.org/the7c/update/raw/master/edge/pub/data.json'
]
}
};// On startup: create a repeating alarm named 'int1' tied to getTiers(). // updateIntervalTime is derived from update_interval_hours in the last fetched config. this.updateInterval = new IntervalTimer( this.getTiers.bind(this), this.updateIntervalTime, // default 6 hours = 21600000ms; server-adjustable 'int1' // alarm name visible in chrome.alarms ); this.updateInterval.start(); this.getTiers(); // also fires immediately at startup // isValidTierResponse() accepts the response only if: // retcode === 200 // mainbase.length > 2 // tierbase.length > 2 // update_interval_hours must be integer 1 < x < 168 (1 week max)
- tierbase3.fra1.cdn.digitaloceanspaces.com
Primary CDN mirror (DigitalOcean Spaces, Frankfurt). Confirmed fetched in dynamic analysis session. Hosts tierssv.json, the extension infrastructure config.
- tierbase4.s3.amazonaws.com
AWS S3 mirror. Fallback if DigitalOcean mirror is unreachable. Bucket name: tierbase4.
- pub-8029ed10cf4e4db0b3757e6b82ef7a40.r2.dev
Cloudflare R2 object storage mirror. The hex ID is the R2 bucket public access subdomain.
- ams1.vultrobjects.com
Vultr Object Storage mirror (Amsterdam). Path: /tierupdate2/tierssv.json.
- mirror4.es-mad-1.linodeobjects.com
Linode Object Storage mirror (Madrid). Five-CDN redundancy makes the config highly available and resistant to domain blocking.
- raw.githubusercontent.com
GitHub raw file mirror. Repository: the7c/update, path master/master/ui/data.json. Public repo acts as a free, highly available CDN fallback.
- bitbucket.org
Bitbucket raw file mirror. Repository: the7c/update, path master/edge/pub/data.json. Same operator (user the7c) controls both GitHub and Bitbucket mirrors.
WASM Fingerprint: Device + Browser Profiling on Every API Call
SetupVPN embeds a Go WASM binary (534KB) fingerprinting the browser at startup, no prompt: screen size, storage quota, heap size, mouse movement, incognito state, DevTools/timezone/Selenium detection, on every POST, XOR-encrypted, claim135.
You open Chrome with SetupVPN installed.
No action is required; the fingerprint is collected automatically at extension startup.
The extension builds a device fingerprint and attaches it to every request to SetupVPN servers.
A Go-compiled WASM binary exports 9 fingerprinting symbols that are called by the background service worker. The results are merged into the params for all POST requests via po().
| Field | Value | Why it matters | |
|---|---|---|---|
Screen dimensions | outerWidth=1920, innerWidth=1904, outerHeight=1080, innerHeight=969 | Outer and inner width/height of your browser window, helps uniquely identify your display configuration. | |
Storage quota | 12884901888 bytes (12 GB) | How much temporary storage your browser has allocated, a stable, hard-to-spoof device fingerprint signal. | |
JS heap size limit | 4294705152 bytes (4 GB) | Maximum memory your browser process can allocate, varies by OS, Chrome version, and hardware. | |
Mouse movement data | mouseMovement=[{x:413,y:208,t:104},{x:414,y:209,t:118}] | The speed and pattern of your mouse movements, used as a biometric signal and bot-detection measure. | |
Incognito / private mode | false | Whether you are browsing in an incognito window, changes the risk profile of your session for the server. | |
DevTools open | false | Whether you have the browser developer tools open, extension uses this to detect researchers or power users. | |
Selenium / automation detection | false | Whether your browser is being controlled by automated test software, used to detect security researchers. | |
Timezone mismatch | false | Whether your system timezone differs from what the browser reports, a VPN or proxy detection signal. | |
OS and platform | os=mac, platform=desktop, brand=Chrome | Your operating system type and Chrome build variant, narrows down your device further. |
WASM fingerprint proxy + request construction (background.bundle.js)
// Loads main.wasm and wraps all exported Go functions in a Promise-based proxy.
const zi = (() => {
let wasmReady = false;
async function initWasm(wasmBytesPromise) {
const go = new Go(); // Go WASM runtime helper (wasm_exec.js)
const bytes = await wasmBytesPromise;
const instance = await WebAssembly.instantiate(bytes, go.importObject);
go.run(instance.instance);
wasmReady = true;
}
initWasm(fetch('/main.wasm').then(r => r.arrayBuffer()));
return new Proxy({}, {
get(_, methodName) {
return (...args) => new Promise((resolve, reject) => {
const poll = () => {
if (!wasmReady) { setTimeout(poll, 10); return; }
window[methodName](...args, (err, ...result) => {
if (err) reject(err); else resolve(result);
});
};
poll();
});
}
});
})();// Every POST request to SetupVPN servers goes through po().
async function po(endpoint, params, { signal, baselink, requestUrl, timeout, defaults } = {}) {
const key = generateRandomKey(); // go(): 3-8 random alphanumeric chars
const headers = buildAuthHeader(key); // mo(): {Content-Type, Authorization: Basic}
const requestOptions = { method: 'POST', headers };
const defaultParams = defaults ? defaults : await getOsDefaults(); // vo()
const mergedParams = { ...params, ...defaultParams };
requestOptions.body = encodeBody(mergedParams, key); // bo(): url-encode -> XOR -> base64
const controller = new AbortController();
requestOptions.signal = controller.signal;
const timer = setTimeout(() => controller.abort(), timeout);
const response = await fetch(requestUrl || (baselink + endpoint), requestOptions);
clearTimeout(timer);
return response;
}
// XOR cipher (Eo)
function xorCipher(text, key) {
const t = text.split('');
const k = key.split('');
for (let i = 0; i < t.length; i++)
t[i] = String.fromCharCode(t[i].charCodeAt(0) ^ k[i % k.length].charCodeAt(0));
return t.join('');
}
function encodeBody(params, key) {
const urlEncoded = Object.keys(params)
.map(k => encodeURIComponent(k) + '=' + encodeURIComponent(params[k]))
.join('&');
return btoa(xorCipher(urlEncoded, key));
}
function decodeResponse(body, key) {
return xorCipher(decodeURIComponent(escape(atob(body))), key);
}- lllm.scanners.fun
Primary mainbase API endpoint. Receives all user telemetry, auth, and fingerprinted payloads. Domain registered via Namecheap; no obvious business identity.
- uabh.talked.run
Secondary mainbase endpoint. Same API as lllm.scanners.fun. Used as failover when primary is unreachable.
- mjgu.figure.run
Tertiary mainbase endpoint. Obfuscated subdomain on figure.run.
- xcxx.pointed.cc
Mainbase endpoint on pointed.cc infrastructure. Confirmed contacted in DA traffic.
- uaia.scanners.fun
Additional scanners.fun mainbase endpoint.
- icax.figure.run
Additional figure.run mainbase endpoint.
- 1.foreground.work
Mainbase endpoint on foreground.work.
- 1.awakened.work
Mainbase endpoint on awakened.work.
- 1.6912044.cc
Mainbase endpoint, numeric.cc domain.
- 1.default2024.uk
Mainbase endpoint on default2024.uk.
- 1.sahi.uk
Mainbase endpoint on sahi.uk.
- 1.area9.uk
Mainbase endpoint on area9.uk.
Dual-Layer Encryption Hides All Traffic: XOR + Go WASM AES
SetupVPN wraps every request in two layers.
The outer is a rolling XOR cipher: a random key scrambles the body and travels with it in the same header, reversible by anyone intercepting both. main.wasm adds a Go AES layer with a 44-char key.
The extension prepares any POST request to a SetupVPN server.
This applies to every API call, authentication, server selection, telemetry, and config fetches.
The request body is XOR-encrypted with a per-request random key, and the key is embedded in the Authorization header.
A Go-compiled WASM binary applies an additional AES cipher layer using a 44-character key hardcoded in the binary: 'JHn7tK7OjOWMHHPm2wEgeS3E8Gbq3PPgZx3dnj3Xx?q!!'
All POST bodies look like random base64 strings in network tools. Decoding requires extracting the XOR key from the Authorization header.
login=user%40example.com&hpassword=<redacted>&os=mac&cv=4.7.2.2&platform=desktop&brand=Chrome
The XOR cipher and encoding pipeline, shipped vs readable
// Eo(text, key) — character-level XOR cipher with a repeating key.
function xorCipher(text, key) {
const textChars = text.split('');
const keyChars = key.split('');
for (let i = 0; i < textChars.length; i++) {
textChars[i] = String.fromCharCode(
textChars[i].charCodeAt(0) ^ keyChars[i % keyChars.length].charCodeAt(0)
);
}
return textChars.join('');
}
// go() — generates a random 3-65 char alphanumeric key (3-8 when used in mo())
function generateKey(length) {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
return Array.from({ length }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
}
// mo(key) — builds request headers; the XOR key goes in Authorization: Basic
function buildHeaders(key) {
const suffix = generateKey(Math.round(3 + 5 * Math.random()));
return {
'Content-Type': 'text/plain',
'Authorization': 'Basic ' + btoa(key + ':' + suffix)
};
}
// bo(params, key) — encodes the request body
function encodeBody(params, key) {
const urlEncoded = Object.keys(params)
.filter(k => Object.prototype.hasOwnProperty.call(params, k))
.map(k => encodeURIComponent(k) + '=' + encodeURIComponent(params[k]))
.join('&');
return btoa(xorCipher(urlEncoded, key));
}
// wo(body, key) — decodes a response body
function decodeBody(encodedBody, key) {
return xorCipher(decodeURIComponent(escape(atob(encodedBody))), key);
}Decodes any captured SetupVPN POST request body using the XOR key extracted from its Authorization header. Paste in the captured Authorization header value and POST body, run the script, and it prints the plaintext params sent by the extension.
#!/usr/bin/env node
// setupvpn-xor-decode.js
//
// Decodes a captured SetupVPN POST request body.
//
// How SetupVPN encodes requests (from background.bundle.js functions Eo/bo/mo/go/wo):
// 1. Generate a random key K (3-8 alphanumeric chars) — function go()
// 2. URL-encode all request params into a query string P — inside bo()
// 3. XOR every character of P with repeating K -> C — function Eo()
// 4. base64-encode C -> wire body — btoa() in bo()
// 5. Put K in the Authorization: Basic base64(K:randomSuffix) header — function mo()
//
// To decode: atob(body) -> unescape(escape(result)) -> XOR(result, K)
// To get K: atob(authHeaderValue.replace("Basic ", "")).split(":")[0]
//
// Usage:
// 1. Capture a POST to any mainbase domain (lllm.scanners.fun, uabh.talked.run, etc.)
// 2. Copy the value from the Authorization header (everything after "Basic ")
// 3. Copy the raw base64 POST body
// 4. Paste them below as CAPTURED_AUTH_HEADER and CAPTURED_BODY
// 5. node setupvpn-xor-decode.js
//
// Requirements: Node.js 16+ (no npm packages needed)
// ─── PASTE YOUR CAPTURED VALUES HERE ────────────────────────────────────────
// The value from the Authorization header (everything after "Basic "):
const CAPTURED_AUTH_HEADER = 'bVEzazo4QTZi';
// Example: base64 decodes to "mQ3k:8A6b" -> XOR key = "mQ3k"
// The raw POST body (base64 string from the request):
const CAPTURED_BODY = 'S3VHcGpXT1FaUFBVUFFUUVZZV1VUUVZaUVlZWVVQUFZXWVRRWFBVUVZWVQ==';
// Example body encoded with key "mQ3k"
// ─────────────────────────────────────────────────────────────────────────────
/**
* XOR cipher — exact match of SetupVPN's Eo() function in background.bundle.js.
* Both text and key are plain JS strings. The key repeats cyclically.
*/
function xorCipher(text, key) {
const textChars = text.split('');
const keyChars = key.split('');
for (let i = 0; i < textChars.length; i++) {
textChars[i] = String.fromCharCode(
textChars[i].charCodeAt(0) ^ keyChars[i % keyChars.length].charCodeAt(0)
);
}
return textChars.join('');
}
/**
* Extract the XOR key from the Authorization: Basic header value.
* Header format (after base64-decoding): "<xorKey>:<randomSuffix>"
* Only the part before the first colon is the XOR key.
*/
function extractKey(authHeaderValue) {
const b64 = authHeaderValue.replace(/^Basic\s+/i, '').trim();
const decoded = Buffer.from(b64, 'base64').toString('utf8');
const key = decoded.split(':')[0];
if (!key) throw new Error('Could not extract XOR key from Authorization header');
return key;
}
/**
* Decode a SetupVPN POST body.
* Mirrors SetupVPN's wo() function: atob -> unescape(escape(s)) -> XOR
*
* The unescape/escape step converts latin-1 byte sequences (produced after
* XOR) back to valid UTF-8 — required for any non-ASCII bytes in the params.
*/
function decodeBody(encodedBody, xorKey) {
// base64-decode to binary string (latin-1 byte values)
const rawBytes = Buffer.from(encodedBody, 'base64').toString('binary');
// unescape(escape(s)) equivalent: percent-encode each byte, then decode as UTF-8
const unescaped = decodeURIComponent(encodeURIComponent(rawBytes));
return xorCipher(unescaped, xorKey);
}
/**
* Parse URL-encoded query string into a plain object.
*/
function parseQueryString(qs) {
const params = {};
for (const part of qs.split('&')) {
if (!part) continue;
const eqIdx = part.indexOf('=');
if (eqIdx === -1) { params[decodeURIComponent(part)] = ''; continue; }
const k = decodeURIComponent(part.slice(0, eqIdx));
const v = decodeURIComponent(part.slice(eqIdx + 1));
params[k] = v;
}
return params;
}
// ─── Main ─────────────────────────────────────────────────────────────────────
let xorKey;
try {
xorKey = extractKey(CAPTURED_AUTH_HEADER);
} catch (err) {
console.error('ERROR extracting XOR key:', err.message);
console.error('Make sure CAPTURED_AUTH_HEADER is the base64 value from the Authorization header,');
console.error('NOT including the "Basic " prefix — or paste the full header value and the script');
console.error('will strip the prefix automatically.');
process.exit(1);
}
console.log('Authorization header : ' + CAPTURED_AUTH_HEADER);
console.log('Decoded header value : ' + Buffer.from(CAPTURED_AUTH_HEADER.replace(/^Basic\s+/i,''), 'base64').toString('utf8'));
console.log('XOR key extracted : ' + JSON.stringify(xorKey));
console.log('');
let decoded;
try {
decoded = decodeBody(CAPTURED_BODY, xorKey);
} catch (err) {
console.error('ERROR decoding body:', err.message);
console.error('Make sure CAPTURED_BODY is the raw base64 string from the POST body.');
process.exit(1);
}
console.log('Decoded query string:');
console.log(decoded);
console.log('');
let params;
try {
params = parseQueryString(decoded);
} catch (err) {
console.error('Parsed as raw string only (not URL-encoded params):', err.message);
params = null;
}
if (params && Object.keys(params).length > 0) {
console.log('Parsed params:');
const maxKeyLen = Math.max(...Object.keys(params).map(k => k.length));
for (const [k, v] of Object.entries(params)) {
console.log(' ' + k.padEnd(maxKeyLen + 2) + '= ' + v);
}
} else if (params !== null) {
console.log('(No URL-encoded params found — decoded value shown above is the plaintext)');
}
- 1node setupvpn-xor-decode.js
| Field | Value | Why it matters | |
|---|---|---|---|
Hardcoded AES key / seed | JHn7tK7OjOWMHHPm2wEgeS3E8Gbq3PPgZx3dnj3Xx?q!! | A 44-character string baked directly into the compiled WASM binary, cannot be changed without releasing a new extension version. | |
Secondary key candidate | X2skj39mcLmc2!js | A 16-character string adjacent to AES symbols in the WASM data segment, matches standard AES-128 key length. |
What it can do
Permissions this extension asks for, as declared in version 4.0.9. Asking for a permission is not a finding on its own - it is what the extension can do if it chooses to.
Read and change your data on every site you visit
<all_urls>
Route all of your browsing through a server of its choosing
proxy
Store data in your browser
storage
Watch every request your browser makes
webRequest
Show you desktop notifications
notifications
See the address and title of every tab you have open
tabs
See, disable and uninstall your other extensions, including your security ones
management
Schedule its own background tasks
alarms
Block and redirect the requests your browser makes
declarativeNetRequest