"""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('=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('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('=128:return None typ=mp['type'];length=struct.unpack_from('>1 if parent is not None:result=parent[offset:offset+length] elif rep==5:result=self.string(struct.unpack_from('>6)*8;entry_bytes=self.mem.read(slot,8) if len(entry_bytes)==8: entry=struct.unpack('>49)&127==2: resource=entry&0xff00ffffffffffff;header=self.mem.read(resource,16) if len(header)==16: vtable,data=struct.unpack('>6)*8,8) if len(data_entry)==8: de=struct.unpack('>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('>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('>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('>1 a=self.ptr(tag) if self.object_type(a)==0x82: b=self.mem.read(a+4,8) return struct.unpack('=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('len(raw):continue map_tag,alln,n=struct.unpack_from('>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 capacity1000000: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('=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('>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('>49)&127!=2:continue res=e&0xff00ffffffffffff;native2=v.mem.read(res,16) if len(native2)!=16:continue vt,chars=struct.unpack('>6)*8,8) if len(data_entry)!=8:continue de=struct.unpack('>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('>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())