Prepare documented WhatsApp injector with portable Python bundle

This commit is contained in:
Ops Agent committed 2026-10-11 07:57:48 +02:00
commit d2b621bce7
49 files changed
+5737

No files matched your search

+110
View File
@@ -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())