455 lines
29 KiB
Python
455 lines
29 KiB
Python
"""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())
|