Prepare documented WhatsApp injector with portable Python bundle
This commit is contained in:
commit
d2b621bce7
49 files changed
+5737
No files matched your search
@@ -0,0 +1 @@
|
||||
"""Consolidated WhatsApp Desktop tools and regression suite."""
|
||||
@@ -0,0 +1,3 @@
|
||||
from .cli import main
|
||||
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,198 @@
|
||||
"""Read-only WhatsApp storage analyzer; optional reads through WhatsApp's own decryption path."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import datetime as dt
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import queue
|
||||
import subprocess
|
||||
import threading
|
||||
import tomllib
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from .common import PROFILE
|
||||
from .exports import inventory
|
||||
|
||||
|
||||
def endpoint(value: str) -> str:
|
||||
parsed = urllib.parse.urlsplit(value)
|
||||
if parsed.scheme not in ("http", "https") or parsed.hostname not in ("127.0.0.1", "::1") or parsed.username or parsed.password or parsed.query or parsed.fragment:
|
||||
raise argparse.ArgumentTypeError("CDP must be an HTTP(S) literal loopback endpoint")
|
||||
return value.rstrip("/")
|
||||
|
||||
|
||||
PROBE = r"""(async () => {
|
||||
const limit = __LIMIT__, includeText = __TEXT__, readDb = __READ_DB__;
|
||||
const hash = async s => Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(s)))).map(x=>x.toString(16).padStart(2,'0')).join('');
|
||||
const len = v => v == null ? null : typeof v==='string' ? v.length : v.byteLength ?? v.length ?? null;
|
||||
const encrypted = v => v == null ? {present:false} : {present:true, envelope: typeof v==='object' && '_data' in v && 'iv' in v, ciphertext_bytes:len(v._data), iv_bytes:len(v.iv), scheme:v._scheme??null, key_id_present:v._keyId!=null};
|
||||
let models=[], modelError=null;
|
||||
try { models=require('WAWebMsgCollection').MsgCollection.getModelsArray(); }
|
||||
catch(e) { modelError=String(e); }
|
||||
const selected=models.slice(0,limit), ids=selected.map(m=>m.id?.toString()).filter(Boolean);
|
||||
const summaries=await Promise.all(selected.map(async m=>{
|
||||
const s={id_sha256:m.id ? await hash(m.id.toString()):null, type:m.type??null, body_length:len(m.body), caption_length:len(m.caption), plaintext_present:typeof m.body==='string'||typeof m.caption==='string', model_encrypted_data_present:m.encryptedData!=null};
|
||||
if(includeText){s.body=typeof m.body==='string'?m.body:null;s.caption=typeof m.caption==='string'?m.caption:null;}
|
||||
return s;
|
||||
}));
|
||||
let appReads=[];
|
||||
if(readDb){
|
||||
for(const m of selected){
|
||||
if(!m.id)continue;
|
||||
try {
|
||||
const row=await require('WAWebModelStorageUtils').getStorage().table('message').get(m.id.toString());
|
||||
const restored=row?require('WAWebDBMessageSerialization').messageFromDbRow(row):null;
|
||||
const result={id_sha256:await hash(m.id.toString()),row_found:!!row,decoded_opaque_bytes:len(row?.msgRowOpaqueData),restored_body_length:len(restored?.body),restored_caption_length:len(restored?.caption),body_matches_loaded_model:restored?restored.body===m.body:null,caption_matches_loaded_model:restored?restored.caption===m.caption:null};
|
||||
if(includeText){result.body=restored?.body??null;result.caption=restored?.caption??null;}
|
||||
appReads.push(result);
|
||||
}catch(e){appReads.push({id_sha256:await hash(m.id.toString()),error:String(e)});}
|
||||
}
|
||||
}
|
||||
// Refuse to create a database: enumerate names first, then abort any upgrade.
|
||||
const dbs=typeof indexedDB.databases==='function'?await indexedDB.databases():null;
|
||||
if(!dbs) return {model_count:models.length,models:summaries,model_error:modelError,database_error:'indexedDB.databases unavailable; refusing open to avoid creating a DB'};
|
||||
if(!dbs.some(d=>d.name==='model-storage')) return {model_count:models.length,models:summaries,model_error:modelError,database_names:dbs.map(d=>d.name),database_error:'model-storage absent; no database opened'};
|
||||
const db=await new Promise((resolve,reject)=>{ const r=indexedDB.open('model-storage');r.onupgradeneeded=()=>{r.transaction.abort();reject(new Error('Upgrade aborted'));};r.onsuccess=()=>resolve(r.result);r.onerror=()=>reject(new Error(r.error?.name??'Open failed'));r.onblocked=()=>reject(new Error('Database open blocked'));});
|
||||
let rows=[],matching=[];
|
||||
try {
|
||||
if(!db.objectStoreNames.contains('message'))return {model_count:models.length,models:summaries,model_error:modelError,database_error:'message store absent',stores:Array.from(db.objectStoreNames)};
|
||||
const request = r => new Promise((resolve,reject)=>{r.onsuccess=()=>resolve(r.result);r.onerror=()=>reject(new Error(r.error?.name??'Read failed'));});
|
||||
const tx=db.transaction(['message'],'readonly'), store=tx.objectStore('message');
|
||||
const reads=[request(store.getAll(undefined,limit)),...ids.map(id=>request(store.get(id)))];
|
||||
const values=await Promise.all(reads);rows=values[0];matching=values.slice(1).filter(Boolean);
|
||||
const summarize=async r=>({id_sha256:await hash(String(r.id)),type:r.type??null,field_names:Object.keys(r).sort(),payload:encrypted(r.msgRowOpaqueData),body_field_present:Object.prototype.hasOwnProperty.call(r,'body'),caption_field_present:Object.prototype.hasOwnProperty.call(r,'caption')});
|
||||
const rowSummary=await Promise.all(rows.map(summarize)), matchingSummary=await Promise.all(matching.map(summarize));
|
||||
const byId=new Map(matchingSummary.map(r=>[r.id_sha256,r]));
|
||||
return {model_count:models.length,models:summaries,model_error:modelError,application_read_path:appReads,database_names:dbs.map(d=>d.name),stores:Array.from(db.objectStoreNames),bounded_row_sample:rowSummary,matching_rows:matchingSummary,comparisons:summaries.filter(m=>byId.has(m.id_sha256)).map(m=>({id_sha256:m.id_sha256,ram_plaintext_present:m.plaintext_present,ram_body_length:m.body_length,ram_caption_length:m.caption_length,disk_payload:byId.get(m.id_sha256).payload}))};
|
||||
} finally { db.close(); }
|
||||
})()"""
|
||||
|
||||
|
||||
def cdp_probe(url: str, limit: int, include_text: bool, read_db: bool = False) -> tuple[dict, str]:
|
||||
from websockets.sync.client import connect
|
||||
with urllib.request.urlopen(url + "/json/list", timeout=10) as response:
|
||||
targets = json.load(response)
|
||||
target = next((t for t in targets if t.get("type") == "page" and urllib.parse.urlsplit(t.get("url", "")).hostname == "web.whatsapp.com"), None)
|
||||
if not target:
|
||||
raise RuntimeError("No WhatsApp page target is exposed; start WhatsApp with CDP enabled.")
|
||||
websocket_url = target["webSocketDebuggerUrl"]
|
||||
if urllib.parse.urlsplit(websocket_url).hostname not in ("127.0.0.1", "::1"):
|
||||
raise RuntimeError("Refusing a non-loopback WebSocket target")
|
||||
expression = PROBE.replace("__LIMIT__", str(limit)).replace("__TEXT__", "true" if include_text else "false").replace("__READ_DB__", "true" if read_db else "false")
|
||||
with connect(websocket_url, max_size=20_000_000, open_timeout=10) as ws:
|
||||
ws.send(json.dumps({"id": 1, "method": "Runtime.evaluate", "params": {"expression": expression, "returnByValue": True, "awaitPromise": True}}))
|
||||
while True:
|
||||
result = json.loads(ws.recv(timeout=30))
|
||||
if result.get("id") == 1:
|
||||
break
|
||||
if "error" in result or result.get("result", {}).get("exceptionDetails"):
|
||||
raise RuntimeError("CDP evaluation failed: " + json.dumps(result.get("error") or result["result"]["exceptionDetails"]))
|
||||
return result["result"]["result"]["value"], target["id"]
|
||||
|
||||
|
||||
class MCP:
|
||||
def __init__(self, config: Path):
|
||||
with config.open("rb") as stream:
|
||||
entry = tomllib.load(stream)["mcp_servers"]["rea"]
|
||||
env = dict(os.environ, **entry.get("env", {}))
|
||||
self.proc = subprocess.Popen([entry["command"], *entry.get("args", [])], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, encoding="utf-8", env=env)
|
||||
self.messages = queue.Queue()
|
||||
self.counter = 0
|
||||
threading.Thread(target=self._read, daemon=True).start()
|
||||
self.call("initialize", {"protocolVersion": "2024-11-05", "capabilities": {}, "clientInfo": {"name": "whatsapp-readonly-analyzer", "version": "1.0"}})
|
||||
self.send({"jsonrpc": "2.0", "method": "notifications/initialized"})
|
||||
|
||||
def _read(self):
|
||||
for line in self.proc.stdout:
|
||||
try:
|
||||
self.messages.put(json.loads(line))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
self.messages.put({"error": {"message": "REA server exited"}})
|
||||
|
||||
def send(self, payload):
|
||||
self.proc.stdin.write(json.dumps(payload) + "\n")
|
||||
self.proc.stdin.flush()
|
||||
|
||||
def call(self, method, params):
|
||||
self.counter += 1
|
||||
ident = self.counter
|
||||
self.send({"jsonrpc": "2.0", "id": ident, "method": method, "params": params})
|
||||
while True:
|
||||
message = self.messages.get(timeout=60)
|
||||
if message.get("id") == ident or ("error" in message and "id" not in message):
|
||||
if "error" in message:
|
||||
raise RuntimeError(str(message["error"]))
|
||||
return message["result"]
|
||||
|
||||
def close(self):
|
||||
self.proc.terminate()
|
||||
try:
|
||||
self.proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
self.proc.kill()
|
||||
|
||||
|
||||
def rea_inspect(url: str, target_id: str, config: Path) -> dict:
|
||||
client = MCP(config)
|
||||
try:
|
||||
results = []
|
||||
for name, arguments in (("list_browser_targets", {"cdp_endpoint": url, "allowed_origins": ["https://web.whatsapp.com"]}), ("inspect_web_page", {"cdp_endpoint": url, "target_id": target_id, "allowed_origins": ["https://web.whatsapp.com"], "observation_ms": 100, "include_script_sources": False, "include_accessibility_text": False, "include_console_text": False, "include_json_body_shapes": False, "include_storage_fingerprints": False, "include_storage_keys": False, "include_websocket_shapes": False})):
|
||||
result = client.call("tools/call", {"name": name, "arguments": arguments})
|
||||
# Keep provenance only: never save raw DOM, console, source, or storage values.
|
||||
evidence = result.get("structuredContent")
|
||||
if evidence is None:
|
||||
for block in result.get("content", []):
|
||||
if block.get("type") == "text":
|
||||
try:
|
||||
evidence = json.loads(block["text"])
|
||||
break
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
evidence = evidence if isinstance(evidence, dict) else {}
|
||||
results.append({"tool": name, "is_error": result.get("isError", False), "evidence_id": evidence.get("evidence_id"), "operation": evidence.get("operation"), "provider": evidence.get("provider"), "confidence": evidence.get("confidence"), "limitations": evidence.get("limitations", []), "response_sha256": hashlib.sha256(json.dumps(result, sort_keys=True).encode()).hexdigest()})
|
||||
return {"source": "REA stdio MCP", "evidence": results, "retention": "Provenance and response digests only; full payload deliberately omitted."}
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--profile", type=Path, default=PROFILE)
|
||||
parser.add_argument("--cdp", type=endpoint, help="Existing loopback CDP endpoint, e.g. http://127.0.0.1:9223")
|
||||
parser.add_argument("--limit", type=int, default=20, help="Maximum models and DB sample rows (1..100)")
|
||||
parser.add_argument("--include-message-text", action="store_true", help="Explicitly save loaded own-account message body/caption in output")
|
||||
parser.add_argument("--read-db", action="store_true", help="Use WhatsApp's own message-table getter to decrypt bounded matching rows and compare text in memory")
|
||||
parser.add_argument("--rea", action="store_true", help="Also request REA passive page evidence over configured stdio MCP")
|
||||
parser.add_argument("--config", type=Path, default=Path.home() / ".codex/config.toml")
|
||||
parser.add_argument("--output", type=Path, default=Path("whatsapp-analyzer-report.json"))
|
||||
args = parser.parse_args(argv)
|
||||
if not 1 <= args.limit <= 100:
|
||||
parser.error("--limit must be between 1 and 100")
|
||||
if (args.rea or args.include_message_text or args.read_db) and not args.cdp:
|
||||
parser.error("--rea, --read-db and --include-message-text require --cdp")
|
||||
report = {"timestamp_utc": dt.datetime.now(dt.timezone.utc).isoformat(), "offline": inventory(args.profile), "message_text_included": args.include_message_text,
|
||||
"application_db_read_enabled": args.read_db,
|
||||
"limitations": ["Static REA findings guide the probe; Runtime.evaluate results are independent CDP observations, not REA attestations.", "Reads only existing loaded models and bounded read-only IndexedDB rows. No send, navigation, record writes or key extraction. --read-db calls WhatsApp's own getter, which decrypts payloads in memory.", "RAM strings are already decrypted by WhatsApp. Raw-envelope comparison alone proves storage shape; --read-db additionally checks restored content equality against loaded models without exporting text by default.", "Not all history is loaded; database getAll sample is bounded and has no recency guarantee. Metadata such as field names and message type remains visible.", "WebView2 and WhatsApp caches are not a forensic snapshot. OS swap, media caches, notifications and native SQLite are outside this probe."]}
|
||||
failed = False
|
||||
if args.cdp:
|
||||
try:
|
||||
result, target = cdp_probe(args.cdp, args.limit, args.include_message_text, args.read_db)
|
||||
report["runtime"] = {"source": "Independent read-only CDP Runtime.evaluate", "target_id": target, "result": result}
|
||||
if args.rea:
|
||||
report["rea"] = rea_inspect(args.cdp, target, args.config)
|
||||
except Exception as error:
|
||||
report["error"] = f"{type(error).__name__}: {error}"
|
||||
failed = True
|
||||
args.output.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(f"Report saved: {args.output.resolve()}")
|
||||
if failed:
|
||||
print(report["error"])
|
||||
return 1 if failed else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,87 @@
|
||||
// REA-backed WinRT ABI for WhatsAppNativeProjection.dll build 2.2639.100.0.
|
||||
// Native calls only; no CDP, UI automation or restart.
|
||||
const native=Process.getModuleByName('WhatsAppNative.dll');
|
||||
if(!native.path.includes('WhatsAppDesktop_2.2639.100.0_'))throw Error('Unsupported Desktop build; ABI must be revalidated with REA');
|
||||
const combase=Process.getModuleByName('combase.dll');
|
||||
function api(name,ret,args){return new NativeFunction(combase.getExportByName(name),ret,args);}
|
||||
const createString=api('WindowsCreateString','int',['pointer','uint','pointer']);
|
||||
const deleteString=api('WindowsDeleteString','int',['pointer']);
|
||||
const rawString=api('WindowsGetStringRawBuffer','pointer',['pointer','pointer']);
|
||||
function check(hr){if(hr<0)throw Error('HRESULT 0x'+(hr>>>0).toString(16));}
|
||||
function hstring(value){const out=Memory.alloc(8);check(createString(Memory.allocUtf16String(value),value.length,out));return out.readPointer();}
|
||||
function readHString(handle){if(handle.isNull())return '';const len=Memory.alloc(4);const p=rawString(handle,len);return p.readUtf16String(len.readU32());}
|
||||
function guid(hex){const p=Memory.alloc(16);p.writeByteArray(hex.match(/../g).map(x=>parseInt(x,16)));return p;}
|
||||
const dbIid=guid('eeb0ca996bf8523780d9cf1b934d62b5');
|
||||
const stmtIid=guid('94f8c705a4cb4b3baa1b9b2ce2ff2734');
|
||||
const propertyIid=guid('dd82d64b5475e9409a9b82654ede7e62');
|
||||
function method(obj,slot,ret,args){return new NativeFunction(obj.readPointer().add(slot*8).readPointer(),ret,['pointer',...args]);}
|
||||
function release(obj){if(!obj.isNull())method(obj,2,'uint',[])(obj);}
|
||||
function query(obj,iid){const out=Memory.alloc(8);check(method(obj,0,'int',['pointer','pointer'])(obj,iid,out));return out.readPointer();}
|
||||
let references=[];
|
||||
function property(value){if(value.isNull())return null;let p=ptr(0);try{
|
||||
p=query(value,propertyIid);const out=Memory.alloc(8);check(method(p,19,'int',['pointer'])(p,out));
|
||||
const h=out.readPointer();try{return readHString(h);}finally{deleteString(h);}
|
||||
}finally{release(p);release(value);}}
|
||||
function statement(db,sql){const h=hstring(sql),error=Memory.alloc(4),partial=Memory.alloc(8),out=Memory.alloc(8);
|
||||
try{check(method(db,9,'int',['pointer','pointer','pointer','pointer'])(db,h,error,partial,out));
|
||||
// The integer out-parameter is the consumed SQL offset, not an error code.
|
||||
const original=out.readPointer();if(original.isNull())throw Error('No prepared statement');
|
||||
try{return query(original,stmtIid);}finally{release(original);}
|
||||
}finally{deleteString(h);}
|
||||
}
|
||||
function select(db,sql,limit){let st=ptr(0);try{
|
||||
st=statement(db,sql);const count=Memory.alloc(4);check(method(st,10,'int',['pointer'])(st,count));
|
||||
const rows=[];while(rows.length<limit){const has=Memory.alloc(1);check(method(st,7,'int',['pointer'])(st,has));if(has.readU8()===0)break;
|
||||
const row=[];for(let i=0;i<count.readS32();i++){const out=Memory.alloc(8);check(method(st,11,'int',['int','pointer'])(st,i,out));row.push(property(out.readPointer()));}rows.push(row);}
|
||||
return rows;
|
||||
}finally{if(!st.isNull()){try{check(method(st,6,'int',[])(st));}finally{release(st);}}}}
|
||||
rpc.exports={
|
||||
discover(maxMb){
|
||||
// Obtain the real interface vtable from a fresh closed Sqlite object. No DB
|
||||
// is opened and no credentials are read from the existing session.
|
||||
const init=api('RoInitialize','int',['uint'])(1);if(init<0&&init!==-2147417850)check(init);
|
||||
const name=hstring('WhatsAppNative.Sqlite'),factoryOut=Memory.alloc(8);
|
||||
const getFactory=new NativeFunction(native.getExportByName('DllGetActivationFactory'),'int',['pointer','pointer']);
|
||||
let factory=ptr(0),sample=ptr(0),iface=ptr(0);
|
||||
try{check(getFactory(name,factoryOut));factory=factoryOut.readPointer();const out=Memory.alloc(8);
|
||||
check(method(factory,6,'int',['pointer'])(factory,out));sample=out.readPointer();iface=query(sample,dbIid);
|
||||
const vtable=iface.readPointer();const bytes=Memory.alloc(8);bytes.writePointer(vtable);
|
||||
const pattern=Array.from(new Uint8Array(bytes.readByteArray(8))).map(v=>v.toString(16).padStart(2,'0')).join(' ');
|
||||
let scanned=0,hitCount=0;const found=[],addresses=new Set(),failures=[];
|
||||
for(const range of Process.enumerateRanges({protection:'rw-',coalesce:true})){
|
||||
if(scanned>=maxMb*1024*1024)break;
|
||||
const size=Math.min(range.size,maxMb*1024*1024-scanned);scanned+=size;
|
||||
let hits=[];try{hits=Memory.scanSync(range.base,size,pattern);}catch{continue;}
|
||||
hitCount+=hits.length;
|
||||
for(const hit of hits){if(hit.address.equals(iface)||!hit.address.and(7).isNull())continue;
|
||||
let object=ptr(0);try{object=query(hit.address,dbIid);if(addresses.has(object.toString())){release(object);continue;}
|
||||
const tables=select(object,"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name",100);
|
||||
const id=references.length;references.push(object);addresses.add(object.toString());
|
||||
found.push({id,address:object.toString(),tables:tables.map(r=>r[0])});object=ptr(0);
|
||||
}catch(error){if(failures.length<20)failures.push({address:hit.address.toString(),error:String(error)});if(!object.isNull())release(object);}
|
||||
}
|
||||
}return {databases:found,bytesScanned:scanned,vtable:vtable.toString(),vtableHits:hitCount,failures};
|
||||
}finally{release(iface);release(sample);release(factory);deleteString(name);}
|
||||
},
|
||||
search(id,term,limit){if(!Number.isInteger(id)||id<0||id>=references.length)throw Error('Unknown database');
|
||||
if(!Number.isInteger(limit)||limit<1||limit>1000)throw Error('Invalid row limit');
|
||||
const tokens=term.trim().split(/\s+/).filter(Boolean);if(!tokens.length)throw Error('Empty query');
|
||||
const match=tokens.map(t=>'"'+t.replace(/"/g,'""')+'"*').join(' AND ').replace(/'/g,"''");
|
||||
const sql="SELECT CAST(t.id AS TEXT),CAST(t.chatId AS TEXT),CAST(t.timestamp AS TEXT),CAST(t.text AS TEXT) FROM message t JOIN message_fts f ON t.rowid=f.rowid WHERE message_fts MATCH '"+match+"' ORDER BY rank LIMIT "+limit;
|
||||
return select(references[id],sql,limit);
|
||||
},
|
||||
oldest(id,limit){
|
||||
if(!Number.isInteger(id)||id<0||id>=references.length)throw Error('Unknown database');
|
||||
if(!Number.isInteger(limit)||limit<1||limit>1000)throw Error('Invalid row limit');
|
||||
return select(references[id],"SELECT CAST(id AS TEXT),CAST(chatId AS TEXT),CAST(timestamp AS TEXT),CAST(text AS TEXT) FROM message WHERE timestamp > 0 AND text IS NOT NULL AND length(text) > 0 ORDER BY timestamp,id LIMIT "+limit,limit);
|
||||
},
|
||||
stats(id){return select(references[id],"SELECT CAST(COUNT(*) AS TEXT),CAST(MIN(timestamp) AS TEXT),CAST(MAX(timestamp) AS TEXT) FROM message",1)[0];},
|
||||
indexbounds(id){return select(references[id],"SELECT CAST(COUNT(*) AS TEXT),CAST(COALESCE(MAX(rowid),0) AS TEXT) FROM message",1)[0];},
|
||||
exportindex(id,after,upper,limit){
|
||||
if(!Number.isInteger(id)||id<0||id>=references.length)throw Error('Unknown database');
|
||||
if(!/^-?\d+$/.test(String(after))||!/^\d+$/.test(String(upper)))throw Error('Invalid row boundary');
|
||||
if(!Number.isInteger(limit)||limit<1||limit>500)throw Error('Invalid batch size');
|
||||
return select(references[id],"SELECT CAST(rowid AS TEXT),CAST(id AS TEXT),CAST(chatId AS TEXT),CAST(timestamp AS TEXT),CAST(text AS TEXT) FROM message WHERE rowid > "+after+" AND rowid <= "+upper+" ORDER BY rowid LIMIT "+limit,limit);
|
||||
},
|
||||
close(){for(const object of references)release(object);references=[];}
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Single entry point for regression tests and explicit app probes."""
|
||||
import argparse
|
||||
import importlib
|
||||
import sys
|
||||
|
||||
COMMANDS = {
|
||||
'inventory': ('exports', 'List local Desktop storage files'),
|
||||
'stats': ('exports', 'Summarize a saved JSON/JSONL export'),
|
||||
'search': ('exports', 'Search a saved export'),
|
||||
'desktop-search': ('exports', 'Search the running Desktop app'),
|
||||
'live': ('live', 'Run fixed Desktop math/capability/data probes'),
|
||||
'webview-test': ('webview', 'Run an isolated WebView2 host test'),
|
||||
'analyze': ('analyzer', 'Inspect storage and optionally an existing CDP session'),
|
||||
'history-export': ('history', 'Export history through an existing Desktop CDP endpoint'),
|
||||
'export-zip': ('zip_export', 'Export all local messages via JavaScript in WebView; no CDP or UI automation'),
|
||||
'send-self': ('self_message', 'Send one test text to your own WhatsApp account via JavaScript'),
|
||||
'contacts-export': ('contacts_export', 'Export all locally stored contacts via JavaScript without CDP'),
|
||||
'ram-read': ('ram', 'Recover loaded message objects from process RAM'),
|
||||
'ram-search': ('ram_search', 'Search verified loaded message objects'),
|
||||
'native-search': ('injected_search', 'Experimental native SQLite search/index export'),
|
||||
'qr-memory': ('qr_memory', 'Find Desktop pairing QR candidates in RAM'),
|
||||
'qr-window': ('qr_window', 'Capture an already displayed Desktop pairing QR'),
|
||||
'pairing': ('pairing', 'Request a linking QR in an isolated browser'),
|
||||
}
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
if not argv:
|
||||
argv = ['test']
|
||||
parser = argparse.ArgumentParser(description='WhatsApp tools and test suite. Default: offline regression tests.')
|
||||
commands = parser.add_subparsers(dest='command', required=True)
|
||||
commands.add_parser('test', help='Run offline regression tests; optionally add live checks', add_help=False)
|
||||
for name, (_, description) in COMMANDS.items():
|
||||
commands.add_parser(name, help=description, add_help=False)
|
||||
args, remaining = parser.parse_known_args(argv)
|
||||
try:
|
||||
if args.command == 'test':
|
||||
from .runner import main as run_tests
|
||||
return run_tests(remaining)
|
||||
module = importlib.import_module('.' + COMMANDS[args.command][0], __package__)
|
||||
if args.command in ('inventory', 'stats', 'search', 'desktop-search'):
|
||||
command = 'whatsapp-search' if args.command == 'desktop-search' else args.command
|
||||
return module.main([command] + remaining)
|
||||
if args.command == 'webview-test':
|
||||
help_parser = argparse.ArgumentParser(description=COMMANDS[args.command][1])
|
||||
help_parser.parse_args(remaining)
|
||||
return module.run_webview_test()
|
||||
return module.main(remaining)
|
||||
except (OSError, ValueError, RuntimeError, ImportError) as error:
|
||||
print(f'{args.command}: {error}', file=sys.stderr)
|
||||
return 1
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Shared workspace paths and text normalization."""
|
||||
import os
|
||||
import sys
|
||||
import unicodedata
|
||||
from pathlib import Path
|
||||
|
||||
_BUNDLE = getattr(sys, '_whatsapp_bundle', None)
|
||||
BASE = Path(_BUNDLE['base']) if _BUNDLE else Path(__file__).resolve().parent.parent
|
||||
ASSETS = Path(__file__).resolve().parent / "assets"
|
||||
PROFILE = Path(os.environ.get("LOCALAPPDATA", str(Path.home() / "AppData/Local"))) / "Packages/5319275A.WhatsAppDesktop_cv1g1gvanyjgm/LocalCache/EBWebView/Default"
|
||||
|
||||
def normalized(value):
|
||||
return unicodedata.normalize('NFC', value).casefold()
|
||||
|
||||
|
||||
def resource_bytes(name):
|
||||
"""Read an embedded bundle resource or its development-tree equivalent."""
|
||||
if _BUNDLE:
|
||||
return _BUNDLE['read'](name)
|
||||
return (BASE / name).read_bytes()
|
||||
|
||||
|
||||
def asset_bytes(name):
|
||||
return resource_bytes('whatsapp_suite/assets/' + name)
|
||||
|
||||
|
||||
def cli_command(*arguments):
|
||||
if _BUNDLE:
|
||||
return [sys.executable, str(_BUNDLE['launcher']), *arguments]
|
||||
return [sys.executable, '-m', 'whatsapp_suite', *arguments]
|
||||
|
||||
|
||||
def verified_helper(asset, digest, cache_dir, filename):
|
||||
"""Materialize a hash-verified DLL only when a live probe needs it."""
|
||||
import ctypes
|
||||
import hashlib
|
||||
import tempfile
|
||||
if os.name != 'nt' or ctypes.sizeof(ctypes.c_void_p) != 8:
|
||||
raise ValueError('Native helpers require Windows and 64-bit Python')
|
||||
payload = asset_bytes(asset)
|
||||
if hashlib.sha256(payload).hexdigest() != digest:
|
||||
raise ValueError('Bundled helper failed SHA-256 verification')
|
||||
folder = BASE / cache_dir / digest
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
dll = folder / filename
|
||||
existed = dll.exists()
|
||||
if existed and hashlib.sha256(dll.read_bytes()).hexdigest() == digest:
|
||||
return dll, 'reused'
|
||||
descriptor, temporary = tempfile.mkstemp(prefix='helper-', suffix='.tmp', dir=folder)
|
||||
try:
|
||||
with os.fdopen(descriptor, 'wb') as stream:
|
||||
stream.write(payload)
|
||||
os.replace(temporary, dll)
|
||||
finally:
|
||||
Path(temporary).unlink(missing_ok=True)
|
||||
return dll, 'repaired' if existed else 'extracted'
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Export all locally stored contact records through WhatsApp's JavaScript API."""
|
||||
import argparse
|
||||
import datetime as dt
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from .live import run_math
|
||||
from .zip_export import evaluate
|
||||
|
||||
PROBE = r'''(async () => {
|
||||
const table=require('WAWebSchemaContact_DO_NOT_USE_DIRECTLY').getContactTable();
|
||||
const rows=await table.all();
|
||||
if(!Array.isArray(rows))throw Error('Unexpected contact table result');
|
||||
const fields=['id','phoneNumber','name','shortName','pushname','notifyName','verifiedName',
|
||||
'addressBookName','isAddressBookContact','isMyContact','isBusiness','isEnterprise'];
|
||||
const contacts=rows.map(row=>{
|
||||
const out={};for(const field of fields){const value=row[field];
|
||||
if(value==null||['string','number','boolean'].includes(typeof value))out[field]=value??null;
|
||||
else if(field==='id')out.id=value.toString();
|
||||
}
|
||||
if(typeof out.id!=='string'||!out.id)throw Error('Contact ID unavailable');
|
||||
return out;
|
||||
});
|
||||
return {source:'WAWebSchemaContact_DO_NOT_USE_DIRECTLY.getContactTable().all',
|
||||
scope:'All local contact rows, not only loaded models or phone address-book entries',
|
||||
count:contacts.length,contacts,cdp:false,ui_automation:false};
|
||||
})()'''
|
||||
|
||||
|
||||
def export_contacts(output,pid=None):
|
||||
output=Path(output)
|
||||
if output.exists():raise ValueError('Output exists; choose a new --output')
|
||||
def operation(execute,session,report):
|
||||
data=evaluate(execute,PROBE,session)
|
||||
if not isinstance(data.get('contacts'),list) or data.get('count')!=len(data['contacts']):
|
||||
raise RuntimeError('Incomplete contact export response')
|
||||
report['data']=data
|
||||
report=run_math(pid,'contacts-export',_operation=operation)
|
||||
output.parent.mkdir(parents=True,exist_ok=True)
|
||||
with output.open('x',encoding='utf-8') as stream:
|
||||
json.dump(report,stream,ensure_ascii=True,indent=2)
|
||||
return report
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser=argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--pid',type=int)
|
||||
parser.add_argument('--output',type=Path,default=Path('exports')/('contacts-'+dt.datetime.now().strftime('%Y%m%d-%H%M%S')+'.json'))
|
||||
args=parser.parse_args(argv)
|
||||
report=export_contacts(args.output,args.pid)
|
||||
print(json.dumps({'passed':report['passed'],'error':report.get('error'),'contacts':report.get('data',{}).get('count'),'output':str(args.output)}))
|
||||
return 0 if report['passed'] else 1
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Shared verified Desktop process and window discovery."""
|
||||
import ctypes
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
SUPPORTED_BUILD = "2.2639.100.0"
|
||||
CORE_SHA256 = "c6da3c67eda66a8d48ff2ae11f68085f1f8250111138a6ae384d38eefb4da536"
|
||||
|
||||
def check_core_build(path):
|
||||
if 'WhatsAppDesktop_' + SUPPORTED_BUILD + '_' not in str(path):
|
||||
raise ValueError('Unsupported WhatsApp build: ' + str(path) +
|
||||
'; supported build is ' + SUPPORTED_BUILD + '. No hook installed.')
|
||||
if hashlib.sha256(Path(path).read_bytes()).hexdigest() != CORE_SHA256:
|
||||
raise ValueError('Unknown WebView2 Core DLL hash. No hook installed.')
|
||||
|
||||
def visible_ui_threads(pid):
|
||||
"""Inspect window ownership; no clicks, navigation or window messages."""
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
user32 = ctypes.WinDLL('user32', use_last_error=True)
|
||||
user32.GetWindowThreadProcessId.argtypes = [wintypes.HWND, ctypes.POINTER(wintypes.DWORD)]
|
||||
user32.GetWindowThreadProcessId.restype = wintypes.DWORD
|
||||
user32.IsWindowVisible.argtypes = [wintypes.HWND]
|
||||
user32.IsWindowVisible.restype = wintypes.BOOL
|
||||
callback_type = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM)
|
||||
threads = set()
|
||||
def visit(hwnd, parameter):
|
||||
process = wintypes.DWORD()
|
||||
thread = user32.GetWindowThreadProcessId(hwnd, ctypes.byref(process))
|
||||
if process.value == pid and user32.IsWindowVisible(hwnd):
|
||||
threads.add(thread)
|
||||
return True
|
||||
callback = callback_type(visit)
|
||||
user32.EnumWindows.argtypes = [callback_type, wintypes.LPARAM]
|
||||
user32.EnumWindows.restype = wintypes.BOOL
|
||||
if not user32.EnumWindows(callback, 0):
|
||||
raise ctypes.WinError(ctypes.get_last_error())
|
||||
return threads
|
||||
|
||||
def detect_desktop_pid(requested_pid=None):
|
||||
"""Find the running supported Desktop host using Python and Windows APIs."""
|
||||
import ctypes
|
||||
from ctypes import wintypes as w
|
||||
import hashlib
|
||||
class ProcessEntry(ctypes.Structure):
|
||||
_fields_ = [('dwSize',w.DWORD),('cntUsage',w.DWORD),('th32ProcessID',w.DWORD),
|
||||
('th32DefaultHeapID',ctypes.c_size_t),('th32ModuleID',w.DWORD),
|
||||
('cntThreads',w.DWORD),('th32ParentProcessID',w.DWORD),
|
||||
('pcPriClassBase',w.LONG),('dwFlags',w.DWORD),('szExeFile',w.WCHAR*260)]
|
||||
class ModuleEntry(ctypes.Structure):
|
||||
_fields_ = [('dwSize',w.DWORD),('th32ModuleID',w.DWORD),('th32ProcessID',w.DWORD),
|
||||
('GlblcntUsage',w.DWORD),('ProccntUsage',w.DWORD),
|
||||
('modBaseAddr',ctypes.POINTER(w.BYTE)),('modBaseSize',w.DWORD),
|
||||
('hModule',w.HMODULE),('szModule',w.WCHAR*256),('szExePath',w.WCHAR*260)]
|
||||
kernel = ctypes.WinDLL('kernel32',use_last_error=True)
|
||||
kernel.CreateToolhelp32Snapshot.argtypes = [w.DWORD,w.DWORD]
|
||||
kernel.CreateToolhelp32Snapshot.restype = w.HANDLE
|
||||
kernel.CloseHandle.argtypes = [w.HANDLE]
|
||||
kernel.CloseHandle.restype = w.BOOL
|
||||
for name, entry in [('Process32FirstW',ProcessEntry),('Process32NextW',ProcessEntry),
|
||||
('Module32FirstW',ModuleEntry),('Module32NextW',ModuleEntry)]:
|
||||
fn = getattr(kernel,name)
|
||||
fn.argtypes = [w.HANDLE,ctypes.POINTER(entry)]
|
||||
fn.restype = w.BOOL
|
||||
invalid = ctypes.c_void_p(-1).value
|
||||
snapshot = kernel.CreateToolhelp32Snapshot(2,0)
|
||||
if snapshot == invalid:
|
||||
raise ctypes.WinError(ctypes.get_last_error())
|
||||
roots = []
|
||||
try:
|
||||
entry = ProcessEntry(dwSize=ctypes.sizeof(ProcessEntry))
|
||||
exists = kernel.Process32FirstW(snapshot,ctypes.byref(entry))
|
||||
while exists:
|
||||
if entry.szExeFile.casefold() == 'whatsapp.root.exe':
|
||||
roots.append(entry.th32ProcessID)
|
||||
exists = kernel.Process32NextW(snapshot,ctypes.byref(entry))
|
||||
finally:
|
||||
kernel.CloseHandle(snapshot)
|
||||
candidates = []
|
||||
rejected = []
|
||||
for pid in roots:
|
||||
if requested_pid is not None and pid != requested_pid:
|
||||
continue
|
||||
snapshot = kernel.CreateToolhelp32Snapshot(8|16,pid)
|
||||
if snapshot == invalid:
|
||||
continue
|
||||
try:
|
||||
entry = ModuleEntry(dwSize=ctypes.sizeof(ModuleEntry))
|
||||
exists = kernel.Module32FirstW(snapshot,ctypes.byref(entry))
|
||||
while exists:
|
||||
if entry.szModule.casefold() == 'microsoft.web.webview2.core.dll':
|
||||
path = Path(entry.szExePath)
|
||||
if len(visible_ui_threads(pid)) == 1:
|
||||
try:
|
||||
check_core_build(path)
|
||||
candidates.append(pid)
|
||||
except (ValueError, OSError) as error:
|
||||
rejected.append(str(error))
|
||||
break
|
||||
exists = kernel.Module32NextW(snapshot,ctypes.byref(entry))
|
||||
except OSError:
|
||||
continue
|
||||
finally:
|
||||
kernel.CloseHandle(snapshot)
|
||||
if not candidates:
|
||||
raise ValueError('; '.join(rejected) if rejected else 'No supported running WhatsApp Desktop host with one identifiable UI thread found; start WhatsApp normally')
|
||||
if len(candidates) != 1:
|
||||
raise ValueError('Multiple active Desktop hosts found; specify --pid: '+', '.join(map(str,candidates)))
|
||||
return candidates[0]
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
"""Storage inventory and saved message export analysis."""
|
||||
import argparse
|
||||
import collections
|
||||
import json
|
||||
from pathlib import Path
|
||||
from .common import BASE, PROFILE, normalized
|
||||
|
||||
def messages(path):
|
||||
with path.open(encoding='utf-8-sig') as stream:
|
||||
if path.suffix.lower() == '.jsonl':
|
||||
for number, line in enumerate(stream, 1):
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
row = json.loads(line)
|
||||
except json.JSONDecodeError as ex:
|
||||
raise ValueError(f'Invalid JSON at line {number}: {ex}') from ex
|
||||
if not isinstance(row, dict):
|
||||
raise ValueError(f'Line {number} is not a message object')
|
||||
yield row
|
||||
else:
|
||||
data = json.load(stream)
|
||||
if isinstance(data, list):
|
||||
rows = data
|
||||
elif isinstance(data, dict):
|
||||
rows = next((data[key] for key in ('messages', 'models', 'results')
|
||||
if isinstance(data.get(key), list)), None)
|
||||
if rows is None:
|
||||
raise ValueError('Expected messages, models or results array')
|
||||
else:
|
||||
raise ValueError('Expected an array or a message report')
|
||||
for row in rows:
|
||||
if not isinstance(row, dict):
|
||||
raise ValueError('Message array contains a non-object value')
|
||||
yield row
|
||||
|
||||
def content(row):
|
||||
return '\n'.join(row[key] for key in ('indexed_text', 'body', 'caption', 'text')
|
||||
if isinstance(row.get(key), str))
|
||||
|
||||
def inventory(profile):
|
||||
entries = []
|
||||
for pattern in ('IndexedDB/*whatsapp*', 'Local Storage/leveldb', 'Session Storage'):
|
||||
for folder in profile.glob(pattern):
|
||||
files = []
|
||||
for file in folder.iterdir() if folder.is_dir() else [folder]:
|
||||
try:
|
||||
if file.is_file():
|
||||
files.append({'name': file.name, 'bytes': file.stat().st_size})
|
||||
except OSError:
|
||||
continue
|
||||
entries.append({'path': str(folder), 'files': files})
|
||||
return {'profile': str(profile), 'exists': profile.exists(), 'storage_inventory': entries,
|
||||
'coverage': 'File names and sizes only; no decrypted messages.'}
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
sub = parser.add_subparsers(dest='command', required=True)
|
||||
inv = sub.add_parser('inventory', help='List local Desktop storage files and sizes')
|
||||
inv.add_argument('--profile', type=Path, default=PROFILE)
|
||||
for name in ('stats', 'search'):
|
||||
cmd = sub.add_parser(name, help='Analyze an existing JSON/JSONL message export')
|
||||
cmd.add_argument('input', type=Path)
|
||||
cmd.add_argument('--output', type=Path)
|
||||
if name == 'search':
|
||||
cmd.add_argument('query')
|
||||
cmd.add_argument('--chat-id')
|
||||
cmd.add_argument('--limit', type=int, default=100)
|
||||
cmd.add_argument('--redact-text', action='store_true')
|
||||
sub.add_parser('webview-test', help='Harmless JavaScript test in a separate local host')
|
||||
live = sub.add_parser('whatsapp-test', help='Run eight fixed arithmetic checks in the running Desktop instance')
|
||||
live.add_argument('--pid', type=int, help='Optional override; running Desktop host is detected automatically')
|
||||
live.add_argument('--output', type=Path, default=BASE / 'analysis/webview2/live-arithmetic-result.json')
|
||||
search = sub.add_parser('whatsapp-search', help='Use the running app native message search through WebView2')
|
||||
search.add_argument('query')
|
||||
search.add_argument('--pid', type=int, help='Optional override; running Desktop host is detected automatically')
|
||||
search.add_argument('--limit', type=int, default=50)
|
||||
search.add_argument('--output', type=Path, default=BASE / 'analysis/webview2/live-message-search.json')
|
||||
args = parser.parse_args(argv)
|
||||
if args.command == 'whatsapp-test':
|
||||
from .live import main as run_live
|
||||
return run_live(['--suite', 'math'] + (['--pid', str(args.pid)] if args.pid else []) + ['--output', str(args.output)])
|
||||
if args.command == 'whatsapp-search':
|
||||
if not args.query.strip() or len(args.query)>200 or not 1<=args.limit<=50:
|
||||
parser.error('Search requires 1..200 characters and --limit 1..50')
|
||||
from .native_search import run_live_probe
|
||||
return run_live_probe(args.pid, args.output, args.query, args.limit)
|
||||
if args.command == 'webview-test':
|
||||
from .webview import run_webview_test
|
||||
return run_webview_test()
|
||||
if args.command == 'inventory':
|
||||
result = inventory(args.profile)
|
||||
elif args.command == 'stats':
|
||||
count = with_text = 0
|
||||
kinds = collections.Counter()
|
||||
chats = set()
|
||||
for row in messages(args.input):
|
||||
count += 1
|
||||
with_text += bool(content(row))
|
||||
kinds[str(row.get('type', 'unknown'))] += 1
|
||||
chat = row.get('chatId', row.get('chat_id'))
|
||||
if chat is not None:
|
||||
chats.add(str(chat))
|
||||
result = {'source': str(args.input), 'messages': count, 'messages_with_text': with_text,
|
||||
'chats': len(chats), 'types': dict(kinds), 'scope': 'Saved export only'}
|
||||
else:
|
||||
if not args.query.strip():
|
||||
parser.error('query must not be empty')
|
||||
if args.limit < 1:
|
||||
parser.error('--limit must be positive')
|
||||
found = []
|
||||
total = 0
|
||||
for row in messages(args.input):
|
||||
chat = row.get('chatId', row.get('chat_id'))
|
||||
if args.chat_id is not None and str(chat) != args.chat_id:
|
||||
continue
|
||||
text = content(row)
|
||||
if normalized(args.query) not in normalized(text):
|
||||
continue
|
||||
total += 1
|
||||
if len(found) >= args.limit:
|
||||
continue
|
||||
item = dict(row)
|
||||
if args.redact_text:
|
||||
# Allowlist metadata so nested fields cannot leak message content.
|
||||
item = {key: value for key, value in row.items()
|
||||
if key in ('id', 'chatId', 'chat_id', 'timestamp', 'type', 'nativeRowID', 'native_rowid')
|
||||
and isinstance(value, (str, int, float, bool, type(None)))}
|
||||
item['text_length'] = len(text)
|
||||
found.append(item)
|
||||
result = {'source': str(args.input), 'matches': total, 'returned': len(found),
|
||||
'results': found, 'scope': 'Literal search of saved export only'}
|
||||
serialized = json.dumps(result, ensure_ascii=False, indent=2)
|
||||
output = getattr(args, 'output', None)
|
||||
if output:
|
||||
if output.resolve() == args.input.resolve():
|
||||
parser.error('output must differ from input')
|
||||
output.write_text(serialized + '\n', encoding='utf-8')
|
||||
print(json.dumps({'output': str(output), 'command': args.command}))
|
||||
else:
|
||||
print(serialized)
|
||||
return 0
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
"""Export local WhatsApp Desktop history through its own database decryption.
|
||||
|
||||
Requires an already-exposed Desktop WebView2 CDP endpoint. Does not enable CDP,
|
||||
restart Desktop, use a separate browser, change messages, or request history sync.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from .analyzer import endpoint
|
||||
|
||||
PROBE = r'''(async () => {
|
||||
const options = __OPTIONS__;
|
||||
if (location.origin !== 'https://web.whatsapp.com') throw Error('Unexpected origin');
|
||||
const table = require('WAWebSchemaMessage').getMessageTable();
|
||||
const serializer = require('WAWebDBMessageSerialization');
|
||||
const names = await indexedDB.databases();
|
||||
if (!names.some(d => d.name === 'model-storage')) throw Error('Database absent');
|
||||
const db = await new Promise((resolve,reject) => {
|
||||
const r=indexedDB.open('model-storage');
|
||||
r.onupgradeneeded=()=>{r.transaction.abort();reject(Error('Refusing database creation or upgrade'));};
|
||||
r.onsuccess=()=>resolve(r.result);r.onerror=()=>reject(Error('Database open failed'));
|
||||
r.onblocked=()=>reject(Error('Database blocked'));
|
||||
});
|
||||
const request=r=>new Promise((resolve,reject)=>{r.onsuccess=()=>resolve(r.result);r.onerror=()=>reject(Error('Database read failed'));});
|
||||
try {
|
||||
if (!db.objectStoreNames.contains('message')) throw Error('Message store absent');
|
||||
if (options.mode==='bounds') {
|
||||
const store=db.transaction('message','readonly').objectStore('message');
|
||||
const [count,last]=await Promise.all([request(store.count()),request(store.openKeyCursor(null,'prev'))]);
|
||||
const upper=last?.primaryKey??null;
|
||||
if(upper!==null&&typeof upper!=='string') throw Error('Unsupported primary key type');
|
||||
return {count,upper};
|
||||
}
|
||||
const keys=await new Promise((resolve,reject)=>{
|
||||
const tx=db.transaction('message','readonly'),store=tx.objectStore('message'),values=[];
|
||||
const range=options.after===null?IDBKeyRange.upperBound(options.upper):IDBKeyRange.bound(options.after,options.upper,true,false);
|
||||
const r=store.openKeyCursor(range);
|
||||
r.onerror=()=>reject(Error('Cursor read failed'));tx.onabort=()=>reject(Error('Cursor transaction aborted'));
|
||||
r.onsuccess=()=>{const c=r.result;if(!c||values.length===options.batch){resolve(values);return;}
|
||||
if(typeof c.primaryKey!=='string'){reject(Error('Unsupported primary key type'));return;}
|
||||
values.push(c.primaryKey);c.continue();};
|
||||
});
|
||||
const messages=[];
|
||||
const asString=v=>v==null?null:typeof v==='string'?v:v.toString();
|
||||
for(const key of keys){
|
||||
try {
|
||||
// Default get(..., true) follows WAWebIdb.asyncView -> decryptDbMaterial.
|
||||
const row=await table.get(key);
|
||||
if(!row){messages.push({id:key,error:'row_missing_during_live_scan'});continue;}
|
||||
if(row.msgRowOpaqueData&&typeof row.msgRowOpaqueData==='object'&&'_data' in row.msgRowOpaqueData)
|
||||
throw Error('Opaque payload still encrypted');
|
||||
const m=serializer.messageFromDbRow(row),id=m.id;
|
||||
const out={id:key,message_id:id?.id??null,chat_id:asString(id?.remote),from_me:id?.fromMe??null,
|
||||
type:m.type??null,timestamp:m.t??null,body_length:typeof m.body==='string'?m.body.length:null,
|
||||
caption_length:typeof m.caption==='string'?m.caption.length:null};
|
||||
if(!options.redact){out.body=typeof m.body==='string'?m.body:null;out.caption=typeof m.caption==='string'?m.caption:null;}
|
||||
messages.push(out);
|
||||
}catch(e){messages.push({id:key,error:'row_decryption_or_decoding_failed',error_type:e?.name??'Error'});}
|
||||
}
|
||||
return {keys,messages,last:keys.length?keys[keys.length-1]:options.after};
|
||||
}finally{db.close();}
|
||||
})()'''
|
||||
|
||||
def desktop_ports():
|
||||
# Read process metadata only; never start a process with debugging flags.
|
||||
script = r'''$p=@(Get-CimInstance Win32_Process -Filter "Name='WhatsApp.Root.exe' OR Name='msedgewebview2.exe'");$ids=@($p | Where-Object Name -eq 'WhatsApp.Root.exe' | ForEach-Object ProcessId);for($i=0;$i -lt 8;$i++){$ids+=@($p | Where-Object {$_.ParentProcessId -in $ids} | ForEach-Object ProcessId);$ids=@($ids | Select-Object -Unique)};$r=@($p | Where-Object {$_.ProcessId -in $ids -and $_.CommandLine -match '--remote-debugging-port=(\d+)'} | ForEach-Object {[int]$Matches[1]});ConvertTo-Json -InputObject $r -Compress'''
|
||||
result=subprocess.run(['powershell.exe','-NoProfile','-NonInteractive','-Command',script],capture_output=True,text=True,check=True)
|
||||
return set(json.loads(result.stdout or '[]'))
|
||||
|
||||
def evaluate(ws, expression, ident):
|
||||
ws.send(json.dumps({'id':ident,'method':'Runtime.evaluate','params':{'expression':expression,'returnByValue':True,'awaitPromise':True}}))
|
||||
while True:
|
||||
response=json.loads(ws.recv(timeout=120))
|
||||
if response.get('id')!=ident:continue
|
||||
if response.get('error') or response.get('result',{}).get('exceptionDetails'):
|
||||
raise RuntimeError('WhatsApp database query failed; session may be locked or modules unavailable')
|
||||
return response['result']['result']['value']
|
||||
|
||||
def expression(options):
|
||||
return PROBE.replace('__OPTIONS__',json.dumps(options))
|
||||
|
||||
def export(cdp, output, batch=100, redact=False):
|
||||
output=Path(output)
|
||||
if output.exists() or output.with_suffix(output.suffix+'.partial').exists():
|
||||
raise RuntimeError('Output already exists; choose a new --output path')
|
||||
return _export(cdp,output,batch,redact)
|
||||
|
||||
def _export(cdp, output, batch=100, redact=False):
|
||||
from websockets.sync.client import connect
|
||||
ports=desktop_ports()
|
||||
if not ports:
|
||||
raise RuntimeError('Running WhatsApp Desktop exposes no CDP interface. ReadProcessMemory cannot execute its database decryption methods; Desktop was not restarted or modified.')
|
||||
if cdp is None:
|
||||
if len(ports)!=1:raise RuntimeError('Multiple Desktop CDP ports; supply --cdp')
|
||||
cdp='http://127.0.0.1:'+str(next(iter(ports)))
|
||||
parsed=urllib.parse.urlsplit(cdp)
|
||||
if (parsed.port or (443 if parsed.scheme=='https' else 80)) not in ports:
|
||||
raise RuntimeError('Endpoint does not belong to a detected WhatsApp Desktop debugging port')
|
||||
with urllib.request.urlopen(cdp+'/json/list',timeout=10) as response:targets=json.load(response)
|
||||
pages=[t for t in targets if t.get('type')=='page' and urllib.parse.urlsplit(t.get('url','')).hostname=='web.whatsapp.com']
|
||||
if len(pages)!=1:raise RuntimeError('Expected one WhatsApp Desktop page target')
|
||||
url=pages[0]['webSocketDebuggerUrl']
|
||||
if urllib.parse.urlsplit(url).hostname not in ('127.0.0.1','::1'):raise RuntimeError('Non-loopback WebSocket refused')
|
||||
output=Path(output)
|
||||
partial=output.with_suffix(output.suffix+'.partial')
|
||||
report_path=output.with_suffix(output.suffix+'.report.json')
|
||||
if output.exists() or partial.exists():raise RuntimeError('Output already exists; choose a new --output path')
|
||||
output.parent.mkdir(parents=True,exist_ok=True)
|
||||
report={'status':'running','rows':0,'row_errors':0,'snapshot_atomic':False,
|
||||
'scope':'All encountered local message rows up to initial upper primary key; no phone sync',
|
||||
'rea_evidence':'ev_8be2c4d68d5e268d7782a8098715202eaeda83625aa5b7f25277b09929f6995e'}
|
||||
try:
|
||||
with connect(url,max_size=16_000_000,open_timeout=10) as ws:
|
||||
bounds=evaluate(ws,expression({'mode':'bounds'}),1)
|
||||
report['initial_row_count']=bounds['count']
|
||||
upper,after,ident=bounds['upper'],None,2
|
||||
with partial.open('x',encoding='utf-8') as stream:
|
||||
while upper is not None and after!=upper:
|
||||
result=evaluate(ws,expression({'mode':'batch','upper':upper,'after':after,'batch':batch,'redact':redact}),ident)
|
||||
ident+=1
|
||||
if not result['keys']:break
|
||||
if result['last']==after:raise RuntimeError('Cursor made no progress')
|
||||
if len(result['messages'])!=len(result['keys']):raise RuntimeError('Incomplete batch response')
|
||||
for message in result['messages']:
|
||||
stream.write(json.dumps(message,ensure_ascii=False)+'\n')
|
||||
report['rows']+=1
|
||||
report['row_errors']+=int('error' in message)
|
||||
stream.flush();after=result['last']
|
||||
print(json.dumps({'exported_rows':report['rows'],'row_errors':report['row_errors']}),flush=True)
|
||||
report['final_row_count']=evaluate(ws,expression({'mode':'bounds'}),ident)['count']
|
||||
report['status']='completed_with_row_errors' if report['row_errors'] else 'completed_live_local_scan'
|
||||
partial.rename(output)
|
||||
report['output']=str(output.resolve())
|
||||
except Exception:
|
||||
report['status']='failed_partial_export'
|
||||
raise
|
||||
finally:
|
||||
report_path.write_text(json.dumps(report,indent=2),encoding='utf-8')
|
||||
return report
|
||||
|
||||
def main(argv=None):
|
||||
p=argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument('--cdp',type=endpoint)
|
||||
p.add_argument('--batch-size',type=int,default=100)
|
||||
p.add_argument('--redact-text',action='store_true')
|
||||
p.add_argument('--output',type=Path,default=Path('whatsapp-local-history.jsonl'))
|
||||
args=p.parse_args(argv)
|
||||
if not 1<=args.batch_size<=500:p.error('--batch-size must be 1..500')
|
||||
try:report=export(args.cdp,args.output,args.batch_size,args.redact_text)
|
||||
except (RuntimeError,OSError) as error:print(str(error));return 2
|
||||
print(json.dumps(report));return 0 if not report['row_errors'] else 1
|
||||
|
||||
if __name__=='__main__':raise SystemExit(main())
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Bounded oldest-message reading through the app's JavaScript database API."""
|
||||
|
||||
PROBE = r'''(async () => {
|
||||
if(location.origin!=='https://web.whatsapp.com')throw Error('Unexpected origin');
|
||||
const table=require('WAWebSchemaMessage').getMessageTable();
|
||||
const serializer=require('WAWebDBMessageSerialization');
|
||||
if(!(await indexedDB.databases()).some(d=>d.name==='model-storage'))throw Error('Database absent');
|
||||
const db=await new Promise((resolve,reject)=>{
|
||||
const r=indexedDB.open('model-storage');
|
||||
r.onupgradeneeded=()=>{r.transaction.abort();reject(Error('Refusing database upgrade'));};
|
||||
r.onsuccess=()=>resolve(r.result);r.onerror=()=>reject(Error('Database open failed'));
|
||||
});
|
||||
let scanned=0;
|
||||
try {
|
||||
const oldest=await new Promise((resolve,reject)=>{
|
||||
const tx=db.transaction('message','readonly'),store=tx.objectStore('message'),rows=[];
|
||||
const r=store.openCursor();
|
||||
r.onerror=()=>reject(Error('Cursor failed'));tx.onabort=()=>reject(Error('Transaction aborted'));
|
||||
r.onsuccess=()=>{
|
||||
const c=r.result;if(!c){resolve(rows);return;}
|
||||
if(++scanned>100000) {reject(Error('History scan exceeds 100000 rows'));return;}
|
||||
const t=c.value.t;
|
||||
if(typeof t==='number'&&Number.isFinite(t)&&t>0){
|
||||
rows.push({key:c.primaryKey,t});rows.sort((a,b)=>a.t-b.t);if(rows.length>5)rows.pop();
|
||||
}
|
||||
c.continue();
|
||||
};
|
||||
});
|
||||
const messages=[];
|
||||
for(const candidate of oldest){
|
||||
const row=await table.get(candidate.key);
|
||||
if(!row)throw Error('Selected row disappeared');
|
||||
if(row.msgRowOpaqueData&&typeof row.msgRowOpaqueData==='object'&&'_data' in row.msgRowOpaqueData)
|
||||
throw Error('Payload remains encrypted');
|
||||
const m=serializer.messageFromDbRow(row);
|
||||
messages.push({id:candidate.key,timestamp:m.t,type:m.type,
|
||||
body:typeof m.body==='string'?m.body:null,caption:typeof m.caption==='string'?m.caption:null,
|
||||
selected_timestamp:candidate.t});
|
||||
}
|
||||
return {scanned,sample_limit:5,source:'WAWebSchemaMessage.getMessageTable().get + WAWebDBMessageSerialization.messageFromDbRow',
|
||||
cdp:false,messages};
|
||||
}finally{db.close();}
|
||||
})()'''
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Inject into normal WhatsApp Desktop and query its existing native FTS index.
|
||||
|
||||
Experimental WinRT ABI is tied to REA-analyzed build 2.2639.100.0. No CDP or UI.
|
||||
"""
|
||||
import argparse, json, subprocess
|
||||
from pathlib import Path
|
||||
from .common import asset_bytes
|
||||
|
||||
def main(argv=None):
|
||||
p=argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument('query',nargs='?')
|
||||
p.add_argument('--pid',type=int)
|
||||
p.add_argument('--limit',type=int,default=100)
|
||||
p.add_argument('--max-mb',type=int,default=512)
|
||||
p.add_argument('--output',type=Path,default=Path('whatsapp-injected-search.json'))
|
||||
p.add_argument('--redact-text',action='store_true')
|
||||
p.add_argument('--export-index',action='store_true',help='Export all rows of the native search index as JSONL')
|
||||
p.add_argument('--oldest',action='store_true',help='Read the oldest indexed messages with nonempty text (bounded by --limit)')
|
||||
p.add_argument('--batch-size',type=int,default=200)
|
||||
args=p.parse_args(argv)
|
||||
import frida
|
||||
if not 1<=args.limit<=1000 or not 16<=args.max_mb<=4096:p.error('Invalid limit or scan budget')
|
||||
if not 1<=args.batch_size<=500:p.error('--batch-size must be 1..500')
|
||||
if args.output.exists():p.error('Output exists; select a new --output')
|
||||
command="@(Get-CimInstance Win32_Process -Filter \"Name='WhatsApp.Root.exe'\" | Select-Object -ExpandProperty ProcessId) | ConvertTo-Json -Compress"
|
||||
data=json.loads(subprocess.check_output(['powershell.exe','-NoProfile','-Command',command],text=True) or '[]')
|
||||
pids=data if isinstance(data,list) else [data]
|
||||
if args.pid:
|
||||
if args.pid not in pids:p.error('PID must be a running WhatsApp.Root.exe')
|
||||
pids=[args.pid]
|
||||
reports=[]
|
||||
exported_path=args.output.with_suffix('.messages.jsonl')
|
||||
partial_path=exported_path.with_suffix(exported_path.suffix+'.partial')
|
||||
if args.export_index and (exported_path.exists() or partial_path.exists()):p.error('Export output exists; choose a new --output')
|
||||
exported_stream=partial_path.open('x',encoding='utf-8') if args.export_index else None
|
||||
source=asset_bytes('sqlite_inject.js').decode('utf-8')
|
||||
for pid in pids:
|
||||
session=None;script=None
|
||||
try:
|
||||
session=frida.attach(pid);script=session.create_script(source);script.load()
|
||||
discovered=script.exports_sync.discover(args.max_mb)
|
||||
result={'pid':pid,'native_sqlite':discovered,'matches':[]}
|
||||
if args.export_index:
|
||||
result['index_exports']=[]
|
||||
for db in discovered['databases']:
|
||||
if not {'message','message_fts'}.issubset(db['tables']):continue
|
||||
expected,upper=script.exports_sync.indexbounds(db['id'])
|
||||
after='0';rows_written=0;text_count=0;seen=set()
|
||||
while True:
|
||||
rows=script.exports_sync.exportindex(db['id'],after,upper,args.batch_size)
|
||||
if not rows:break
|
||||
for row in rows:
|
||||
native_rowid=int(row[0])
|
||||
if native_rowid in seen or native_rowid<=int(after):raise RuntimeError('Duplicate/nonmonotonic row returned')
|
||||
seen.add(native_rowid)
|
||||
record={'pid':pid,'database_id':db['id'],'native_rowid':row[0],
|
||||
'message_row_id':row[1],'chat_id':row[2],'timestamp':row[3]}
|
||||
if row[4] is not None:text_count+=1
|
||||
if args.redact_text:record['indexed_text_length']=len(row[4]) if row[4] is not None else None
|
||||
else:record['indexed_text']=row[4]
|
||||
exported_stream.write(json.dumps(record,ensure_ascii=False)+'\n')
|
||||
rows_written+=1
|
||||
after=rows[-1][0];exported_stream.flush()
|
||||
final_count=script.exports_sync.indexbounds(db['id'])[0]
|
||||
result['index_exports'].append({'database_id':db['id'],'initial_count':int(expected),
|
||||
'final_count':int(final_count),'rows_written':rows_written,
|
||||
'rows_with_text':text_count,'duplicate_rowids':0,
|
||||
'snapshot_atomic':False})
|
||||
if args.query or args.oldest:
|
||||
for db in discovered['databases']:
|
||||
if not {'message','message_fts'}.issubset(db['tables']):continue
|
||||
count,earliest,latest=script.exports_sync.stats(db['id'])
|
||||
db['indexed_message_count']=int(count)
|
||||
db['earliest_indexed_timestamp']=earliest
|
||||
db['latest_indexed_timestamp']=latest
|
||||
rows = script.exports_sync.oldest(db['id'], args.limit) if args.oldest else script.exports_sync.search(db['id'],args.query,args.limit)
|
||||
for row in rows:
|
||||
message={'database_id':db['id'],'message_row_id':row[0],'chat_id':row[1],'timestamp':row[2]}
|
||||
if args.redact_text:message['indexed_text_length']=len(row[3]) if row[3] is not None else None
|
||||
else:message['indexed_text']=row[3]
|
||||
result['matches'].append(message)
|
||||
reports.append(result)
|
||||
except Exception as error:reports.append({'pid':pid,'error':str(error),'matches':[]})
|
||||
finally:
|
||||
if script:
|
||||
try:script.exports_sync.close()
|
||||
except Exception:pass
|
||||
if session:session.detach()
|
||||
report={'mode':'Native process injection; existing WinRT SQLite connection; SELECT only',
|
||||
'cdp':False,'ui_automation':False,'results':reports,
|
||||
'rea_evidence':['ev_fd70aa06525b6f2259bf6d98bc879adb2472f63f2407a2d0d1186cc9e27b2f21',
|
||||
'ev_e744051231ce7d2e1f4c72c194492b1029b9e9336a5ff90145bb5d48d332ad8e'],
|
||||
'limits':['Search returns native indexed text, not decoded complete IndexedDB message objects.',
|
||||
'Whitespace/prefix query construction does not fully reproduce WhatsApp multilingual tokenization.',
|
||||
'COM layout is version-specific. Discovery and invocation are experimental.']}
|
||||
if exported_stream:
|
||||
exported_stream.close()
|
||||
successful=any(r.get('index_exports') for r in reports) and not any('error' in r for r in reports if r.get('index_exports'))
|
||||
if successful:
|
||||
partial_path.rename(exported_path)
|
||||
report['export_file']=str(exported_path.resolve())
|
||||
else:report['partial_export_file']=str(partial_path.resolve())
|
||||
args.output.write_text(json.dumps(report,indent=2,ensure_ascii=False),encoding='utf-8')
|
||||
print(json.dumps({'databases':sum(len(r.get('native_sqlite',{}).get('databases',[])) for r in reports),
|
||||
'matches':sum(len(r['matches']) for r in reports),
|
||||
'errors':[{'pid':r['pid'],'error':r['error']} for r in reports if 'error' in r],
|
||||
'report':str(args.output.resolve())}))
|
||||
return 0 if any(any({'message','message_fts'}.issubset(db['tables']) for db in r.get('native_sqlite',{}).get('databases',[])) and 'error' not in r for r in reports) else 2
|
||||
|
||||
if __name__=='__main__':raise SystemExit(main())
|
||||
@@ -0,0 +1,589 @@
|
||||
"""Standalone fixed arithmetic and capability tests in the supported WhatsApp WebView.
|
||||
|
||||
Windows x64 and 64-bit Python; standard library only. The bundled native DLL is
|
||||
hash-verified and cached in the workspace. Capability tests use fixed queries,
|
||||
empty-input reads and simple getters. Explicit message-json and contacts
|
||||
suites serialize the fixed search results or a five-contact sample.
|
||||
There is no arbitrary JavaScript CLI and settings setters are never invoked.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import argparse
|
||||
import ctypes
|
||||
from ctypes import wintypes as w
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import time
|
||||
import uuid
|
||||
import sys
|
||||
|
||||
from .common import BASE, verified_helper
|
||||
from .desktop import visible_ui_threads, detect_desktop_pid, check_core_build, SUPPORTED_BUILD, CORE_SHA256
|
||||
WAKE = 0x8000 + 0x498
|
||||
PROTOCOL = 0x4d415432
|
||||
# SHA-256 of assets/math_probe.dll.
|
||||
EMBEDDED_SHA256 = '136620d96f4745a4c41edfe1f4a9272ef25fc060ddace1c0eec1027bf252e02a'
|
||||
|
||||
CASES = (
|
||||
('(17 * 23) - 19', 372),
|
||||
('2 ** 10', 1024),
|
||||
('Math.sqrt(144) + Math.cbrt(27)', 15),
|
||||
('Math.hypot(3, 4)', 5),
|
||||
('Math.sin(Math.PI / 2)', 1),
|
||||
('[1,2,3,4,5].reduce((sum,n) => sum + n*n, 0)', 55),
|
||||
('0.1 + 0.2', 0.3),
|
||||
('(12345678901234567890n * 9n).toString()', '111111110111111111010'),
|
||||
)
|
||||
|
||||
# Fixed probes only. Availability checks never invoke the selected member.
|
||||
CAPABILITIES = (
|
||||
('search.entry', 'search', 'WAWebMsgCollection', 'MsgCollection.search', 'search'),
|
||||
('search.local', 'search', 'WAWebDBMessageFindLocal', 'msgFindSearch', 'presence'),
|
||||
('search.fts', 'search', 'WAWebFtsClient', 'ftsClient.search', 'presence'),
|
||||
('messages.by_id', 'messages', 'WAWebMsgCollection', 'MsgCollection.getMessagesById', 'empty_messages'),
|
||||
('messages.hydrate', 'messages', 'WAWebMsgCollection', 'MsgCollection.hydrateOrGetMessages', 'empty_array'),
|
||||
('messages.before', 'messages', 'WAWebDBMessageFindLocal', 'msgFindBefore', 'presence'),
|
||||
('messages.after', 'messages', 'WAWebDBMessageFindLocal', 'msgFindAfter', 'presence'),
|
||||
('messages.media', 'messages', 'WAWebDBMessageFindLocal', 'msgFindMedia', 'presence'),
|
||||
('messages.serialization', 'messages', 'WAWebDBMessageSerialization', 'messageFromDbRow', 'presence'),
|
||||
('storage.message_table', 'storage', 'WAWebSchemaMessage', 'getMessageTable', 'table_empty'),
|
||||
('storage.indexeddb', 'storage', 'WAWebIndexedDB', 'default.open', 'presence'),
|
||||
('storage.class', 'storage', 'WAWebStorage', 'Storage', 'presence'),
|
||||
('storage.permanent', 'storage', 'WAWebPermanentStorage', 'default.getItem', 'presence'),
|
||||
('settings.theme', 'settings', 'WAWebUserPrefsGeneral', 'getTheme', 'theme'),
|
||||
('settings.theme_setter', 'settings', 'WAWebUserPrefsGeneral', 'setTheme', 'presence'),
|
||||
('settings.dark_theme', 'settings', 'WAWebThemeSession', 'getIsDarkTheme', 'boolean'),
|
||||
('settings.notifications', 'settings', 'WAWebUserPrefsNotifications', 'getGlobalNotifications', 'flag'),
|
||||
('settings.notifications_setter', 'settings', 'WAWebUserPrefsNotifications', 'setGlobalNotifications', 'presence'),
|
||||
('settings.privacy', 'settings', 'WAWebUserPrefsGeneral', 'getUserPrivacySettings', 'privacy_shape'),
|
||||
('settings.auto_download', 'settings', 'WAWebUserPrefsGeneral', 'getAutoDownloadPhotos', 'boolean'),
|
||||
('settings.archive', 'settings', 'WAWebSettingsGetters', 'getArchive', 'presence'),
|
||||
('settings.apply', 'settings', 'WAWebSettingsBridgeApi', 'SettingsBridgeApi.applyAppSetting', 'presence'),
|
||||
('settings.sync', 'settings', 'WAWebSettingsSyncBridge', 'sendSettingChange', 'presence'),
|
||||
('chats.collection', 'chats', 'WAWebChatCollection', 'ChatCollection.get', 'presence'),
|
||||
('contacts.collection', 'contacts', 'WAWebContactCollection', 'ContactCollection.get', 'presence'),
|
||||
('bridge.ready', 'bridge', 'WAWebWindowsHybridBridgeFactory', 'getIsBridgeReady', 'boolean'),
|
||||
('bridge.get', 'bridge', 'WAWebWindowsHybridBridgeFactory', 'getWindowsBridge', 'presence'),
|
||||
('bridge.frontend', 'bridge', 'WAWebBackendApi', 'frontendSendAndReceive', 'presence'),
|
||||
('bridge.worker', 'bridge', 'WAWebWorkerSafeBackendApi', 'workerSafeSendAndReceive', 'presence'),
|
||||
)
|
||||
|
||||
|
||||
def capability_expressions(probe_id):
|
||||
key = '__whatsapp_fixed_capability_' + probe_id
|
||||
specs = json.dumps(CAPABILITIES, separators=(',', ':'))
|
||||
start = r'''(() => {
|
||||
const key=__KEY__,id=__ID__,specs=__SPECS__;
|
||||
const state={probe_id:id,state:'pending',checks:[]};
|
||||
window[key]=state;
|
||||
if(typeof require!=='function'){
|
||||
state.state='error';state.error='WhatsApp module loader unavailable';return {probe_id:id,state:'started'};
|
||||
}
|
||||
async function bounded(fn){
|
||||
let timer;
|
||||
try{return await Promise.race([Promise.resolve().then(fn),new Promise((_,reject)=>{
|
||||
timer=setTimeout(()=>reject(Error('Fixed getter/query timeout')),2500);
|
||||
})]);}finally{clearTimeout(timer);}
|
||||
}
|
||||
(async()=>{
|
||||
const loaded=new Map();
|
||||
for(const [test,area,module,path,action] of specs){
|
||||
const row={id:test,area,module,member:path,probe:action,invoked:false,passed:false};
|
||||
try{
|
||||
let exports=loaded.get(module);
|
||||
if(!exports){exports=require(module);loaded.set(module,exports);}
|
||||
const parts=path.split('.');let owner=exports;
|
||||
// Meta's global require may unwrap a default-only module.
|
||||
if(parts[0]==='default'&&parts.length>1&&exports?.default===undefined&&
|
||||
typeof exports?.[parts[parts.length-1]]==='function'){
|
||||
parts.shift();row.import_shape='default-export-unwrapped';
|
||||
}
|
||||
for(const part of parts.slice(0,-1))owner=owner?.[part];
|
||||
const value=owner?.[parts[parts.length-1]];
|
||||
row.available=typeof value==='function';
|
||||
if(!row.available){
|
||||
row.status='unavailable';
|
||||
row.summary={module_type:typeof exports,default_type:typeof exports?.default,
|
||||
direct_member_type:typeof exports?.[parts[parts.length-1]],
|
||||
export_names:exports&&typeof exports==='object'?Object.keys(exports).slice(0,12):[]};
|
||||
state.checks.push(row);continue;
|
||||
}
|
||||
if(action==='presence'){row.passed=true;row.status='available';}
|
||||
else{
|
||||
row.invoked=true;
|
||||
if(action==='search'){
|
||||
const result=await bounded(()=>value.call(owner,'meow',1,3));
|
||||
row.summary={query:'meow',matches:Array.isArray(result?.messages)?result.messages.length:null,
|
||||
canceled:result?.canceled===true,eof:result?.eof===true};
|
||||
row.passed=Array.isArray(result?.messages)&&result?.canceled!==true;
|
||||
}else if(action==='empty_messages'||action==='empty_array'){
|
||||
const result=await bounded(()=>value.call(owner,[]));
|
||||
const list=action==='empty_array'?result:result?.messages;
|
||||
row.summary={input_count:0,returned_count:Array.isArray(list)?list.length:null};
|
||||
row.passed=Array.isArray(list)&&list.length===0;
|
||||
}else if(action==='table_empty'){
|
||||
const table=value.call(owner);
|
||||
row.summary={methods:['get','bulkGet','all','count'].filter(name=>typeof table?.[name]==='function')};
|
||||
if(typeof table?.bulkGet!=='function')throw Error('Message table bulkGet unavailable in this context');
|
||||
const result=await bounded(()=>table.bulkGet([]));
|
||||
row.summary.input_count=0;row.summary.returned_count=Array.isArray(result)?result.length:null;
|
||||
row.passed=Array.isArray(result)&&result.length===0;
|
||||
}else{
|
||||
const result=await bounded(()=>value.call(owner));
|
||||
if(action==='privacy_shape'){
|
||||
row.summary={type:typeof result,fields:result&&typeof result==='object'?Object.keys(result).sort():[]};
|
||||
row.passed=result!==null&&typeof result==='object'&&!Array.isArray(result);
|
||||
}else if(action==='theme'){
|
||||
row.summary={type:typeof result};
|
||||
if(['light','dark','system','auto'].includes(result))row.summary.value=result;
|
||||
row.passed=typeof result==='string';
|
||||
}else{
|
||||
row.summary={type:typeof result};
|
||||
row.passed=action==='boolean'?typeof result==='boolean':
|
||||
result==null||typeof result==='boolean'||typeof result==='number';
|
||||
}
|
||||
}
|
||||
row.status=row.passed?'passed':'unexpected-result';
|
||||
}
|
||||
}catch(error){row.status='error';row.error=String(error).slice(0,400);}
|
||||
state.checks.push(row);
|
||||
}
|
||||
state.state='done';
|
||||
})().catch(error=>{state.state='error';state.error=String(error).slice(0,400);});
|
||||
return {probe_id:id,state:'started'};
|
||||
})()'''.replace('__KEY__', json.dumps(key)).replace('__ID__', json.dumps(probe_id)).replace('__SPECS__', specs)
|
||||
poll = '''(() => {const key=__KEY__,state=window[key];
|
||||
if(!state)return {probe_id:__ID__,state:'error',error:'Probe state missing'};
|
||||
if(state.state==='done'||state.state==='error')delete window[key];
|
||||
return state;})()'''.replace('__KEY__', json.dumps(key)).replace('__ID__', json.dumps(probe_id))
|
||||
remove = 'delete window[' + json.dumps(key) + ']'
|
||||
if len(start.encode('utf-16-le')) // 2 >= 8192:
|
||||
raise ValueError('Fixed capability probe exceeds native input buffer')
|
||||
return start, poll, remove
|
||||
|
||||
|
||||
def validate_capabilities(parsed, probe_id):
|
||||
if parsed.get('probe_id') != probe_id or parsed.get('state') != 'done':
|
||||
raise ValueError('Capability probe incomplete: ' + str(parsed.get('error', parsed.get('state'))))
|
||||
checks = parsed.get('checks', [])
|
||||
if [row.get('id') for row in checks] != [spec[0] for spec in CAPABILITIES]:
|
||||
raise ValueError('Capability response does not match the fixed test list')
|
||||
return checks
|
||||
|
||||
|
||||
def data_expressions(probe_id, kind):
|
||||
"""Fixed meow search or loaded-contact sample; no arbitrary query/code input."""
|
||||
key = '__whatsapp_fixed_data_' + probe_id
|
||||
start = r'''(() => {
|
||||
const key=__KEY__,id=__ID__,kind=__KIND__;
|
||||
const state={probe_id:id,state:'pending'};window[key]=state;
|
||||
function snapshot(model){
|
||||
if(typeof model?.serialize!=='function')throw Error('App model serializer unavailable');
|
||||
return model.serialize();
|
||||
}
|
||||
Promise.resolve().then(async()=>{
|
||||
if(typeof require!=='function')throw Error('WhatsApp module loader unavailable');
|
||||
let payload;
|
||||
if(kind==='message-json'){
|
||||
const collection=require('WAWebMsgCollection').MsgCollection;
|
||||
const result=await collection.search('meow',1,3);
|
||||
if(!Array.isArray(result?.messages)||result?.canceled)throw Error('Search did not complete');
|
||||
payload={probe_id:id,kind,query:'meow',matched_count:result.messages.length,
|
||||
eof:result.eof===true,serializer:'BaseModel.serialize',messages:result.messages.map(snapshot)};
|
||||
}else if(kind==='history-read'){
|
||||
payload={probe_id:id,kind,...await __HISTORY_PROBE__};
|
||||
}else{
|
||||
const collection=require('WAWebContactCollection').ContactCollection;
|
||||
if(typeof collection?.getModelsArray!=='function')throw Error('Contact getter unavailable');
|
||||
const contacts=collection.getModelsArray();
|
||||
if(!Array.isArray(contacts))throw Error('Contact getter returned an unexpected type');
|
||||
payload={probe_id:id,kind,loaded_count:contacts.length,sample_limit:5,
|
||||
serializer:'BaseModel.serialize',contacts:contacts.slice(0,5).map(snapshot)};
|
||||
}
|
||||
const seen=new WeakMap();let reference=0;
|
||||
state.json=JSON.stringify(payload,function(name,value){
|
||||
if(value===undefined)return {$type:'undefined'};
|
||||
if(typeof value==='bigint')return {$type:'BigInt',decimal:value.toString()};
|
||||
if(typeof value==='function')return {$type:'function',name:value.name};
|
||||
if(value&&typeof value==='object'){
|
||||
if(seen.has(value))return {$ref:seen.get(value)};
|
||||
seen.set(value,++reference);
|
||||
if(value instanceof ArrayBuffer)return {$type:'ArrayBuffer',bytes:Array.from(new Uint8Array(value))};
|
||||
if(ArrayBuffer.isView(value))return {$type:value.constructor.name,bytes:Array.from(new Uint8Array(value.buffer,value.byteOffset,value.byteLength))};
|
||||
if(value instanceof Map)return {$type:'Map',entries:Array.from(value.entries())};
|
||||
if(value instanceof Set)return {$type:'Set',values:Array.from(value.values())};
|
||||
}
|
||||
return value;
|
||||
});
|
||||
state.state='ready';
|
||||
}).catch(error=>{state.state='error';state.error=String(error).slice(0,400);});
|
||||
return {probe_id:id,state:'started'};
|
||||
})()'''.replace('__KEY__', json.dumps(key)).replace('__ID__', json.dumps(probe_id)).replace('__KIND__', json.dumps(kind))
|
||||
from .history_read import PROBE as history_probe
|
||||
start = start.replace('__HISTORY_PROBE__', history_probe)
|
||||
poll = '''(() => {const value=window[__KEY__];if(!value)return {probe_id:__ID__,state:'error',error:'Data probe state missing'};
|
||||
return {probe_id:value.probe_id,state:value.state,error:value.error,length:value.json?.length};})()'''.replace('__KEY__', json.dumps(key)).replace('__ID__', json.dumps(probe_id))
|
||||
return start, poll, key, 'delete window[' + json.dumps(key) + ']'
|
||||
|
||||
|
||||
def data_chunk_expression(key, offset):
|
||||
return '''(() => {const value=window[__KEY__];if(value?.state!=='ready')throw Error('Data not ready');
|
||||
const start=__OFFSET__;let end=Math.min(start+4096,value.json.length);
|
||||
if(end<value.json.length&&value.json.charCodeAt(end-1)>=0xd800&&value.json.charCodeAt(end-1)<=0xdbff)end++;
|
||||
return {probe_id:value.probe_id,offset:start,next:end,chunk:value.json.slice(start,end)};})()'''.replace('__KEY__', json.dumps(key)).replace('__OFFSET__', str(offset))
|
||||
|
||||
|
||||
def extract_helper():
|
||||
return verified_helper('math_probe.dll', EMBEDDED_SHA256, '.whatsapp-math-native', 'math_probe.dll')
|
||||
|
||||
|
||||
def fixed_expression(probe_id):
|
||||
values = ','.join('{expression:' + json.dumps(expression) + ',value:(' + expression + ')}'
|
||||
for expression, _ in CASES)
|
||||
return '({probe_id:' + json.dumps(probe_id) + ',cases:[' + values + ']})'
|
||||
|
||||
|
||||
def validate_result(raw, probe_id):
|
||||
parsed = json.loads(raw)
|
||||
if parsed.get('probe_id') != probe_id or len(parsed.get('cases', [])) != len(CASES):
|
||||
raise ValueError('Arithmetic response does not match this run')
|
||||
results = []
|
||||
for (expression, expected), observed in zip(CASES, parsed['cases']):
|
||||
value = observed.get('value')
|
||||
passed = observed.get('expression') == expression
|
||||
if isinstance(expected, float):
|
||||
passed = passed and type(value) in (int, float) and math.isclose(
|
||||
value, expected, rel_tol=1e-12, abs_tol=1e-12)
|
||||
else:
|
||||
passed = passed and type(value) is type(expected) and value == expected
|
||||
results.append(dict(expression=expression, expected=expected, actual=value, passed=passed))
|
||||
return results
|
||||
|
||||
|
||||
class Packet(ctypes.Structure):
|
||||
_fields_ = [('state', w.LONG), ('hr', w.LONG), ('thread', w.DWORD),
|
||||
('protocol', w.DWORD), ('script', w.WCHAR * 8192),
|
||||
('result', w.WCHAR * 32768)]
|
||||
|
||||
|
||||
def windows_api():
|
||||
kernel = ctypes.WinDLL('kernel32', use_last_error=True)
|
||||
user = ctypes.WinDLL('user32', use_last_error=True)
|
||||
declarations = {
|
||||
'CreateFileMappingW': ([w.HANDLE, ctypes.c_void_p, w.DWORD, w.DWORD, w.DWORD, w.LPCWSTR], w.HANDLE),
|
||||
'MapViewOfFile': ([w.HANDLE, w.DWORD, w.DWORD, w.DWORD, ctypes.c_size_t], ctypes.c_void_p),
|
||||
'UnmapViewOfFile': ([ctypes.c_void_p], w.BOOL),
|
||||
'CloseHandle': ([w.HANDLE], w.BOOL),
|
||||
'LoadLibraryW': ([w.LPCWSTR], w.HMODULE),
|
||||
'FreeLibrary': ([w.HMODULE], w.BOOL),
|
||||
'GetProcAddress': ([w.HMODULE, ctypes.c_char_p], ctypes.c_void_p),
|
||||
'OpenProcess': ([w.DWORD, w.BOOL, w.DWORD], w.HANDLE),
|
||||
'WaitForSingleObject': ([w.HANDLE, w.DWORD], w.DWORD),
|
||||
}
|
||||
for name, (arguments, returns) in declarations.items():
|
||||
function = getattr(kernel, name)
|
||||
function.argtypes = arguments
|
||||
function.restype = returns
|
||||
user.SetWindowsHookExW.argtypes = [ctypes.c_int, ctypes.c_void_p, w.HMODULE, w.DWORD]
|
||||
user.SetWindowsHookExW.restype = w.HANDLE
|
||||
user.UnhookWindowsHookEx.argtypes = [w.HANDLE]
|
||||
user.UnhookWindowsHookEx.restype = w.BOOL
|
||||
user.PostThreadMessageW.argtypes = [w.DWORD, w.UINT, w.WPARAM, w.LPARAM]
|
||||
user.PostThreadMessageW.restype = w.BOOL
|
||||
return kernel, user
|
||||
|
||||
|
||||
def wait_for_state(packet, desired, seconds, wake, alive, phase):
|
||||
deadline = time.monotonic() + seconds
|
||||
while time.monotonic() < deadline:
|
||||
if not alive():
|
||||
raise RuntimeError('WhatsApp exited during ' + phase)
|
||||
wake()
|
||||
time.sleep(.025)
|
||||
if packet.state == desired:
|
||||
return
|
||||
if packet.state == -1:
|
||||
raise RuntimeError(phase + ': native error 0x' + format(packet.hr & 0xffffffff, '08x'))
|
||||
if phase == 'startup':
|
||||
raise TimeoutError('Helper startup not acknowledged within ' + str(seconds) +
|
||||
's; no JavaScript requested. Check DLL path access in packaged WhatsApp.')
|
||||
raise TimeoutError(phase + ' timed out (native state ' + str(packet.state) + ')')
|
||||
|
||||
|
||||
def helper_loaded(pid, path):
|
||||
"""Observe Windows' asynchronous hook-DLL unloading using module metadata."""
|
||||
class ModuleEntry(ctypes.Structure):
|
||||
_fields_ = [('dwSize', w.DWORD), ('th32ModuleID', w.DWORD),
|
||||
('th32ProcessID', w.DWORD), ('GlblcntUsage', w.DWORD),
|
||||
('ProccntUsage', w.DWORD), ('modBaseAddr', ctypes.c_void_p),
|
||||
('modBaseSize', w.DWORD), ('hModule', w.HMODULE),
|
||||
('szModule', w.WCHAR * 256), ('szExePath', w.WCHAR * 260)]
|
||||
kernel = ctypes.WinDLL('kernel32', use_last_error=True)
|
||||
kernel.CreateToolhelp32Snapshot.argtypes = [w.DWORD, w.DWORD]
|
||||
kernel.CreateToolhelp32Snapshot.restype = w.HANDLE
|
||||
kernel.CloseHandle.argtypes = [w.HANDLE]
|
||||
kernel.CloseHandle.restype = w.BOOL
|
||||
for name in ('Module32FirstW', 'Module32NextW'):
|
||||
function = getattr(kernel, name)
|
||||
function.argtypes = [w.HANDLE, ctypes.POINTER(ModuleEntry)]
|
||||
function.restype = w.BOOL
|
||||
snapshot = kernel.CreateToolhelp32Snapshot(8 | 16, pid)
|
||||
if snapshot == ctypes.c_void_p(-1).value:
|
||||
raise ctypes.WinError(ctypes.get_last_error())
|
||||
try:
|
||||
entry = ModuleEntry(dwSize=ctypes.sizeof(ModuleEntry))
|
||||
present = kernel.Module32FirstW(snapshot, ctypes.byref(entry))
|
||||
while present:
|
||||
if os.path.normcase(entry.szExePath) == os.path.normcase(str(path)):
|
||||
return True
|
||||
present = kernel.Module32NextW(snapshot, ctypes.byref(entry))
|
||||
error = ctypes.get_last_error()
|
||||
if error != 18: # ERROR_NO_MORE_FILES
|
||||
raise ctypes.WinError(error)
|
||||
return False
|
||||
finally:
|
||||
kernel.CloseHandle(snapshot)
|
||||
|
||||
|
||||
def run_math(pid=None, suite='math', _operation=None):
|
||||
scopes = {'math': 'fixed-arithmetic-only', 'capabilities': 'fixed-capability-probes',
|
||||
'all': 'fixed-arithmetic-and-capability-probes',
|
||||
'message-json': 'fixed-meow-message-models', 'contacts': 'loaded-contact-sample',
|
||||
'history-read': 'oldest-local-messages-through-javascript-no-cdp',
|
||||
'history-export': 'all-local-message-rows-through-javascript-no-cdp',
|
||||
'send-self': 'one-text-message-to-own-account-through-javascript',
|
||||
'contacts-export': 'all-local-contact-records-through-javascript'}
|
||||
report = {'passed': False, 'scope': scopes.get(suite, 'invalid-suite'),
|
||||
'suite': suite, 'frida_used': False,
|
||||
'startup_acknowledged': False, 'javascript_executed': False,
|
||||
'cleanup': {'acknowledged': False, 'hook_removed': False},
|
||||
'supported_build': SUPPORTED_BUILD}
|
||||
kernel = user = None
|
||||
module = hook = mapping = view = process = None
|
||||
packet = None
|
||||
remove_expression = None
|
||||
execute = None
|
||||
try:
|
||||
if suite not in scopes:
|
||||
raise ValueError('Unknown suite; choose math, capabilities, all, message-json or contacts')
|
||||
if os.name != 'nt' or ctypes.sizeof(ctypes.c_void_p) != 8:
|
||||
raise ValueError('Windows and 64-bit Python are required')
|
||||
selected = detect_desktop_pid(pid)
|
||||
thread = next(iter(visible_ui_threads(selected)))
|
||||
report.update(pid=selected, ui_thread_id=thread, pid_auto_detected=pid is None)
|
||||
kernel, user = windows_api()
|
||||
process = kernel.OpenProcess(0x100000, False, selected) # SYNCHRONIZE only
|
||||
if not process:
|
||||
raise ctypes.WinError(ctypes.get_last_error())
|
||||
dll, cache = extract_helper()
|
||||
report.update(native_helper_path=str(dll), cache=cache)
|
||||
ctypes.set_last_error(0)
|
||||
mapping = kernel.CreateFileMappingW(w.HANDLE(-1), None, 4, 0, ctypes.sizeof(Packet),
|
||||
'Local\\WhatsAppMathProbe-v2-' + str(selected))
|
||||
mapping_error = ctypes.get_last_error()
|
||||
if not mapping:
|
||||
raise ctypes.WinError(mapping_error)
|
||||
if mapping_error == 183:
|
||||
raise ValueError('Another math probe owns this WhatsApp instance; no hook installed')
|
||||
view = kernel.MapViewOfFile(mapping, 0xf001f, 0, 0, ctypes.sizeof(Packet))
|
||||
if not view:
|
||||
raise ctypes.WinError(ctypes.get_last_error())
|
||||
packet = Packet.from_address(view)
|
||||
packet.thread = thread
|
||||
packet.protocol = PROTOCOL
|
||||
packet.state = 3 # Startup only: native helper must acknowledge before any JS.
|
||||
module = kernel.LoadLibraryW(str(dll))
|
||||
if not module:
|
||||
raise ctypes.WinError(ctypes.get_last_error())
|
||||
callback = kernel.GetProcAddress(module, b'ProbeHook')
|
||||
if not callback:
|
||||
raise ctypes.WinError(ctypes.get_last_error())
|
||||
hook = user.SetWindowsHookExW(3, callback, module, thread)
|
||||
if not hook:
|
||||
raise ctypes.WinError(ctypes.get_last_error())
|
||||
|
||||
def wake():
|
||||
if not user.PostThreadMessageW(thread, WAKE, 0, 0):
|
||||
raise ctypes.WinError(ctypes.get_last_error())
|
||||
|
||||
def alive():
|
||||
status = kernel.WaitForSingleObject(process, 0)
|
||||
if status == 0xffffffff:
|
||||
raise ctypes.WinError(ctypes.get_last_error())
|
||||
return status == 258
|
||||
|
||||
started = time.monotonic()
|
||||
wait_for_state(packet, 4, 5, wake, alive, 'startup')
|
||||
report.update(startup_acknowledged=True, startup_seconds=round(time.monotonic() - started, 3))
|
||||
probe_id = uuid.uuid4().hex
|
||||
report['probe_id'] = probe_id
|
||||
|
||||
def execute(expression, phase):
|
||||
if len(expression.encode('utf-16-le')) // 2 >= 8192:
|
||||
raise ValueError('Fixed probe exceeds native input buffer')
|
||||
packet.script = expression
|
||||
packet.result = ''
|
||||
packet.hr = 0
|
||||
packet.state = 0
|
||||
wait_for_state(packet, 2, 20, wake, alive, phase)
|
||||
report['javascript_executed'] = True
|
||||
return packet.result
|
||||
|
||||
if suite in ('history-export', 'send-self', 'contacts-export'):
|
||||
if _operation is None:
|
||||
raise ValueError('Use the export-zip command for history export')
|
||||
_operation(execute, probe_id, report)
|
||||
report['operation_passed'] = True
|
||||
|
||||
if suite in ('math', 'all'):
|
||||
report['tests'] = validate_result(execute(fixed_expression(probe_id), 'arithmetic'), probe_id)
|
||||
report['math_passed'] = all(result['passed'] for result in report['tests'])
|
||||
if suite in ('capabilities', 'all'):
|
||||
start, poll, remove_expression = capability_expressions(probe_id)
|
||||
initial = json.loads(execute(start, 'capability startup'))
|
||||
if initial.get('probe_id') != probe_id or initial.get('state') != 'started':
|
||||
raise ValueError('Capability startup response does not match this run')
|
||||
deadline = time.monotonic() + 80
|
||||
while time.monotonic() < deadline:
|
||||
parsed = json.loads(execute(poll, 'capability poll'))
|
||||
if parsed.get('state') != 'pending':
|
||||
checks = validate_capabilities(parsed, probe_id)
|
||||
report['capabilities'] = checks
|
||||
report['capability_summary'] = {
|
||||
'total': len(checks), 'passed': sum(row['passed'] for row in checks),
|
||||
'invoked': sum(row['invoked'] for row in checks),
|
||||
'unavailable': sum(row.get('status') == 'unavailable' for row in checks),
|
||||
'errors': sum(row.get('status') == 'error' for row in checks),
|
||||
}
|
||||
report['capabilities_passed'] = all(row['passed'] for row in checks)
|
||||
remove_expression = None # Poll deletes completed state in the page.
|
||||
report['page_probe_state_removed'] = True
|
||||
break
|
||||
time.sleep(.1)
|
||||
else:
|
||||
raise TimeoutError('Fixed capability suite did not finish within 80 seconds')
|
||||
if suite in ('message-json', 'contacts', 'history-read'):
|
||||
start, poll, key, remove_expression = data_expressions(probe_id, suite)
|
||||
initial = json.loads(execute(start, 'data startup'))
|
||||
if initial.get('probe_id') != probe_id or initial.get('state') != 'started':
|
||||
raise ValueError('Data probe startup does not match this run')
|
||||
deadline = time.monotonic() + 30
|
||||
while time.monotonic() < deadline:
|
||||
metadata = json.loads(execute(poll, 'data poll'))
|
||||
if metadata.get('probe_id') != probe_id:
|
||||
raise ValueError('Data probe session mismatch')
|
||||
if metadata.get('state') == 'error':
|
||||
raise ValueError(str(metadata.get('error')))
|
||||
if metadata.get('state') == 'ready':
|
||||
break
|
||||
time.sleep(.1)
|
||||
else:
|
||||
raise TimeoutError('Fixed data probe did not become ready')
|
||||
length = metadata.get('length')
|
||||
if type(length) is not int or not 0 < length <= 16 * 1024 * 1024:
|
||||
raise ValueError('Data probe exceeds the bounded JSON transport size')
|
||||
pieces = []
|
||||
offset = 0
|
||||
while offset < length:
|
||||
part = json.loads(execute(data_chunk_expression(key, offset), 'data chunk'))
|
||||
following = part.get('next')
|
||||
if (part.get('probe_id') != probe_id or part.get('offset') != offset
|
||||
or type(following) is not int or not offset < following <= length
|
||||
or not isinstance(part.get('chunk'), str)):
|
||||
raise ValueError('Invalid JSON transport chunk')
|
||||
if len(part['chunk'].encode('utf-16-le', errors='surrogatepass')) // 2 != following - offset:
|
||||
raise ValueError('JSON transport chunk length mismatch')
|
||||
pieces.append(part['chunk'])
|
||||
offset = following
|
||||
data = json.loads(''.join(pieces))
|
||||
if data.get('probe_id') != probe_id or data.get('kind') != suite:
|
||||
raise ValueError('JSON payload does not match this data probe')
|
||||
report['data'] = data
|
||||
report['data_passed'] = isinstance(data.get('contacts' if suite == 'contacts' else 'messages'), list)
|
||||
if suite == 'history-read':
|
||||
report['data_passed'] = report['data_passed'] and bool(data['messages']) and all(
|
||||
isinstance(row.get('timestamp'), (int, float)) and row['timestamp'] == row.get('selected_timestamp')
|
||||
for row in data['messages'])
|
||||
report['serialization_notes'] = (
|
||||
'Declared model properties and nested collections through app serialize(); '
|
||||
'undefined/BigInt/binary values are tagged. Repeated object references use traversal-order $ref IDs. '
|
||||
'This is a model snapshot, not prototype methods or every private heap field.'
|
||||
)
|
||||
execute(remove_expression, 'data state cleanup')
|
||||
remove_expression = None
|
||||
report['page_probe_state_removed'] = True
|
||||
except Exception as error:
|
||||
report['error'] = str(error)
|
||||
finally:
|
||||
cleanup = report['cleanup']
|
||||
if remove_expression and execute and packet is not None and packet.state in (2, 4, -1):
|
||||
try:
|
||||
execute(remove_expression, 'capability state cleanup')
|
||||
report['page_probe_state_removed'] = True
|
||||
except Exception as error:
|
||||
cleanup['page_state_error'] = str(error)
|
||||
if hook and packet is not None:
|
||||
try:
|
||||
packet.state = 6
|
||||
wait_for_state(packet, 7, 5, wake, alive, 'cleanup')
|
||||
cleanup['acknowledged'] = True
|
||||
cleanup['async_hresult'] = '0x' + format(packet.hr & 0xffffffff, '08x')
|
||||
if packet.hr < 0:
|
||||
cleanup['error'] = 'Native cancel/close failed: ' + cleanup['async_hresult']
|
||||
except Exception as error:
|
||||
cleanup['error'] = str(error)
|
||||
cleanup['hook_removed'] = bool(user.UnhookWindowsHookEx(hook))
|
||||
if not cleanup['hook_removed']:
|
||||
cleanup['unhook_error'] = str(ctypes.WinError(ctypes.get_last_error()))
|
||||
elif not hook:
|
||||
cleanup['not_started'] = True
|
||||
for name, handle, function in [('module', module, 'FreeLibrary'),
|
||||
('mapping_view', view, 'UnmapViewOfFile'),
|
||||
('mapping_handle', mapping, 'CloseHandle'),
|
||||
('process_handle', process, 'CloseHandle')]:
|
||||
if handle:
|
||||
ok = bool(getattr(kernel, function)(handle))
|
||||
cleanup[name + '_released'] = ok
|
||||
if not ok:
|
||||
cleanup[name + '_error'] = str(ctypes.WinError(ctypes.get_last_error()))
|
||||
if hook and cleanup['hook_removed']:
|
||||
try:
|
||||
deadline = time.monotonic() + 3
|
||||
while helper_loaded(selected, dll) and time.monotonic() < deadline:
|
||||
time.sleep(.05)
|
||||
cleanup['native_helper_unloaded'] = not helper_loaded(selected, dll)
|
||||
if not cleanup['native_helper_unloaded']:
|
||||
cleanup['unload_error'] = 'Windows has not unloaded the hook DLL yet'
|
||||
except Exception as error:
|
||||
cleanup['unload_error'] = str(error)
|
||||
suites_ok = ((suite not in ('math', 'all') or report.get('math_passed', False))
|
||||
and (suite not in ('capabilities', 'all') or report.get('capabilities_passed', False))
|
||||
and (suite not in ('message-json', 'contacts', 'history-read') or report.get('data_passed', False))
|
||||
and (suite not in ('history-export', 'send-self', 'contacts-export') or report.get('operation_passed', False)))
|
||||
report['passed'] = (suites_ok and 'error' not in report and cleanup['acknowledged']
|
||||
and cleanup['hook_removed'] and not any(
|
||||
key.endswith('error') for key in cleanup))
|
||||
return report
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--suite', choices=('math', 'capabilities', 'all', 'message-json', 'contacts', 'history-read'), default='math',
|
||||
help='Fixed tests (default: math); message-json prints meow matches, contacts prints a five-contact sample')
|
||||
parser.add_argument('--pid', type=int, help='Optional supported Desktop host PID')
|
||||
parser.add_argument('--output', type=Path)
|
||||
args = parser.parse_args(argv)
|
||||
if args.output is None:
|
||||
args.output = BASE / ('whatsapp-' + args.suite + '-report.json')
|
||||
report = run_math(args.pid, args.suite)
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output.write_text(json.dumps(report, indent=2) + '\n', encoding='utf-8')
|
||||
print(json.dumps(report, indent=2))
|
||||
return 0 if report['passed'] else 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,183 @@
|
||||
"""Bounded live native WebView search backend."""
|
||||
import os
|
||||
import json
|
||||
from pathlib import Path
|
||||
from .common import BASE, verified_helper
|
||||
from .desktop import detect_desktop_pid, visible_ui_threads
|
||||
|
||||
|
||||
EMBEDDED_PROBE_SHA256 = '911beb9f6bb52390cde8a75562ca1d6c46f4629f641426a5250286903de60895'
|
||||
|
||||
|
||||
def build_native_probe():
|
||||
"""Extract the bundled Windows x64 helper; no compiler or downloads."""
|
||||
return verified_helper('search_probe.dll', EMBEDDED_PROBE_SHA256, '.whatsapp-native', 'probe.dll')[0]
|
||||
|
||||
|
||||
def native_webview_test(pid, output, config, search_query=None, limit=50):
|
||||
"""Python/Windows backend; no Frida import, server, agent or runtime."""
|
||||
import ctypes
|
||||
from ctypes import wintypes as w
|
||||
import time
|
||||
import hashlib
|
||||
automatic_pid = pid is None
|
||||
if automatic_pid:
|
||||
pid = detect_desktop_pid()
|
||||
threads = visible_ui_threads(pid)
|
||||
if len(threads)!=1:
|
||||
raise ValueError('Cannot identify one Desktop UI thread')
|
||||
thread = next(iter(threads))
|
||||
class Packet(ctypes.Structure):
|
||||
_fields_=[('state',w.LONG),('hr',w.LONG),('thread',w.DWORD),('reserved',w.DWORD),
|
||||
('script',w.WCHAR*8192),('result',w.WCHAR*32768)]
|
||||
kernel=ctypes.WinDLL('kernel32',use_last_error=True)
|
||||
user=ctypes.WinDLL('user32',use_last_error=True)
|
||||
declarations = {
|
||||
'CreateFileMappingW':([w.HANDLE,ctypes.c_void_p,w.DWORD,w.DWORD,w.DWORD,w.LPCWSTR],w.HANDLE),
|
||||
'MapViewOfFile':([w.HANDLE,w.DWORD,w.DWORD,w.DWORD,ctypes.c_size_t],ctypes.c_void_p),
|
||||
'UnmapViewOfFile':([ctypes.c_void_p],w.BOOL),
|
||||
'CloseHandle':([w.HANDLE],w.BOOL),
|
||||
'LoadLibraryW':([w.LPCWSTR],w.HMODULE),
|
||||
'FreeLibrary':([w.HMODULE],w.BOOL),
|
||||
'GetProcAddress':([w.HMODULE,ctypes.c_char_p],ctypes.c_void_p),
|
||||
}
|
||||
for name,(arguments,returns) in declarations.items():
|
||||
function=getattr(kernel,name);function.argtypes=arguments;function.restype=returns
|
||||
user.SetWindowsHookExW.argtypes=[ctypes.c_int,ctypes.c_void_p,w.HMODULE,w.DWORD]
|
||||
user.SetWindowsHookExW.restype=w.HANDLE
|
||||
user.UnhookWindowsHookEx.argtypes=[w.HANDLE];user.UnhookWindowsHookEx.restype=w.BOOL
|
||||
user.PostThreadMessageW.argtypes=[w.DWORD,w.UINT,w.WPARAM,w.LPARAM]
|
||||
user.PostThreadMessageW.restype=w.BOOL
|
||||
# PID discovery checks the shipped native library hash before any target injection.
|
||||
if pid != detect_desktop_pid():
|
||||
raise ValueError('Explicit PID is not the validated active Desktop instance')
|
||||
dll=build_native_probe()
|
||||
module=hook=mapping=view=None
|
||||
report={'pid':pid,'pid_auto_detected':automatic_pid,'backend':'python-win32-native',
|
||||
'frida_used':False,'mode':config['mode'],'passed':False,'javascript_executed':False,
|
||||
'ui_thread_id':thread,'native_helper_path':str(dll)}
|
||||
report.update({'test_count':len(config.get('cases',[]))} if search_query is None else {'query':search_query,'limit':limit})
|
||||
try:
|
||||
mapping=kernel.CreateFileMappingW(w.HANDLE(-1),None,4,0,ctypes.sizeof(Packet),
|
||||
'Local\\WhatsAppLocalProbe-'+str(pid))
|
||||
mapping_error=ctypes.get_last_error()
|
||||
if not mapping:raise ctypes.WinError(mapping_error)
|
||||
if mapping_error==183:raise ValueError('Another native probe already owns this Desktop instance')
|
||||
view=kernel.MapViewOfFile(mapping,0xf001f,0,0,ctypes.sizeof(Packet))
|
||||
if not view:raise ctypes.WinError(ctypes.get_last_error())
|
||||
packet=Packet.from_address(view);packet.thread=thread;packet.state=3
|
||||
module=kernel.LoadLibraryW(str(dll))
|
||||
if not module:raise ctypes.WinError(ctypes.get_last_error())
|
||||
callback=kernel.GetProcAddress(module,b'ProbeHook')
|
||||
if not callback:raise ctypes.WinError(ctypes.get_last_error())
|
||||
hook=user.SetWindowsHookExW(3,callback,module,thread)
|
||||
if not hook:raise ctypes.WinError(ctypes.get_last_error())
|
||||
deadline=time.monotonic()+30
|
||||
def execute(expression):
|
||||
if len(expression.encode('utf-16-le'))//2>=8192:raise ValueError('Probe expression too long')
|
||||
packet.script=expression;packet.result='';packet.hr=0;packet.state=0
|
||||
while time.monotonic()<deadline:
|
||||
if not user.PostThreadMessageW(thread,0x8000+0x497,0,0):
|
||||
raise ctypes.WinError(ctypes.get_last_error())
|
||||
time.sleep(.025)
|
||||
if packet.state==1:report['javascript_executed']=True
|
||||
if packet.state==-1:raise ValueError('Native HRESULT 0x'+format(packet.hr&0xffffffff,'08x'))
|
||||
if packet.state==2:
|
||||
report['javascript_executed']=True
|
||||
return packet.result
|
||||
if packet.state==0:
|
||||
raise TimeoutError('Native hook did not acknowledge the request; check helper path accessibility in the packaged Desktop process')
|
||||
raise TimeoutError('Timed out waiting for native WebView2 completion (state='+str(packet.state)+')')
|
||||
result=execute(config['start'])
|
||||
if config['mode']=='arithmetic':
|
||||
if config.get('cases'):
|
||||
import math
|
||||
parsed=json.loads(result)
|
||||
actual=parsed.get('cases',[])
|
||||
checks=[]
|
||||
if parsed.get('probe_id')!=config['probe_id'] or len(actual)!=len(config['cases']):
|
||||
raise ValueError('Arithmetic result does not match this test session')
|
||||
for expected,observed in zip(config['cases'],actual):
|
||||
value=observed.get('value')
|
||||
target=expected['expected']
|
||||
same=observed.get('expression')==expected['expression']
|
||||
if isinstance(target,float):
|
||||
same=same and isinstance(value,(int,float)) and not isinstance(value,bool) and math.isclose(value,target,rel_tol=1e-12,abs_tol=1e-12)
|
||||
else:
|
||||
same=same and type(value) is type(target) and value==target
|
||||
checks.append({'expression':expected['expression'],'expected':target,'actual':value,'passed':same})
|
||||
report.update({'probe_id':config['probe_id'],'tests':checks,'passed':all(c['passed'] for c in checks)})
|
||||
else:
|
||||
report.update({'result':result,'passed':result=='2'})
|
||||
else:
|
||||
parsed=json.loads(result)
|
||||
deadline = time.monotonic() + 30
|
||||
while parsed.get('state') in ('started','pending'):
|
||||
if time.monotonic() >= deadline:
|
||||
raise TimeoutError('Desktop search did not finish within 30 seconds')
|
||||
time.sleep(.2);parsed=json.loads(execute(config['poll']))
|
||||
report.update({'search':parsed,'passed':parsed.get('state')=='done'})
|
||||
except Exception as error:
|
||||
report['error']=str(error)
|
||||
finally:
|
||||
if hook and view:
|
||||
packet.state=6
|
||||
close_deadline=time.monotonic()+3
|
||||
while packet.state!=7 and time.monotonic()<close_deadline:
|
||||
user.PostThreadMessageW(thread,0x8000+0x497,0,0);time.sleep(.025)
|
||||
if packet.state!=7:report['cleanup_error']='Owner-thread cleanup did not acknowledge completion'
|
||||
if hook:user.UnhookWindowsHookEx(hook)
|
||||
if module:kernel.FreeLibrary(module)
|
||||
if view:kernel.UnmapViewOfFile(view)
|
||||
if mapping:kernel.CloseHandle(mapping)
|
||||
output.parent.mkdir(parents=True,exist_ok=True)
|
||||
output.write_text(json.dumps(report,indent=2)+'\n',encoding='utf-8')
|
||||
print(json.dumps(report,indent=2))
|
||||
if report.get('cleanup_error'):
|
||||
report['passed'] = False
|
||||
output.write_text(json.dumps(report,indent=2)+'\n',encoding='utf-8')
|
||||
return 0 if report.get('passed') else 1
|
||||
|
||||
|
||||
def run_live_probe(pid, output, search_query=None, limit=50):
|
||||
"""Run the bounded native WebView2 probe without Frida."""
|
||||
import uuid
|
||||
if search_query is None:
|
||||
raise ValueError('A search query is required; use the live math suite for arithmetic')
|
||||
if not search_query.strip() or len(search_query) > 200 or not 1 <= limit <= 50:
|
||||
raise ValueError('Search requires 1..200 characters and a limit of 1..50')
|
||||
key = '__local_search_probe_' + uuid.uuid4().hex
|
||||
# JSON serialization keeps the keyword a JavaScript string literal.
|
||||
query_literal, key_literal = json.dumps(search_query), json.dumps(key)
|
||||
start = r'''(() => {
|
||||
const key=__KEY__,query=__QUERY__,limit=__LIMIT__;
|
||||
if(typeof require!=='function')return {state:'error',error:'WhatsApp module loader unavailable'};
|
||||
window[key]={state:'pending'};
|
||||
Promise.resolve().then(()=>{
|
||||
const collection=require('WAWebMsgCollection').MsgCollection;
|
||||
if(!collection||typeof collection.search!=='function')throw Error('MsgCollection.search unavailable');
|
||||
return collection.search(query,1,limit);
|
||||
}).then(result=>{
|
||||
const messages=Array.isArray(result.messages)?result.messages:[];
|
||||
window[key]={state:'done',query,entry:'WAWebMsgCollection.MsgCollection.search',
|
||||
returned:messages.length,eof:result.eof,canceled:result.canceled,
|
||||
messages:messages.slice(0,limit).map(message=>{
|
||||
const body=typeof message.body==='string'?message.body:'';
|
||||
const caption=typeof message.caption==='string'?message.caption:'';
|
||||
return {id:message.id?.toString()??null,chat_id:message.id?.remote?.toString()??null,
|
||||
timestamp:message.t??null,type:message.type??null,
|
||||
body_length:body.length,caption_length:caption.length,
|
||||
contains_query:(body+'\n'+caption).toLowerCase().includes(query.toLowerCase())};
|
||||
})};
|
||||
}).catch(error=>{window[key]={state:'error',error:String(error),query};});
|
||||
return {state:'started',query};
|
||||
})()'''.replace('__KEY__',key_literal).replace('__QUERY__',query_literal).replace('__LIMIT__',str(limit))
|
||||
poll = r'''(() => {
|
||||
const key=__KEY__,value=window[key];
|
||||
if(!value)return {state:'error',error:'Search state missing'};
|
||||
if(value.state==='done'||value.state==='error')delete window[key];
|
||||
return value;
|
||||
})()'''.replace('__KEY__',key_literal)
|
||||
config = {'mode': 'search', 'start': start, 'poll': poll}
|
||||
return native_webview_test(pid, output, config, search_query, limit)
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Request a real WhatsApp mobile-linking QR in an isolated background browser.
|
||||
|
||||
The official WhatsApp Web client creates the keys and requests the server ref.
|
||||
This script reads the displayed QR payload and renders it locally. It does not
|
||||
extract credentials from the existing Desktop account or implement fake pairing.
|
||||
Requires: pip install websockets 'qrcode[pil]'
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import argparse, base64, datetime as dt, hashlib, json, os
|
||||
from pathlib import Path
|
||||
import socket, subprocess, sys, time, urllib.parse, urllib.request
|
||||
import http.server,threading
|
||||
|
||||
from .common import BASE as ROOT
|
||||
READ_QR=r'''(() => {
|
||||
const element=Array.from(document.querySelectorAll('[data-ref]')).find(e=>{const v=e.getAttribute('data-ref');return v&&v.split(',').length>=4;});
|
||||
const payload=element?.getAttribute('data-ref')??null;
|
||||
let linked=false;
|
||||
try {linked=!!require('WAWebUserPrefsMeUser').getMaybeMePnUser();}catch{}
|
||||
let refreshRequested=false;
|
||||
if(!linked&&!payload){const button=document.querySelector('[data-testid="link_device_qr_expired_refresh_button"]');if(button){button.click();refreshRequested=true;}}
|
||||
return {payload,linked,refreshRequested,title:document.title,origin:location.origin};
|
||||
})()'''
|
||||
|
||||
def chrome_path(explicit):
|
||||
if explicit:return Path(explicit).resolve()
|
||||
candidates=[Path(os.environ.get('PROGRAMFILES','C:/Program Files'))/'Google/Chrome/Application/chrome.exe',Path(os.environ.get('PROGRAMFILES(X86)','C:/Program Files (x86)'))/'Google/Chrome/Application/chrome.exe',Path(os.environ.get('LOCALAPPDATA',''))/'Google/Chrome/Application/chrome.exe']
|
||||
return next((p for p in candidates if p.is_file()),None)
|
||||
|
||||
def call(url,expression):
|
||||
from websockets.sync.client import connect
|
||||
with connect(url,open_timeout=10,max_size=1_000_000) as ws:
|
||||
ws.send(json.dumps({'id':1,'method':'Runtime.evaluate','params':{'expression':expression,'returnByValue':True}}))
|
||||
while True:
|
||||
result=json.loads(ws.recv(timeout=15))
|
||||
if result.get('id')!=1:continue
|
||||
if result.get('error') or result.get('result',{}).get('exceptionDetails'):raise RuntimeError('Page probe failed')
|
||||
return result['result']['result'].get('value')
|
||||
|
||||
def describe(payload):
|
||||
parts=payload.split(',')
|
||||
lengths=[]
|
||||
for part in parts[1:]:
|
||||
try:lengths.append(len(base64.b64decode(part,validate=True)))
|
||||
except Exception:lengths.append(None)
|
||||
return {'field_count':len(parts),'payload_length':len(payload),'sha256':hashlib.sha256(payload.encode()).hexdigest(),'base64_field_byte_lengths':lengths}
|
||||
|
||||
def write_qr(payload,out):
|
||||
import qrcode
|
||||
qr=qrcode.QRCode(error_correction=qrcode.constants.ERROR_CORRECT_M,box_size=10,border=4)
|
||||
qr.add_data(payload);qr.make(fit=True)
|
||||
qr.make_image(fill_color='black',back_color='white').save(out/'pairing-qr.png')
|
||||
# No raw QR payload, identity secret or session key is written to the report.
|
||||
report={'timestamp_utc':dt.datetime.now(dt.timezone.utc).isoformat(),'status':'waiting_for_mobile_scan','source':'Official WhatsApp Web QR DOM in isolated headless Chrome','qr_metadata':describe(payload),'note':'QR rotates and requires the matching browser session to remain running.'}
|
||||
(out/'pairing-status.json').write_text(json.dumps(report,indent=2),encoding='utf-8')
|
||||
(out/'pairing-qr.html').write_text('<!doctype html><html><head><meta charset="utf-8"><meta http-equiv="refresh" content="3"><title>WhatsApp test pairing</title></head><body style="font-family:Arial;text-align:center"><h1>Link your own WhatsApp phone</h1><p>WhatsApp → Linked devices → Link a device.</p><img src="pairing-qr.png" style="max-width:90vw"><p>This QR links the isolated Python test session. The image updates while the script runs.</p></body></html>',encoding='utf-8')
|
||||
|
||||
def serve_viewer(out):
|
||||
class Handler(http.server.SimpleHTTPRequestHandler):
|
||||
def __init__(self,*a,**kw):super().__init__(*a,directory=str(out),**kw)
|
||||
def log_message(self,*a):pass
|
||||
def do_GET(self):
|
||||
if urllib.parse.urlsplit(self.path).path not in ('/pairing-qr.html','/pairing-qr.png','/pairing-status.json'):
|
||||
self.send_error(404);return
|
||||
super().do_GET()
|
||||
server=http.server.ThreadingHTTPServer(('127.0.0.1',0),Handler)
|
||||
threading.Thread(target=server.serve_forever,daemon=True).start()
|
||||
return server,f'http://127.0.0.1:{server.server_port}/pairing-qr.html'
|
||||
|
||||
def main(argv=None):
|
||||
p=argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument('--browser',type=Path);p.add_argument('--profile',type=Path,default=ROOT/'whatsapp-pairing-profile');p.add_argument('--output-dir',type=Path,default=ROOT/'whatsapp-pairing');p.add_argument('--watch-seconds',type=int,default=300);p.add_argument('--startup-seconds',type=int,default=90)
|
||||
args=p.parse_args(argv);browser=chrome_path(args.browser)
|
||||
if not browser or not browser.is_file():p.error('Chrome executable missing; supply --browser')
|
||||
if not 10<=args.watch_seconds<=3600:p.error('--watch-seconds must be 10..3600')
|
||||
profile=args.profile.resolve();out=args.output_dir.resolve()
|
||||
if '5319275a.whatsappdesktop' in str(profile).lower():p.error('Refusing the existing WhatsApp Desktop profile')
|
||||
marker=profile/'python-pairing-profile.json'
|
||||
if profile.exists() and any(profile.iterdir()) and not marker.exists():p.error('Profile must be empty or created by this script')
|
||||
profile.mkdir(parents=True,exist_ok=True);out.mkdir(parents=True,exist_ok=True)
|
||||
marker.write_text(json.dumps({'purpose':'isolated official WhatsApp Web pairing test'}),encoding='utf-8')
|
||||
with socket.socket() as sock:sock.bind(('127.0.0.1',0));port=sock.getsockname()[1]
|
||||
command=[str(browser),'--headless=new','--no-first-run','--no-default-browser-check','--remote-debugging-address=127.0.0.1',f'--remote-debugging-port={port}',f'--user-data-dir={profile}','--window-size=1200,1000','https://web.whatsapp.com/']
|
||||
flags=subprocess.CREATE_NO_WINDOW if sys.platform=='win32' else 0
|
||||
proc=subprocess.Popen(command,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,creationflags=flags)
|
||||
viewer,viewer_url=serve_viewer(out)
|
||||
endpoint=f'http://127.0.0.1:{port}';(out/'pairing-runtime.json').write_text(json.dumps({'browser_pid':proc.pid,'cdp_endpoint':endpoint,'viewer_url':viewer_url,'profile':str(profile)},indent=2),encoding='utf-8')
|
||||
start=time.monotonic();last_payload=None;generated=0;linked=False
|
||||
print(f'Isolated background session started; QR output: {out}',flush=True)
|
||||
print(f'REA observation endpoint: {endpoint}',flush=True)
|
||||
print(f'QR viewer (auto-refresh): {viewer_url}',flush=True)
|
||||
try:
|
||||
while time.monotonic()-start<args.watch_seconds:
|
||||
if proc.poll() is not None:raise RuntimeError('Background browser exited')
|
||||
try:
|
||||
with urllib.request.urlopen(endpoint+'/json/list',timeout=3) as response:targets=json.load(response)
|
||||
target=next((t for t in targets if t.get('type')=='page' and urllib.parse.urlsplit(t.get('url','')).hostname=='web.whatsapp.com'),None)
|
||||
if not target:time.sleep(1);continue
|
||||
state=call(target['webSocketDebuggerUrl'],READ_QR)
|
||||
except (OSError,StopIteration):time.sleep(1);continue
|
||||
if not state or state.get('origin')!='https://web.whatsapp.com':
|
||||
if time.monotonic()-start>args.startup_seconds:raise RuntimeError('WhatsApp page did not commit before startup timeout')
|
||||
time.sleep(1);continue
|
||||
if state.get('linked'):
|
||||
linked=True;(out/'pairing-status.json').write_text(json.dumps({'status':'linked','timestamp_utc':dt.datetime.now(dt.timezone.utc).isoformat(),'profile':str(profile),'note':'Browser is kept alive until the watch timeout; credentials remain only in its isolated profile.'},indent=2),encoding='utf-8')
|
||||
if last_payload is not None:print('Mobile linking confirmed.',flush=True);last_payload=None
|
||||
elif state.get('payload') and state['payload']!=last_payload:
|
||||
last_payload=state['payload'];write_qr(last_payload,out);generated+=1
|
||||
print(f'QR refreshed ({generated}); scan pairing-qr.png from your own phone.',flush=True)
|
||||
elif state.get('refreshRequested'):
|
||||
print('Expired QR: requested a fresh reference through the official reload button.',flush=True)
|
||||
elif not generated and time.monotonic()-start>args.startup_seconds:raise RuntimeError('No pairing QR appeared before startup timeout')
|
||||
time.sleep(2)
|
||||
return 0 if generated or linked else 1
|
||||
except KeyboardInterrupt:return 0
|
||||
finally:
|
||||
viewer.shutdown();viewer.server_close()
|
||||
try:
|
||||
with urllib.request.urlopen(endpoint+'/json/version',timeout=3) as response:version=json.load(response)
|
||||
with connect(version['webSocketDebuggerUrl'],open_timeout=3) as ws:ws.send(json.dumps({'id':1,'method':'Browser.close'}))
|
||||
except Exception:proc.terminate()
|
||||
try:proc.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:proc.kill()
|
||||
(out/'pairing-runtime.json').write_text(json.dumps({'running':False,'linked':linked,'qr_refreshes':generated},indent=2),encoding='utf-8')
|
||||
if not linked and (out/'pairing-status.json').exists():
|
||||
report=json.loads((out/'pairing-status.json').read_text());report['status']='expired_browser_closed';(out/'pairing-status.json').write_text(json.dumps(report,indent=2),encoding='utf-8')
|
||||
|
||||
if __name__=='__main__':raise SystemExit(main())
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Find WhatsApp Desktop pairing QR payload candidates using read-only process RAM.
|
||||
|
||||
No screenshots, browser, debug connection or injected code. Finding a payload
|
||||
does not establish that its server reference is active; no session is created.
|
||||
"""
|
||||
import argparse, base64, ctypes, datetime as dt, hashlib, json, re, struct, sys
|
||||
from pathlib import Path
|
||||
from .ram import Memory, discover
|
||||
|
||||
REA_EVIDENCE = 'ev_6c3a7930e51a95d162c3634ecdc0b73652cb640663681a7cb9962a5b45a9c1da'
|
||||
PATTERN = re.compile(r'(?:https://wa\.me/settings/linked_devices#)?[A-Za-z0-9+/=_:-]{8,512},[A-Za-z0-9+/]{43}=,[A-Za-z0-9+/]{43}=,[A-Za-z0-9+/]{43}=,8(?:,https://faq\.whatsapp\.com/r/ld)?')
|
||||
|
||||
def valid_payload(value):
|
||||
fields = value.removeprefix('https://wa.me/settings/linked_devices#').split(',')
|
||||
if len(fields) not in (5, 6) or fields[4] != '8': return False
|
||||
if len(fields) == 6 and fields[5] != 'https://faq.whatsapp.com/r/ld': return False
|
||||
try: return all(len(base64.b64decode(f, validate=True)) == 32 for f in fields[1:4])
|
||||
except ValueError: return False
|
||||
|
||||
def scan(mem, budget_mb):
|
||||
scanned, seen = 0, set()
|
||||
budget = budget_mb * 1024 * 1024
|
||||
for region in mem.regions():
|
||||
for offset in range(0, region['size'], 1024 * 1024):
|
||||
if scanned >= budget: return
|
||||
address = region['base'] + offset
|
||||
size = min(1024 * 1024, region['size'] - offset, budget - scanned)
|
||||
data = mem.read(address, min(size + 2048, region['size'] - offset))
|
||||
scanned += size
|
||||
variants = [(data.decode('latin-1'), 1, 0)]
|
||||
if b',\x008\x00' in data:
|
||||
for alignment in (0, 1):
|
||||
tail = len(data) - (len(data) - alignment) % 2
|
||||
variants.append((data[alignment:tail].decode('utf-16-le', errors='surrogatepass'), 2, alignment))
|
||||
for text, width, alignment in variants:
|
||||
for match in PATTERN.finditer(text):
|
||||
payload = match.group()
|
||||
if not valid_payload(payload): continue
|
||||
start = address + alignment + match.start() * width
|
||||
header = mem.read(start - 12, 12)
|
||||
if len(header) != 12: continue
|
||||
map_tag, _, length = struct.unpack('<III', header)
|
||||
if not map_tag & 1 or length != len(payload) or (start - 12) % 4: continue
|
||||
raw = payload.encode('latin-1' if width == 1 else 'utf-16-le')
|
||||
if mem.read(start, len(raw)) != raw: continue
|
||||
digest = hashlib.sha256(payload.encode()).hexdigest()
|
||||
if digest in seen: continue
|
||||
seen.add(digest)
|
||||
yield payload, {'pid': mem.pid, 'string_address': hex(start - 12),
|
||||
'encoding': 'Latin-1' if width == 1 else 'UTF-16LE',
|
||||
'payload_sha256': digest, 'server_validity': 'unverified',
|
||||
'validation': 'field lengths, candidate V8 string header, repeat memory read'}
|
||||
|
||||
def main(argv=None):
|
||||
p = argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument('--output-dir', type=Path, default=Path.cwd() / 'whatsapp-desktop-pairing-memory')
|
||||
p.add_argument('--max-mb', type=int, default=512)
|
||||
args = p.parse_args(argv)
|
||||
import qrcode
|
||||
if sys.platform != 'win32' or ctypes.sizeof(ctypes.c_void_p) != 8: p.error('64-bit Windows Python required')
|
||||
if not 16 <= args.max_mb <= 4096: p.error('--max-mb must be 16..4096')
|
||||
targets = discover()
|
||||
candidates, diagnostics = [], []
|
||||
args.output_dir.mkdir(parents=True, exist_ok=True)
|
||||
for target in targets:
|
||||
pid = target['ProcessId']
|
||||
try: mem = Memory(pid)
|
||||
except OSError as error:
|
||||
diagnostics.append({'pid': pid, 'error': str(error)}); continue
|
||||
try:
|
||||
for payload, metadata in scan(mem, args.max_mb):
|
||||
path = args.output_dir / ('pairing-candidate-' + metadata['payload_sha256'][:16] + '.png')
|
||||
qrcode.make(payload).save(path)
|
||||
metadata['file'] = str(path.resolve())
|
||||
candidates.append(metadata)
|
||||
diagnostics.append({'pid': pid, 'bytes_read': mem.read_bytes, 'debug_flags_present': target['DebugFlagsPresent']})
|
||||
finally: mem.close()
|
||||
report = {'timestamp_utc': dt.datetime.now(dt.timezone.utc).isoformat(),
|
||||
'mode': 'ReadProcessMemory; running Desktop renderer descendants only',
|
||||
'rea_static_evidence': REA_EVIDENCE, 'candidates': candidates, 'diagnostics': diagnostics,
|
||||
'limitations': ['Payloads can be expired or unreachable heap objects; active registration is not verified.',
|
||||
'Bounded scan of private readable memory; Cons/external strings or separately stored fields are not reconstructed.',
|
||||
'No new server reference is requested; a linked app may have no pairing payload.',
|
||||
'PNG candidates contain temporary pairing credentials. JSON omits raw payload and private keys.']}
|
||||
path = args.output_dir / 'report.json'
|
||||
path.write_text(json.dumps(report, indent=2), encoding='utf-8')
|
||||
print(json.dumps({'status': 'unverified_candidates_saved' if candidates else 'no_pairing_payload_found',
|
||||
'candidates': len(candidates), 'targets': len(targets), 'report': str(path.resolve())}))
|
||||
return 0 if candidates else 2
|
||||
|
||||
if __name__ == '__main__': raise SystemExit(main())
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Save the pairing QR displayed by the already-running WhatsApp Desktop app.
|
||||
|
||||
No browser, debug port, process injection, restart, or logout. Requires Pillow
|
||||
and zxing-cpp. The official Desktop app must be displaying its linking QR.
|
||||
"""
|
||||
import argparse
|
||||
import ctypes as C
|
||||
from ctypes import wintypes as W
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
|
||||
user = C.WinDLL('user32', use_last_error=True)
|
||||
gdi = C.WinDLL('gdi32', use_last_error=True)
|
||||
user.GetDC.argtypes = [W.HWND]; user.GetDC.restype = W.HDC
|
||||
user.ReleaseDC.argtypes = [W.HWND, W.HDC]
|
||||
user.GetWindowThreadProcessId.argtypes = [W.HWND, C.POINTER(W.DWORD)]
|
||||
user.GetWindowRect.argtypes = [W.HWND, C.POINTER(W.RECT)]
|
||||
user.PrintWindow.argtypes = [W.HWND, W.HDC, W.UINT]
|
||||
user.IsWindowVisible.argtypes = [W.HWND]
|
||||
gdi.CreateCompatibleDC.argtypes = [W.HDC]; gdi.CreateCompatibleDC.restype = W.HDC
|
||||
gdi.CreateCompatibleBitmap.argtypes = [W.HDC, C.c_int, C.c_int]; gdi.CreateCompatibleBitmap.restype = W.HBITMAP
|
||||
gdi.SelectObject.argtypes = [W.HDC, W.HGDIOBJ]; gdi.SelectObject.restype = W.HGDIOBJ
|
||||
gdi.DeleteObject.argtypes = [W.HGDIOBJ]
|
||||
gdi.DeleteDC.argtypes = [W.HDC]
|
||||
|
||||
class Header(C.Structure):
|
||||
_fields_ = [('size', W.DWORD), ('width', W.LONG), ('height', W.LONG),
|
||||
('planes', W.WORD), ('bits', W.WORD), ('compression', W.DWORD),
|
||||
('image_size', W.DWORD), ('xppm', W.LONG), ('yppm', W.LONG),
|
||||
('used', W.DWORD), ('important', W.DWORD)]
|
||||
|
||||
class Info(C.Structure):
|
||||
_fields_ = [('header', Header), ('colors', W.DWORD * 3)]
|
||||
|
||||
gdi.GetDIBits.argtypes = [W.HDC, W.HBITMAP, W.UINT, W.UINT, C.c_void_p, C.POINTER(Info), W.UINT]
|
||||
|
||||
def desktop_windows():
|
||||
query = "@(Get-CimInstance Win32_Process -Filter \"Name='WhatsApp.Root.exe'\" | Select-Object -ExpandProperty ProcessId) | ConvertTo-Json -Compress"
|
||||
result = subprocess.run(['powershell.exe', '-NoProfile', '-NonInteractive', '-Command', query], capture_output=True, text=True, check=True)
|
||||
data = json.loads(result.stdout) if result.stdout.strip() else []
|
||||
pids = set(data if isinstance(data, list) else [data])
|
||||
windows = []
|
||||
callback_type = C.WINFUNCTYPE(W.BOOL, W.HWND, W.LPARAM)
|
||||
@callback_type
|
||||
def callback(hwnd, _):
|
||||
pid = W.DWORD()
|
||||
user.GetWindowThreadProcessId(hwnd, C.byref(pid))
|
||||
if pid.value in pids and user.IsWindowVisible(hwnd):
|
||||
windows.append((hwnd, pid.value))
|
||||
return True
|
||||
user.EnumWindows.argtypes = [callback_type, W.LPARAM]
|
||||
user.EnumWindows(callback, 0)
|
||||
return windows
|
||||
|
||||
def capture(hwnd):
|
||||
from PIL import Image
|
||||
rect = W.RECT()
|
||||
if not user.GetWindowRect(hwnd, C.byref(rect)):
|
||||
return None
|
||||
width, height = rect.right - rect.left, rect.bottom - rect.top
|
||||
if not (100 <= width <= 10000 and 100 <= height <= 10000):
|
||||
return None
|
||||
screen = user.GetDC(hwnd)
|
||||
dc = gdi.CreateCompatibleDC(screen)
|
||||
bitmap = gdi.CreateCompatibleBitmap(screen, width, height)
|
||||
old = gdi.SelectObject(dc, bitmap)
|
||||
try:
|
||||
if not user.PrintWindow(hwnd, dc, 2):
|
||||
return None
|
||||
gdi.SelectObject(dc, old)
|
||||
info = Info(Header(C.sizeof(Header), width, -height, 1, 32, 0, 0, 0, 0, 0, 0))
|
||||
pixels = C.create_string_buffer(width * height * 4)
|
||||
if gdi.GetDIBits(dc, bitmap, 0, height, pixels, C.byref(info), 0) != height:
|
||||
return None
|
||||
return Image.frombytes('RGB', (width, height), pixels.raw, 'raw', 'BGRX')
|
||||
finally:
|
||||
gdi.SelectObject(dc, old)
|
||||
gdi.DeleteObject(bitmap)
|
||||
gdi.DeleteDC(dc)
|
||||
user.ReleaseDC(hwnd, screen)
|
||||
|
||||
def pairing_payload(text):
|
||||
import base64
|
||||
parts = text.split(',')
|
||||
if len(parts) not in (5, 6):
|
||||
return False
|
||||
try:
|
||||
return bool(parts[0]) and all(len(base64.b64decode(p, validate=True)) == 32 for p in parts[1:4]) and parts[4].isdigit()
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--output', type=Path, default=Path.cwd() / 'whatsapp-desktop-qr.png')
|
||||
parser.add_argument('--wait-seconds', type=int, default=0)
|
||||
args = parser.parse_args(argv)
|
||||
import zxingcpp
|
||||
if not 0 <= args.wait_seconds <= 3600:
|
||||
parser.error('--wait-seconds must be 0..3600')
|
||||
deadline = time.monotonic() + args.wait_seconds
|
||||
while True:
|
||||
windows = desktop_windows()
|
||||
for hwnd, pid in windows:
|
||||
image = capture(hwnd)
|
||||
if image is None:
|
||||
continue
|
||||
for code in zxingcpp.read_barcodes(image):
|
||||
if code.format != zxingcpp.BarcodeFormat.QRCode or not pairing_payload(code.text):
|
||||
continue
|
||||
points = [code.position.top_left, code.position.top_right, code.position.bottom_left, code.position.bottom_right]
|
||||
margin = 25
|
||||
box = (max(0, min(p.x for p in points)-margin), max(0, min(p.y for p in points)-margin),
|
||||
min(image.width, max(p.x for p in points)+margin), min(image.height, max(p.y for p in points)+margin))
|
||||
cropped = image.crop(box)
|
||||
if not any(c.text == code.text for c in zxingcpp.read_barcodes(cropped)):
|
||||
continue
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
cropped.save(args.output, format='PNG')
|
||||
print(json.dumps({'status': 'saved', 'source': 'running WhatsApp Desktop window', 'pid': pid,
|
||||
'file': str(args.output.resolve()), 'payload_sha256': hashlib.sha256(code.text.encode()).hexdigest()}))
|
||||
return 0
|
||||
if time.monotonic() >= deadline:
|
||||
print(json.dumps({'status': 'no_active_desktop_qr', 'windows_found': len(windows),
|
||||
'note': 'Desktop must display its linking QR. A linked account has no login QR; GPU rendering or a minimized window can also prevent capture. No QR file was created or overwritten.'}))
|
||||
return 2
|
||||
time.sleep(2)
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,454 @@
|
||||
"""Read-only external V8 heap reader for normal WhatsApp WebView2 processes.
|
||||
|
||||
No CDP, injected code, process writes, debugger attachment, or process restart.
|
||||
Uses OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ).
|
||||
Experimental layouts must pass runtime pointer/type/schema checks.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import argparse, ctypes as C, ctypes.wintypes as W, json, struct, subprocess, sys, re
|
||||
import datetime as dt
|
||||
from pathlib import Path
|
||||
|
||||
class MBI(C.Structure):
|
||||
_fields_=[('BaseAddress',C.c_void_p),('AllocationBase',C.c_void_p),('AllocationProtect',W.DWORD),('PartitionId',W.WORD),('RegionSize',C.c_size_t),('State',W.DWORD),('Protect',W.DWORD),('Type',W.DWORD)]
|
||||
|
||||
class Memory:
|
||||
def __init__(self,pid):
|
||||
self.pid=pid; self.k=C.WinDLL('kernel32',use_last_error=True)
|
||||
self.k.OpenProcess.argtypes=[W.DWORD,W.BOOL,W.DWORD];self.k.OpenProcess.restype=W.HANDLE
|
||||
self.k.ReadProcessMemory.argtypes=[W.HANDLE,C.c_void_p,C.c_void_p,C.c_size_t,C.POINTER(C.c_size_t)];self.k.ReadProcessMemory.restype=W.BOOL
|
||||
self.k.VirtualQueryEx.argtypes=[W.HANDLE,C.c_void_p,C.POINTER(MBI),C.c_size_t];self.k.VirtualQueryEx.restype=C.c_size_t
|
||||
self.k.CloseHandle.argtypes=[W.HANDLE]
|
||||
self.handle=self.k.OpenProcess(0x410,False,pid)
|
||||
if not self.handle:raise C.WinError(C.get_last_error())
|
||||
self.cache={};self.read_bytes=0
|
||||
def close(self):self.k.CloseHandle(self.handle)
|
||||
def read(self,address,size):
|
||||
if not address or size<0 or size>8*1024*1024:return b''
|
||||
buf=C.create_string_buffer(size);n=C.c_size_t()
|
||||
self.k.ReadProcessMemory(self.handle,address,buf,size,C.byref(n))
|
||||
self.read_bytes+=n.value
|
||||
return buf.raw[:n.value]
|
||||
def u32(self,a):
|
||||
b=self.read(a,4);return struct.unpack('<I',b)[0] if len(b)==4 else None
|
||||
def regions(self):
|
||||
a=0
|
||||
while a<0x7fffffffffff:
|
||||
m=MBI()
|
||||
if not self.k.VirtualQueryEx(self.handle,a,C.byref(m),C.sizeof(m)):break
|
||||
base=m.BaseAddress or 0;size=m.RegionSize
|
||||
if m.State==0x1000 and m.Type==0x20000 and not(m.Protect&0x101) and m.Protect&0xee:
|
||||
yield {'base':base,'size':size,'protection':m.Protect,'allocation_base':m.AllocationBase or 0}
|
||||
if base+size<=a:break
|
||||
a=base+size
|
||||
|
||||
def discover():
|
||||
script=r'''$p=@(Get-CimInstance Win32_Process -Filter "Name='WhatsApp.Root.exe' OR Name='msedgewebview2.exe'" | Select-Object ProcessId,ParentProcessId,Name,CommandLine); ConvertTo-Json -InputObject $p -Compress'''
|
||||
r=subprocess.run(['powershell.exe','-NoProfile','-NonInteractive','-Command',script],capture_output=True,text=True,check=True)
|
||||
procs=json.loads(r.stdout);roots={p['ProcessId'] for p in procs if p['Name']=='WhatsApp.Root.exe'}
|
||||
descendants=set(roots)
|
||||
for _ in range(5):
|
||||
descendants.update(p['ProcessId'] for p in procs if p['ParentProcessId'] in descendants)
|
||||
selected=[p for p in procs if p['ProcessId'] in descendants and '--type=renderer' in (p.get('CommandLine') or '')]
|
||||
by_pid={p['ProcessId']:p for p in procs}
|
||||
for target in selected:
|
||||
chain=[];current=target;visited=set()
|
||||
while current and current['ProcessId'] not in visited:
|
||||
visited.add(current['ProcessId']);chain.append(current.get('CommandLine') or '');current=by_pid.get(current['ParentProcessId'])
|
||||
target['DebugFlagsPresent']=any(flag in command for command in chain for flag in ('--remote-debugging','--inspect'))
|
||||
return selected
|
||||
|
||||
def scan_markers(mem,regions,max_mb):
|
||||
markers=[b'msgRowOpaqueData',b'body',b'caption',b'internalId',b'fromMe',b'_serialized',b'__x_body',b'__x_id',b'__x_type',b'__x_t',b'__x_caption',b'_models']
|
||||
found=[];total=0
|
||||
for region in regions:
|
||||
for delta in range(0,region['size'],1024*1024):
|
||||
if total>=max_mb*1024*1024:return found
|
||||
a=region['base']+delta; b=mem.read(a,min(1024*1024,region['size']-delta));total+=len(b)
|
||||
for marker in markers:
|
||||
start=0
|
||||
while True:
|
||||
pos=b.find(marker,start)
|
||||
if pos<0:break
|
||||
start=pos+len(marker)
|
||||
# A sequential Latin-1 V8 String has length at payload-4.
|
||||
obj=a+pos-12
|
||||
if obj%4 or pos<12:continue
|
||||
map_tag,raw_hash,length=struct.unpack_from('<III',b,pos-12)
|
||||
if length!=len(marker) or not(map_tag&1):continue
|
||||
found.append({'name':marker.decode(),'address':obj,'map_tag':map_tag,'cage':obj&~0xffffffff})
|
||||
return found
|
||||
|
||||
class V8:
|
||||
"""Compressed-pointer layout from V8 15.4.11; all reads remain external."""
|
||||
def __init__(self,mem,cage):self.mem=mem;self.cage=cage;self.maps={};self.strings={};self.external_requests={};self.external_data_handles={};self.external_table=None
|
||||
def ptr(self,tag):return self.cage+(tag&~1) if tag is not None and tag&1 else 0
|
||||
def map(self,address):
|
||||
if address in self.maps:return self.maps[address]
|
||||
b=self.mem.read(address,40)
|
||||
if len(b)!=40:return None
|
||||
result={'tag':struct.unpack_from('<I',b)[0],'size':b[4]*4,'inobject_start':b[5]*4,'type':struct.unpack_from('<H',b,8)[0],'bits':struct.unpack_from('<I',b,12)[0],'descriptors':struct.unpack_from('<I',b,24)[0]}
|
||||
self.maps[address]=result;return result
|
||||
def object_type(self,address):
|
||||
tag=self.mem.u32(address);mp=self.map(self.ptr(tag))
|
||||
return mp['type'] if mp else None
|
||||
def string(self,tag,depth=0,max_length=100000):
|
||||
if tag is None or not tag&1 or depth>12:return None
|
||||
a=self.ptr(tag)
|
||||
if a in self.strings:return self.strings[a]
|
||||
b=self.mem.read(a,24)
|
||||
if len(b)!=24:return None
|
||||
mp=self.map(self.ptr(struct.unpack_from('<I',b)[0]))
|
||||
if not mp or mp['type']>=128:return None
|
||||
typ=mp['type'];length=struct.unpack_from('<i',b,8)[0]
|
||||
if not 0<=length<=max_length:return None
|
||||
rep=typ&7;result=None
|
||||
if rep==0:
|
||||
width=1 if typ&8 else 2;raw=self.mem.read(a+12,length*width)
|
||||
if len(raw)==length*width:result=raw.decode('latin1' if width==1 else 'utf-16-le',errors='replace')
|
||||
elif rep==1:
|
||||
left=self.string(struct.unpack_from('<I',b,12)[0],depth+1,max_length);right=self.string(struct.unpack_from('<I',b,16)[0],depth+1,max_length)
|
||||
if left is not None and right is not None:result=left+right
|
||||
elif rep==3:
|
||||
parent=self.string(struct.unpack_from('<I',b,12)[0],depth+1,max_length);offset=struct.unpack_from('<i',b,16)[0]>>1
|
||||
if parent is not None:result=parent[offset:offset+length]
|
||||
elif rep==5:result=self.string(struct.unpack_from('<I',b,12)[0],depth+1,max_length)
|
||||
elif rep==2:
|
||||
handle=struct.unpack_from('<I',b,12)[0];width=1 if typ&8 else 2
|
||||
if handle and not handle&63:self.external_requests[handle]=(length,width)
|
||||
data_handle=struct.unpack_from('<I',b,16)[0] if not typ&16 else None
|
||||
if data_handle and not data_handle&63:self.external_data_handles[handle]=data_handle
|
||||
if self.external_table and handle and not handle&63:
|
||||
slot=self.external_table+(handle>>6)*8;entry_bytes=self.mem.read(slot,8)
|
||||
if len(entry_bytes)==8:
|
||||
entry=struct.unpack('<Q',entry_bytes)[0]
|
||||
if (entry>>49)&127==2:
|
||||
resource=entry&0xff00ffffffffffff;header=self.mem.read(resource,16)
|
||||
if len(header)==16:
|
||||
vtable,data=struct.unpack('<QQ',header)
|
||||
if data_handle:
|
||||
data_entry=self.mem.read(self.external_table+(data_handle>>6)*8,8)
|
||||
if len(data_entry)==8:
|
||||
de=struct.unpack('<Q',data_entry)[0]
|
||||
data=de&0xff00ffffffffffff if (de>>49)&127==3 else 0
|
||||
raw=self.mem.read(data,length*width)
|
||||
if len(raw)==length*width and self.mem.read(slot,8)==entry_bytes and self.mem.read(a,20 if data_handle else 16)==b[:20 if data_handle else 16]:
|
||||
result=raw.decode('latin1' if width==1 else 'utf-16-le',errors='replace')
|
||||
if result is not None and len(result.encode('utf-16-le',errors='replace'))//2==length:
|
||||
self.strings[a]=result;return result
|
||||
return None
|
||||
def descriptors(self,address):
|
||||
if self.object_type(address)!=0x106:return None
|
||||
b=self.mem.read(address,16)
|
||||
if len(b)!=16:return None
|
||||
alln,n=struct.unpack_from('<HH',b,4)
|
||||
if not 0<n<=alln<=1024:return None
|
||||
raw=self.mem.read(address+16,n*12)
|
||||
if len(raw)!=n*12:return None
|
||||
fields=[]
|
||||
for i in range(n):
|
||||
key,details,value=struct.unpack_from('<III',raw,i*12)
|
||||
name=self.string(key,max_length=256)
|
||||
if name is None:return None
|
||||
d=details>>1
|
||||
fields.append({'name':name,'field':not bool(d&32),'offset':((d>>19)&2047)*4,'inobject':bool(d&(1<<30)),'representation':(d>>6)&7,'value':value})
|
||||
return fields
|
||||
def fields(self,address,mp,names):
|
||||
if mp['bits']&(1<<21):
|
||||
props=self.ptr(self.mem.u32(address+4))
|
||||
return self.dictionary(props,names) or {}
|
||||
descriptors=self.descriptors(self.ptr(mp['descriptors']))
|
||||
if descriptors is None:return {}
|
||||
own=(mp['bits']>>10)&1023;out={}
|
||||
for d in descriptors[:own]:
|
||||
if d['name'] not in names:continue
|
||||
if d['field']:
|
||||
base=address if d['inobject'] else self.ptr(self.mem.u32(address+4))
|
||||
tag=self.mem.u32(base+d['offset']) if base else None
|
||||
if d['representation']==2:
|
||||
raw=self.mem.read(base+d['offset'],8) if base else b''
|
||||
out[d['name']]=struct.unpack('<d',raw)[0] if len(raw)==8 else None
|
||||
else:out[d['name']]=tag
|
||||
else:out[d['name']]=d['value']
|
||||
return out
|
||||
def dictionary(self,address,names):
|
||||
b=self.mem.read(address,32)
|
||||
if len(b)!=32:return None
|
||||
map_tag,length,count,deleted,capacity=struct.unpack_from('<5I',b)
|
||||
if not map_tag&1 or any(x&1 for x in (count,deleted,capacity)):return None
|
||||
cap=capacity>>1
|
||||
if cap<4 or cap>2048 or cap&(cap-1) or length!=6+3*cap or (count+deleted)>>1>cap:return None
|
||||
mp=self.map(self.ptr(map_tag))
|
||||
if not mp or self.object_type(self.ptr(map_tag))!=0x110:return None
|
||||
raw=self.mem.read(address+32,cap*12)
|
||||
if len(raw)!=cap*12:return None
|
||||
result={}
|
||||
for i in range(cap):
|
||||
key,value,details=struct.unpack_from('<III',raw,i*12)
|
||||
name=self.string(key,max_length=256)
|
||||
if name in names:result[name]=value
|
||||
return result
|
||||
def number(self,tag):
|
||||
if tag is None:return None
|
||||
if not tag&1:return (tag if tag<0x80000000 else tag-0x100000000)>>1
|
||||
a=self.ptr(tag)
|
||||
if self.object_type(a)==0x82:
|
||||
b=self.mem.read(a+4,8)
|
||||
return struct.unpack('<d',b)[0] if len(b)==8 else None
|
||||
return None
|
||||
|
||||
def chunks(mem,regions,cage,max_mb):
|
||||
total=0
|
||||
for r in regions:
|
||||
if r['base']&~0xffffffff!=cage:continue
|
||||
for delta in range(0,r['size'],1024*1024):
|
||||
if total>=max_mb*1024*1024:return
|
||||
a=r['base']+delta;b=mem.read(a,min(1024*1024,r['size']-delta));total+=len(b)
|
||||
yield a,b
|
||||
|
||||
def refs(mem,regions,cage,tags,max_mb):
|
||||
patterns={struct.pack('<I',tag):tag for tag in tags};hits=[]
|
||||
for a,b in chunks(mem,regions,cage,max_mb):
|
||||
for pattern,tag in patterns.items():
|
||||
start=0
|
||||
while True:
|
||||
i=b.find(pattern,start)
|
||||
if i<0:break
|
||||
start=i+4
|
||||
if (a+i)%4==0:hits.append((a+i,tag))
|
||||
return hits
|
||||
|
||||
def locate_collection(v,regions,markers,max_mb):
|
||||
tags={(h['address']-v.cage)|1 for h in markers if h['cage']==v.cage and h['name']=='_models'}
|
||||
arrays={}
|
||||
for address,tag in refs(v.mem,regions,v.cage,tags,max_mb):
|
||||
base=max(v.cage,address-12304);raw=v.mem.read(base,address-base+16)
|
||||
for i in range(min(1024,(address-base)//12+1)):
|
||||
candidate=address-16-i*12;offset=candidate-base
|
||||
if offset<0 or offset+16>len(raw):continue
|
||||
map_tag,alln,n=struct.unpack_from('<IHH',raw,offset)
|
||||
if not map_tag&1 or not i<n<=alln<=1024:continue
|
||||
d=v.descriptors(candidate)
|
||||
if d and d[i]['name']=='_models' and {'pendingAdd','ftsCache','_models','byChat','byParentMessage'}.issubset({x['name'] for x in d}):arrays[candidate]=d
|
||||
maps={}
|
||||
for reference,tag in refs(v.mem,regions,v.cage,{(a-v.cage)|1 for a in arrays},max_mb):
|
||||
candidate=reference-24;mp=v.map(candidate)
|
||||
if not mp or v.object_type(candidate)!=0x110 or mp['bits']&(1<<21):continue
|
||||
own=(mp['bits']>>10)&1023
|
||||
if {'pendingAdd','ftsCache','_models','byChat','byParentMessage'}.issubset({d['name'] for d in arrays[v.ptr(tag)][:own]}):maps[(candidate-v.cage)|1]=(candidate,mp)
|
||||
collections=[]
|
||||
for address,tag in refs(v.mem,regions,v.cage,maps,max_mb):
|
||||
map_address,mp=maps[tag]
|
||||
if mp['type']<1000:continue
|
||||
fields=v.fields(address,mp,{'_models'});arr=v.ptr(fields.get('_models'))
|
||||
if not arr or v.object_type(arr)!=0x84c:continue
|
||||
n=v.number(v.mem.u32(arr+12))
|
||||
if n is None or not 0<=n<=100000:continue
|
||||
n=int(n);elements=v.ptr(v.mem.u32(arr+8));capacity=v.mem.u32(elements+4)
|
||||
if capacity is None or capacity<n or capacity>1000000:continue
|
||||
raw=v.mem.read(elements+8,n*4)
|
||||
if len(raw)!=n*4:continue
|
||||
members=[v.ptr(t[0]) for t in struct.iter_unpack('<I',raw)]
|
||||
if v.mem.u32(address)!=tag or v.mem.u32(arr+12)!=(n<<1):continue
|
||||
collections.append({'owner_address':hex(address),'array_address':hex(arr),'length':n,'members':members})
|
||||
return collections
|
||||
|
||||
def locate_external_table(v,regions,max_mb):
|
||||
"""Locate the shared resource table from several observed external handles.
|
||||
|
||||
Calibration uses public field/type names, never a guessed message string.
|
||||
It verifies independent resource handles before accepting an aligned base.
|
||||
"""
|
||||
requests={h:spec for h,spec in v.external_requests.items() if 0<spec[0]<=64}
|
||||
if len(requests)<2:return None
|
||||
constants={'chat','image','video','audio','document','sticker','id','remote','fromMe','type','body','caption','t','$1','participant','self'}
|
||||
checked=set();total=0
|
||||
for region in regions:
|
||||
if region['base']&~0xffffffff==v.cage:continue
|
||||
for delta in range(0,region['size'],1024*1024):
|
||||
if total>=max_mb*1024*1024:return None
|
||||
address=region['base']+delta;raw=v.mem.read(address,min(1024*1024,region['size']-delta));total+=len(raw)
|
||||
for hit in re.finditer(rb'[\x04\x05]\x00',raw):
|
||||
pos=hit.start()-6
|
||||
if pos<0 or (address+pos)%8:continue
|
||||
entry=struct.unpack_from('<Q',raw,pos)[0];resource=entry&0xff00ffffffffffff
|
||||
if not 0x10000<resource<0x7fffffffffff:continue
|
||||
native=v.mem.read(resource,16)
|
||||
if len(native)!=16:continue
|
||||
vtable,data=struct.unpack('<QQ',native)
|
||||
if not 0x7ff000000000<vtable<0x7fffffffffff:continue
|
||||
for handle,(length,width) in requests.items():
|
||||
base=address+pos-(handle>>6)*8
|
||||
if base<0x10000 or base%65536 or base in checked:continue
|
||||
text=v.mem.read(data,length*width)
|
||||
if len(text)!=length*width:continue
|
||||
if text.decode('latin1' if width==1 else 'utf-16-le',errors='replace') not in constants:continue
|
||||
checked.add(base);valid=0
|
||||
for another,(n,w) in requests.items():
|
||||
b=v.mem.read(base+(another>>6)*8,8)
|
||||
if len(b)!=8:continue
|
||||
e=struct.unpack('<Q',b)[0]
|
||||
if (e>>49)&127!=2:continue
|
||||
res=e&0xff00ffffffffffff;native2=v.mem.read(res,16)
|
||||
if len(native2)!=16:continue
|
||||
vt,chars=struct.unpack('<QQ',native2)
|
||||
if not 0x7ff000000000<vt<0x7fffffffffff:continue
|
||||
data_handle=v.external_data_handles.get(another)
|
||||
if data_handle:
|
||||
data_entry=v.mem.read(base+(data_handle>>6)*8,8)
|
||||
if len(data_entry)!=8:continue
|
||||
de=struct.unpack('<Q',data_entry)[0]
|
||||
if (de>>49)&127!=3:continue
|
||||
chars=de&0xff00ffffffffffff
|
||||
if len(v.mem.read(chars,n*w))==n*w:valid+=1
|
||||
if valid>=3:return base
|
||||
return None
|
||||
|
||||
def read_model(v,address,collection_verified=False):
|
||||
tag=v.mem.u32(address);mp=v.map(v.ptr(tag))
|
||||
if not mp or mp['type']!=0x421:return None
|
||||
props=v.mem.u32(address+4)
|
||||
fields=v.fields(address,mp,{'__x_body','__x_caption','__x_id','__x_type','__x_t'})
|
||||
if '__x_id' not in fields:return None
|
||||
key_address=v.ptr(fields.get('__x_id'));key_tag=v.mem.u32(key_address) if key_address else None
|
||||
key_map=v.map(v.ptr(key_tag)) if key_address else None
|
||||
key_fields=v.fields(key_address,key_map,{'$1','id','fromMe','remote'}) if key_map else {}
|
||||
if not {'id','fromMe','remote'}.issubset(key_fields):return None
|
||||
body=v.string(fields.get('__x_body'));caption=v.string(fields.get('__x_caption'))
|
||||
typ=v.string(fields.get('__x_type'),max_length=64);message_id=v.string(key_fields.get('id'),max_length=256)
|
||||
key=v.string(key_fields.get('$1'));timestamp=v.number(fields.get('__x_t'))
|
||||
if timestamp is not None and not 1200000000<=timestamp<=2200000000:return None
|
||||
if v.mem.u32(address)!=tag or v.mem.u32(address+4)!=props or v.mem.u32(key_address)!=key_tag:return None
|
||||
if v.fields(address,mp,{'__x_body','__x_caption','__x_id','__x_type','__x_t'})!=fields:return None
|
||||
states={}
|
||||
for name,field,value in [('body','__x_body',body),('caption','__x_caption',caption),('type','__x_type',typ)]:
|
||||
raw=fields.get(field);object_type=v.object_type(v.ptr(raw)) if raw and raw&1 else None
|
||||
states[name]='decoded' if value is not None else 'unsupported_string_representation' if object_type is not None and object_type<128 else 'not_a_string_or_absent'
|
||||
states['message_id']='decoded' if message_id is not None else 'unsupported_or_absent'
|
||||
states['timestamp']='decoded' if timestamp is not None else 'unsupported_or_absent'
|
||||
key_parts=key.split('_',2) if key else []
|
||||
chat_id=key_parts[1] if len(key_parts)==3 and key_parts[0] in ('true','false') else None
|
||||
from_me=(key_parts[0]=='true') if chat_id is not None else None
|
||||
return {'pid':v.mem.pid,'object_address':hex(address),'map_address':hex(v.ptr(tag)),'message_key':key,'message_id':message_id,'chat_id':chat_id,'from_me':from_me,'type':typ,'timestamp':timestamp,'body':body,'caption':caption,'field_status':states,'in_message_collection':collection_verified,'confidence':'validated V8 Msg model and MsgKey layout; '+('membership in MsgCollection._models' if collection_verified else 'collection membership not established')}
|
||||
|
||||
def recover(mem,regions,markers,max_mb,limit):
|
||||
messages=[];diagnostics=[]
|
||||
for cage in sorted({h['cage'] for h in markers if h['name']=='__x_body'}):
|
||||
v=V8(mem,cage);tags={(h['address']-cage)|1 for h in markers if h['cage']==cage and h['name']=='__x_body'}
|
||||
arrays={}
|
||||
for address,tag in refs(mem,regions,cage,tags,max_mb):
|
||||
# Search backwards for an actual DescriptorArray containing this key.
|
||||
raw=mem.read(max(cage,address-12304),min(address-cage,12304)+16)
|
||||
base=max(cage,address-12304)
|
||||
for index in range(min(1024,(address-base)//12+1)):
|
||||
candidate=address-16-index*12
|
||||
pos=candidate-base
|
||||
if pos<0 or pos+16>len(raw):continue
|
||||
map_tag=struct.unpack_from('<I',raw,pos)[0]
|
||||
if not map_tag&1:continue
|
||||
alln,n=struct.unpack_from('<HH',raw,pos+4)
|
||||
if not index<n<=alln<=1024:continue
|
||||
if v.object_type(candidate)!=0x106:continue
|
||||
d=v.descriptors(candidate)
|
||||
if d and d[index]['name']=='__x_body' and {'__x_id','__x_type','__x_t'}.issubset({f['name'] for f in d}):arrays[candidate]=d
|
||||
maps={}
|
||||
for reference,tag in refs(mem,regions,cage,{(a-cage)|1 for a in arrays},max_mb):
|
||||
candidate=reference-24;mp=v.map(candidate)
|
||||
if not mp or v.object_type(candidate)!=0x110 or mp['bits']&(1<<21):continue
|
||||
own=(mp['bits']>>10)&1023;descs=arrays.get(v.ptr(tag),[])[:own]
|
||||
if {'__x_body','__x_id','__x_type','__x_t'}.issubset({d['name'] for d in descs}):maps[(candidate-cage)|1]=(candidate,mp)
|
||||
candidates=refs(mem,regions,cage,maps,max_mb)
|
||||
# WhatsApp's large models usually use a NameDictionary rather than fast properties.
|
||||
dictionaries=set()
|
||||
for address,tag in refs(mem,regions,cage,tags,max_mb):
|
||||
base=max(cage,address-24608);raw=mem.read(base,address-base+16)
|
||||
for i in range(min(2048,(address-base)//12+1)):
|
||||
obj=address-32-12*i;offset=obj-base
|
||||
if offset<0 or offset+20>len(raw):continue
|
||||
map_tag,length,count,deleted,capacity=struct.unpack_from('<5I',raw,offset)
|
||||
cap=capacity>>1
|
||||
if any(x&1 for x in (count,deleted,capacity)) or cap<4 or cap>2048 or cap&(cap-1) or i>=cap or length!=6+cap*3 or (count+deleted)>>1>cap:continue
|
||||
d=v.dictionary(obj,{'__x_body','__x_id','__x_type','__x_t','__x_caption'})
|
||||
if d and {'__x_body','__x_id','__x_type','__x_t'}.issubset(d):dictionaries.add(obj)
|
||||
for reference,dict_tag in refs(mem,regions,cage,{(a-cage)|1 for a in dictionaries},max_mb):
|
||||
obj=reference-4;obj_tag=mem.u32(obj);mp=v.map(v.ptr(obj_tag))
|
||||
if not mp or not mp['bits']&(1<<21) or v.object_type(v.ptr(obj_tag))!=0x110:continue
|
||||
# The candidate's actual properties pointer must equal the discovered dictionary.
|
||||
if mem.u32(obj+4)!=dict_tag:continue
|
||||
maps[obj_tag]=(v.ptr(obj_tag),mp);candidates.append((obj,obj_tag))
|
||||
seen=set()
|
||||
for address,tag in candidates:
|
||||
if address in seen:continue
|
||||
seen.add(address);map_address,mp=maps[tag]
|
||||
# Race check: field reads are discarded if the object's Map changes.
|
||||
fields=v.fields(address,mp,{'__x_body','__x_caption','__x_id','__x_type','__x_t'})
|
||||
body=v.string(fields.get('__x_body'));caption=v.string(fields.get('__x_caption'));typ=v.string(fields.get('__x_type'),max_length=64)
|
||||
if body is None and caption is None:continue
|
||||
if not typ or not re.fullmatch(r'[a-zA-Z_]{1,40}',typ):continue
|
||||
key_address=v.ptr(fields.get('__x_id'));key_map=v.map(v.ptr(mem.u32(key_address))) if key_address else None
|
||||
key_fields=v.fields(key_address,key_map,{'$1','id','fromMe','remote'}) if key_map else {}
|
||||
key=v.string(key_fields.get('$1'));message_id=v.string(key_fields.get('id'),max_length=256)
|
||||
if not message_id or not re.fullmatch(r'[A-Za-z0-9_-]{6,200}',message_id):continue
|
||||
timestamp=v.number(fields.get('__x_t'))
|
||||
if timestamp is None or not 1200000000<=timestamp<=2200000000:continue
|
||||
if mem.u32(address)!=tag:continue
|
||||
messages.append({'pid':mem.pid,'object_address':hex(address),'map_address':hex(map_address),'message_key':key,'message_id':message_id,'type':typ,'timestamp':timestamp,'body':body,'caption':caption,'confidence':'validated V8 Map/descriptors and MsgKey shape; liveness not established'})
|
||||
if len(messages)>=limit:break
|
||||
collections=locate_collection(v,regions,markers,max_mb)
|
||||
member_addresses={a for c in collections for a in c['members']}
|
||||
# The preliminary schema reads above record handles for external strings.
|
||||
v.external_table=locate_external_table(v,regions,max_mb)
|
||||
if collections:
|
||||
messages=[message for message in messages if message['pid']!=mem.pid]
|
||||
for collection in collections:
|
||||
for address in collection['members']:
|
||||
if len(messages)>=limit:break
|
||||
message=read_model(v,address,True)
|
||||
if message:messages.append(message)
|
||||
diagnostics.append({'cage_base':hex(cage),'descriptor_arrays':len(arrays),'message_dictionaries':len(dictionaries),'message_maps':len(maps),'candidate_objects':len(candidates),'external_string_table_found':v.external_table is not None,'collections':[{k:val for k,val in c.items() if k!='members'} for c in collections]})
|
||||
if len(messages)>=limit:break
|
||||
return messages,diagnostics
|
||||
|
||||
def main(argv=None):
|
||||
p=argparse.ArgumentParser(description=__doc__);p.add_argument('--pid',type=int);p.add_argument('--max-mb',type=int,default=512);p.add_argument('--limit',type=int,default=100);p.add_argument('--redact-text',action='store_true');p.add_argument('--output',type=Path,default=Path('whatsapp-ram-report.json'))
|
||||
p.add_argument('--export-db',action='store_true',help='Export all local database rows through an already-exposed Desktop CDP interface')
|
||||
p.add_argument('--cdp',help='Existing WhatsApp Desktop loopback CDP endpoint for --export-db')
|
||||
p.add_argument('--batch-size',type=int,default=100)
|
||||
args=p.parse_args(argv)
|
||||
if args.export_db:
|
||||
from .history import export
|
||||
from .analyzer import endpoint
|
||||
if not 1<=args.batch_size<=500:p.error('--batch-size must be 1..500')
|
||||
destination=Path('whatsapp-local-history.jsonl') if args.output==Path('whatsapp-ram-report.json') else args.output
|
||||
try:
|
||||
report=export(endpoint(args.cdp) if args.cdp else None,destination,args.batch_size,args.redact_text)
|
||||
except (RuntimeError,OSError,argparse.ArgumentTypeError) as error:
|
||||
print(str(error));return 2
|
||||
print(json.dumps(report));return 0 if not report['row_errors'] else 1
|
||||
if args.cdp:p.error('--cdp requires --export-db')
|
||||
if sys.platform!='win32' or C.sizeof(C.c_void_p)!=8:p.error('64-bit Windows Python required')
|
||||
if not 1<=args.limit<=1000:p.error('--limit must be between 1 and 1000')
|
||||
if not 16<=args.max_mb<=4096:p.error('--max-mb must be between 16 and 4096')
|
||||
targets=discover()
|
||||
if args.pid:targets=[t for t in targets if t['ProcessId']==args.pid]
|
||||
results=[]
|
||||
for target in targets:
|
||||
try:mem=Memory(target['ProcessId'])
|
||||
except OSError as error:
|
||||
results.append({'pid':target['ProcessId'],'messages':[],'markers':[],'diagnostics':[],'error':str(error)});continue
|
||||
try:
|
||||
regions=list(mem.regions());hits=scan_markers(mem,regions,args.max_mb)
|
||||
messages,diagnostics=recover(mem,regions,hits,args.max_mb,args.limit)
|
||||
if args.redact_text:
|
||||
for message in messages:
|
||||
for field in ('body','caption'):
|
||||
message[field+'_length']=len(message[field]) if message.get(field) is not None else None
|
||||
message.pop(field,None)
|
||||
results.append({'pid':mem.pid,'debug_flags_present':target['DebugFlagsPresent'],'private_readable_bytes':sum(r['size'] for r in regions),'markers':hits,'messages':messages,'diagnostics':diagnostics,'bytes_read':mem.read_bytes})
|
||||
finally:mem.close()
|
||||
report={'timestamp_utc':dt.datetime.now(dt.timezone.utc).isoformat(),'mode':'ReadProcessMemory only; normal running application','layout':'V8 15.4.11 compressed pointers; runtime type and schema validated','rea_static_evidence':'ev_6186f4a795decd53dc3dde7935f9f6dd6bb2d2bb32816e7640842d5d41010d3d','results':results,'limitations':['Live heap can move during collection; this is not an atomic snapshot.','MsgCollection membership is checked when the collection is located; fallback objects are explicitly marked unverified.','Only loaded models can be recovered; all chat history is not necessarily in RAM.','Sequential, Cons, Thin, Sliced and validated external strings are supported. Null/uncacheable external resources or unexpected layouts are marked unsupported.','Numeric V8 instance types were corroborated for this WebView2 build; another engine version may require layout changes.','Missing fields remain null and are marked in field_status; no public JavaScript getter is executed to synthesize defaults.','Output contains private message text unless --redact-text is used. No credentials, keys or full process dump are exported.']}
|
||||
args.output.write_text(json.dumps(report,indent=2),encoding='utf-8')
|
||||
print(json.dumps({'targets':len(results),'markers':[len(r['markers']) for r in results],'messages':[len(r['messages']) for r in results],'diagnostics':[r['diagnostics'] for r in results],'report':str(args.output.resolve())}))
|
||||
return 0 if any(r['messages'] for r in results) else 1
|
||||
|
||||
if __name__=='__main__':raise SystemExit(main())
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Search loaded WhatsApp Desktop message objects via read-only process RAM.
|
||||
|
||||
Does not invoke WhatsApp's native FTS engine or load older history. The query
|
||||
matches Unicode-normalized, case-insensitive literal text in body/caption.
|
||||
"""
|
||||
from .common import normalized as normalize
|
||||
import argparse, ctypes, datetime as dt, json, sys, unicodedata
|
||||
from pathlib import Path
|
||||
from .ram import Memory, discover, scan_markers, recover
|
||||
|
||||
EVIDENCE='ev_fd70aa06525b6f2259bf6d98bc879adb2472f63f2407a2d0d1186cc9e27b2f21'
|
||||
|
||||
|
||||
def search_models(models,query,chat_id=None,redact=False):
|
||||
needle=normalize(query)
|
||||
if not needle.strip():raise ValueError('Query must contain non-whitespace text')
|
||||
matches=[]
|
||||
for model in models:
|
||||
# Reject schema-only candidates without verified collection membership.
|
||||
if not model.get('in_message_collection'):continue
|
||||
if chat_id is not None and model.get('chat_id')!=chat_id:continue
|
||||
fields=[field for field in ('body','caption') if isinstance(model.get(field),str) and needle in normalize(model[field])]
|
||||
if not fields:continue
|
||||
result=dict(model);result['matched_fields']=fields
|
||||
if redact:
|
||||
for field in ('body','caption'):
|
||||
value=result.pop(field,None)
|
||||
result[field+'_length']=len(value) if isinstance(value,str) else None
|
||||
matches.append(result)
|
||||
return matches
|
||||
|
||||
def main(argv=None):
|
||||
p=argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument('query')
|
||||
p.add_argument('--chat-id')
|
||||
p.add_argument('--max-mb',type=int,default=512)
|
||||
p.add_argument('--max-models',type=int,default=10000)
|
||||
p.add_argument('--redact-text',action='store_true')
|
||||
p.add_argument('--output',type=Path,default=Path('whatsapp-process-search.json'))
|
||||
args=p.parse_args(argv)
|
||||
if sys.platform!='win32' or ctypes.sizeof(ctypes.c_void_p)!=8:p.error('64-bit Windows Python required')
|
||||
if not args.query.strip():p.error('Query must contain non-whitespace text')
|
||||
if not 16<=args.max_mb<=4096:p.error('--max-mb must be 16..4096')
|
||||
if not 1<=args.max_models<=10000:p.error('--max-models must be 1..10000')
|
||||
if args.output.exists():p.error('Output already exists; choose a new --output path')
|
||||
targets=discover();results=[]
|
||||
for target in targets:
|
||||
pid=target['ProcessId']
|
||||
try:mem=Memory(pid)
|
||||
except OSError as error:
|
||||
results.append({'pid':pid,'error':str(error),'matches':[]});continue
|
||||
try:
|
||||
regions=list(mem.regions())
|
||||
markers=scan_markers(mem,regions,args.max_mb)
|
||||
models,diagnostics=recover(mem,regions,markers,args.max_mb,args.max_models)
|
||||
verified=[m for m in models if m.get('in_message_collection')]
|
||||
matches=search_models(verified,args.query,args.chat_id,args.redact_text)
|
||||
results.append({'pid':pid,'loaded_models_examined':len(verified),'matches':matches,
|
||||
'diagnostics':diagnostics,'bytes_read':mem.read_bytes,
|
||||
'model_limit_reached':len(models)>=args.max_models,
|
||||
'debug_flags_present':target['DebugFlagsPresent']})
|
||||
finally:mem.close()
|
||||
report={'timestamp_utc':dt.datetime.now(dt.timezone.utc).isoformat(),
|
||||
'mode':'ReadProcessMemory; literal search of collection-verified loaded Msg objects',
|
||||
'whatsapp_native_search_invoked':False,'rea_search_evidence':EVIDENCE,
|
||||
'results':results,'limitations':['Loaded message objects only; not the full IndexedDB or native FTS index.',
|
||||
'No process execution, writes, injection, debugger, UI control, or new history query.',
|
||||
'Live heap may move; the reader checks object layout and collection membership but is not an atomic snapshot.',
|
||||
'Search uses NFC/casefold literal matching; it does not reproduce WhatsApp tokenizer, prefix matching or ranking.',
|
||||
'No match is not evidence of absence from account history.']}
|
||||
args.output.parent.mkdir(parents=True,exist_ok=True)
|
||||
args.output.write_text(json.dumps(report,indent=2,ensure_ascii=False),encoding='utf-8')
|
||||
print(json.dumps({'targets':len(results),'loaded_models_examined':sum(r.get('loaded_models_examined',0) for r in results),
|
||||
'matches':sum(len(r['matches']) for r in results),'native_search_invoked':False,
|
||||
'report':str(args.output.resolve())}))
|
||||
return 0 if any(r.get('loaded_models_examined') for r in results) else 2
|
||||
|
||||
if __name__=='__main__':raise SystemExit(main())
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Execute regression tests and write a machine-readable result."""
|
||||
import argparse
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import time
|
||||
import unittest
|
||||
|
||||
from .common import BASE, _BUNDLE
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--live', action='store_true', help='Also run fixed arithmetic probes in running WhatsApp Desktop')
|
||||
parser.add_argument('--capabilities', action='store_true', help='Also run fixed Desktop capability checks (implies --live)')
|
||||
parser.add_argument('--pid', type=int, help='Desktop host PID for live checks')
|
||||
parser.add_argument('--output', type=Path, default=BASE / 'analysis/whatsapp-suite/results.json')
|
||||
args = parser.parse_args(argv)
|
||||
if args.pid is not None and not (args.live or args.capabilities):
|
||||
parser.error('--pid requires --live or --capabilities')
|
||||
started = time.monotonic()
|
||||
if _BUNDLE:
|
||||
from . import _regression
|
||||
tests = unittest.defaultTestLoader.loadTestsFromModule(_regression)
|
||||
else:
|
||||
tests = unittest.defaultTestLoader.discover(str(BASE / 'tests'), pattern='test_whatsapp_*.py')
|
||||
result = unittest.TextTestRunner(verbosity=2).run(tests)
|
||||
report = {
|
||||
'passed': result.wasSuccessful() and result.testsRun > 0,
|
||||
'offline': {'tests': result.testsRun, 'failures': len(result.failures),
|
||||
'errors': len(result.errors), 'skipped': len(result.skipped),
|
||||
'failure_details': [{'test': str(test), 'detail': detail} for test, detail in result.failures + result.errors],
|
||||
'skip_details': [{'test': str(test), 'reason': reason} for test, reason in result.skipped]},
|
||||
'live': {'status': 'not_requested'},
|
||||
}
|
||||
if args.live or args.capabilities:
|
||||
from .live import run_math
|
||||
# Keep complete live probe data in the JSON report, with a compact console summary.
|
||||
with contextlib.redirect_stdout(io.StringIO()):
|
||||
try:
|
||||
report['live'] = run_math(args.pid, 'all' if args.capabilities else 'math')
|
||||
except Exception as error:
|
||||
report['live'] = {'passed': False, 'error': str(error)}
|
||||
report['passed'] = report['passed'] and report['live'].get('passed', False)
|
||||
report['duration_seconds'] = round(time.monotonic() - started, 3)
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output.write_text(json.dumps(report, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
|
||||
print(json.dumps({'passed': report['passed'], 'tests': result.testsRun,
|
||||
'skipped': len(result.skipped), 'report': str(args.output)}))
|
||||
return 0 if report['passed'] else 1
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Temporary app-scoped WebView2 debugging for the installed Desktop session."""
|
||||
from contextlib import contextmanager
|
||||
import json, socket, subprocess, time, urllib.request
|
||||
|
||||
KEY=r'Software\Policies\Microsoft\Edge\WebView2\AdditionalBrowserArguments'
|
||||
APP='WhatsApp.Root.exe'
|
||||
ACTIVATE=r"Get-Process -Name WhatsApp.Root -ErrorAction SilentlyContinue | Stop-Process; Start-Process -FilePath explorer.exe -ArgumentList 'shell:AppsFolder\5319275A.WhatsAppDesktop_cv1g1gvanyjgm!App' -WindowStyle Hidden"
|
||||
|
||||
def restart():
|
||||
subprocess.run(['powershell.exe','-NoProfile','-NonInteractive','-Command',ACTIVATE],capture_output=True,text=True,check=True)
|
||||
|
||||
@contextmanager
|
||||
def temporary_desktop_cdp():
|
||||
import winreg
|
||||
with socket.socket() as sock:
|
||||
sock.bind(('127.0.0.1',0));port=sock.getsockname()[1]
|
||||
key=winreg.CreateKeyEx(winreg.HKEY_CURRENT_USER,KEY,0,winreg.KEY_READ|winreg.KEY_SET_VALUE)
|
||||
try:
|
||||
try:old=winreg.QueryValueEx(key,APP)
|
||||
except FileNotFoundError:old=None
|
||||
flags=f'--remote-debugging-address=127.0.0.1 --remote-debugging-port={port}'
|
||||
if old and old[1]!=winreg.REG_SZ:raise RuntimeError('Unexpected existing WebView2 policy value type')
|
||||
if old and '--remote-debugging' in old[0]:raise RuntimeError('Existing debug policy requires explicit endpoint selection')
|
||||
winreg.SetValueEx(key,APP,0,winreg.REG_SZ,((old[0]+' ') if old else '')+flags)
|
||||
try:
|
||||
print('Restarting WhatsApp Desktop with a temporary loopback execution interface.',flush=True)
|
||||
restart();endpoint=f'http://127.0.0.1:{port}'
|
||||
deadline=time.monotonic()+60
|
||||
while time.monotonic()<deadline:
|
||||
try:
|
||||
with urllib.request.urlopen(endpoint+'/json/list',timeout=2) as response:targets=json.load(response)
|
||||
if any(t.get('type')=='page' and t.get('url','').startswith('https://web.whatsapp.com/') for t in targets):
|
||||
yield endpoint
|
||||
return
|
||||
except OSError:pass
|
||||
time.sleep(1)
|
||||
raise RuntimeError('Desktop did not expose its WhatsApp page within 60 seconds')
|
||||
finally:
|
||||
if old is None:winreg.DeleteValue(key,APP)
|
||||
else:winreg.SetValueEx(key,APP,0,old[1],old[0])
|
||||
restart()
|
||||
print('Temporary setting restored; Desktop restarted normally.',flush=True)
|
||||
finally:winreg.CloseKey(key)
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Send one fixed test message exclusively to the signed-in account itself."""
|
||||
import argparse
|
||||
import datetime as dt
|
||||
import json
|
||||
from pathlib import Path
|
||||
import uuid
|
||||
|
||||
from .live import run_math
|
||||
from .zip_export import evaluate
|
||||
|
||||
PROBE = r'''(async () => {
|
||||
const prefs=require('WAWebUserPrefsMeUser');
|
||||
const me=prefs.getMaybeMeLidUser()??prefs.getMaybeMePnUser();
|
||||
if(!me||!prefs.isMeAccount(me))throw Error('Own account identity unavailable');
|
||||
const {chat}=await require('WAWebFindChatAction').findOrCreateLatestChat(me,'sendSelfTest');
|
||||
if(!chat||!prefs.isMeAccount(chat.id))throw Error('Refusing to send outside own account');
|
||||
const action=require('WAWebSendTextMsgChatAction');
|
||||
const body=__BODY__;
|
||||
const data=await action.createTextMsgData(chat,body,{});
|
||||
if(!data?.id||!prefs.isMeAccount(data.id.remote))throw Error('Outgoing message is not addressed to self');
|
||||
const id=data.id.toString();
|
||||
await action.addAndSendTextMsg(chat,data);
|
||||
const model=chat.msgs.get(data.id)??require('WAWebMsgCollection').MsgCollection.get(data.id);
|
||||
const row=await require('WAWebSchemaMessage').getMessageTable().get(id);
|
||||
const decoded=row?require('WAWebDBMessageSerialization').messageFromDbRow(row):null;
|
||||
return {recipient_is_self:true,message_id:id,ack:model?.ack??null,
|
||||
send_completed:true,persisted:!!row,plaintext_matches:decoded?.body===body,
|
||||
timestamp:decoded?.t??null,body_length:body.length,cdp:false,ui_automation:false};
|
||||
})()'''
|
||||
|
||||
|
||||
def send_self(output, pid=None):
|
||||
output=Path(output)
|
||||
if output.exists():
|
||||
raise ValueError('Receipt exists; choose a new output. Sending is never retried automatically.')
|
||||
output.parent.mkdir(parents=True,exist_ok=True)
|
||||
body='WhatsApp JS-Sendetest an mich selbst — '+dt.datetime.now().isoformat(timespec='seconds')+' — '+uuid.uuid4().hex[:8]
|
||||
# Reserve the receipt before sending; a failed/uncertain attempt must be inspected before retrying.
|
||||
output.write_text(json.dumps({'status':'reserved','test_message':body}),encoding='utf-8')
|
||||
def operation(execute,session,report):
|
||||
result=evaluate(execute,PROBE.replace('__BODY__',json.dumps(body)),session)
|
||||
report['send_result']=result
|
||||
if not (result.get('recipient_is_self') and result.get('send_completed') and result.get('persisted') and result.get('plaintext_matches')):
|
||||
raise RuntimeError('Self-message send/readback verification failed; do not retry without checking the receipt')
|
||||
report=run_math(pid,'send-self',_operation=operation)
|
||||
report['test_message']=body
|
||||
output.write_text(json.dumps(report,indent=2),encoding='utf-8')
|
||||
return report
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser=argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--pid',type=int)
|
||||
parser.add_argument('--output',type=Path,default=Path('analysis/whatsapp-suite')/('self-message-'+dt.datetime.now().strftime('%Y%m%d-%H%M%S')+'.json'))
|
||||
args=parser.parse_args(argv)
|
||||
report=send_self(args.output,args.pid)
|
||||
print(json.dumps({'passed':report['passed'],'error':report.get('error'),'send_result':report.get('send_result'),'receipt':str(args.output)}))
|
||||
return 0 if report['passed'] else 1
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Isolated WebView2 test host."""
|
||||
import json
|
||||
import pathlib
|
||||
import shutil
|
||||
import subprocess
|
||||
import urllib.request
|
||||
import zipfile
|
||||
from .common import BASE
|
||||
ROOT = BASE / "webview2-harmless-test"
|
||||
VERSION = "1.0.2903.40"
|
||||
|
||||
SOURCE = r'''
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Windows.Forms;
|
||||
using Microsoft.Web.WebView2.Core;
|
||||
using Microsoft.Web.WebView2.WinForms;
|
||||
class Probe : Form {
|
||||
WebView2 view = new WebView2();
|
||||
string report;
|
||||
Timer deadline = new Timer();
|
||||
bool completed;
|
||||
Probe(string path) {
|
||||
report = path; ShowInTaskbar = false; Opacity = 0;
|
||||
Controls.Add(view); view.Dock = DockStyle.Fill;
|
||||
deadline.Interval = 45000;
|
||||
deadline.Tick += (s,e) => Finish("{\"passed\":false,\"error\":\"timeout\"}", 2);
|
||||
Shown += async (s,e) => {
|
||||
deadline.Start();
|
||||
try {
|
||||
var env = await CoreWebView2Environment.CreateAsync(null,
|
||||
Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "test-profile"));
|
||||
await view.EnsureCoreWebView2Async(env);
|
||||
var result = await view.CoreWebView2.ExecuteScriptAsync(
|
||||
"({arithmetic:1+1,marker:'isolated-webview2-test',url:location.href})");
|
||||
bool ok = result.Contains("\"arithmetic\":2") && result.Contains("isolated-webview2-test");
|
||||
Finish("{\"passed\":" + (ok ? "true" : "false") +
|
||||
",\"method\":\"CoreWebView2.ExecuteScriptAsync\",\"external_process_injection\":false," +
|
||||
"\"result\":" + result + "}", ok ? 0 : 1);
|
||||
} catch(Exception ex) {
|
||||
File.WriteAllText(report + ".error.txt", ex.ToString());
|
||||
Finish("{\"passed\":false,\"error\":\"See .error.txt\"}", 1);
|
||||
}
|
||||
};
|
||||
}
|
||||
void Finish(string data, int code) {
|
||||
if(completed) return; completed=true; deadline.Stop();
|
||||
File.WriteAllText(report,data); Environment.ExitCode=code;
|
||||
view.Dispose(); Close();
|
||||
}
|
||||
[STAThread] static void Main(string[] args) {
|
||||
Application.EnableVisualStyles(); Application.Run(new Probe(args[0]));
|
||||
}
|
||||
}
|
||||
'''
|
||||
|
||||
def run_webview_test():
|
||||
ROOT.mkdir(exist_ok=True)
|
||||
package = ROOT / ('webview2-' + VERSION + '.nupkg')
|
||||
if not package.exists():
|
||||
urllib.request.urlretrieve(
|
||||
'https://api.nuget.org/v3-flatcontainer/microsoft.web.webview2/' + VERSION +
|
||||
'/microsoft.web.webview2.' + VERSION + '.nupkg', package)
|
||||
with zipfile.ZipFile(package) as archive:
|
||||
for name in ('lib/net462/Microsoft.Web.WebView2.Core.dll',
|
||||
'lib/net462/Microsoft.Web.WebView2.WinForms.dll',
|
||||
'runtimes/win-x64/native/WebView2Loader.dll'):
|
||||
with archive.open(name) as src, (ROOT / pathlib.Path(name).name).open('wb') as dest:
|
||||
shutil.copyfileobj(src, dest)
|
||||
source = ROOT / 'Probe.cs'
|
||||
source.write_text(SOURCE, encoding='utf-8')
|
||||
exe = ROOT / 'Probe.exe'
|
||||
compiler = r'C:\Windows\Microsoft.NET\Framework64\v4.0.30319\csc.exe'
|
||||
subprocess.run([compiler, '/nologo', '/target:winexe', '/platform:x64',
|
||||
'/out:' + str(exe), '/r:System.Windows.Forms.dll',
|
||||
'/r:System.Drawing.dll',
|
||||
'/r:' + str(ROOT / 'Microsoft.Web.WebView2.Core.dll'),
|
||||
'/r:' + str(ROOT / 'Microsoft.Web.WebView2.WinForms.dll'),
|
||||
str(source)], check=True, creationflags=subprocess.CREATE_NO_WINDOW)
|
||||
report = ROOT / 'result.json'
|
||||
report.unlink(missing_ok=True)
|
||||
run = subprocess.run([str(exe), str(report)], cwd=ROOT, timeout=60,
|
||||
creationflags=subprocess.CREATE_NO_WINDOW)
|
||||
if report.exists():
|
||||
print(json.dumps(json.loads(report.read_text(encoding='utf-8-sig')), indent=2))
|
||||
else:
|
||||
raise RuntimeError('Test host exited without a report: ' + str(run.returncode))
|
||||
return run.returncode
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
"""Export all local message rows to ZIP via the native WebView JavaScript bridge.
|
||||
|
||||
No CDP, UI automation, app restart, or phone history sync. The ZIP contains
|
||||
messages.jsonl and manifest.json; media binaries are not downloaded.
|
||||
"""
|
||||
import argparse
|
||||
import datetime as dt
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import time
|
||||
import zipfile
|
||||
|
||||
from .history import expression
|
||||
from .live import data_chunk_expression, run_math
|
||||
|
||||
|
||||
def evaluate(execute, value, session):
|
||||
"""Run the fixed async database probe and stream its result across the bridge."""
|
||||
key = '__whatsapp_zip_' + session
|
||||
literal = json.dumps(key)
|
||||
start = '''(() => {
|
||||
const key=__KEY__,id=__ID__,state={probe_id:id,state:'pending'};window[key]=state;
|
||||
Promise.resolve(__VALUE__).then(value=>{
|
||||
state.json=JSON.stringify(value);state.state='ready';
|
||||
}).catch(error=>{state.state='error';state.error=String(error).slice(0,500);});
|
||||
return {probe_id:id,state:'started'};
|
||||
})()'''.replace('__KEY__', literal).replace('__ID__', json.dumps(session)).replace('__VALUE__', value)
|
||||
poll = '''(() => {const v=window[__KEY__];return {probe_id:v?.probe_id,state:v?.state,error:v?.error,length:v?.json?.length};})()'''.replace('__KEY__', literal)
|
||||
try:
|
||||
initial = json.loads(execute(start, 'export batch startup'))
|
||||
if initial.get('probe_id') != session or initial.get('state') != 'started':
|
||||
raise ValueError('Export startup response mismatch')
|
||||
deadline = time.monotonic() + 60
|
||||
while True:
|
||||
meta = json.loads(execute(poll, 'export batch poll'))
|
||||
if meta.get('probe_id') != session:
|
||||
raise ValueError('Export session mismatch')
|
||||
if meta.get('state') == 'error':
|
||||
raise RuntimeError(meta.get('error', 'JavaScript export failed'))
|
||||
if meta.get('state') == 'ready':
|
||||
break
|
||||
if time.monotonic() >= deadline:
|
||||
raise TimeoutError('JavaScript export batch timed out')
|
||||
time.sleep(.05)
|
||||
length = meta.get('length')
|
||||
if type(length) is not int or not 0 < length <= 16 * 1024 * 1024:
|
||||
raise ValueError('Export batch exceeds 16 MiB transport bound; reduce --batch-size')
|
||||
offset, parts = 0, []
|
||||
while offset < length:
|
||||
part = json.loads(execute(data_chunk_expression(key, offset), 'export batch chunk'))
|
||||
following = part.get('next')
|
||||
if (part.get('probe_id') != session or part.get('offset') != offset
|
||||
or type(following) is not int or not offset < following <= length
|
||||
or not isinstance(part.get('chunk'), str)
|
||||
or len(part['chunk'].encode('utf-16-le', errors='surrogatepass')) // 2 != following-offset):
|
||||
raise ValueError('Export transport chunk mismatch')
|
||||
parts.append(part['chunk'])
|
||||
offset = following
|
||||
return json.loads(''.join(parts))
|
||||
finally:
|
||||
execute('delete window[' + literal + ']', 'export JavaScript state cleanup')
|
||||
|
||||
|
||||
def write_messages(read, archive, batch, redact):
|
||||
"""Stream a bounded keyset scan, preserving a record for every row error."""
|
||||
bounds = read({'mode': 'bounds'})
|
||||
count, upper = bounds['count'], bounds['upper']
|
||||
after, rows, errors, seen = None, 0, 0, set()
|
||||
with archive.open('messages.jsonl', 'w', force_zip64=True) as stream:
|
||||
while upper is not None and after != upper:
|
||||
page = read({'mode': 'batch', 'upper': upper, 'after': after, 'batch': batch, 'redact': redact})
|
||||
keys, messages = page['keys'], page['messages']
|
||||
if not keys:
|
||||
raise RuntimeError('History cursor ended before the initial upper bound')
|
||||
if len(keys) != len(messages) or page['last'] != keys[-1] or page['last'] == after:
|
||||
raise RuntimeError('Invalid or nonprogressing history page')
|
||||
for key, message in zip(keys, messages):
|
||||
if key in seen or message.get('id') != key:
|
||||
raise RuntimeError('Duplicate key or mismatched history message')
|
||||
seen.add(key)
|
||||
stream.write((json.dumps(message, ensure_ascii=True) + '\n').encode('utf-8'))
|
||||
rows += 1
|
||||
errors += int('error' in message)
|
||||
after = page['last']
|
||||
print(json.dumps({'exported_rows': rows, 'initial_rows': count, 'row_errors': errors}), flush=True)
|
||||
final = read({'mode': 'bounds'})
|
||||
return {'rows': rows, 'row_errors': errors, 'initial_row_count': count,
|
||||
'final_row_count': final['count'], 'initial_upper_key': upper,
|
||||
'reached_initial_upper': after == upper, 'snapshot_atomic': False,
|
||||
'count_matches_initial': rows == count}
|
||||
|
||||
|
||||
def export_zip(output, pid=None, batch=100, redact=False):
|
||||
output = Path(output).resolve()
|
||||
if not 1 <= batch <= 500:
|
||||
raise ValueError('Batch size must be 1..500')
|
||||
partial = output.with_suffix(output.suffix + '.partial')
|
||||
report_path = output.with_suffix(output.suffix + '.report.json')
|
||||
if output.exists() or partial.exists() or report_path.exists():
|
||||
raise ValueError('Output or partial/report exists; select a new --output')
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
summary = {'cdp': False, 'ui_automation': False, 'transport': 'native WebView ExecuteScriptAsync',
|
||||
'source': 'WhatsApp JavaScript message table getter and decoder',
|
||||
'scope': 'All local message rows up to initial upper primary key; no phone sync or media download',
|
||||
'created_utc': dt.datetime.now(dt.timezone.utc).isoformat(), 'text_redacted': redact}
|
||||
def operation(execute, session, bridge):
|
||||
with zipfile.ZipFile(partial, 'x', compression=zipfile.ZIP_DEFLATED, allowZip64=True) as archive:
|
||||
summary.update(write_messages(lambda options: evaluate(execute, expression(options), session), archive, batch, redact))
|
||||
bridge = run_math(pid, 'history-export', _operation=operation)
|
||||
summary['bridge'] = bridge
|
||||
summary['passed'] = bool(bridge['passed'] and summary.get('reached_initial_upper') and not summary.get('row_errors'))
|
||||
summary['status'] = 'completed' if summary['passed'] else 'completed_with_errors' if bridge['passed'] else 'failed_partial_export'
|
||||
try:
|
||||
if bridge['passed']:
|
||||
with zipfile.ZipFile(partial, 'a', compression=zipfile.ZIP_DEFLATED) as archive:
|
||||
archive.writestr('manifest.json', json.dumps(summary, indent=2))
|
||||
with zipfile.ZipFile(partial) as archive:
|
||||
bad = archive.testzip()
|
||||
if bad:
|
||||
raise RuntimeError('ZIP integrity failed: ' + bad)
|
||||
# Publish without overwriting an output created during the live scan.
|
||||
os.link(partial, output)
|
||||
partial.unlink()
|
||||
summary['output'] = str(output)
|
||||
else:
|
||||
summary['partial_output'] = str(partial)
|
||||
except Exception as error:
|
||||
summary.update(passed=False, status='failed_partial_export', error=str(error), partial_output=str(partial))
|
||||
report_path.write_text(json.dumps(summary, indent=2) + '\n', encoding='utf-8')
|
||||
return summary
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--output', type=Path, default=Path('exports') / ('whatsapp-' + dt.datetime.now().strftime('%Y%m%d-%H%M%S') + '.zip'))
|
||||
parser.add_argument('--pid', type=int)
|
||||
parser.add_argument('--batch-size', type=int, default=100)
|
||||
parser.add_argument('--redact-text', action='store_true')
|
||||
args = parser.parse_args(argv)
|
||||
report = export_zip(args.output, args.pid, args.batch_size, args.redact_text)
|
||||
print(json.dumps({k: report[k] for k in ('passed', 'status', 'rows', 'row_errors', 'output', 'partial_output', 'error') if k in report}))
|
||||
return 0 if report['passed'] else 1
|
||||
Reference in new issue
Block a user