111 lines
7.2 KiB
Python
111 lines
7.2 KiB
Python
"""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())
|