Is Coin98 Wallet Extension: Crypto & Defi safe?
Coin98 Wallet is medium risk. Coin98 Wallet carries the social/email registration email into its GA helper. On first run, absent a client ID, it POSTs social_new_install using that email as client_id. DA saw a different install POST, not this; registration wasn't done.
Who publishes it98-publisher-group - 2 other listings from the same operator, none carrying a finding
98-publisher-group - 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, 2k+ 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
Email wallet registration can use your email as a GA identifier
Coin98 Wallet carries the social/email registration email into its GA helper.
On first run, absent a client ID, it POSTs social_new_install using that email as client_id.
DA saw a different install POST, not this; registration wasn't done.
- Severity
- Medium unwanted
- Type
- Unexpected
- CWE
- CWE-359
- Source
- Dynamic sandbox
You submit an email address while setting up a social/email wallet.
The form validates the address, sends an invite-link request, and passes the same address into the success route.
The extension prepares a Google Analytics event that can use that email address as the analytics identifier.
This happens in the first-run analytics branch where no client ID is already stored.
- Your email addressalex.rivera@example.com
This directly identifies you and is placed where Google Analytics expects a client identifier.
- Registration eventsocial_new_install
This labels the analytics record as a new social/email wallet installation event.
- Sign-in typeemail
This says the wallet was registered through the email-based flow.
- Generated device ID7f1c4e8a-33f6-4c52-9b0d-0b59ad3dc6d4
This lets the analytics record tie the registration event to an extension-generated identifier on the same browser profile.
Dynamic analysis observed a POST to this Google Analytics endpoint for a different install event; the email-registration request body was not recorded.
The form carries the submitted email into the success route
Readable form submit handler
WalletSocialEmail.ddb01262.jsconst submitEmail = async (formValues) => { const validationError = validateEmail(formValues.email); if (typeof validationError === "string") { return window.openModal({ type: "error", content: t("invalidEmail") }); } try { setLoading(true); const invite = await sendInviteLink(formValues.email); if (String(invite?.error).toLowerCase().includes("for 1 minute before sending the next request")) { window.openModal({ type: "error", title: t("alertMessage"), content: t("waitSendEmail") }); } else if (String(invite?.error).toLowerCase().includes("invalid email address")) { window.openModal({ type: "error", title: t("alertMessage"), content: t("invalidEmail") }); } else if (invite?.error) { window.openModal({ type: "error", title: t("alertMessage"), content: t("error") }); } if (!invite.success) return; const { pendingToken, transactionId, appId } = invite; history.push(history.location.pathname + "/success", { pendingToken, transactionId, appId, email: formValues.email }); } catch (error) { setLoading(false); } finally { setLoading(false); }};The success handler adds the email to the GA event
Readable success handler
WalletSocialEmailSuccess.66912161.jsconst createEmailWallet = async (walletResult) => { setLoading(true); mountedRef.current = false; clearTimeout(firstTimer.current); clearTimeout(secondTimer.current); const isCreate = !!get(walletResult, "address", false); if (!walletResult) return false; const authProvider = AUTH_PROVIDER.EMAIL; const routeEmail = routeState.email; const mnemonic = get(walletResult, "mnemonic"); const email = routeEmail || get(walletResult, "email") || get(walletResult, "displayName", ""); const rawId = routeEmail || get(walletResult, "email") || get(walletResult, "rawId") || generateId(); const socialInfo = { uid: `${authProvider}_${rawId}`, email: email.toLowerCase(), avatar: authProvider, uidSync: `${authProvider};${rawId}`, mnemonic }; const createdWallets = await walletService.createOrRestore({ name: socialInfo.email, mnemonic: socialInfo.mnemonic }); const encryptedMnemonic = encryptService.encrypt(socialInfo.mnemonic); const wallets = (Array.isArray(createdWallets) ? createdWallets : [createdWallets]).map((wallet) => ({ address: wallet.address, meta: wallet.meta, nearDomain: wallet.nearDomain, name: wallet.name, publicKey: wallet.publicKey })); const socialInfoWithoutMnemonic = { ...socialInfo, mnemonic: undefined, version: RAMPER_VERSION.V2 }; const walletRecord = { name: get(socialInfo, "email", ""), meta: { chain: CHAIN_TYPE.multiChain, isMulti: true, socialType: "email", socialInfo: socialInfoWithoutMnemonic }, wallets, mnemonic: encryptedMnemonic }; setStep((step) => step ? 5 : null); await sleep(1000); GA4Analytics.fireNewUserAndDevice("social_new_install", { email: socialInfo.email, socialType: "email" }); dispatch(onUpdateWallet(walletRecord)); setWallets([walletRecord]); airbridgeService.sendAirbridgeWallet(walletRecord); trackWallet({ hardwareWallet: false, kind: WALLET_TYPE.social, name: walletRecord?.name, wallets: walletRecord?.wallets, isCreate, social: "email" }); if (shortcuts.length) { history.replace({ pathname: "/shortcuts", state: { title: t("loginEmailSuccess") } }); } else { history.replace({ pathname: "/shortcuts", state: { isShortcut: true } }); } return true;};The GA helper places email into client_id
Readable GA helper module
static/background/index_10.6.0.jsconst GA_URL = "https://www.google-analytics.com/mp/collect";const GA_DEBUG_URL = "https://www.google-analytics.com/debug/mp/collect";const MEASUREMENT_ID = "G-CCPPHL1RJT";const API_SECRET = "<redacted>";const DEFAULT_ENGAGEMENT_TIME_MSEC = 100;const SESSION_TIMEOUT_MINUTES = 30;class Analytics { constructor(debug = false) { this.debug = debug; } async getOrCreateClientId() { const { clientId } = await chrome.storage.local.get("clientId"); if (!clientId) { const newClientId = self.crypto.randomUUID(); await chrome.storage.local.set({ clientId: newClientId }); return newClientId; } return clientId; } async getOrCreateSessionId() { let { sessionData } = await chrome.storage.session.get("sessionData"); const timestamp = Date.now(); if (sessionData && sessionData.timestamp) { const minutesSinceLastEvent = (timestamp - sessionData.timestamp) / 60000; if (minutesSinceLastEvent > SESSION_TIMEOUT_MINUTES) { sessionData = null; } else { sessionData.timestamp = timestamp; await chrome.storage.session.set({ sessionData }); } } if (!sessionData) { sessionData = { session_id: timestamp.toString(), timestamp: timestamp.toString() }; await chrome.storage.session.set({ sessionData }); } return sessionData.session_id; } async fireNewUserAndDevice(eventName, params = {}) { const { clientId } = await chrome.storage.local.get("clientId"); if (!clientId) { params.session_id || (params.session_id = await this.getOrCreateSessionId()); params.engagement_time_msec || (params.engagement_time_msec = DEFAULT_ENGAGEMENT_TIME_MSEC); try { const generatedClientId = await this.getOrCreateClientId(); await fetch(`${this.debug ? GA_DEBUG_URL : GA_URL}?measurement_id=${MEASUREMENT_ID}&api_secret=${API_SECRET}`, { method: "POST", body: JSON.stringify({ client_id: params?.email || generatedClientId, events: [ { name: eventName, params: { deviceId: generatedClientId, ...params } } ] }) }); if (!this.debug) return; } catch (error) { console.error("Google Analytics request failed with an exception", error); } } } async firePageViewEvent(title, location, params = {}) { return this.fireNewUserAndDevice("page_view", { page_title: title, page_location: location, ...params }); } async fireErrorEvent(error, params = {}) { return this.fireNewUserAndDevice("extension_error", { ...error, ...params }); }}export default new Analytics();- www.google-analytics.com
Receives the Google Analytics Measurement Protocol event built by the extension.