"""manual_purge.py Force a UIItem pool drain by calling UpdateEmptySlots on every UIElement_ItemList instance in the target process. Bypasses any visibility-trigger questions — just calls the trim function directly via CreateRemoteThread. Requires v8-minimal applied (otherwise UpdateEmptySlots bails on the internal IsVisible guard). """ import argparse, ctypes, ctypes.wintypes as wt, struct, sys, time UPDATEEMPTYSLOTS_VA = 0x004e4390 # Heuristic: ItemList primary vtable — derive from xrefs. # For our test, we scan heap for ItemList vtable matches. # Actually we don't have the ItemList vtable VA — we need to find ItemLists # differently. Best: scan for UIItem container pattern (this->+0x608 valid). PROCESS_VM_READ = 0x10 PROCESS_VM_WRITE = 0x20 PROCESS_VM_OPERATION = 0x8 PROCESS_QUERY_INFORMATION = 0x400 PROCESS_CREATE_THREAD = 0x2 MEM_COMMIT_RESERVE = 0x1000 | 0x2000 PAGE_EXECUTE_READWRITE = 0x40 class MBI(ctypes.Structure): _fields_ = [('BaseAddress', ctypes.c_void_p), ('AllocationBase', ctypes.c_void_p), ('AllocationProtect', wt.DWORD), ('PartitionId', wt.WORD), ('RegionSize', ctypes.c_size_t), ('State', wt.DWORD), ('Protect', wt.DWORD), ('Type', wt.DWORD)] k = ctypes.windll.kernel32 k.OpenProcess.argtypes = [wt.DWORD, wt.BOOL, wt.DWORD]; k.OpenProcess.restype = wt.HANDLE k.CloseHandle.argtypes = [wt.HANDLE]; k.CloseHandle.restype = wt.BOOL k.ReadProcessMemory.argtypes = [wt.HANDLE, wt.LPCVOID, wt.LPVOID, ctypes.c_size_t, ctypes.POINTER(ctypes.c_size_t)] k.ReadProcessMemory.restype = wt.BOOL k.WriteProcessMemory.argtypes = [wt.HANDLE, wt.LPVOID, wt.LPCVOID, ctypes.c_size_t, ctypes.POINTER(ctypes.c_size_t)] k.WriteProcessMemory.restype = wt.BOOL k.VirtualAllocEx.argtypes = [wt.HANDLE, ctypes.c_void_p, ctypes.c_size_t, wt.DWORD, wt.DWORD] k.VirtualAllocEx.restype = wt.LPVOID k.VirtualQueryEx.argtypes = [wt.HANDLE, ctypes.c_void_p, ctypes.POINTER(MBI), ctypes.c_size_t] k.VirtualQueryEx.restype = ctypes.c_size_t k.CreateRemoteThread.argtypes = [wt.HANDLE, ctypes.c_void_p, ctypes.c_size_t, ctypes.c_void_p, ctypes.c_void_p, wt.DWORD, ctypes.POINTER(wt.DWORD)] k.CreateRemoteThread.restype = wt.HANDLE k.WaitForSingleObject = k.WaitForSingleObject k.WaitForSingleObject.argtypes = [wt.HANDLE, wt.DWORD] k.WaitForSingleObject.restype = wt.DWORD def find_itemlist_instances(h): """Scan heap for objects whose first DWORD points to a code address AND whose +0x610 looks like a small positive int (count) AND whose +0x608 points to a valid memory address (the items array). Returns list of candidate addresses.""" results = [] mbi = MBI() addr = 0 while k.VirtualQueryEx(h, addr, ctypes.byref(mbi), ctypes.sizeof(mbi)): pr = mbi.Protect & 0xff if mbi.State == 0x1000 and mbi.Type == 0x20000 and pr in (0x04, 0x40): try: buf = (ctypes.c_ubyte * mbi.RegionSize)() sz = ctypes.c_size_t(0) if k.ReadProcessMemory(h, mbi.BaseAddress, buf, mbi.RegionSize, ctypes.byref(sz)): data = bytes(buf[:sz.value]) # Walk DWORD-aligned, check pattern at each potential instance end = len(data) - 0x614 for off in range(0, end, 4): # vtable check: first DWORD points into image vt = struct.unpack_from(" 0x00800000: continue # +0x610 count check: small positive int cnt = struct.unpack_from(" 2000: continue # +0x608 array pointer check: into private heap arr = struct.unpack_from(" 0x80000000: continue results.append(mbi.BaseAddress + off) except Exception: pass addr = (mbi.BaseAddress or 0) + mbi.RegionSize if addr >= 0x80000000: break return results def call_remote(h, target_va, ecx_arg): """Allocate a tiny stub that loads ECX and calls target_va, then RETs. CreateRemoteThread runs the stub. ecx_arg = the this pointer.""" # Stub: mov ecx, ecx_arg; mov eax, target_va; call eax; xor eax, eax; ret 4 # Thread proc signature is DWORD WINAPI(LPVOID arg) — arg comes in as [esp+4] # We'll use the arg passed by CreateRemoteThread as the this ptr. stub = bytes([ 0x8b, 0x4c, 0x24, 0x04, # mov ecx, [esp+4] ; this 0xb8, target_va & 0xff, (target_va >> 8) & 0xff, (target_va >> 16) & 0xff, (target_va >> 24) & 0xff, # mov eax, target_va 0xff, 0xd0, # call eax 0x33, 0xc0, # xor eax, eax 0xc2, 0x04, 0x00, # ret 4 ]) page = k.VirtualAllocEx(h, None, 0x40, MEM_COMMIT_RESERVE, PAGE_EXECUTE_READWRITE) if not page: raise OSError(f"VirtualAllocEx failed err={ctypes.get_last_error()}") sz = ctypes.c_size_t(0) if not k.WriteProcessMemory(h, page, stub, len(stub), ctypes.byref(sz)): raise OSError(f"WriteProcessMemory stub failed") tid = wt.DWORD(0) th = k.CreateRemoteThread(h, None, 0, page, ctypes.c_void_p(ecx_arg), 0, ctypes.byref(tid)) if not th: raise OSError(f"CreateRemoteThread failed err={ctypes.get_last_error()}") k.WaitForSingleObject(th, 5000) # 5 sec timeout k.CloseHandle(th) # Note: leaving stub page allocated (would need to free after) ap = argparse.ArgumentParser() ap.add_argument("pid", type=int) ap.add_argument("--scan-only", action="store_true", help="just print candidate ItemList instances, don't call drain") ap.add_argument("--limit", type=int, default=999, help="max instances to drain (default 999)") args = ap.parse_args() h = k.OpenProcess( PROCESS_VM_READ | PROCESS_VM_WRITE | PROCESS_VM_OPERATION | PROCESS_QUERY_INFORMATION | PROCESS_CREATE_THREAD, False, args.pid) if not h: print(f"OpenProcess({args.pid}) err={ctypes.get_last_error()}"); sys.exit(2) print(f"Scanning PID {args.pid} for ItemList candidates...") candidates = find_itemlist_instances(h) print(f"Found {len(candidates)} candidates") for c in candidates[:20]: print(f" 0x{c:08x}") if len(candidates) > 20: print(f" ... +{len(candidates)-20} more") if args.scan_only: sys.exit(0) if not candidates: print("No ItemList candidates found") sys.exit(0) print(f"\nCalling UpdateEmptySlots on first {min(args.limit, len(candidates))} candidates...") for i, c in enumerate(candidates[:args.limit]): try: call_remote(h, UPDATEEMPTYSLOTS_VA, c) print(f" [{i+1}/{min(args.limit, len(candidates))}] called on 0x{c:08x}") except Exception as e: print(f" [{i+1}] FAILED on 0x{c:08x}: {e}") break print("\nDone. Recount with count_uiitem_live.py to see effect.") k.CloseHandle(h)