Is VibeMate safe?
VibeMate stays dormant until you open a livestream cam site, then reads the page's traffic and forwards chat and tip data to its servers.
On configured adult cam sites (Chaturbate, Stripchat, BongaCams, Cam4, CamSoda, MyFreeCams), VibeMate injects a page script that replaces the site's WebSocket, XMLHttpRequest, Worker and fetch with wrappers that read every response body. It parses out tip events — model name, tip amount and the tipping viewer's username, including other viewers' data — and relays them through an offscreen iframe to fanberry.com. The extension keeps its features hidden until it detects one of these sites, and the list of targeted sites is stored AES-encrypted and can be expanded remotely via messages from fanberry.com or vibemate.com without an update.
AI-generated. Findings may contain errors. Those marked Verified have been manually reviewed.
Publishers can request a review.
Findings
VibeMate intercepts all cam-site WebSocket and HTTP traffic
On cam sites VibeMate supports, base.js replaces WebSocket, XHR, fetch, and Worker, then forwards tip events (model, amount, sender) to an offscreen fanberry.com iframe from Chaturbate, Stripchat, BongaCams, Cam4, CamSoda, MyFreeCams.
What actually happens
You open a cam site that VibeMate supports (e.g. Chaturbate, Stripchat, BongaCams).
base.js replaces the page's WebSocket, XMLHttpRequest, fetch, and Worker, forwarding captured responses to an offscreen fanberry.com iframe.
This happens on every page load once the extension's cam-site list matches the current tab URL.
The code that does this
J1(window), WebSocket and fetch override in base.js (lines 2131-2222)
// J1(window) — installed by base.js on every cam-site pagefunction J1(n) { // --- WebSocket: capture every incoming message --- n.WebSocket = class extends n.WebSocket { constructor(...args) { super(...args); const socketId = `lvs-ws-${randomId(8)}`; this.addEventListener('open', () => { openSockets[socketId] = this; }); this.addEventListener('close', () => { delete openSockets[socketId]; }); this.addEventListener('message', evt => { X('LVS_VIEWER_WEB_SOCKET_MESSAGE', { socketId, socketUrl: this.url, messageType: 'ws', platform: currentPlatform, data: evt?.data // ← full message payload }); }); } }; // --- XMLHttpRequest: capture completed responses --- n.XMLHttpRequest = class extends n.XMLHttpRequest { constructor(...args) { super(...args); this.addEventListener('readystatechange', () => { if (this.readyState !== 4 || this.responseType === 'arraybuffer') return; X('LVS_VIEWER_XHR_MESSAGE', { messageType: 'http', data: { readyState: 4, response: this.response, responseURL: this.responseURL || location.origin, responseType: this.responseType, status: this.status } }); }, false); } }; // --- fetch: clone every response body (JSON or text) --- const origFetch = n.fetch; n.fetch = async (...args) => { const resp = await origFetch(...args); const clone = resp.clone(); let body; let type = ''; try { body = await resp.clone().json(); type = 'json'; } catch {} if (!body) try { body = await resp.clone().text(); type = 'text'; } catch {} if (body) X('LVS_VIEWER_XHR_MESSAGE', { messageType: 'http', data: { readyState: 4, response: body, responseURL: new URL(args[0], location.origin).href, responseType: type, status: clone.status } }); return resp; // original response returned to page }; // --- Worker messages --- n.Worker = class extends n.Worker { constructor(...args) { super(...args); this.addEventListener('message', evt => { X('LVS_VIEWER_WORKER_MESSAGE', { data: evt?.data }); }); } }; // ServiceWorker messages if ('serviceWorker' in navigator) navigator.serviceWorker.addEventListener('message', evt => { X('LVS_VIEWER_SERVICE_WORKER_MESSAGE', evt); });}The code that does this
Tip event parsing for 6 cam platforms, offscreen-8cn6P2JZ.js TipHandler
// Platform-name constants — each DTX$ blob decrypts to the cam-site domain nameconst PLATFORMS = { CB: decrypt('DTX$71yjytze...'), // → 'chaturbate.com' SC: decrypt('DTX$71hRYfow...'), // → 'stripchat.com' BC: decrypt('DTX$31SWJGIE...'), // → 'bongacams.com' C4: decrypt('DTX$51sOaDID...'), // → 'cam4.com' CS: decrypt('DTX$31pbIV6B...'), // → 'camsoda.com' MF: decrypt('DTX$612mvQ4x...'), // → 'myfreecams.com'};class TipHandler { handle(event) { // Match event.platform to a key; call the platform-specific parser const key = Object.keys(PLATFORMS).find(k => PLATFORMS[k] === event.platform); if (key) this[key](event); } async dispatch(tipInfo) { // Fires LVS_VIEWER_TIP_MESSAGE — picked up by the offscreen iframe relay E('LVS_VIEWER_TIP_MESSAGE', tipInfo); } CB({ data, platform, sender }) { if (!data.includes('RoomTipAlertTopic')) return; const msg = JSON.parse(JSON.parse(data).messages[0].data); this.dispatch({ tabId: sender.tab?.id, platform, modelName: msg.to_username, amount: msg.amount, viewer: msg.from_username, // ← the tipping viewer's name time: Date.now() }); } // SC, BC, C4, CS, MF follow the same shape: // extract { modelName, amount, viewer } and dispatch}Data extracted per tip event (Chaturbate example)
- Model namesweetkittyx
The screen name of the performer receiving the tip, visible to anyone monitoring the data.
- Tipping viewer usernametipperlover99
The screen name of the viewer who sent the tip, another user's identity disclosed without their knowledge.
- Tip amount (tokens)250
The number of tokens in the tip, revealing financial transaction details for third-party viewers.
- Event timestamp1749254832471
Unix millisecond timestamp of when the tip occurred.
- Platform identifierchaturbate.com
The cam site domain the event came from, used to route to the correct parser.
Data destination
- www.fanberry.com
Hosts the offscreen iframe at /extension/<id>/background.html. Intercepted traffic and parsed tip events post into it via postMessage. Confirmed active in dynamic analysis.
VibeMate hides cam-site features until a cam site is visited
VibeMate ships locked, withholding cam tools until you open a cam site, fanberry.com, or lovense.com.
Opening one triggers a check against an encrypted site list, an unlock flag, a log to fanberry.com, and an uninstall-URL update.
What actually happens
You open any browser tab on a cam site, fanberry.com, or lovense.com.
The extension checks open tabs against its encrypted cam-site list, marks the session unlocked, logs the event, and updates the uninstall URL to record the visit.
This check happens automatically without any user action inside the extension.
The code that does this
Undisguise routine, background.js E() (deobfuscated)
// E() — conditional unlock routineasync function E() { // Short-circuit if already unlocked if (await r.getItem('local:isShowMoreFeature')) return; // Log initial disguised state once if (!await r.getItem('local:isSendLockLog')) { f.add({ eventId: 'disguise', content: { position: 'sidepanel', click_type: 'lock' } }); await r.setItem('local:isSendLockLog', true); } await k(5000); // brief delay let unlockReason = ''; const camSitePatterns = await P(); // decrypted site list from AES blob const openUrls = (await n.tabs.query({})).map(t => t.url); let matched = false; for (const url of openUrls) { const isCamSite = camSitePatterns.some(p => S(p, url)); const isFanberry = /^https:\/\/www\.fanberry\.com\/[^/?#].*$/.test(url) && !url.includes('uninstall'); const isLovense = (new URL(url).host || '').includes(N); // N = decrypted 'lovense.com' if (isCamSite) unlockReason = 'camsite'; else if (isFanberry) unlockReason = 'fanberry'; else if (isLovense) unlockReason = 'lovense'; if (isCamSite || isFanberry || isLovense) { matched = true; break; } } if (matched) { await r.setItem('local:isShowMoreFeature', true); // persist unlocked // Log unlock sequence f.add({ eventId: 'disguise', content: { click_type: 'unlock' } }); f.add({ eventId: 'disguise', content: { click_type: 'undisguised', click_name: unlockReason } }); v(); // update uninstall URL with s=1 m('UPDATE_UNDISGUISED', true); }}The code that does this
Uninstall URL encodes whether you ever visited a cam site
// v() — updates the uninstall URL to record whether the user reached a cam sitefunction v() { let url = 'https://www.fanberry.com/uninstall'; const version = '1.4.4'; r.getItem('local:isShowMoreFeature').then(unlocked => { // s=1 means user visited a cam site; s=0 means they never did url += unlocked ? `?s=1&v=${version}&t=${Date.now()}` : `?s=0&v=${version}&t=${Date.now()}`; n.runtime.setUninstallURL(url); // registered as the uninstall beacon });}Why you can't catch this in DevTools
The list of cam sites where the extension activates ships AES-CBC encrypted. Only the extension runtime can read which sites trigger the unlock.
The encrypted blob (variable q in background.js, decrypted by V1() from chunks/index-C0YbUdoZ.js using AES-CBC, key = last 16 chars of blob, IV = reversed 16 chars after the length digit) decodes to a JSON object mapping platform names to arrays of URL match patterns — the full list of cam sites that trigger the unlock. dynamic analysis confirmed this list includes chaturbate.com patterns among others.Where your cam-site visit status is reported
- www.fanberry.com
Receives the uninstall beacon (GET /uninstall?s=1&v=1.4.4&t=...) encoding whether you ever visited a cam site; also the base domain for the extension's operator.
- customer-api.fanberry.com
Receives AES-encrypted BI log events (POST /api/camlient/log/report) including the disguise/unlock event sequence. Confirmed by dynamic analysis.
Page-window bridge reads site state
We observed VibeMate load its page-world script during browsing.
It registers GET_BASE_WINDOW_VALUE: given a dotted path from the content script, the page script walks it from window, calls it if callable, and returns the JSON result.
What actually happens
You visit a page where VibeMate loads its page-world script.
The extension can ask that page script to read a named value from the page window and return it.
Captured request
The extension page script was loaded during browsing.
Bridge fields used to select and return page state
- Requested window pathdetail.data.key
This tells the page script which value on the page to read for the extension.
- Function-call flagdetail.data.isFn
This lets the bridge call the selected page value when it is a function, then return the result.
- Returned page valueGET_BASE_WINDOW_VALUE_RESPONSE.detail.data
This carries the JSON-serialized value back from the page to the extension.
The code that does this
The content script request and page-world bridge
Readable page-world bridge
base.jsfunction q1() { $ = G1(), X("LVS_SITE", { currentSite: $ }), window.addEventListener("GET_LVS_CURRENT_SITE", n => { const a = n.detail?.uuid || ""; X("GET_LVS_CURRENT_SITE_RESPONSE", a ? { uuid: a, data: $ } : $) }), window.addEventListener("GET_BASE_WINDOW_VALUE", n => { const a = n.detail?.uuid || "", l = n.detail?.data?.key || "", r = n.detail?.data?.isFn || !1, c = l.split("."); let i = null, e = ""; if (c.reduce((t, u) => (i = t?.[u], i), window), r && typeof i == "function") try { e = JSON.stringify(i() || "") || "" } catch { e = JSON.stringify(i) || "" } else e = JSON.stringify(i) || ""; X("GET_BASE_WINDOW_VALUE_RESPONSE", a ? { uuid: a, data: e } : e) }), $ && (window.addEventListener("SEND_LVS_VIEWER_MESSAGE_BY_WEB_SOCKET", n => { const { socketId: a = "", socketUrl: l = "", arrToString: r = !1, params: c = "", paramsPrefix: i = "" } = n.detail || {}; a ? Z[a] && (r ? Z[a]?.send(i + c.join(" ")) : Z[a]?.send(i + JSON.stringify(c))) : l && Object.values(Z).forEach(e => { e.url === l && (r ? e.send(i + c.join(" ")) : e.send(i + JSON.stringify(c))) }) }), window.addEventListener("SEND_SYNC_LVS_VIEWER_MESSAGE_BY_WEB_SOCKET", n => { const { uuid: a = "", data: l = null } = n.detail || {}, { socketId: r = "", socketUrl: c = "", arrToString: i = !1, callbackId: e = "", params: w = "", paramsPrefix: t = "" } = l || {}; let u = { uuid: a }, h = null; if (r ? h = Z[r] : c && Object.values(Z).forEach(g => { g.url === c && (h = g) }), h) if (e) { const g = p => { const S = p.detail || {}; if (S.socketId === r) { const { data: B = "" } = S || {}; if (B && F1(B)) { let s = JSON.parse(B); s.id === e && (window.removeEventListener("LVS_VIEWER_WEB_SOCKET_MESSAGE", g), X("SEND_SYNC_LVS_VIEWER_MESSAGE_BY_WEB_SOCKET_RESPONSE", { ...u, data: s })) } } }; window.addEventListener("LVS_VIEWER_WEB_SOCKET_MESSAGE", g), i ? h.send(t + w.join(" ")) : h.send(t + JSON.stringify(w)) } else i ? h.send(t + w.join(" ")) : h.send(t + JSON.stringify(w)), X("SEND_SYNC_LVS_VIEWER_MESSAGE_BY_WEB_SOCKET_RESPONSE", { ...u, data: "success" }); else X("SEND_SYNC_LVS_VIEWER_MESSAGE_BY_WEB_SOCKET_RESPONSE", { ...u, error: "socket not found" }) }), window.addEventListener("SEND_LVS_VIEWER_MESSAGE_BY_WORKER", n => { const { workerId: a = "", data: l = "" } = n.detail || {}; E1[a] && E1[a]?.postMessage(l) }), J1(window)) }Readable content-script helper
content-scripts/common.jsAr = async (t, e = !1) => { try { return await nt("GET_BASE_WINDOW_VALUE", { key: t, isFn: e }) } catch { return "" } }= "string" ? s = sessionStorage.getItem(r) : s = sessionStorage.getItem(r.key); break } case "GET_WINDOW_VALUE": { try { typeof r == "string" ? s = await Ar(r) : s = await Ar(r.key, r.isFn) } catch {} break } case "GET_DOM_SRC": { typeof r == "string" ? s = Rr(r) : s = Rr(r.selector); break } case "GET_DOM_TEXT_CONTENT": {Hosts allowed to communicate with the extension
- fanberry.com
- vibemate.com
Allowed by externally_connectable to send extension messages from matching subdomains.
+1 more finding not shown
Where it sends data
Destinations our analysis observed VibeMate contacting. Sending data somewhere is not a finding on its own - an extension that syncs your settings has to talk to its own server - but it is where your data can go, and who else it goes to.
- fanberry.com
VibeMate sends data to fanberry.com. No other extension we have analyzed sends data here.
- vibemate.com
VibeMate sends data to vibemate.com. No other extension we have analyzed sends data here.