"""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) if discovered.get('compatibility',{}).get('warning'): import warnings warnings.warn(discovered['compatibility']['warning'], RuntimeWarning) 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())