Is Mask Network safe?
Mask Network encrypts Twitter/X session cookies with a weak 6-digit key and uploads them to firefly.social.
When a user logs in to Twitter/X through the extension, Mask Network collects session cookies (auth_token, ct0, twid) and OAuth credentials, encrypts them with AES-CBC using a key derived from a 6-digit random number (~20 bits of entropy), and uploads the ciphertext to firefly.social/api/firefly/desktop-sync/upload. The extension also ships hardcoded Twitter OAuth1 consumer credentials (key and secret) in its bundle, which are sent to firefly.social to authenticate API calls.
Who publishes itDimension - no other listings under this identity, 18 shared hostnames
Dimension - no other listings under this identity, 18 shared hostnames
What this publisher told the store about itself, and the other listings that told it the same thing.
Shared hosts - 18 hostnames
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
X session cookies encrypted with a 6-digit brute-forceable key before upload
During Firefly's Twitter-sync, it collects X.com cookies (auth_token, ct0, twid, etc.) and OAuth1 tokens, encrypts with a 20-bit key/fixed IV, uploads to firefly.social.
A PoC brute-forced it in 4.55s; it leaks in cleartext + a QR code too.
What actually happens
You start the 'Connect your Firefly App' / sync-Twitter-cookies flow and grant the optional cookies permission, then click Upload.
The flow lives at the /wallet/sync-twitter-cookies route in the extension.
The extension encrypts your X.com session cookies and OAuth tokens with a six-digit key and uploads them to firefly.social.
The same six-digit key is included in cleartext in the upload and embedded in an on-screen QR code.
The code that does this
Six-digit key derivation, hardcoded IV, and the upload
Readable form of the scheme
// Key: a 6-digit decimal string, 000000..999999 (~20 bits of entropy)function generateCryptoKey() { const a = new Uint32Array(1); crypto.getRandomValues(a); return (a[0] % 1_000_000).toString().padStart(6, "0");}// Same IV for every user, every upload:const IV = fromHex("0x4f05c37c16c801c2516b0338a8fd0cf9");async function encrypt(plaintext, cryptoKey) { const aesKey = await crypto.subtle.digest("SHA-256", utf8(cryptoKey)); // key = SHA-256(6-digit string) return aesCbc(aesKey, IV, plaintext);}// Upload body carries the ciphertext AND the 6-digit key in cleartext:// { session, cryptoKey, encryptedPayload }Session material inside the encrypted payload
- X.com auth_tokenauth_token=a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0
The primary X.com session cookie. Possession grants logged-in access to the account.
- X.com ct0ct0=9f8e7d6c5b4a39281706f5e4d3c2b1a0
The CSRF token cookie paired with auth_token for authenticated X.com requests.
- X.com twidtwid="u=1234567890"
Encodes the numeric account user id.
- kdt / _twitter_sesskdt=Hk9...; _twitter_sess=BAh7...
Additional X.com session cookies bundled into the payload.
- OAuth1 access token + credentialaccessToken=1234567890-AbCdEf...; accessTokenSecret=VictimAccessTokenSecret9876543210
The account's OAuth1 access token and access-token credential, allowing API access on the account's behalf.
Why you can't catch this in DevTools
The payload is uploaded as AES-CBC ciphertext, but the AES key is SHA-256 of a 6-digit string and the IV is hardcoded, so the entire keyspace is searchable in seconds. The proof-of-concept below encrypted a sample cookie payload with a random 6-digit key and recovered it by exhausting all 1,000,000 keys.
{ "twitterAccounts": [ { "type": "x", "user_id": "1234567890", "handle": "victim_user", "accessToken": "1234567890-AbCdEf...", "accessTokenSecret": "VictimAccessTokenSecret9876543210", "cookie": "auth_token=a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0; ct0=9f8e7d6c5b4a39281706f5e4d3c2b1a0; twid=\"u=1234567890\"; kdt=Hk9..." } ]}Reproduce it yourself
Reproduces the extension's exact encryption scheme (SHA-256 of a 6-digit key, AES-256-CBC with the hardcoded IV), encrypts a sample cookie payload with a random 6-digit key, then brute-forces all 1,000,000 keys to recover the plaintext, demonstrating ~20 bits of effective entropy.
- Node.js 18+
#!/usr/bin/env nodeconst crypto = require('crypto');const APP_LOGIN_ENCRYPT_IV = '0x4f05c37c16c801c2516b0338a8fd0cf9';const iv = Buffer.from(APP_LOGIN_ENCRYPT_IV.replace(/^0x/, ''), 'hex');// generateCryptoKey() from the extensionfunction generateCryptoKey() { const n = crypto.randomBytes(4).readUInt32LE(0); return (n % 1000000).toString().padStart(6, '0');}// encrypt(): aesKey = SHA-256(cryptoKey), AES-256-CBC w/ hardcoded IVfunction encrypt(plainText, cryptoKey) { const aesKey = crypto.createHash('sha256').update(Buffer.from(cryptoKey, 'utf8')).digest(); const c = crypto.createCipheriv('aes-256-cbc', aesKey, iv); return Buffer.concat([c.update(Buffer.from(plainText, 'utf8')), c.final()]);}function tryDecrypt(ct, cryptoKey) { try { const aesKey = crypto.createHash('sha256').update(Buffer.from(cryptoKey, 'utf8')).digest(); const d = crypto.createDecipheriv('aes-256-cbc', aesKey, iv); return Buffer.concat([d.update(ct), d.final()]).toString('utf8'); } catch (e) { return null; }}const payload = JSON.stringify({ twitterAccounts: [{ type: 'x', user_id: '1234567890', handle: 'victim_user', accessTokenSecret: 'VictimAccessTokenSecret9876543210', cookie: 'auth_token=SAMPLE_AUTHTOKEN; ct0=SAMPLE_CT0; twid="u=1234567890"; kdt=SAMPLE_KDT' }] });const realKey = generateCryptoKey();const ct = encrypt(payload, realKey);console.log('Victim key (unknown to attacker):', realKey, '| ciphertext', ct.length, 'bytes');const t0 = Date.now();for (let i = 0; i < 1000000; i++) { const k = i.toString().padStart(6, '0'); const pt = tryDecrypt(ct, k); if (pt && pt.includes('auth_token') && pt.includes('twitterAccounts')) { console.log(`Recovered key ${k} in ${((Date.now()-t0)/1000).toFixed(2)}s`); console.log('Recovered cookies:', JSON.parse(pt).twitterAccounts[0].cookie); process.exit(0); }}console.log('Key not recovered');process.exit(1);- 1Save as firefly-sync-bruteforce.js.
- 2node firefly-sync-bruteforce.js.
- 3Observe the random 6-digit key recovered and the cookie payload decrypted in a few seconds.
The key is also disclosed outside the ciphertext
Even without brute-forcing, the six-digit key travels in cleartext as the cryptoKey field of the same upload request, and it is rendered into a QR code shown to the user during the flow. Anyone able to read that request body or photograph the QR code obtains the key directly and can decrypt the payload immediately.
Where it sends data
Destinations our analysis observed Mask Network 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.
- firefly.social
Mask Network sends data to firefly.social. No other extension we have analyzed sends data here.
