Is Export Chats and Contacts safe?

Medium risk

WAexport is medium risk. cs.js relays window messages from any page script to the developer's server without checking their origin. We posted two marker values from an ordinary page script and both arrived intact at the vendor's log-data endpoint.…

WAtextv5.1.0.0Chrome Web Store
52Risk

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

Publishers can request a review.

Findings

cs.js forwards any page script's message to a vendor server unchecked

cs.js relays window messages from any page script to the developer's server without checking their origin.

We posted two marker values from an ordinary page script and both arrived intact at the vendor's log-data endpoint.

Severity
Medium unwanted
Type
Unexpected
CWE
CWE-200
Source
Dynamic sandbox
What actually happens
You did this

You load web.whatsapp.com with this extension installed.

Any script running in that page, not only the extension's own code, can then post a window message the content script acts on.

The extension did this

cs.js accepts the message without checking who sent it and forwards its contents to the developer's server.

The listener only checks that the message's source is the same window, never its origin, before logging it.

What ends up in the forwarded log record
  • Export title / chat name
    Family Group Export

    The name of the chat, group, or export job, when the triggering message includes it.

  • Progress and status text
    Images 42/50

    Status text the extension generates while exporting, such as how many items were processed.

  • Any field an unrelated script adds
    note: value written by a script the extension did not create

    Because there is no origin check, another script on the same page can attach values of its choosing and they get logged the same way.

Captured request
POSThttps://2oo3bvwn63uezlmkhjyb3e4zma0iljdk.lambda-url.eu-north-1.on.aws/user/log-data

200 OK, empty body. The extension does not check or use the response.

Headers
Content-Type
application/json
Body
{  "waexportlog": "{\n  \"type\": \"LOG_DATA\",\n  \"title\": \"Family Group Export\",\n  \"message\": \"Images 42/50\",\n  \"progress\": 84\n}"}
The code that does this

cs.js: the message listener with no origin check

Readable version

Deobfuscated

js/cs.js
async function logActivity(e) {  try {    delete e.selected_contacts, delete e.selected_groups, delete e.processed_contacts, await fetch("https://2oo3bvwn63uezlmkhjyb3e4zma0iljdk.lambda-url.eu-north-1.on.aws/user/log-data", {      method: "POST",      headers: {        "Content-Type": "application/json"      },      body: JSON.stringify({        waexportlog: JSON.stringify(e, null, 2)      })    })  } catch (e) {}}window.addEventListener("message", function(e) {  var t;  if (e.source == window && (e.data.type && "FROM_PAGE" == e.data.type && zipBlob(e.data.media, e.data.doc, e.data.title), e.data.type && "FROM_PAGE_MULTI_HTML" == e.data.type && zipMultiHtml(e.data.indexDoc, e.data.chats, e.data.title), e.data.type && "LOG_DATA" === e.data.type && logActivity(e.data), e.data.type && "FROM_PAGE_PROGRESS" == e.data.type && (progressData.title = e.data.title, progressData.message = e.data.message, progressData.progress = e.data.progress, progressData.skipVisible = !!e.data.skipVisible, progressData.autoSkipEnabled = !!e.data.autoSkipEnabled, chrome.runtime.sendMessage({      key: "progress_update",      ...progressData    })), e.data.type && "FROM_PAGE_CONTACT_PROCESSED" === e.data.type && (processedContactIds.includes(e.data.contactId) || processedContactIds.push(e.data.contactId), chrome.runtime.sendMessage({      key: "contact_processed",      contactId: e.data.contactId    })), e.data.type && "FROM_PAGE_CSV" == e.data.type && (t = new Blob([e.data.doc], {      type: "text/csv"    }), saveAs(t, e.data.title + ".csv")), e.data.type && "FROM_PAGE_TXT" == e.data.type && (t = new Blob([e.data.doc], {      type: "text/plain"    }), saveAs(t, e.data.title + ".txt")), e.data.type && "FROM_PAGE_PDF" == e.data.type && openPrintWindow(e.data.doc, e.data.title, e.data.media), e.data.type && "FROM_PAGE_SINGLE_XLSX" == e.data.type)) try {    var a, o = e.data.sheets,      i = e.data.title || "WhatsApp_All_Chats",      s = XLSX.utils.book_new();    for (a of o) {      let e = a.csvContent;      65279 === e.charCodeAt(0) && (e = e.substring(1));      var n = XLSX.utils.aoa_to_sheet(e.split("\n").filter(e => e.trim()).map(t => {          var a = [];          let o = "",            i = !1;          for (let e = 0; e < t.length; e++) {            var s = t[e];            i ? '"' === s && e + 1 < t.length && '"' === t[e + 1] ? (o += '"', e++) : '"' === s ? i = !1 : o += s : '"' === s ? i = !0 : ";" === s || "," === s ? (a.push(o), o = "") : o += s          }          return a.push(o), a        })),        r = XLSX.utils.decode_range(n["!ref"] || "A1");      for (let t = r.s.r; t <= r.e.r; t++)        for (let e = r.s.c; e <= r.e.c; e++) {          var d = n[XLSX.utils.encode_cell({            r: t,            c: e          })];          d && "s" === d.t && d.v && 32767 < d.v.length && (d.v = d.v.substring(0, 32764) + "...")        }      XLSX.utils.book_append_sheet(s, n, a.sheetName)    }    var c = XLSX.write(s, {        bookType: "xlsx",        type: "array"      }),      p = new Blob([c], {        type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"      });    saveAs(p, i + ".xlsx")  } catch (e) {    console.error("XLSX creation error:", e)  }});
Where the forwarded data goes
    • 2oo3bvwn63uezlmkhjyb3e4zma0iljdk.lambda-url.eu-north-1.on.aws

    Vendor-operated AWS Lambda function URL (WAtext) that stores whatever is posted to /user/log-data under the waexportlog field.

Encrypted Remote Config Fetched on Every WhatsApp Navigation

Opening WhatsApp Web sends your license key to a remote AWS Lambda server, which returns an encrypted payload.

Decrypted with AES-CBC, it sets this session's behavior, and the server can change that anytime without an update.

Severity
Medium unwanted
Type
Unexpected
CWE
CWE-829
Source
Dynamic sandbox
What actually happens
You did this

You open WhatsApp Web in your browser.

The extension did this

The extension contacts a remote AWS Lambda server, submits your license key, gets an encrypted config payload, decrypts it, and applies it to control this session's behavior.

This happens on every navigation to web.whatsapp.com, before any export interaction.

Captured request
POSThttps://2oo3bvwn63uezlmkhjyb3e4zma0iljdk.lambda-url.eu-north-1.on.aws/user/get-license-data

200 OK, {"iv":"4b67e901953b3cf5c45b30e9588d5079","encryptedData":"Ru+ZT2t3..."}

Headers
Content-Type
application/json
Body
{  "licenseKey": "waexport--"}
Why you can't catch this in DevTools

The server's response is encrypted with AES-CBC. The extension decrypts it locally using the SHA-256 hash of the license key as the AES key, and the IV from the response.

Decoded value
{ "mods": { ... } }  (JSON object controlling export behavior — exact content server-defined and changeable at any time)
The code that does this

Remote config fetch and decrypt (cs.js)

Readable version
async function getEncryptedData(licenseKey) {  const response = await fetch(    'https://2oo3bvwn63uezlmkhjyb3e4zma0iljdk.lambda-url.eu-north-1.on.aws/user/get-license-data',    {      method: 'POST',      headers: { 'Content-Type': 'application/json' },      body: JSON.stringify({ licenseKey })    }  );  if (response.ok) return await response.json();  throw new Error('Failed to fetch encrypted data');}async function decryptData(encryptedData, ivHex, licenseKey) {  const iv = hexToBuffer(ivHex);  // AES key = SHA-256 of the license key  const keyMaterial = await window.crypto.subtle.digest(    'SHA-256', new TextEncoder().encode(licenseKey)  );  const ciphertext = base64ToArrayBuffer(encryptedData);  const aesKey = await window.crypto.subtle.importKey(    'raw', keyMaterial, { name: 'AES-CBC' }, false, ['decrypt']  );  const plaintext = await window.crypto.subtle.decrypt(    { name: 'AES-CBC', iv }, aesKey, ciphertext  );  return new TextDecoder().decode(plaintext);}// Called on every content script load:async function processDecryption() {  return new Promise((resolve) => {    chrome?.storage?.local.get(['key'], async (stored) => {      const key = stored.key || 'waexport--';      try {        const { encryptedData, iv } = await getEncryptedData(key);        const plaintext = await decryptData(encryptedData, iv, key);        mods = JSON.parse(plaintext); // global — controls export behavior        resolve();      } catch (e) {        resolve(); // silently swallows errors      }    });  });}processDecryption(); // invoked at module load
Reproduce it yourself

Decrypts the encrypted configuration payload returned by the extension's AWS Lambda endpoint, so you can see exactly what behavioral instructions are delivered to the extension on each WhatsApp Web session.

Requires
  • Node.js 18+
wa-export-config-decoder.js · js
#!/usr/bin/env node// wa-export-config-decoder.js// Fetches and decrypts the remote config the extension receives on every WhatsApp Web load.//// Usage: node wa-export-config-decoder.js [licenseKey]//   licenseKey defaults to 'waexport--' (the extension default)//// Requires: Node.js 18+ (built-in fetch + WebCrypto)const LAMBDA_URL = 'https://2oo3bvwn63uezlmkhjyb3e4zma0iljdk.lambda-url.eu-north-1.on.aws/user/get-license-data';function hexToBuffer(hex) {  const bytes = new Uint8Array(hex.length / 2);  for (let i = 0; i < bytes.length; i++) {    bytes[i] = parseInt(hex.substr(i * 2, 2), 16);  }  return bytes.buffer;}function base64ToArrayBuffer(b64) {  const binary = Buffer.from(b64, 'base64');  return binary.buffer.slice(binary.byteOffset, binary.byteOffset + binary.byteLength);}async function main() {  const licenseKey = process.argv[2] || 'waexport--';  console.log(`[*] Fetching config for licenseKey: ${licenseKey}`);  const res = await fetch(LAMBDA_URL, {    method: 'POST',    headers: { 'Content-Type': 'application/json' },    body: JSON.stringify({ licenseKey })  });  if (!res.ok) {    console.error(`[!] Server returned ${res.status}`);    process.exit(1);  }  const { encryptedData, iv } = await res.json();  console.log('[*] Encrypted response received');  console.log('    iv:', iv);  console.log('    encryptedData (first 40 chars):', encryptedData.slice(0, 40) + '...');  // Reproduce the extension's decryption exactly:  // AES key = SHA-256 of the license key  const keyBytes = new TextEncoder().encode(licenseKey);  const keyHash = await crypto.subtle.digest('SHA-256', keyBytes);  const aesKey = await crypto.subtle.importKey('raw', keyHash, { name: 'AES-CBC' }, false, ['decrypt']);  const cipherBuf = base64ToArrayBuffer(encryptedData);  const ivBuf = hexToBuffer(iv);  const plainBuf = await crypto.subtle.decrypt({ name: 'AES-CBC', iv: ivBuf }, aesKey, cipherBuf);  const plaintext = new TextDecoder().decode(plainBuf);  console.log('[+] Decrypted config:');  try {    console.log(JSON.stringify(JSON.parse(plaintext), null, 2));  } catch {    console.log(plaintext);  }}main().catch(e => { console.error('[!]', e.message); process.exit(1); });
How to run it
  1. 1node wa-export-config-decoder.js <licenseKey>
Remote endpoint receiving the license key and delivering the encrypted config
    • 2oo3bvwn63uezlmkhjyb3e4zma0iljdk.lambda-url.eu-north-1.on.aws

    AWS Lambda function URL (eu-north-1) operated by the extension developer. Receives license keys and delivers encrypted behavioral configuration on every WhatsApp Web navigation.

Extension Pulls Encrypted Remote Config from AWS Lambda at Load

The content script sends your license key to a remote AWS Lambda server on WhatsApp Web, gets an encrypted payload back.

Decrypted with AES-CBC, it becomes the `mods` config controlling exports, so the server can change behavior anytime.

Severity
Medium unwanted
Type
Unexpected
CWE
CWE-829
Source
Dynamic sandbox
What actually happens
You did this

You open WhatsApp Web in your browser.

The extension did this

The extension contacts a remote AWS Lambda server, submits your license key, gets an encrypted config payload, decrypts it, and applies it to control this session's behavior.

This happens on every navigation to web.whatsapp.com, before any export interaction.

Captured request
POSThttps://2oo3bvwn63uezlmkhjyb3e4zma0iljdk.lambda-url.eu-north-1.on.aws/user/get-license-data

200 OK, {"iv":"4b67e901953b3cf5c45b30e9588d5079","encryptedData":"Ru+ZT2t3..."}

Headers
Content-Type
application/json
Body
{  "licenseKey": "waexport--"}
Why you can't catch this in DevTools

The server's response is encrypted with AES-CBC. The extension decrypts it locally using the SHA-256 hash of the license key as the AES key, and the IV from the response.

Decoded value
{ "mods": { ... } }  (JSON object controlling export behavior — exact content server-defined and changeable at any time)
The code that does this

Remote config fetch and decrypt (cs.js)

Readable version
async function getEncryptedData(licenseKey) {  const response = await fetch(    'https://2oo3bvwn63uezlmkhjyb3e4zma0iljdk.lambda-url.eu-north-1.on.aws/user/get-license-data',    {      method: 'POST',      headers: { 'Content-Type': 'application/json' },      body: JSON.stringify({ licenseKey })    }  );  if (response.ok) return await response.json();  throw new Error('Failed to fetch encrypted data');}async function decryptData(encryptedData, ivHex, licenseKey) {  const iv = hexToBuffer(ivHex);  // AES key = SHA-256 of the license key  const keyMaterial = await window.crypto.subtle.digest(    'SHA-256', new TextEncoder().encode(licenseKey)  );  const ciphertext = base64ToArrayBuffer(encryptedData);  const aesKey = await window.crypto.subtle.importKey(    'raw', keyMaterial, { name: 'AES-CBC' }, false, ['decrypt']  );  const plaintext = await window.crypto.subtle.decrypt(    { name: 'AES-CBC', iv }, aesKey, ciphertext  );  return new TextDecoder().decode(plaintext);}// Called on every content script load:async function processDecryption() {  return new Promise((resolve) => {    chrome?.storage?.local.get(['key'], async (stored) => {      const key = stored.key || 'waexport--';      try {        const { encryptedData, iv } = await getEncryptedData(key);        const plaintext = await decryptData(encryptedData, iv, key);        mods = JSON.parse(plaintext); // global — controls export behavior        resolve();      } catch (e) {        resolve(); // silently swallows errors      }    });  });}processDecryption(); // invoked at module load
Reproduce it yourself

Decrypts the encrypted configuration payload returned by the extension's AWS Lambda endpoint, so you can see exactly what behavioral instructions are delivered to the extension on each WhatsApp Web session.

Requires
  • Node.js 18+
wa-export-config-decoder.js · js
#!/usr/bin/env node// wa-export-config-decoder.js// Fetches and decrypts the remote config the extension receives on every WhatsApp Web load.//// Usage: node wa-export-config-decoder.js [licenseKey]//   licenseKey defaults to 'waexport--' (the extension default)//// Requires: Node.js 18+ (built-in fetch + WebCrypto)const LAMBDA_URL = 'https://2oo3bvwn63uezlmkhjyb3e4zma0iljdk.lambda-url.eu-north-1.on.aws/user/get-license-data';function hexToBuffer(hex) {  const bytes = new Uint8Array(hex.length / 2);  for (let i = 0; i < bytes.length; i++) {    bytes[i] = parseInt(hex.substr(i * 2, 2), 16);  }  return bytes.buffer;}function base64ToArrayBuffer(b64) {  const binary = Buffer.from(b64, 'base64');  return binary.buffer.slice(binary.byteOffset, binary.byteOffset + binary.byteLength);}async function main() {  const licenseKey = process.argv[2] || 'waexport--';  console.log(`[*] Fetching config for licenseKey: ${licenseKey}`);  const res = await fetch(LAMBDA_URL, {    method: 'POST',    headers: { 'Content-Type': 'application/json' },    body: JSON.stringify({ licenseKey })  });  if (!res.ok) {    console.error(`[!] Server returned ${res.status}`);    process.exit(1);  }  const { encryptedData, iv } = await res.json();  console.log('[*] Encrypted response received');  console.log('    iv:', iv);  console.log('    encryptedData (first 40 chars):', encryptedData.slice(0, 40) + '...');  // Reproduce the extension's decryption exactly:  // AES key = SHA-256 of the license key  const keyBytes = new TextEncoder().encode(licenseKey);  const keyHash = await crypto.subtle.digest('SHA-256', keyBytes);  const aesKey = await crypto.subtle.importKey('raw', keyHash, { name: 'AES-CBC' }, false, ['decrypt']);  const cipherBuf = base64ToArrayBuffer(encryptedData);  const ivBuf = hexToBuffer(iv);  const plainBuf = await crypto.subtle.decrypt({ name: 'AES-CBC', iv: ivBuf }, aesKey, cipherBuf);  const plaintext = new TextDecoder().decode(plainBuf);  console.log('[+] Decrypted config:');  try {    console.log(JSON.stringify(JSON.parse(plaintext), null, 2));  } catch {    console.log(plaintext);  }}main().catch(e => { console.error('[!]', e.message); process.exit(1); });
How to run it
  1. 1node wa-export-config-decoder.js <licenseKey>
Remote endpoint receiving the license key and delivering the encrypted config
    • 2oo3bvwn63uezlmkhjyb3e4zma0iljdk.lambda-url.eu-north-1.on.aws

    AWS Lambda function URL (eu-north-1) operated by the extension developer. Receives license keys and delivers encrypted behavioral configuration on every WhatsApp Web navigation.

+3 more findings not shown

Updated 30 September 2026jndgjmiigkfgngemhobhnibilojkkfhi