Files
ghostwire/whatsapp_suite/ram.py
T

454 lines
29 KiB
Python
Raw Normal View History

"""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())