Prepare documented WhatsApp injector with portable Python bundle

This commit is contained in:
Ops Agent committed 2026-10-11 07:57:48 +02:00
commit d2b621bce7
49 files changed
+5737

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
* text=auto
*.py text eol=lf
*.md text eol=lf
*.cjs text eol=lf
*.js text eol=lf
*.cpp text eol=lf
*.dll binary
+24
View File
@@ -0,0 +1,24 @@
# Python and local environments
__pycache__/
*.py[cod]
.venv/
venv/
# Runtime data and research output
analysis/
exports/
.whatsapp-*/
webview2-harmless-test/
*.zip
*.partial
*.report.json
*-report.json
contacts*.json
messages*.json*
.env
.env.*
*.log
# Editor and OS files
.vscode/
.idea/
.DS_Store
Thumbs.db
+3
View File
@@ -0,0 +1,3 @@
# Project instructions
Use `whatsapp_suite/` as canonical source; regenerate `whatsapp.py` after source changes. Run source and portable offline tests. Keep default tests free of live side effects. Do not commit exports, profiles, real message/contact fixtures, credentials or research captures. Preserve explicit self-message recipient guards and supported-build checks. Read `CONTRIBUTING.md` and `docs/VALIDATION.md` before changing native integration.
+9
View File
@@ -0,0 +1,9 @@
# Changelog
## Initial prepared project — 2026-10-11
- Consolidated toolkit with one CLI and a portable Python bundle.
- Native WebView message/contact export, old-message reading and fixed self-send test.
- Saved-export search plus optional CDP, RAM, SQLite and pairing research tools.
- Thirty offline regression tests and documented live validation boundaries.
- Clean Git layout excluding private exports, runtime captures and research artifacts.
+9
View File
@@ -0,0 +1,9 @@
# Development
The canonical implementation is `whatsapp_suite/`. Edit it rather than the encoded payload in `whatsapp.py`. Synthetic fixtures belong in `tests/`; never use captured messages or contacts as fixtures.
Use Python 3.11+ and Node.js for the complete offline suite. Optional requirements are in `requirements-whatsapp-optional.txt`. Run source tests, rebuild with `python build_whatsapp_bundle.py`, and run the portable tests before committing. Check that unchanged inputs produce the same bundle hash.
Native sources are in `native/`; existing verified runtime DLLs are in `whatsapp_suite/assets/`. Changing native code requires rebuilding for Windows x64, updating asset hashes and conducting explicit build-specific live validation. The repository does not yet contain a reproducible native toolchain/build recipe. The Python builder packages existing DLLs and does not compile them.
Document compatibility changes and distinguish synthetic checks from live checks. Keep send operations explicit, fixed-recipient guards intact, and default tests free of live side effects. Inspect `git diff --cached` and the tracked file list before publication.
+3
View File
@@ -0,0 +1,3 @@
# Licensing status
No open-source license has been selected for this project. This preparation does not grant a redistribution license or apply a new license to existing code or third-party assets. Before making the repository public, the owner should choose appropriate terms and review the provenance and redistribution status of the bundled DLLs and agent. WhatsApp and other product names belong to their respective owners.
+70
View File
@@ -0,0 +1,70 @@
# WhatsApp Desktop Suite
A Windows toolkit for reading, searching and exporting local WhatsApp Desktop data, with a portable single-file Python distribution and an editable source package.
The core live commands execute JavaScript inside the signed-in Desktop WebView through a bundled native bridge. They require neither CDP nor UI automation. Separate optional research commands use CDP, Frida or process-memory inspection.
## Features
| Feature | Command | Scope |
| --- | --- | --- |
| Contact export | `contacts-export` | Local contact rows, including distinct PN/LID records |
| Message ZIP export | `export-zip` | Local message rows, decoded text/captions and manifest; no media downloads |
| Old message reading | `live --suite history-read` | Five oldest local records, including records outside loaded chats |
| Desktop search | `desktop-search` | Bounded app search through the native bridge |
| Saved export search | `search` | Unicode-aware literal search, filtering and optional redaction |
| Self-message test | `send-self` | One fixed message to the signed-in account, with stored-text verification |
| Inspection and diagnostics | `inventory`, `stats`, `live` | Storage inventory, export summaries and fixed probes |
| Optional research tools | See [CLI reference](docs/CLI.md) | CDP history export, RAM tools, SQLite probes and pairing tools |
## Quick start
Copy `whatsapp.py` into a writable folder. Run with Python 3.11 or newer:
```powershell
python whatsapp.py --help
python whatsapp.py test
python whatsapp.py contacts-export --output contacts.json
python whatsapp.py export-zip --output messages.zip
python whatsapp.py live --suite history-read
```
Live bridge commands require **Windows x64, 64-bit Python and WhatsApp Desktop 2.2639.100.0** with the verified WebView component. This is a version-specific integration, not a general promise of compatibility with future WhatsApp releases. The core commands need only Python's standard library. Optional tools have [additional dependencies](docs/CLI.md#optional-dependencies); Node.js enables the JavaScript fixture tests.
`send-self` actually sends a message. It is never part of the default tests and does not retry automatically. ZIP exports cover the local database, not necessarily every message ever received. Contacts are local table rows, not a deduplicated phone address book.
## Development
```powershell
python -m whatsapp_suite --help
python -m whatsapp_suite test
python build_whatsapp_bundle.py
python whatsapp.py test
```
The builder embeds the maintained modules, DLLs and fixtures. Modules load from memory; a verified DLL is extracted only when needed. The distribution is approximately 158 KiB. Keep source and bundle changes together.
## Project layout
```text
whatsapp.py Portable generated distribution
whatsapp_suite/ Canonical Python implementations
whatsapp_suite/assets/ Runtime DLLs and SQLite agent
native/ Native helper C++ source
tests/ Synthetic Python and JavaScript fixtures
build_whatsapp_bundle.py Deterministic bundle builder
docs/ Architecture, validation and publishing guide
```
## Documentation
- [CLI and export behavior](docs/CLI.md)
- [Architecture and native bridge](docs/ARCHITECTURE.md)
- [Validation and limitations](docs/VALIDATION.md)
- [Development and contributions](CONTRIBUTING.md)
- [Publishing to tea.karlcom.de](docs/PUBLISHING.md)
- [Handling private data](SECURITY.md)
## Project status
Repository: [karl/whatsapp_injector](https://tea.karlcom.de/karl/whatsapp_injector). It is independent of WhatsApp/Meta. No open-source license has been selected; see [licensing status](LICENSE.md).
+7
View File
@@ -0,0 +1,7 @@
# Private data and security
Use the toolkit only with accounts and installations you are authorized to inspect. Live reports, contacts, messages, browser profiles, pairing QR payloads and process dumps can contain private data. Keep them outside Git. The ignore rules are a guardrail; review staged files before upload.
The native bridge executes inside the Desktop process and depends on a specific build. Use a trusted bundle. An embedded hash detects accidental modification but is not a digital signature. Do not make a CDP endpoint publicly reachable.
For a sensitive issue, contact the repository owner privately through an established channel. Do not attach live exports, session material or credentials to issues. For ordinary bug reports, include the command, Python/app versions and a redacted diagnostic summary.
+100
View File
@@ -0,0 +1,100 @@
"""Build a deterministic single Python file; run this only for development."""
import base64
import hashlib
import io
import json
import lzma
from pathlib import Path
import textwrap
import zipfile
ROOT = Path(__file__).resolve().parent
BOOTSTRAP = '''#!/usr/bin/env python3
"""Portable WhatsApp suite: python whatsapp.py --help
All Python modules, native helpers and regression fixtures are embedded.
Only native DLLs are cached beside this file when a live command needs them.
Core commands use the standard library. Legacy CDP/Frida/QR commands retain
their optional dependencies. Live WebView probes require the supported
WhatsApp Desktop build, Windows x64 and 64-bit Python 3.11+.
Generated by build_whatsapp_bundle.py; edit the development sources to rebuild.
"""
import base64
import hashlib
import importlib.abc
import importlib.util
import io
import lzma
from pathlib import Path
import sys
import zipfile
PAYLOAD_SHA256 = __DIGEST__
PAYLOAD = (
__PAYLOAD__
)
def main():
compressed = base64.b85decode(PAYLOAD)
if hashlib.sha256(compressed).hexdigest() != PAYLOAD_SHA256:
raise RuntimeError('Bundle integrity check failed')
archive = zipfile.ZipFile(io.BytesIO(lzma.decompress(compressed)))
members = frozenset(archive.namelist())
launcher = Path(__file__).resolve()
class EmbeddedModules(importlib.abc.MetaPathFinder, importlib.abc.Loader):
def find_spec(self, fullname, path=None, target=None):
relative = fullname.replace('.', '/')
for name, package in ((relative + '/__init__.py', True), (relative + '.py', False)):
if name in members:
return importlib.util.spec_from_loader(fullname, self, is_package=package)
return None
def create_module(self, spec):
return None
def exec_module(self, module):
relative = module.__name__.replace('.', '/')
name = relative + ('/__init__.py' if module.__spec__.submodule_search_locations is not None else '.py')
module.__file__ = str(launcher) + '!' + name
exec(compile(archive.read(name), module.__file__, 'exec'), module.__dict__)
sys._whatsapp_bundle = {'base': launcher.parent, 'launcher': launcher, 'read': archive.read}
sys.meta_path.insert(0, EmbeddedModules())
from whatsapp_suite.cli import main as cli
return cli()
if __name__ == '__main__':
raise SystemExit(main())
'''
def main():
files={p.relative_to(ROOT).as_posix():p.read_bytes() for p in sorted((ROOT/'whatsapp_suite').glob('*.py'))}
for name in ('math_probe.dll','search_probe.dll','sqlite_inject.js'):
files['whatsapp_suite/assets/'+name]=(ROOT/'whatsapp_suite/assets'/name).read_bytes()
files['whatsapp_suite/_regression.py']=(ROOT/'tests/test_whatsapp_suite.py').read_bytes()
for p in sorted((ROOT/'tests').glob('*.cjs')):
files['tests/'+p.name]=p.read_bytes()
# Whole-archive compression shares repeated source and native-helper bytes.
memory=io.BytesIO()
with zipfile.ZipFile(memory,'w',compression=zipfile.ZIP_STORED) as archive:
for name,data in sorted(files.items()):
entry=zipfile.ZipInfo(name,date_time=(1980,1,1,0,0,0))
archive.writestr(entry,data)
payload=lzma.compress(memory.getvalue(),preset=9)
encoded=base64.b85encode(payload).decode('ascii')
lines='\n'.join(' '+repr(line) for line in textwrap.wrap(encoded,100,break_on_hyphens=False))
source=BOOTSTRAP.replace('__DIGEST__',repr(hashlib.sha256(payload).hexdigest())).replace('__PAYLOAD__',lines)
destination=ROOT/'whatsapp.py'
destination.write_text(source,encoding='utf-8',newline='\n')
report={'bundle':destination.name,'bundle_bytes':destination.stat().st_size,'embedded_source_and_asset_bytes':sum(map(len,files.values())),'files_embedded':len(files),'sha256':hashlib.sha256(destination.read_bytes()).hexdigest()}
(ROOT/'analysis/whatsapp-suite').mkdir(parents=True, exist_ok=True)
(ROOT/'analysis/whatsapp-suite/bundle-build.json').write_text(json.dumps(report,indent=2),encoding='utf-8')
print(json.dumps(report))
if __name__=='__main__':main()
+19
View File
@@ -0,0 +1,19 @@
# Architecture
## Execution path
The portable launcher verifies and decompresses its embedded payload, then imports the Python package directly from the in-memory ZIP. It uses the same CLI and modules as `python -m whatsapp_suite`.
For a core live operation, Python uses Windows APIs through `ctypes`. It verifies the helper DLL and supported application build, installs a temporary `WH_GETMESSAGE` hook on the Desktop UI thread, and passes a bounded JavaScript request through shared memory. The native helper locates the existing WebView object using build-specific signatures and calls its `ExecuteScriptAsync` interface. A thread message wakes the hook. Results return through the shared mapping; the hook, mapping and process handles are then released. Reports record cleanup outcomes.
This still uses native C++ DLLs. Python packages their bytes; it does not replace their implementation with pure Python. The C++ source is for maintenance and is unnecessary on a machine running the portable file. SHA-256 checks verify embedded asset integrity, not publisher identity or authenticity of a downloaded file.
Message export uses the app's own JavaScript database getter and row decoder. It paginates a bounded range, streams decoded records into a ZIP and writes a manifest. The scan is not an atomic database snapshot. Failed decodes remain visible and cause failure status. Final publication refuses existing output and verifies ZIP CRCs.
Contact export reads selected public fields from the local contact table. Sending is restricted to a fixed self-message test with recipient checks and persisted plaintext readback. No general-purpose arbitrary-recipient send CLI is provided.
## Boundaries
Live native operations support Desktop build `2.2639.100.0`. Updates may invalidate signatures and require new evidence and validation. Core bridge commands do not use CDP or UI automation; optional legacy commands have their own transports and dependencies. RAM readers only see loaded models. Exported message text does not include media file downloads. Password or passkey private-key extraction is not an implemented feature.
The repository includes only maintained source, synthetic fixtures and required runtime assets. Local profiles, extracted application code, credentials, messages, contacts and research dumps are excluded.
+179
View File
@@ -0,0 +1,179 @@
# WhatsApp test suite
## Portable single-file bundle
Copy **`whatsapp.py`** to another writable folder. That one Python file includes
all tools, both native helpers and the regression fixtures. No adjacent source
package, DLLs, C++ compiler or build script is needed to run it.
```powershell
python whatsapp.py --help
python whatsapp.py test
python whatsapp.py live --suite history-read
python whatsapp.py contacts-export --output contacts.json
python whatsapp.py export-zip --output messages.zip
python whatsapp.py send-self
```
The bundle compresses the original modules and resources together with LZMA.
It verifies its embedded payload and imports Python modules directly from memory.
Only a native helper needed for a live command is materialized in a hash-verified
cache beside the bundle. Keep the containing folder writable. Native helper
bytes, build checks, batch sizes and message read/send paths are unchanged.
Core WebView commands use the standard library. Python 3.11+, Windows x64,
64-bit Python and the supported WhatsApp build are still required for live
Desktop operations. Optional legacy CDP, Frida and QR tools retain the packages
listed below. Node.js remains optional for JavaScript fixture tests.
The development package below remains the editable source. Rebuild the generated
single-file distribution after changing it:
```powershell
python build_whatsapp_bundle.py
```
The build is deterministic and records size and SHA-256 in
`analysis/whatsapp-suite/bundle-build.json`. The build file is not needed on a
machine running the bundle.
The WhatsApp scripts are consolidated in `whatsapp_suite/`, with one CLI and
offline regression tests in `tests/`. Run commands from this workspace.
```powershell
python -m whatsapp_suite
python -m whatsapp_suite --help
python -m whatsapp_suite test --output analysis/whatsapp-suite/results.json
```
`python whatsapp_tests.py` is an equivalent entry point. The default run uses
fixtures and temporary files. It verifies saved-export parsing, Unicode search,
chat filtering, text redaction, history pagination/decryption contracts, fixed
probe responses, command dispatch, and bundled DLL hashes. Node.js runs the
JavaScript history fixtures; that test is reported as skipped if Node is absent.
Python 3.11 or newer is required.
To test an already running supported WhatsApp Desktop installation:
```powershell
python -m whatsapp_suite test --live
python -m whatsapp_suite test --capabilities
python -m whatsapp_suite live --suite math
python -m whatsapp_suite live --suite capabilities
python -m whatsapp_suite live --suite history-read --output analysis/whatsapp-suite/js-history-read.json
python -m whatsapp_suite export-zip --output exports/whatsapp-messages.zip
python -m whatsapp_suite contacts-export --output exports/contacts.json
python -m whatsapp_suite send-self
```
Live WebView probes require Windows x64, 64-bit Python, Desktop build
`2.2639.100.0`, and the verified WebView2 Core DLL. They use bundled native
helpers and check the build before installing a temporary hook. A missing or
unsupported app produces a failed live result. The JSON report includes cleanup
status. `--pid` selects a particular Desktop host.
## Tools
Every command supports `--help` for its options.
| Command | Purpose |
| --- | --- |
| `inventory` | List local Desktop storage files and sizes |
| `stats EXPORT` | Summarize a saved JSON or JSONL export |
| `search EXPORT QUERY` | Unicode-aware literal search of saved messages |
| `desktop-search QUERY` | Bounded native app search through WebView2; metadata output |
| `live --suite math\|capabilities\|all` | Fixed live WebView test suites |
| `live --suite message-json\|contacts` | Explicit fixed message search/contact sample; saves private data |
| `live --suite history-read` | Read the five oldest local message records through the JS database getter and decoder, without CDP |
| `webview-test` | Arithmetic check in a separate WebView2 host; may download its SDK |
| `analyze` | Storage analysis, optionally using an existing loopback CDP endpoint |
| `history-export` | Export local history through Desktop's own database decryption |
| `export-zip` | Export all local message rows to ZIP through JavaScript on WebView, without CDP or UI automation |
| `contacts-export` | Export all local contact rows to JSON through the app's JS database getter |
| `send-self` | Send one fixed test message to the signed-in account itself and verify stored plaintext |
| `ram-read` | Read loaded V8 message objects from process memory |
| `ram-search QUERY` | Search RAM models with verified collection membership |
| `native-search [QUERY]` | Experimental injected SQLite search, `--oldest --limit 5`, or `--export-index` |
| `qr-memory` | Recover candidate Desktop pairing QR payloads from RAM |
| `qr-window` | Save an already displayed Desktop linking QR |
| `pairing` | Start an isolated browser and request an official linking QR |
```powershell
python -m whatsapp_suite search export.jsonl "keyword" --redact-text
python -m whatsapp_suite desktop-search "keyword" --limit 10
python -m whatsapp_suite history-export --help
```
`history-read` uses the native WebView bridge to execute JavaScript in the running
app. It scans at most 100,000 local record timestamps in a read-only transaction,
then calls `WAWebSchemaMessage.getMessageTable().get` and
`WAWebDBMessageSerialization.messageFromDbRow` for the five oldest records.
It saves decoded body/caption text when present, including messages absent from
the loaded conversation models. System events and media-only records can have
no text. The live result includes page-state and native bridge cleanup status.
This command requires neither CDP nor optional Python packages.
`export-zip` uses the same native bridge and the app's JavaScript database getter
and decoder. It paginates all local rows up to the initial upper primary key,
streams them into `messages.jsonl`, and includes a `manifest.json` with row counts,
decoding errors, scope, and bridge cleanup status. Rows include message IDs,
chat IDs, timestamps, types, body and caption text; media files are not downloaded.
This is a live scan, not an atomic database snapshot. Row decoding failures are
preserved as error records and make the command return a failure status.
`--batch-size` controls bounded reads (default 100); `--redact-text` omits body and
caption text. Existing outputs are refused. Interrupted exports retain a
`.zip.partial` file and a `.zip.report.json` diagnostic report. A completed ZIP
is checked for CRC errors before publication.
`contacts-export` reads the local contact table rather than the five-contact
loaded-model sample. It exports selected identity, name, phone and business
fields. Local records can include separate PN/LID entries and contacts outside
the phone address book; this is not a deduplicated phone address-book export.
`send-self` resolves the signed-in account and checks both the selected chat and
outgoing message recipient before invoking the app's text-send action. It sends
one identifiable test message and reads it back through the message database
decoder, recording ACK and exact plaintext equality in a receipt. An existing
receipt is refused and sending is never retried automatically. Unlike the
offline test suite, this command actually sends a WhatsApp message.
The separate bulk `history-export` command requires an existing Desktop CDP endpoint. RAM tools only see
loaded objects and cannot retrieve all history. Pairing commands are separate
from the automated test run. The `runtime` module preserves the explicit
temporary CDP context manager for research scripts; entering that context
restarts Desktop and is never part of the default tests.
## Optional dependencies
The default suite, saved-export tools, and native WebView probes need only the
Python standard library (plus Node.js for the history fixture test).
| Tools | Additional Python packages |
| --- | --- |
| CDP analysis and history export | `websockets` |
| Injected SQLite search | `frida` |
| QR from RAM | `qrcode[pil]` |
| QR from Desktop window | `Pillow`, `zxing-cpp` |
| Isolated browser pairing | `websockets`, `qrcode[pil]`; installed Chrome |
Install all optional packages into a virtual environment with:
```powershell
python -m pip install -r requirements-whatsapp-optional.txt
```
## Layout and migration
- `whatsapp_suite/`: canonical tool implementations, common normalization,
storage paths, Desktop discovery, CLI, and test runner.
- `whatsapp_suite/assets/`: verified native DLLs and the native SQLite agent.
- `tests/`: synthetic Python regressions and the JavaScript history fixture.
- `analysis/whatsapp-suite/results.json`: latest automated test result.
The former `whatsapp_tool.py` commands now use `python -m whatsapp_suite`;
`whatsapp-search` is named `desktop-search`, and `whatsapp-test` is
`live --suite math`. The former `whatsapp_math.py` options are available under
`live`. Archived scripts have become package modules; the identical Desktop QR
RAM scripts have one implementation (`qr_memory`). Research helpers now import
these modules. Old script archive and portable script copies were removed.
The clean Git project excludes research reports, extracted application code and browser profiles. The surrounding original workspace retains those local files.
+17
View File
@@ -0,0 +1,17 @@
# Publish to tea.karlcom.de
This directory is the clean repository root. The surrounding research workspace is not the repository and must not be uploaded.
1. The configured repository is `https://tea.karlcom.de/karl/whatsapp_injector.git`.
2. Copy the SSH or HTTPS clone URL supplied by the server. Use your existing credential manager or SSH configuration; never embed tokens in the URL.
3. From this directory, inspect the tracked file list and configure the remote:
```powershell
git ls-files
git remote add origin https://tea.karlcom.de/karl/whatsapp_injector.git
git push -u origin main
```
The owner and remote URL were supplied by Karl. Repository visibility is controlled on the server. Never push the surrounding research workspace.
For a release, rebuild and test the bundle, then attach only `whatsapp.py` and a SHA-256 checksum to a server release. State the supported Desktop build and optional dependencies. Do not attach test exports or research reports. No server-specific CI workflow is included because runner availability and configuration have not been verified.
+17
View File
@@ -0,0 +1,17 @@
# Validation
Observed on 2026-10-11 in the original Windows development workspace. These observations describe that installation; they do not certify every command on every machine.
| Check | Result | Scope |
| --- | --- | --- |
| Portable offline regression suite | 30 tests passed, no failures/errors/skips | Isolated folder, Python `-I`, no adjacent source/assets |
| Portable contact export | Passed | 313 local contact rows |
| Portable old-message reading | Passed | 9,741 timestamp records scanned; five oldest records decoded |
| Portable native cleanup | Passed | Hook removed, handles released, helper unloaded |
| Deterministic build | Passed | Repeated build produced identical SHA-256 |
| Message ZIP export before bundling | Passed | 9,740 records, zero decode errors; plaintext verified |
| Self-message before bundling | Passed | One message sent; ACK 3 and exact persisted plaintext readback |
Every CLI help path is covered by regression tests. Synthetic fixtures cover parsing, normalization/search, export contracts, recipient guarding and helper cache integrity. Not every optional or live feature was rerun from the final bundle. Default tests do not send messages, link accounts or run live probes. JavaScript fixture checks can be skipped when Node.js is absent; inspect the report.
Run `python whatsapp.py test` for portable checks, or `python -m whatsapp_suite test` for source checks. Use `--help` before explicit live operations. Test reports and export artifacts are generated locally and ignored by Git. Do not commit live test output.
+8
View File
@@ -0,0 +1,8 @@
{
"note": "Runtime asset hashes; integrity only, not a publisher signature",
"sha256": {
"math_probe.dll": "136620d96f4745a4c41edfe1f4a9272ef25fc060ddace1c0eec1027bf252e02a",
"search_probe.dll": "911beb9f6bb52390cde8a75562ca1d6c46f4629f641426a5250286903de60895",
"sqlite_inject.js": "f31f31e170d30c0c845a3759db631d084b0bb096f1b654a9d9168a1078daed3f"
}
}
+122
View File
@@ -0,0 +1,122 @@
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#include <inspectable.h>
#include <winstring.h>
#include <stdint.h>
#include <stdio.h>
#include <wchar.h>
struct Packet { volatile LONG state; LONG hr; DWORD thread; DWORD protocol;
wchar_t script[8192]; wchar_t result[32768]; };
static const UINT Wake = WM_APP + 0x498;
static Packet* packet=nullptr;
static HANDLE mapping=nullptr;
static IUnknown* core=nullptr;
static IUnknown* operation=nullptr;
static IUnknown* info=nullptr;
static const GUID CoreIID={0x3a3f559a,0xe5e9,0x5338,{0xbb,0x67,0x4e,0xb0,0x50,0x4a,0x4f,0x14}};
static const GUID InfoIID={0x00000036,0,0,{0xc0,0,0,0,0,0,0,0x46}};
static void** table(void* p) { return *(void***)p; }
static HRESULT finishAsync() {
HRESULT outcome=S_OK;
if(info) {
using Status=HRESULT(STDMETHODCALLTYPE*)(void*,INT32*);
using Action=HRESULT(STDMETHODCALLTYPE*)(void*);
INT32 status=0;
HRESULT hr=((Status)table(info)[7])(info,&status);
if(SUCCEEDED(hr) && status==0) {
hr=((Action)table(info)[9])(info); // IAsyncInfo.Cancel
if(FAILED(hr))outcome=hr;
}
hr=((Action)table(info)[10])(info); // IAsyncInfo.Close
if(FAILED(hr) && SUCCEEDED(outcome))outcome=hr;
}
return outcome;
}
static void clearAsync() {
if(info){info->Release();info=nullptr;}
if(operation){operation->Release();operation=nullptr;}
}
static void fail(HRESULT hr) { clearAsync();packet->hr=hr;InterlockedExchange(&packet->state,-1); }
static HRESULT findCore() {
auto module=(uintptr_t)GetModuleHandleW(L"Microsoft.Web.WebView2.Core.dll");
if(!module)return HRESULT_FROM_WIN32(ERROR_MOD_NOT_FOUND);
MEMORY_BASIC_INFORMATION mbi;
uintptr_t address=0,scanned=0;
while(VirtualQuery((void*)address,&mbi,sizeof(mbi))) {
uintptr_t end=(uintptr_t)mbi.BaseAddress+mbi.RegionSize;
if(end<=address)break;
address=end;
if(mbi.State!=MEM_COMMIT||mbi.Type!=MEM_PRIVATE||mbi.Protect!=PAGE_READWRITE||
mbi.RegionSize>64*1024*1024)continue;
if(scanned+mbi.RegionSize>256*1024*1024)break;
scanned+=mbi.RegionSize;
for(uintptr_t p=(uintptr_t)mbi.BaseAddress;p+0x118<=end;p+=8) {
if(*(uintptr_t*)p!=module+0xa6ef0 || *(uintptr_t*)(p+0x108)!=module+0xa6eb0)continue;
if(table((void*)p)[0]!=(void*)(module+0x5ac50))continue;
auto hr=((IUnknown*)p)->QueryInterface(CoreIID,(void**)&core);
if(FAILED(hr))continue;
if(table(core)[49]!=(void*)(module+0x59060)) { core->Release();core=nullptr;continue; }
return S_OK;
}
}
return HRESULT_FROM_WIN32(ERROR_NOT_FOUND);
}
static void runProbe() {
if(!packet) {
wchar_t name[96];swprintf_s(name,L"Local\\WhatsAppMathProbe-v2-%lu",GetCurrentProcessId());
mapping=OpenFileMappingW(FILE_MAP_ALL_ACCESS,FALSE,name);
if(!mapping)return;
packet=(Packet*)MapViewOfFile(mapping,FILE_MAP_ALL_ACCESS,0,0,sizeof(Packet));
if(!packet){CloseHandle(mapping);mapping=nullptr;return;}
}
if(packet->protocol!=0x4d415432){fail(E_INVALIDARG);return;}
if(packet->thread!=GetCurrentThreadId()) { fail(RPC_E_WRONG_THREAD);return; }
LONG state=packet->state;
if(state==3) {InterlockedExchange(&packet->state,4);return;}
if(state==6) {
packet->hr=finishAsync();clearAsync();if(core){core->Release();core=nullptr;}
InterlockedExchange(&packet->state,7);
UnmapViewOfFile(packet);packet=nullptr;CloseHandle(mapping);mapping=nullptr;
return;
}
if(state==0) {
if(!core){auto hr=findCore();if(FAILED(hr)){fail(hr);return;}}
HSTRING text=nullptr;
auto hr=WindowsCreateString(packet->script,(UINT32)wcsnlen_s(packet->script,8192),&text);
if(FAILED(hr)){fail(hr);return;}
using Execute=HRESULT(STDMETHODCALLTYPE*)(void*,HSTRING,IUnknown**);
hr=((Execute)table(core)[49])(core,text,&operation);
WindowsDeleteString(text);
if(FAILED(hr)){fail(hr);return;}
hr=operation->QueryInterface(InfoIID,(void**)&info);
if(FAILED(hr)){fail(hr);return;}
InterlockedExchange(&packet->state,1);
}
if(packet->state==1) {
using Status=HRESULT(STDMETHODCALLTYPE*)(void*,INT32*);
INT32 status=0;auto hr=((Status)table(info)[7])(info,&status);
if(FAILED(hr)){fail(hr);return;}
if(status==0)return;
if(status!=1){fail(E_ABORT);return;}
using Results=HRESULT(STDMETHODCALLTYPE*)(void*,HSTRING*);
HSTRING value=nullptr;hr=((Results)table(operation)[8])(operation,&value);
if(FAILED(hr)){fail(hr);return;}
UINT32 length=0;auto raw=WindowsGetStringRawBuffer(value,&length);
if(length>=32768){WindowsDeleteString(value);fail(HRESULT_FROM_WIN32(ERROR_INSUFFICIENT_BUFFER));return;}
wmemcpy(packet->result,raw,length);packet->result[length]=0;
WindowsDeleteString(value);finishAsync();clearAsync();packet->hr=S_OK;
InterlockedExchange(&packet->state,2);
}
}
extern "C" __declspec(dllexport) LRESULT CALLBACK ProbeHook(int code,WPARAM wParam,LPARAM lParam) {
if(code>=0 && wParam==PM_REMOVE && ((MSG*)lParam)->message==Wake) {
((MSG*)lParam)->message=WM_NULL;
__try {runProbe();}
__except(EXCEPTION_EXECUTE_HANDLER) {
if(packet){packet->hr=(LONG)GetExceptionCode();InterlockedExchange(&packet->state,-1);}
}
}
return CallNextHookEx(nullptr,code,wParam,lParam);
}
BOOL WINAPI DllMain(HINSTANCE,DWORD,LPVOID) {return TRUE;}
+104
View File
@@ -0,0 +1,104 @@
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#include <inspectable.h>
#include <winstring.h>
#include <stdint.h>
#include <stdio.h>
#include <wchar.h>
struct Packet { volatile LONG state; LONG hr; DWORD thread; DWORD reserved;
wchar_t script[8192]; wchar_t result[32768]; };
static const UINT Wake = WM_APP + 0x497;
static Packet* packet=nullptr;
static HANDLE mapping=nullptr;
static IUnknown* core=nullptr;
static IUnknown* operation=nullptr;
static IUnknown* info=nullptr;
static const GUID CoreIID={0x3a3f559a,0xe5e9,0x5338,{0xbb,0x67,0x4e,0xb0,0x50,0x4a,0x4f,0x14}};
static const GUID InfoIID={0x00000036,0,0,{0xc0,0,0,0,0,0,0,0x46}};
static void** table(void* p) { return *(void***)p; }
static void clearAsync() {
if(info){info->Release();info=nullptr;}
if(operation){operation->Release();operation=nullptr;}
}
static void fail(HRESULT hr) { clearAsync();packet->hr=hr;InterlockedExchange(&packet->state,-1); }
static HRESULT findCore() {
auto module=(uintptr_t)GetModuleHandleW(L"Microsoft.Web.WebView2.Core.dll");
if(!module)return HRESULT_FROM_WIN32(ERROR_MOD_NOT_FOUND);
MEMORY_BASIC_INFORMATION mbi;
uintptr_t address=0,scanned=0;
while(VirtualQuery((void*)address,&mbi,sizeof(mbi))) {
uintptr_t end=(uintptr_t)mbi.BaseAddress+mbi.RegionSize;
if(end<=address)break;
address=end;
if(mbi.State!=MEM_COMMIT||mbi.Type!=MEM_PRIVATE||mbi.Protect!=PAGE_READWRITE||
mbi.RegionSize>64*1024*1024)continue;
if(scanned+mbi.RegionSize>256*1024*1024)break;
scanned+=mbi.RegionSize;
for(uintptr_t p=(uintptr_t)mbi.BaseAddress;p+0x118<=end;p+=8) {
if(*(uintptr_t*)p!=module+0xa6ef0 || *(uintptr_t*)(p+0x108)!=module+0xa6eb0)continue;
if(table((void*)p)[0]!=(void*)(module+0x5ac50))continue;
auto hr=((IUnknown*)p)->QueryInterface(CoreIID,(void**)&core);
if(FAILED(hr))continue;
if(table(core)[49]!=(void*)(module+0x59060)) { core->Release();core=nullptr;continue; }
return S_OK;
}
}
return HRESULT_FROM_WIN32(ERROR_NOT_FOUND);
}
static void runProbe() {
if(!packet) {
wchar_t name[96];swprintf_s(name,L"Local\\WhatsAppLocalProbe-%lu",GetCurrentProcessId());
mapping=OpenFileMappingW(FILE_MAP_ALL_ACCESS,FALSE,name);
if(!mapping)return;
packet=(Packet*)MapViewOfFile(mapping,FILE_MAP_ALL_ACCESS,0,0,sizeof(Packet));
if(!packet){CloseHandle(mapping);mapping=nullptr;return;}
}
if(packet->thread!=GetCurrentThreadId()) { fail(RPC_E_WRONG_THREAD);return; }
LONG state=packet->state;
if(state==6) {
clearAsync();if(core){core->Release();core=nullptr;}
InterlockedExchange(&packet->state,7);
UnmapViewOfFile(packet);packet=nullptr;CloseHandle(mapping);mapping=nullptr;
return;
}
if(state==0) {
if(!core){auto hr=findCore();if(FAILED(hr)){fail(hr);return;}}
HSTRING text=nullptr;
auto hr=WindowsCreateString(packet->script,(UINT32)wcsnlen_s(packet->script,8192),&text);
if(FAILED(hr)){fail(hr);return;}
using Execute=HRESULT(STDMETHODCALLTYPE*)(void*,HSTRING,IUnknown**);
hr=((Execute)table(core)[49])(core,text,&operation);
WindowsDeleteString(text);
if(FAILED(hr)){fail(hr);return;}
hr=operation->QueryInterface(InfoIID,(void**)&info);
if(FAILED(hr)){fail(hr);return;}
InterlockedExchange(&packet->state,1);
}
if(packet->state==1) {
using Status=HRESULT(STDMETHODCALLTYPE*)(void*,INT32*);
INT32 status=0;auto hr=((Status)table(info)[7])(info,&status);
if(FAILED(hr)){fail(hr);return;}
if(status==0)return;
if(status!=1){fail(E_ABORT);return;}
using Results=HRESULT(STDMETHODCALLTYPE*)(void*,HSTRING*);
HSTRING value=nullptr;hr=((Results)table(operation)[8])(operation,&value);
if(FAILED(hr)){fail(hr);return;}
UINT32 length=0;auto raw=WindowsGetStringRawBuffer(value,&length);
if(length>=32768){WindowsDeleteString(value);fail(HRESULT_FROM_WIN32(ERROR_INSUFFICIENT_BUFFER));return;}
wmemcpy(packet->result,raw,length);packet->result[length]=0;
WindowsDeleteString(value);clearAsync();packet->hr=S_OK;
InterlockedExchange(&packet->state,2);
}
}
extern "C" __declspec(dllexport) LRESULT CALLBACK ProbeHook(int code,WPARAM wParam,LPARAM lParam) {
if(code>=0 && wParam==PM_REMOVE && ((MSG*)lParam)->message==Wake) {
((MSG*)lParam)->message=WM_NULL;
__try {runProbe();}
__except(EXCEPTION_EXECUTE_HANDLER) {
if(packet){packet->hr=(LONG)GetExceptionCode();InterlockedExchange(&packet->state,-1);}
}
}
return CallNextHookEx(nullptr,code,wParam,lParam);
}
BOOL WINAPI DllMain(HINSTANCE,DWORD,LPVOID) {return TRUE;}
+6
View File
@@ -0,0 +1,6 @@
# Optional live CDP, native SQLite, and pairing tools.
websockets
frida
qrcode[pil]
Pillow
zxing-cpp
+62
View File
@@ -0,0 +1,62 @@
// Fixture tests for pagination, decryption errors, and plaintext redaction.
const assert = require('node:assert/strict');
const vm = require('node:vm');
const fs = require('node:fs');
const probe = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
const rows = ['a','b','c','d','e'].map(id => ({id, body:'text-'+id, type:'chat', t:123, invisible:id==='b'}));
const events = [];
function req(result) {
const r = {};
queueMicrotask(() => {r.result=result; r.onsuccess();});
return r;
}
const store = {
count:()=>req(rows.length),
openKeyCursor(range,direction) {
const selected=rows.filter(r=>!range || ((range.lower===undefined || r.id>range.lower) && r.id<=range.upper));
if(direction==='prev')selected.reverse();
const request={};let index=0;
function next(){queueMicrotask(()=>{
request.result=index<selected.length?{primaryKey:selected[index++].id,continue:next}:null;
request.onsuccess();
});}
next();return request;
}
};
const db={objectStoreNames:{contains:n=>n==='message'},transaction:(name,mode)=>{
assert.equal(name,'message');assert.equal(mode,'readonly');return {objectStore:()=>store};
},close:()=>events.push('close')};
const modules={
WAWebSchemaMessage:{getMessageTable:()=>({get:async key=>{
events.push('decrypt:'+key);
if(key==='c')throw Error('simulated unavailable decryption key');
return {...rows.find(r=>r.id===key)};
}})},
WAWebDBMessageSerialization:{messageFromDbRow:row=>{
events.push('decode:'+row.id);
return {...row,id:{id:row.id,remote:'test-chat',fromMe:false}};
}}
};
async function run(options){return vm.runInNewContext(probe.replace('__OPTIONS__',JSON.stringify(options)),{
location:{origin:'https://web.whatsapp.com'}, require:n=>modules[n],
indexedDB:{databases:async()=>[{name:'model-storage'}],open:()=>req(db)},
IDBKeyRange:{upperBound:upper=>({upper}),bound:(lower,upper,openLower,openUpper)=>{
assert.equal(openLower,true);assert.equal(openUpper,false);return {lower,upper};
}}
});}
(async()=>{
const bounds=await run({mode:'bounds'});assert.equal(bounds.count,5);assert.equal(bounds.upper,'e');
let after=null,all=[];
while(after!=='e'){
const page=await run({mode:'batch',upper:'e',after,batch:2,redact:false});
assert.ok(page.keys.length<=2);all.push(...page.messages);after=page.last;
}
assert.deepEqual(all.map(m=>m.id),['a','b','c','d','e']); // UI-invisible b remains included.
assert.equal(all[2].error,'row_decryption_or_decoding_failed');
assert.equal(all[1].body,'text-b');
const redacted=await run({mode:'batch',upper:'b',after:null,batch:2,redact:true});
assert.ok(redacted.messages.every(m=>!('body' in m)&&!('caption' in m)));
assert.equal(redacted.messages[0].body_length,6);
assert.ok(events.indexOf('decrypt:a')<events.indexOf('decode:a'));
console.log('PASS: bounded pagination, upper bound, invisible rows, row errors, decryption order, redaction');
})().catch(error=>{console.error(error);process.exitCode=1;});
+41
View File
@@ -0,0 +1,41 @@
const assert = require('node:assert/strict');
const vm = require('node:vm');
const fs = require('node:fs');
const probe = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
const rows = [90, 20, 60, 10, 70, 30, 40, 0].map((t, i) => ({id: String(i), t}));
const reads = [];
let closed = false;
const store = {openCursor() {
const request = {}; let index = 0;
function advance() {queueMicrotask(() => {
const row = rows[index++];
request.result = row ? {primaryKey: row.id, value: row, continue: advance} : null;
request.onsuccess();
});}
advance(); return request;
}};
const db = {transaction(name, mode) {
assert.equal(name, 'message'); assert.equal(mode, 'readonly');
return {objectStore: () => store};
}, close: () => {closed = true;}};
const modules = {
WAWebSchemaMessage: {getMessageTable: () => ({get: async key => {
reads.push(key); return rows.find(r => r.id === key);
}})},
WAWebDBMessageSerialization: {messageFromDbRow: row => ({...row, type: 'chat', body: 'fixture-' + row.id})},
};
(async () => {
const result = await vm.runInNewContext(probe, {
location: {origin: 'https://web.whatsapp.com'}, require: n => modules[n],
indexedDB: {databases: async () => [{name: 'model-storage'}], open: () => {
const request = {}; queueMicrotask(() => {request.result = db; request.onsuccess();}); return request;
}},
});
assert.deepEqual(Array.from(result.messages, m => m.timestamp), [10, 20, 30, 40, 60]);
assert.equal(result.scanned, 8);
assert.equal(reads.length, 5);
assert.ok(result.messages.every(m => m.body.startsWith('fixture-')));
assert.equal(result.cdp, false);
assert.equal(closed, true);
console.log('PASS: oldest selection, bounded app decryption, decoded text, readonly transaction, cleanup');
})().catch(error => {console.error(error); process.exitCode = 1;});
+36
View File
@@ -0,0 +1,36 @@
const assert=require('node:assert/strict');
const vm=require('node:vm');
const fs=require('node:fs');
const probes=JSON.parse(fs.readFileSync(process.argv[2],'utf8'));
const me={toString:()=> 'self@lid'};
const other={toString:()=> 'other@lid'};
let sends=0;
const data={id:{remote:me,toString:()=> 'self-message'},body:'fixture'};
const chat={id:me,msgs:{get:()=>({ack:3})}};
const modules={
WAWebUserPrefsMeUser:{getMaybeMeLidUser:()=>me,isMeAccount:v=>v===me},
WAWebFindChatAction:{findOrCreateLatestChat:async()=>({chat})},
WAWebSendTextMsgChatAction:{createTextMsgData:async()=>data,addAndSendTextMsg:async()=>{sends++;}},
WAWebSchemaMessage:{getMessageTable:()=>({get:async()=>({body:'fixture',t:123})})},
WAWebDBMessageSerialization:{messageFromDbRow:v=>v},
WAWebSchemaContact_DO_NOT_USE_DIRECTLY:{getContactTable:()=>({all:async()=>[
{id:'a@lid',name:'Grüße 😺',phoneNumber:'123',unexpectedSecret:{value:'never export'}},
{id:'b@lid',name:null,isBusiness:true},
]})},
};
const run=code=>vm.runInNewContext(code,{require:name=>modules[name]});
(async()=>{
const good=await run(probes.send);
assert.equal(sends,1);assert.equal(good.persisted,true);assert.equal(good.plaintext_matches,true);
chat.id=other;
await assert.rejects(run(probes.send),/Refusing to send outside own account/);
assert.equal(sends,1);
chat.id=me;data.id.remote=other;
await assert.rejects(run(probes.send),/not addressed to self/);
assert.equal(sends,1);
const contacts=await run(probes.contacts);
assert.equal(contacts.count,2);assert.equal(contacts.contacts[0].name,'Grüße 😺');
assert.equal('unexpectedSecret' in contacts.contacts[0],false);
assert.equal(contacts.cdp,false);assert.equal(contacts.ui_automation,false);
console.log('PASS: send once, own-chat guard, own-recipient guard, plaintext readback, contact fields');
})().catch(error=>{console.error(error);process.exitCode=1;});
+291
View File
@@ -0,0 +1,291 @@
import argparse
import base64
import contextlib
import hashlib
import importlib
import io
import json
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import unittest
import zipfile
from unittest.mock import patch
from whatsapp_suite import exports, history, live, qr_memory, ram_search
from whatsapp_suite.common import BASE, asset_bytes, resource_bytes, cli_command
from whatsapp_suite.analyzer import endpoint
class SavedExportTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.folder = Path(self.temp.name)
self.rows = [
{'id': '1', 'chat_id': 'a', 'body': 'Cafe\u0301 STRASSE', 'type': 'chat', 'nested': {'secret': 'private'}},
{'id': '2', 'chatId': 'b', 'caption': 'CAFÉ Straße', 'type': 'image'},
{'id': '3', 'chat_id': 'a', 'indexed_text': 'other', 'type': 'chat'},
]
self.path = self.folder / 'messages.json'
self.path.write_text(json.dumps(self.rows), encoding='utf-8')
def invoke(self, *args):
out = io.StringIO()
with contextlib.redirect_stdout(out):
self.assertEqual(exports.main(list(args)), 0)
return json.loads(out.getvalue())
def test_json_report_wrappers(self):
for key in ('messages', 'models', 'results'):
self.path.write_text(json.dumps({key: self.rows}), encoding='utf-8')
self.assertEqual(list(exports.messages(self.path)), self.rows)
def test_jsonl_bom_and_blank_lines(self):
path = self.folder / 'messages.jsonl'
path.write_text('\n' + '\n\n'.join(json.dumps(r) for r in self.rows), encoding='utf-8-sig')
self.assertEqual(list(exports.messages(path)), self.rows)
def test_malformed_jsonl_reports_line(self):
path = self.folder / 'broken.jsonl'
path.write_text('{}\ninvalid', encoding='utf-8')
with self.assertRaisesRegex(ValueError, 'line 2'):
list(exports.messages(path))
def test_nonobject_records_rejected(self):
for data in ([None], {'messages': [42]}, 'wrong', {}):
self.path.write_text(json.dumps(data), encoding='utf-8')
with self.assertRaises(ValueError):
list(exports.messages(self.path))
def test_unicode_search_and_total_beyond_limit(self):
result = self.invoke('search', str(self.path), 'café straße', '--limit', '1')
self.assertEqual((result['matches'], result['returned']), (2, 1))
def test_chat_filter_and_nested_redaction(self):
result = self.invoke('search', str(self.path), 'café', '--chat-id', 'a', '--redact-text')
self.assertEqual(result['matches'], 1)
row = result['results'][0]
self.assertEqual(row['id'], '1')
self.assertNotIn('body', row)
self.assertNotIn('nested', row)
self.assertGreater(row['text_length'], 0)
def test_stats_understands_both_chat_id_fields(self):
result = self.invoke('stats', str(self.path))
self.assertEqual((result['messages'], result['messages_with_text'], result['chats']), (3, 3, 2))
self.assertEqual(result['types'], {'chat': 2, 'image': 1})
def test_output_cannot_overwrite_input(self):
before = self.path.read_bytes()
with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit):
exports.main(['stats', str(self.path), '--output', str(self.path)])
self.assertEqual(self.path.read_bytes(), before)
def test_empty_query_and_invalid_limit_rejected(self):
for tail in ([' '], ['café', '--limit', '0']):
with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit):
exports.main(['search', str(self.path)] + tail)
def test_inventory_empty_and_existing_profile(self):
self.assertFalse(exports.inventory(self.folder / 'absent')['exists'])
store = self.folder / 'Local Storage/leveldb'
store.mkdir(parents=True)
(store / 'sample.ldb').write_bytes(b'123')
report = exports.inventory(self.folder)
self.assertEqual(report['storage_inventory'][0]['files'], [{'name': 'sample.ldb', 'bytes': 3}])
class ProbeContractTests(unittest.TestCase):
@unittest.skipUnless(shutil.which('node'), 'Node.js is needed for JavaScript fixtures')
def test_self_message_guards_and_contact_export(self):
from whatsapp_suite.self_message import PROBE as send
from whatsapp_suite.contacts_export import PROBE as contacts
with tempfile.TemporaryDirectory() as folder:
probe=Path(folder)/'probes.json'
probe.write_text(json.dumps({'send':send.replace('__BODY__',json.dumps('fixture')),'contacts':contacts}),encoding='utf-8')
result=subprocess.run(['node','-',str(probe)],input=resource_bytes('tests/self_and_contacts.cjs').decode('utf-8'),capture_output=True,text=True,encoding='utf-8',timeout=20)
self.assertEqual(result.returncode,0,result.stdout+result.stderr)
def test_self_send_refuses_existing_receipt_without_retry(self):
from whatsapp_suite.self_message import send_self
with tempfile.TemporaryDirectory() as folder:
path=Path(folder)/'receipt.json'
path.write_text('original',encoding='utf-8')
with patch('whatsapp_suite.self_message.run_math') as bridge:
with self.assertRaises(ValueError):send_self(path)
bridge.assert_not_called()
self.assertEqual(path.read_text(),'original')
@unittest.skipUnless(shutil.which('node'), 'Node.js is needed for JavaScript fixtures')
def test_oldest_history_read_without_cdp(self):
from whatsapp_suite.history_read import PROBE
with tempfile.TemporaryDirectory() as folder:
probe = Path(folder) / 'probe.json'
probe.write_text(json.dumps(PROBE), encoding='utf-8')
result = subprocess.run(['node', '-', str(probe)], input=resource_bytes('tests/history_read.cjs').decode('utf-8'), capture_output=True, text=True, encoding='utf-8', timeout=20)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
def test_ram_search_requires_verified_membership(self):
rows = [{'body': 'café', 'chat_id': 'a', 'in_message_collection': valid} for valid in (True, False)]
result = ram_search.search_models(rows, 'CAFE\u0301', chat_id='a', redact=True)
self.assertEqual(len(result), 1)
self.assertNotIn('body', result[0])
self.assertEqual(result[0]['body_length'], 4)
self.assertEqual(ram_search.search_models(rows, 'café', chat_id='b'), [])
with self.assertRaises(ValueError):
ram_search.search_models(rows, ' ')
def test_loopback_endpoint_validation(self):
self.assertEqual(endpoint('http://127.0.0.1:9223/'), 'http://127.0.0.1:9223')
self.assertEqual(endpoint('http://[::1]:9223'), 'http://[::1]:9223')
for url in ('https://example.com', 'file:///tmp/x', 'http://localhost:9223', 'http://user:[email protected]', 'http://127.0.0.1?x=1'):
with self.assertRaises(argparse.ArgumentTypeError):
endpoint(url)
def test_native_assets_match_verified_hashes(self):
from whatsapp_suite.native_search import EMBEDDED_PROBE_SHA256
for filename, expected in (('math_probe.dll', live.EMBEDDED_SHA256), ('search_probe.dll', EMBEDDED_PROBE_SHA256)):
payload = asset_bytes(filename)
self.assertEqual(payload[:2], b'MZ')
self.assertEqual(hashlib.sha256(payload).hexdigest(), expected)
def test_math_response_binding_and_float_tolerance(self):
data = {'probe_id': 'test', 'cases': [{'expression': expression, 'value': expected} for expression, expected in live.CASES]}
self.assertTrue(all(row['passed'] for row in live.validate_result(json.dumps(data), 'test')))
with self.assertRaises(ValueError):
live.validate_result(json.dumps(data), 'other')
data['cases'][0]['value'] = -1
self.assertFalse(live.validate_result(json.dumps(data), 'test')[0]['passed'])
def test_capability_response_must_match_fixed_checks(self):
data = {'probe_id': 'test', 'state': 'done', 'checks': [{'id': spec[0]} for spec in live.CAPABILITIES]}
self.assertEqual(live.validate_capabilities(data, 'test'), data['checks'])
data['checks'].reverse()
with self.assertRaises(ValueError):
live.validate_capabilities(data, 'test')
def test_pairing_payload_shape(self):
key = base64.b64encode(bytes(range(32))).decode()
value = ','.join(['reference123', key, key, key, '8'])
self.assertTrue(qr_memory.valid_payload(value))
self.assertTrue(qr_memory.valid_payload('https://wa.me/settings/linked_devices#' + value))
self.assertFalse(qr_memory.valid_payload(value.replace(',8', ',7')))
self.assertFalse(qr_memory.valid_payload('reference,invalid,invalid,invalid,8'))
def test_history_refuses_existing_export(self):
with tempfile.TemporaryDirectory() as folder:
path = Path(folder) / 'history.jsonl'
path.write_text('original', encoding='utf-8')
with self.assertRaisesRegex(RuntimeError, 'already exists'):
history.export(None, path)
self.assertEqual(path.read_text(), 'original')
def test_desktop_search_escapes_query_and_enforces_bounds(self):
from whatsapp_suite import native_search
query = '"; window.unwanted = true; //'
with patch.object(native_search, 'native_webview_test', return_value=0) as backend:
self.assertEqual(native_search.run_live_probe(None, Path('unused.json'), query, 10), 0)
config = backend.call_args.args[2]
self.assertIn('query=' + json.dumps(query), config['start'])
self.assertEqual(config['mode'], 'search')
for query, limit in (('', 10), ('valid', 0), ('valid', 51), ('x' * 201, 10)):
with self.assertRaises(ValueError):
native_search.run_live_probe(None, Path('unused.json'), query, limit)
@unittest.skipUnless(shutil.which('node'), 'Node.js is needed for the JavaScript history fixtures')
def test_history_javascript_pagination_decryption_redaction(self):
with tempfile.TemporaryDirectory() as folder:
probe = Path(folder) / 'probe.json'
probe.write_text(json.dumps(history.PROBE), encoding='utf-8')
result = subprocess.run(['node', '-', str(probe)], input=resource_bytes('tests/history_export.cjs').decode('utf-8'), capture_output=True, text=True, encoding='utf-8', timeout=20)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
class CommandTests(unittest.TestCase):
def test_all_commands_have_help(self):
from whatsapp_suite.cli import COMMANDS
for command in ['test'] + list(COMMANDS):
with self.subTest(command=command):
result = subprocess.run(cli_command(command, '--help'), cwd=BASE, capture_output=True, text=True, timeout=15)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn('usage:', result.stdout)
def test_saved_search_dispatch(self):
from whatsapp_suite.cli import main
with patch('whatsapp_suite.exports.main', return_value=0) as target:
self.assertEqual(main(['search', 'export.json', 'keyword']), 0)
target.assert_called_once_with(['search', 'export.json', 'keyword'])
def test_live_probe_is_explicit(self):
from whatsapp_suite.cli import main
with patch('whatsapp_suite.runner.main', return_value=0) as target:
self.assertEqual(main([]), 0)
target.assert_called_once_with([])
class ZipExportTests(unittest.TestCase):
def test_pagination_zip_unicode_and_row_errors(self):
from whatsapp_suite.zip_export import write_messages
pages = iter([
{'count': 3, 'upper': 'c'},
{'keys': ['a', 'b'], 'last': 'b', 'messages': [{'id': 'a', 'body': 'Grüße 😺'}, {'id': 'b', 'error': 'decode_failed'}]},
{'keys': ['c'], 'last': 'c', 'messages': [{'id': 'c', 'body': 'old message'}]},
{'count': 3, 'upper': 'c'},
])
with tempfile.TemporaryDirectory() as folder:
path = Path(folder) / 'test.zip'
with zipfile.ZipFile(path, 'w') as archive, contextlib.redirect_stdout(io.StringIO()):
result = write_messages(lambda _: next(pages), archive, 2, False)
with zipfile.ZipFile(path) as archive:
self.assertIsNone(archive.testzip())
rows = [json.loads(line) for line in archive.read('messages.jsonl').decode('utf-8').splitlines()]
self.assertEqual(rows[0]['body'], 'Grüße 😺')
self.assertEqual((result['rows'], result['row_errors']), (3, 1))
self.assertTrue(result['reached_initial_upper'])
def test_duplicate_rows_fail(self):
from whatsapp_suite.zip_export import write_messages
pages = iter([{'count': 2, 'upper': 'b'}, {'keys': ['a', 'a'], 'last': 'a', 'messages': [{'id': 'a'}, {'id': 'a'}]}])
with tempfile.TemporaryDirectory() as folder, zipfile.ZipFile(Path(folder) / 'test.zip', 'w') as archive:
with self.assertRaisesRegex(RuntimeError, 'Duplicate'):
write_messages(lambda _: next(pages), archive, 2, False)
def test_existing_output_preserved(self):
from whatsapp_suite.zip_export import export_zip
with tempfile.TemporaryDirectory() as folder:
path = Path(folder) / 'test.zip'
path.write_bytes(b'original')
with self.assertRaises(ValueError):
export_zip(path)
self.assertEqual(path.read_bytes(), b'original')
def test_empty_database(self):
from whatsapp_suite.zip_export import write_messages
with tempfile.TemporaryDirectory() as folder, zipfile.ZipFile(Path(folder) / 'test.zip', 'w') as archive:
result = write_messages(lambda _: {'count': 0, 'upper': None}, archive, 100, False)
self.assertEqual(result['rows'], 0)
self.assertTrue(result['reached_initial_upper'])
class NativeCacheTests(unittest.TestCase):
@unittest.skipUnless(sys.platform == 'win32', 'Native helper cache requires Windows')
def test_verified_helper_reuses_and_repairs_cache(self):
from whatsapp_suite import common
payload=b'MZ synthetic fixture, never loaded'
digest=hashlib.sha256(payload).hexdigest()
with tempfile.TemporaryDirectory() as folder, patch.object(common,'BASE',Path(folder)), patch.object(common,'asset_bytes',return_value=payload):
path,status=common.verified_helper('fixture.dll',digest,'cache','helper.dll')
self.assertEqual(status,'extracted')
self.assertEqual(common.verified_helper('fixture.dll',digest,'cache','helper.dll')[1],'reused')
path.write_bytes(b'tampered')
self.assertEqual(common.verified_helper('fixture.dll',digest,'cache','helper.dll')[1],'repaired')
self.assertEqual(path.read_bytes(),payload)
with self.assertRaisesRegex(ValueError,'SHA-256'):
common.verified_helper('fixture.dll','0'*64,'cache','helper.dll')
if __name__ == '__main__':
unittest.main()
+1547
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -0,0 +1 @@
"""Consolidated WhatsApp Desktop tools and regression suite."""
+3
View File
@@ -0,0 +1,3 @@
from .cli import main
raise SystemExit(main())
+198
View File
@@ -0,0 +1,198 @@
"""Read-only WhatsApp storage analyzer; optional reads through WhatsApp's own decryption path."""
from __future__ import annotations
import argparse
import datetime as dt
import hashlib
import json
import os
from pathlib import Path
import queue
import subprocess
import threading
import tomllib
import urllib.parse
import urllib.request
from .common import PROFILE
from .exports import inventory
def endpoint(value: str) -> str:
parsed = urllib.parse.urlsplit(value)
if parsed.scheme not in ("http", "https") or parsed.hostname not in ("127.0.0.1", "::1") or parsed.username or parsed.password or parsed.query or parsed.fragment:
raise argparse.ArgumentTypeError("CDP must be an HTTP(S) literal loopback endpoint")
return value.rstrip("/")
PROBE = r"""(async () => {
const limit = __LIMIT__, includeText = __TEXT__, readDb = __READ_DB__;
const hash = async s => Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(s)))).map(x=>x.toString(16).padStart(2,'0')).join('');
const len = v => v == null ? null : typeof v==='string' ? v.length : v.byteLength ?? v.length ?? null;
const encrypted = v => v == null ? {present:false} : {present:true, envelope: typeof v==='object' && '_data' in v && 'iv' in v, ciphertext_bytes:len(v._data), iv_bytes:len(v.iv), scheme:v._scheme??null, key_id_present:v._keyId!=null};
let models=[], modelError=null;
try { models=require('WAWebMsgCollection').MsgCollection.getModelsArray(); }
catch(e) { modelError=String(e); }
const selected=models.slice(0,limit), ids=selected.map(m=>m.id?.toString()).filter(Boolean);
const summaries=await Promise.all(selected.map(async m=>{
const s={id_sha256:m.id ? await hash(m.id.toString()):null, type:m.type??null, body_length:len(m.body), caption_length:len(m.caption), plaintext_present:typeof m.body==='string'||typeof m.caption==='string', model_encrypted_data_present:m.encryptedData!=null};
if(includeText){s.body=typeof m.body==='string'?m.body:null;s.caption=typeof m.caption==='string'?m.caption:null;}
return s;
}));
let appReads=[];
if(readDb){
for(const m of selected){
if(!m.id)continue;
try {
const row=await require('WAWebModelStorageUtils').getStorage().table('message').get(m.id.toString());
const restored=row?require('WAWebDBMessageSerialization').messageFromDbRow(row):null;
const result={id_sha256:await hash(m.id.toString()),row_found:!!row,decoded_opaque_bytes:len(row?.msgRowOpaqueData),restored_body_length:len(restored?.body),restored_caption_length:len(restored?.caption),body_matches_loaded_model:restored?restored.body===m.body:null,caption_matches_loaded_model:restored?restored.caption===m.caption:null};
if(includeText){result.body=restored?.body??null;result.caption=restored?.caption??null;}
appReads.push(result);
}catch(e){appReads.push({id_sha256:await hash(m.id.toString()),error:String(e)});}
}
}
// Refuse to create a database: enumerate names first, then abort any upgrade.
const dbs=typeof indexedDB.databases==='function'?await indexedDB.databases():null;
if(!dbs) return {model_count:models.length,models:summaries,model_error:modelError,database_error:'indexedDB.databases unavailable; refusing open to avoid creating a DB'};
if(!dbs.some(d=>d.name==='model-storage')) return {model_count:models.length,models:summaries,model_error:modelError,database_names:dbs.map(d=>d.name),database_error:'model-storage absent; no database opened'};
const db=await new Promise((resolve,reject)=>{ const r=indexedDB.open('model-storage');r.onupgradeneeded=()=>{r.transaction.abort();reject(new Error('Upgrade aborted'));};r.onsuccess=()=>resolve(r.result);r.onerror=()=>reject(new Error(r.error?.name??'Open failed'));r.onblocked=()=>reject(new Error('Database open blocked'));});
let rows=[],matching=[];
try {
if(!db.objectStoreNames.contains('message'))return {model_count:models.length,models:summaries,model_error:modelError,database_error:'message store absent',stores:Array.from(db.objectStoreNames)};
const request = r => new Promise((resolve,reject)=>{r.onsuccess=()=>resolve(r.result);r.onerror=()=>reject(new Error(r.error?.name??'Read failed'));});
const tx=db.transaction(['message'],'readonly'), store=tx.objectStore('message');
const reads=[request(store.getAll(undefined,limit)),...ids.map(id=>request(store.get(id)))];
const values=await Promise.all(reads);rows=values[0];matching=values.slice(1).filter(Boolean);
const summarize=async r=>({id_sha256:await hash(String(r.id)),type:r.type??null,field_names:Object.keys(r).sort(),payload:encrypted(r.msgRowOpaqueData),body_field_present:Object.prototype.hasOwnProperty.call(r,'body'),caption_field_present:Object.prototype.hasOwnProperty.call(r,'caption')});
const rowSummary=await Promise.all(rows.map(summarize)), matchingSummary=await Promise.all(matching.map(summarize));
const byId=new Map(matchingSummary.map(r=>[r.id_sha256,r]));
return {model_count:models.length,models:summaries,model_error:modelError,application_read_path:appReads,database_names:dbs.map(d=>d.name),stores:Array.from(db.objectStoreNames),bounded_row_sample:rowSummary,matching_rows:matchingSummary,comparisons:summaries.filter(m=>byId.has(m.id_sha256)).map(m=>({id_sha256:m.id_sha256,ram_plaintext_present:m.plaintext_present,ram_body_length:m.body_length,ram_caption_length:m.caption_length,disk_payload:byId.get(m.id_sha256).payload}))};
} finally { db.close(); }
})()"""
def cdp_probe(url: str, limit: int, include_text: bool, read_db: bool = False) -> tuple[dict, str]:
from websockets.sync.client import connect
with urllib.request.urlopen(url + "/json/list", timeout=10) as response:
targets = json.load(response)
target = next((t for t in targets if t.get("type") == "page" and urllib.parse.urlsplit(t.get("url", "")).hostname == "web.whatsapp.com"), None)
if not target:
raise RuntimeError("No WhatsApp page target is exposed; start WhatsApp with CDP enabled.")
websocket_url = target["webSocketDebuggerUrl"]
if urllib.parse.urlsplit(websocket_url).hostname not in ("127.0.0.1", "::1"):
raise RuntimeError("Refusing a non-loopback WebSocket target")
expression = PROBE.replace("__LIMIT__", str(limit)).replace("__TEXT__", "true" if include_text else "false").replace("__READ_DB__", "true" if read_db else "false")
with connect(websocket_url, max_size=20_000_000, open_timeout=10) as ws:
ws.send(json.dumps({"id": 1, "method": "Runtime.evaluate", "params": {"expression": expression, "returnByValue": True, "awaitPromise": True}}))
while True:
result = json.loads(ws.recv(timeout=30))
if result.get("id") == 1:
break
if "error" in result or result.get("result", {}).get("exceptionDetails"):
raise RuntimeError("CDP evaluation failed: " + json.dumps(result.get("error") or result["result"]["exceptionDetails"]))
return result["result"]["result"]["value"], target["id"]
class MCP:
def __init__(self, config: Path):
with config.open("rb") as stream:
entry = tomllib.load(stream)["mcp_servers"]["rea"]
env = dict(os.environ, **entry.get("env", {}))
self.proc = subprocess.Popen([entry["command"], *entry.get("args", [])], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, encoding="utf-8", env=env)
self.messages = queue.Queue()
self.counter = 0
threading.Thread(target=self._read, daemon=True).start()
self.call("initialize", {"protocolVersion": "2024-11-05", "capabilities": {}, "clientInfo": {"name": "whatsapp-readonly-analyzer", "version": "1.0"}})
self.send({"jsonrpc": "2.0", "method": "notifications/initialized"})
def _read(self):
for line in self.proc.stdout:
try:
self.messages.put(json.loads(line))
except json.JSONDecodeError:
continue
self.messages.put({"error": {"message": "REA server exited"}})
def send(self, payload):
self.proc.stdin.write(json.dumps(payload) + "\n")
self.proc.stdin.flush()
def call(self, method, params):
self.counter += 1
ident = self.counter
self.send({"jsonrpc": "2.0", "id": ident, "method": method, "params": params})
while True:
message = self.messages.get(timeout=60)
if message.get("id") == ident or ("error" in message and "id" not in message):
if "error" in message:
raise RuntimeError(str(message["error"]))
return message["result"]
def close(self):
self.proc.terminate()
try:
self.proc.wait(timeout=5)
except subprocess.TimeoutExpired:
self.proc.kill()
def rea_inspect(url: str, target_id: str, config: Path) -> dict:
client = MCP(config)
try:
results = []
for name, arguments in (("list_browser_targets", {"cdp_endpoint": url, "allowed_origins": ["https://web.whatsapp.com"]}), ("inspect_web_page", {"cdp_endpoint": url, "target_id": target_id, "allowed_origins": ["https://web.whatsapp.com"], "observation_ms": 100, "include_script_sources": False, "include_accessibility_text": False, "include_console_text": False, "include_json_body_shapes": False, "include_storage_fingerprints": False, "include_storage_keys": False, "include_websocket_shapes": False})):
result = client.call("tools/call", {"name": name, "arguments": arguments})
# Keep provenance only: never save raw DOM, console, source, or storage values.
evidence = result.get("structuredContent")
if evidence is None:
for block in result.get("content", []):
if block.get("type") == "text":
try:
evidence = json.loads(block["text"])
break
except json.JSONDecodeError:
pass
evidence = evidence if isinstance(evidence, dict) else {}
results.append({"tool": name, "is_error": result.get("isError", False), "evidence_id": evidence.get("evidence_id"), "operation": evidence.get("operation"), "provider": evidence.get("provider"), "confidence": evidence.get("confidence"), "limitations": evidence.get("limitations", []), "response_sha256": hashlib.sha256(json.dumps(result, sort_keys=True).encode()).hexdigest()})
return {"source": "REA stdio MCP", "evidence": results, "retention": "Provenance and response digests only; full payload deliberately omitted."}
finally:
client.close()
def main(argv=None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--profile", type=Path, default=PROFILE)
parser.add_argument("--cdp", type=endpoint, help="Existing loopback CDP endpoint, e.g. http://127.0.0.1:9223")
parser.add_argument("--limit", type=int, default=20, help="Maximum models and DB sample rows (1..100)")
parser.add_argument("--include-message-text", action="store_true", help="Explicitly save loaded own-account message body/caption in output")
parser.add_argument("--read-db", action="store_true", help="Use WhatsApp's own message-table getter to decrypt bounded matching rows and compare text in memory")
parser.add_argument("--rea", action="store_true", help="Also request REA passive page evidence over configured stdio MCP")
parser.add_argument("--config", type=Path, default=Path.home() / ".codex/config.toml")
parser.add_argument("--output", type=Path, default=Path("whatsapp-analyzer-report.json"))
args = parser.parse_args(argv)
if not 1 <= args.limit <= 100:
parser.error("--limit must be between 1 and 100")
if (args.rea or args.include_message_text or args.read_db) and not args.cdp:
parser.error("--rea, --read-db and --include-message-text require --cdp")
report = {"timestamp_utc": dt.datetime.now(dt.timezone.utc).isoformat(), "offline": inventory(args.profile), "message_text_included": args.include_message_text,
"application_db_read_enabled": args.read_db,
"limitations": ["Static REA findings guide the probe; Runtime.evaluate results are independent CDP observations, not REA attestations.", "Reads only existing loaded models and bounded read-only IndexedDB rows. No send, navigation, record writes or key extraction. --read-db calls WhatsApp's own getter, which decrypts payloads in memory.", "RAM strings are already decrypted by WhatsApp. Raw-envelope comparison alone proves storage shape; --read-db additionally checks restored content equality against loaded models without exporting text by default.", "Not all history is loaded; database getAll sample is bounded and has no recency guarantee. Metadata such as field names and message type remains visible.", "WebView2 and WhatsApp caches are not a forensic snapshot. OS swap, media caches, notifications and native SQLite are outside this probe."]}
failed = False
if args.cdp:
try:
result, target = cdp_probe(args.cdp, args.limit, args.include_message_text, args.read_db)
report["runtime"] = {"source": "Independent read-only CDP Runtime.evaluate", "target_id": target, "result": result}
if args.rea:
report["rea"] = rea_inspect(args.cdp, target, args.config)
except Exception as error:
report["error"] = f"{type(error).__name__}: {error}"
failed = True
args.output.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"Report saved: {args.output.resolve()}")
if failed:
print(report["error"])
return 1 if failed else 0
if __name__ == "__main__":
raise SystemExit(main())
Binary file not shown.
Binary file not shown.
+87
View File
@@ -0,0 +1,87 @@
// REA-backed WinRT ABI for WhatsAppNativeProjection.dll build 2.2639.100.0.
// Native calls only; no CDP, UI automation or restart.
const native=Process.getModuleByName('WhatsAppNative.dll');
if(!native.path.includes('WhatsAppDesktop_2.2639.100.0_'))throw Error('Unsupported Desktop build; ABI must be revalidated with REA');
const combase=Process.getModuleByName('combase.dll');
function api(name,ret,args){return new NativeFunction(combase.getExportByName(name),ret,args);}
const createString=api('WindowsCreateString','int',['pointer','uint','pointer']);
const deleteString=api('WindowsDeleteString','int',['pointer']);
const rawString=api('WindowsGetStringRawBuffer','pointer',['pointer','pointer']);
function check(hr){if(hr<0)throw Error('HRESULT 0x'+(hr>>>0).toString(16));}
function hstring(value){const out=Memory.alloc(8);check(createString(Memory.allocUtf16String(value),value.length,out));return out.readPointer();}
function readHString(handle){if(handle.isNull())return '';const len=Memory.alloc(4);const p=rawString(handle,len);return p.readUtf16String(len.readU32());}
function guid(hex){const p=Memory.alloc(16);p.writeByteArray(hex.match(/../g).map(x=>parseInt(x,16)));return p;}
const dbIid=guid('eeb0ca996bf8523780d9cf1b934d62b5');
const stmtIid=guid('94f8c705a4cb4b3baa1b9b2ce2ff2734');
const propertyIid=guid('dd82d64b5475e9409a9b82654ede7e62');
function method(obj,slot,ret,args){return new NativeFunction(obj.readPointer().add(slot*8).readPointer(),ret,['pointer',...args]);}
function release(obj){if(!obj.isNull())method(obj,2,'uint',[])(obj);}
function query(obj,iid){const out=Memory.alloc(8);check(method(obj,0,'int',['pointer','pointer'])(obj,iid,out));return out.readPointer();}
let references=[];
function property(value){if(value.isNull())return null;let p=ptr(0);try{
p=query(value,propertyIid);const out=Memory.alloc(8);check(method(p,19,'int',['pointer'])(p,out));
const h=out.readPointer();try{return readHString(h);}finally{deleteString(h);}
}finally{release(p);release(value);}}
function statement(db,sql){const h=hstring(sql),error=Memory.alloc(4),partial=Memory.alloc(8),out=Memory.alloc(8);
try{check(method(db,9,'int',['pointer','pointer','pointer','pointer'])(db,h,error,partial,out));
// The integer out-parameter is the consumed SQL offset, not an error code.
const original=out.readPointer();if(original.isNull())throw Error('No prepared statement');
try{return query(original,stmtIid);}finally{release(original);}
}finally{deleteString(h);}
}
function select(db,sql,limit){let st=ptr(0);try{
st=statement(db,sql);const count=Memory.alloc(4);check(method(st,10,'int',['pointer'])(st,count));
const rows=[];while(rows.length<limit){const has=Memory.alloc(1);check(method(st,7,'int',['pointer'])(st,has));if(has.readU8()===0)break;
const row=[];for(let i=0;i<count.readS32();i++){const out=Memory.alloc(8);check(method(st,11,'int',['int','pointer'])(st,i,out));row.push(property(out.readPointer()));}rows.push(row);}
return rows;
}finally{if(!st.isNull()){try{check(method(st,6,'int',[])(st));}finally{release(st);}}}}
rpc.exports={
discover(maxMb){
// Obtain the real interface vtable from a fresh closed Sqlite object. No DB
// is opened and no credentials are read from the existing session.
const init=api('RoInitialize','int',['uint'])(1);if(init<0&&init!==-2147417850)check(init);
const name=hstring('WhatsAppNative.Sqlite'),factoryOut=Memory.alloc(8);
const getFactory=new NativeFunction(native.getExportByName('DllGetActivationFactory'),'int',['pointer','pointer']);
let factory=ptr(0),sample=ptr(0),iface=ptr(0);
try{check(getFactory(name,factoryOut));factory=factoryOut.readPointer();const out=Memory.alloc(8);
check(method(factory,6,'int',['pointer'])(factory,out));sample=out.readPointer();iface=query(sample,dbIid);
const vtable=iface.readPointer();const bytes=Memory.alloc(8);bytes.writePointer(vtable);
const pattern=Array.from(new Uint8Array(bytes.readByteArray(8))).map(v=>v.toString(16).padStart(2,'0')).join(' ');
let scanned=0,hitCount=0;const found=[],addresses=new Set(),failures=[];
for(const range of Process.enumerateRanges({protection:'rw-',coalesce:true})){
if(scanned>=maxMb*1024*1024)break;
const size=Math.min(range.size,maxMb*1024*1024-scanned);scanned+=size;
let hits=[];try{hits=Memory.scanSync(range.base,size,pattern);}catch{continue;}
hitCount+=hits.length;
for(const hit of hits){if(hit.address.equals(iface)||!hit.address.and(7).isNull())continue;
let object=ptr(0);try{object=query(hit.address,dbIid);if(addresses.has(object.toString())){release(object);continue;}
const tables=select(object,"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name",100);
const id=references.length;references.push(object);addresses.add(object.toString());
found.push({id,address:object.toString(),tables:tables.map(r=>r[0])});object=ptr(0);
}catch(error){if(failures.length<20)failures.push({address:hit.address.toString(),error:String(error)});if(!object.isNull())release(object);}
}
}return {databases:found,bytesScanned:scanned,vtable:vtable.toString(),vtableHits:hitCount,failures};
}finally{release(iface);release(sample);release(factory);deleteString(name);}
},
search(id,term,limit){if(!Number.isInteger(id)||id<0||id>=references.length)throw Error('Unknown database');
if(!Number.isInteger(limit)||limit<1||limit>1000)throw Error('Invalid row limit');
const tokens=term.trim().split(/\s+/).filter(Boolean);if(!tokens.length)throw Error('Empty query');
const match=tokens.map(t=>'"'+t.replace(/"/g,'""')+'"*').join(' AND ').replace(/'/g,"''");
const sql="SELECT CAST(t.id AS TEXT),CAST(t.chatId AS TEXT),CAST(t.timestamp AS TEXT),CAST(t.text AS TEXT) FROM message t JOIN message_fts f ON t.rowid=f.rowid WHERE message_fts MATCH '"+match+"' ORDER BY rank LIMIT "+limit;
return select(references[id],sql,limit);
},
oldest(id,limit){
if(!Number.isInteger(id)||id<0||id>=references.length)throw Error('Unknown database');
if(!Number.isInteger(limit)||limit<1||limit>1000)throw Error('Invalid row limit');
return select(references[id],"SELECT CAST(id AS TEXT),CAST(chatId AS TEXT),CAST(timestamp AS TEXT),CAST(text AS TEXT) FROM message WHERE timestamp > 0 AND text IS NOT NULL AND length(text) > 0 ORDER BY timestamp,id LIMIT "+limit,limit);
},
stats(id){return select(references[id],"SELECT CAST(COUNT(*) AS TEXT),CAST(MIN(timestamp) AS TEXT),CAST(MAX(timestamp) AS TEXT) FROM message",1)[0];},
indexbounds(id){return select(references[id],"SELECT CAST(COUNT(*) AS TEXT),CAST(COALESCE(MAX(rowid),0) AS TEXT) FROM message",1)[0];},
exportindex(id,after,upper,limit){
if(!Number.isInteger(id)||id<0||id>=references.length)throw Error('Unknown database');
if(!/^-?\d+$/.test(String(after))||!/^\d+$/.test(String(upper)))throw Error('Invalid row boundary');
if(!Number.isInteger(limit)||limit<1||limit>500)throw Error('Invalid batch size');
return select(references[id],"SELECT CAST(rowid AS TEXT),CAST(id AS TEXT),CAST(chatId AS TEXT),CAST(timestamp AS TEXT),CAST(text AS TEXT) FROM message WHERE rowid > "+after+" AND rowid <= "+upper+" ORDER BY rowid LIMIT "+limit,limit);
},
close(){for(const object of references)release(object);references=[];}
};
+52
View File
@@ -0,0 +1,52 @@
"""Single entry point for regression tests and explicit app probes."""
import argparse
import importlib
import sys
COMMANDS = {
'inventory': ('exports', 'List local Desktop storage files'),
'stats': ('exports', 'Summarize a saved JSON/JSONL export'),
'search': ('exports', 'Search a saved export'),
'desktop-search': ('exports', 'Search the running Desktop app'),
'live': ('live', 'Run fixed Desktop math/capability/data probes'),
'webview-test': ('webview', 'Run an isolated WebView2 host test'),
'analyze': ('analyzer', 'Inspect storage and optionally an existing CDP session'),
'history-export': ('history', 'Export history through an existing Desktop CDP endpoint'),
'export-zip': ('zip_export', 'Export all local messages via JavaScript in WebView; no CDP or UI automation'),
'send-self': ('self_message', 'Send one test text to your own WhatsApp account via JavaScript'),
'contacts-export': ('contacts_export', 'Export all locally stored contacts via JavaScript without CDP'),
'ram-read': ('ram', 'Recover loaded message objects from process RAM'),
'ram-search': ('ram_search', 'Search verified loaded message objects'),
'native-search': ('injected_search', 'Experimental native SQLite search/index export'),
'qr-memory': ('qr_memory', 'Find Desktop pairing QR candidates in RAM'),
'qr-window': ('qr_window', 'Capture an already displayed Desktop pairing QR'),
'pairing': ('pairing', 'Request a linking QR in an isolated browser'),
}
def main(argv=None):
argv = list(sys.argv[1:] if argv is None else argv)
if not argv:
argv = ['test']
parser = argparse.ArgumentParser(description='WhatsApp tools and test suite. Default: offline regression tests.')
commands = parser.add_subparsers(dest='command', required=True)
commands.add_parser('test', help='Run offline regression tests; optionally add live checks', add_help=False)
for name, (_, description) in COMMANDS.items():
commands.add_parser(name, help=description, add_help=False)
args, remaining = parser.parse_known_args(argv)
try:
if args.command == 'test':
from .runner import main as run_tests
return run_tests(remaining)
module = importlib.import_module('.' + COMMANDS[args.command][0], __package__)
if args.command in ('inventory', 'stats', 'search', 'desktop-search'):
command = 'whatsapp-search' if args.command == 'desktop-search' else args.command
return module.main([command] + remaining)
if args.command == 'webview-test':
help_parser = argparse.ArgumentParser(description=COMMANDS[args.command][1])
help_parser.parse_args(remaining)
return module.run_webview_test()
return module.main(remaining)
except (OSError, ValueError, RuntimeError, ImportError) as error:
print(f'{args.command}: {error}', file=sys.stderr)
return 1
+57
View File
@@ -0,0 +1,57 @@
"""Shared workspace paths and text normalization."""
import os
import sys
import unicodedata
from pathlib import Path
_BUNDLE = getattr(sys, '_whatsapp_bundle', None)
BASE = Path(_BUNDLE['base']) if _BUNDLE else Path(__file__).resolve().parent.parent
ASSETS = Path(__file__).resolve().parent / "assets"
PROFILE = Path(os.environ.get("LOCALAPPDATA", str(Path.home() / "AppData/Local"))) / "Packages/5319275A.WhatsAppDesktop_cv1g1gvanyjgm/LocalCache/EBWebView/Default"
def normalized(value):
return unicodedata.normalize('NFC', value).casefold()
def resource_bytes(name):
"""Read an embedded bundle resource or its development-tree equivalent."""
if _BUNDLE:
return _BUNDLE['read'](name)
return (BASE / name).read_bytes()
def asset_bytes(name):
return resource_bytes('whatsapp_suite/assets/' + name)
def cli_command(*arguments):
if _BUNDLE:
return [sys.executable, str(_BUNDLE['launcher']), *arguments]
return [sys.executable, '-m', 'whatsapp_suite', *arguments]
def verified_helper(asset, digest, cache_dir, filename):
"""Materialize a hash-verified DLL only when a live probe needs it."""
import ctypes
import hashlib
import tempfile
if os.name != 'nt' or ctypes.sizeof(ctypes.c_void_p) != 8:
raise ValueError('Native helpers require Windows and 64-bit Python')
payload = asset_bytes(asset)
if hashlib.sha256(payload).hexdigest() != digest:
raise ValueError('Bundled helper failed SHA-256 verification')
folder = BASE / cache_dir / digest
folder.mkdir(parents=True, exist_ok=True)
dll = folder / filename
existed = dll.exists()
if existed and hashlib.sha256(dll.read_bytes()).hexdigest() == digest:
return dll, 'reused'
descriptor, temporary = tempfile.mkstemp(prefix='helper-', suffix='.tmp', dir=folder)
try:
with os.fdopen(descriptor, 'wb') as stream:
stream.write(payload)
os.replace(temporary, dll)
finally:
Path(temporary).unlink(missing_ok=True)
return dll, 'repaired' if existed else 'extracted'
+52
View File
@@ -0,0 +1,52 @@
"""Export all locally stored contact records through WhatsApp's JavaScript API."""
import argparse
import datetime as dt
import json
from pathlib import Path
from .live import run_math
from .zip_export import evaluate
PROBE = r'''(async () => {
const table=require('WAWebSchemaContact_DO_NOT_USE_DIRECTLY').getContactTable();
const rows=await table.all();
if(!Array.isArray(rows))throw Error('Unexpected contact table result');
const fields=['id','phoneNumber','name','shortName','pushname','notifyName','verifiedName',
'addressBookName','isAddressBookContact','isMyContact','isBusiness','isEnterprise'];
const contacts=rows.map(row=>{
const out={};for(const field of fields){const value=row[field];
if(value==null||['string','number','boolean'].includes(typeof value))out[field]=value??null;
else if(field==='id')out.id=value.toString();
}
if(typeof out.id!=='string'||!out.id)throw Error('Contact ID unavailable');
return out;
});
return {source:'WAWebSchemaContact_DO_NOT_USE_DIRECTLY.getContactTable().all',
scope:'All local contact rows, not only loaded models or phone address-book entries',
count:contacts.length,contacts,cdp:false,ui_automation:false};
})()'''
def export_contacts(output,pid=None):
output=Path(output)
if output.exists():raise ValueError('Output exists; choose a new --output')
def operation(execute,session,report):
data=evaluate(execute,PROBE,session)
if not isinstance(data.get('contacts'),list) or data.get('count')!=len(data['contacts']):
raise RuntimeError('Incomplete contact export response')
report['data']=data
report=run_math(pid,'contacts-export',_operation=operation)
output.parent.mkdir(parents=True,exist_ok=True)
with output.open('x',encoding='utf-8') as stream:
json.dump(report,stream,ensure_ascii=True,indent=2)
return report
def main(argv=None):
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--pid',type=int)
parser.add_argument('--output',type=Path,default=Path('exports')/('contacts-'+dt.datetime.now().strftime('%Y%m%d-%H%M%S')+'.json'))
args=parser.parse_args(argv)
report=export_contacts(args.output,args.pid)
print(json.dumps({'passed':report['passed'],'error':report.get('error'),'contacts':report.get('data',{}).get('count'),'output':str(args.output)}))
return 0 if report['passed'] else 1
+109
View File
@@ -0,0 +1,109 @@
"""Shared verified Desktop process and window discovery."""
import ctypes
import hashlib
from pathlib import Path
SUPPORTED_BUILD = "2.2639.100.0"
CORE_SHA256 = "c6da3c67eda66a8d48ff2ae11f68085f1f8250111138a6ae384d38eefb4da536"
def check_core_build(path):
if 'WhatsAppDesktop_' + SUPPORTED_BUILD + '_' not in str(path):
raise ValueError('Unsupported WhatsApp build: ' + str(path) +
'; supported build is ' + SUPPORTED_BUILD + '. No hook installed.')
if hashlib.sha256(Path(path).read_bytes()).hexdigest() != CORE_SHA256:
raise ValueError('Unknown WebView2 Core DLL hash. No hook installed.')
def visible_ui_threads(pid):
"""Inspect window ownership; no clicks, navigation or window messages."""
import ctypes
from ctypes import wintypes
user32 = ctypes.WinDLL('user32', use_last_error=True)
user32.GetWindowThreadProcessId.argtypes = [wintypes.HWND, ctypes.POINTER(wintypes.DWORD)]
user32.GetWindowThreadProcessId.restype = wintypes.DWORD
user32.IsWindowVisible.argtypes = [wintypes.HWND]
user32.IsWindowVisible.restype = wintypes.BOOL
callback_type = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM)
threads = set()
def visit(hwnd, parameter):
process = wintypes.DWORD()
thread = user32.GetWindowThreadProcessId(hwnd, ctypes.byref(process))
if process.value == pid and user32.IsWindowVisible(hwnd):
threads.add(thread)
return True
callback = callback_type(visit)
user32.EnumWindows.argtypes = [callback_type, wintypes.LPARAM]
user32.EnumWindows.restype = wintypes.BOOL
if not user32.EnumWindows(callback, 0):
raise ctypes.WinError(ctypes.get_last_error())
return threads
def detect_desktop_pid(requested_pid=None):
"""Find the running supported Desktop host using Python and Windows APIs."""
import ctypes
from ctypes import wintypes as w
import hashlib
class ProcessEntry(ctypes.Structure):
_fields_ = [('dwSize',w.DWORD),('cntUsage',w.DWORD),('th32ProcessID',w.DWORD),
('th32DefaultHeapID',ctypes.c_size_t),('th32ModuleID',w.DWORD),
('cntThreads',w.DWORD),('th32ParentProcessID',w.DWORD),
('pcPriClassBase',w.LONG),('dwFlags',w.DWORD),('szExeFile',w.WCHAR*260)]
class ModuleEntry(ctypes.Structure):
_fields_ = [('dwSize',w.DWORD),('th32ModuleID',w.DWORD),('th32ProcessID',w.DWORD),
('GlblcntUsage',w.DWORD),('ProccntUsage',w.DWORD),
('modBaseAddr',ctypes.POINTER(w.BYTE)),('modBaseSize',w.DWORD),
('hModule',w.HMODULE),('szModule',w.WCHAR*256),('szExePath',w.WCHAR*260)]
kernel = ctypes.WinDLL('kernel32',use_last_error=True)
kernel.CreateToolhelp32Snapshot.argtypes = [w.DWORD,w.DWORD]
kernel.CreateToolhelp32Snapshot.restype = w.HANDLE
kernel.CloseHandle.argtypes = [w.HANDLE]
kernel.CloseHandle.restype = w.BOOL
for name, entry in [('Process32FirstW',ProcessEntry),('Process32NextW',ProcessEntry),
('Module32FirstW',ModuleEntry),('Module32NextW',ModuleEntry)]:
fn = getattr(kernel,name)
fn.argtypes = [w.HANDLE,ctypes.POINTER(entry)]
fn.restype = w.BOOL
invalid = ctypes.c_void_p(-1).value
snapshot = kernel.CreateToolhelp32Snapshot(2,0)
if snapshot == invalid:
raise ctypes.WinError(ctypes.get_last_error())
roots = []
try:
entry = ProcessEntry(dwSize=ctypes.sizeof(ProcessEntry))
exists = kernel.Process32FirstW(snapshot,ctypes.byref(entry))
while exists:
if entry.szExeFile.casefold() == 'whatsapp.root.exe':
roots.append(entry.th32ProcessID)
exists = kernel.Process32NextW(snapshot,ctypes.byref(entry))
finally:
kernel.CloseHandle(snapshot)
candidates = []
rejected = []
for pid in roots:
if requested_pid is not None and pid != requested_pid:
continue
snapshot = kernel.CreateToolhelp32Snapshot(8|16,pid)
if snapshot == invalid:
continue
try:
entry = ModuleEntry(dwSize=ctypes.sizeof(ModuleEntry))
exists = kernel.Module32FirstW(snapshot,ctypes.byref(entry))
while exists:
if entry.szModule.casefold() == 'microsoft.web.webview2.core.dll':
path = Path(entry.szExePath)
if len(visible_ui_threads(pid)) == 1:
try:
check_core_build(path)
candidates.append(pid)
except (ValueError, OSError) as error:
rejected.append(str(error))
break
exists = kernel.Module32NextW(snapshot,ctypes.byref(entry))
except OSError:
continue
finally:
kernel.CloseHandle(snapshot)
if not candidates:
raise ValueError('; '.join(rejected) if rejected else 'No supported running WhatsApp Desktop host with one identifiable UI thread found; start WhatsApp normally')
if len(candidates) != 1:
raise ValueError('Multiple active Desktop hosts found; specify --pid: '+', '.join(map(str,candidates)))
return candidates[0]
+143
View File
@@ -0,0 +1,143 @@
"""Storage inventory and saved message export analysis."""
import argparse
import collections
import json
from pathlib import Path
from .common import BASE, PROFILE, normalized
def messages(path):
with path.open(encoding='utf-8-sig') as stream:
if path.suffix.lower() == '.jsonl':
for number, line in enumerate(stream, 1):
if not line.strip():
continue
try:
row = json.loads(line)
except json.JSONDecodeError as ex:
raise ValueError(f'Invalid JSON at line {number}: {ex}') from ex
if not isinstance(row, dict):
raise ValueError(f'Line {number} is not a message object')
yield row
else:
data = json.load(stream)
if isinstance(data, list):
rows = data
elif isinstance(data, dict):
rows = next((data[key] for key in ('messages', 'models', 'results')
if isinstance(data.get(key), list)), None)
if rows is None:
raise ValueError('Expected messages, models or results array')
else:
raise ValueError('Expected an array or a message report')
for row in rows:
if not isinstance(row, dict):
raise ValueError('Message array contains a non-object value')
yield row
def content(row):
return '\n'.join(row[key] for key in ('indexed_text', 'body', 'caption', 'text')
if isinstance(row.get(key), str))
def inventory(profile):
entries = []
for pattern in ('IndexedDB/*whatsapp*', 'Local Storage/leveldb', 'Session Storage'):
for folder in profile.glob(pattern):
files = []
for file in folder.iterdir() if folder.is_dir() else [folder]:
try:
if file.is_file():
files.append({'name': file.name, 'bytes': file.stat().st_size})
except OSError:
continue
entries.append({'path': str(folder), 'files': files})
return {'profile': str(profile), 'exists': profile.exists(), 'storage_inventory': entries,
'coverage': 'File names and sizes only; no decrypted messages.'}
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
sub = parser.add_subparsers(dest='command', required=True)
inv = sub.add_parser('inventory', help='List local Desktop storage files and sizes')
inv.add_argument('--profile', type=Path, default=PROFILE)
for name in ('stats', 'search'):
cmd = sub.add_parser(name, help='Analyze an existing JSON/JSONL message export')
cmd.add_argument('input', type=Path)
cmd.add_argument('--output', type=Path)
if name == 'search':
cmd.add_argument('query')
cmd.add_argument('--chat-id')
cmd.add_argument('--limit', type=int, default=100)
cmd.add_argument('--redact-text', action='store_true')
sub.add_parser('webview-test', help='Harmless JavaScript test in a separate local host')
live = sub.add_parser('whatsapp-test', help='Run eight fixed arithmetic checks in the running Desktop instance')
live.add_argument('--pid', type=int, help='Optional override; running Desktop host is detected automatically')
live.add_argument('--output', type=Path, default=BASE / 'analysis/webview2/live-arithmetic-result.json')
search = sub.add_parser('whatsapp-search', help='Use the running app native message search through WebView2')
search.add_argument('query')
search.add_argument('--pid', type=int, help='Optional override; running Desktop host is detected automatically')
search.add_argument('--limit', type=int, default=50)
search.add_argument('--output', type=Path, default=BASE / 'analysis/webview2/live-message-search.json')
args = parser.parse_args(argv)
if args.command == 'whatsapp-test':
from .live import main as run_live
return run_live(['--suite', 'math'] + (['--pid', str(args.pid)] if args.pid else []) + ['--output', str(args.output)])
if args.command == 'whatsapp-search':
if not args.query.strip() or len(args.query)>200 or not 1<=args.limit<=50:
parser.error('Search requires 1..200 characters and --limit 1..50')
from .native_search import run_live_probe
return run_live_probe(args.pid, args.output, args.query, args.limit)
if args.command == 'webview-test':
from .webview import run_webview_test
return run_webview_test()
if args.command == 'inventory':
result = inventory(args.profile)
elif args.command == 'stats':
count = with_text = 0
kinds = collections.Counter()
chats = set()
for row in messages(args.input):
count += 1
with_text += bool(content(row))
kinds[str(row.get('type', 'unknown'))] += 1
chat = row.get('chatId', row.get('chat_id'))
if chat is not None:
chats.add(str(chat))
result = {'source': str(args.input), 'messages': count, 'messages_with_text': with_text,
'chats': len(chats), 'types': dict(kinds), 'scope': 'Saved export only'}
else:
if not args.query.strip():
parser.error('query must not be empty')
if args.limit < 1:
parser.error('--limit must be positive')
found = []
total = 0
for row in messages(args.input):
chat = row.get('chatId', row.get('chat_id'))
if args.chat_id is not None and str(chat) != args.chat_id:
continue
text = content(row)
if normalized(args.query) not in normalized(text):
continue
total += 1
if len(found) >= args.limit:
continue
item = dict(row)
if args.redact_text:
# Allowlist metadata so nested fields cannot leak message content.
item = {key: value for key, value in row.items()
if key in ('id', 'chatId', 'chat_id', 'timestamp', 'type', 'nativeRowID', 'native_rowid')
and isinstance(value, (str, int, float, bool, type(None)))}
item['text_length'] = len(text)
found.append(item)
result = {'source': str(args.input), 'matches': total, 'returned': len(found),
'results': found, 'scope': 'Literal search of saved export only'}
serialized = json.dumps(result, ensure_ascii=False, indent=2)
output = getattr(args, 'output', None)
if output:
if output.resolve() == args.input.resolve():
parser.error('output must differ from input')
output.write_text(serialized + '\n', encoding='utf-8')
print(json.dumps({'output': str(output), 'command': args.command}))
else:
print(serialized)
return 0
+156
View File
@@ -0,0 +1,156 @@
"""Export local WhatsApp Desktop history through its own database decryption.
Requires an already-exposed Desktop WebView2 CDP endpoint. Does not enable CDP,
restart Desktop, use a separate browser, change messages, or request history sync.
"""
import argparse
import json
from pathlib import Path
import subprocess
import urllib.parse
import urllib.request
from .analyzer import endpoint
PROBE = r'''(async () => {
const options = __OPTIONS__;
if (location.origin !== 'https://web.whatsapp.com') throw Error('Unexpected origin');
const table = require('WAWebSchemaMessage').getMessageTable();
const serializer = require('WAWebDBMessageSerialization');
const names = await indexedDB.databases();
if (!names.some(d => d.name === 'model-storage')) throw Error('Database absent');
const db = await new Promise((resolve,reject) => {
const r=indexedDB.open('model-storage');
r.onupgradeneeded=()=>{r.transaction.abort();reject(Error('Refusing database creation or upgrade'));};
r.onsuccess=()=>resolve(r.result);r.onerror=()=>reject(Error('Database open failed'));
r.onblocked=()=>reject(Error('Database blocked'));
});
const request=r=>new Promise((resolve,reject)=>{r.onsuccess=()=>resolve(r.result);r.onerror=()=>reject(Error('Database read failed'));});
try {
if (!db.objectStoreNames.contains('message')) throw Error('Message store absent');
if (options.mode==='bounds') {
const store=db.transaction('message','readonly').objectStore('message');
const [count,last]=await Promise.all([request(store.count()),request(store.openKeyCursor(null,'prev'))]);
const upper=last?.primaryKey??null;
if(upper!==null&&typeof upper!=='string') throw Error('Unsupported primary key type');
return {count,upper};
}
const keys=await new Promise((resolve,reject)=>{
const tx=db.transaction('message','readonly'),store=tx.objectStore('message'),values=[];
const range=options.after===null?IDBKeyRange.upperBound(options.upper):IDBKeyRange.bound(options.after,options.upper,true,false);
const r=store.openKeyCursor(range);
r.onerror=()=>reject(Error('Cursor read failed'));tx.onabort=()=>reject(Error('Cursor transaction aborted'));
r.onsuccess=()=>{const c=r.result;if(!c||values.length===options.batch){resolve(values);return;}
if(typeof c.primaryKey!=='string'){reject(Error('Unsupported primary key type'));return;}
values.push(c.primaryKey);c.continue();};
});
const messages=[];
const asString=v=>v==null?null:typeof v==='string'?v:v.toString();
for(const key of keys){
try {
// Default get(..., true) follows WAWebIdb.asyncView -> decryptDbMaterial.
const row=await table.get(key);
if(!row){messages.push({id:key,error:'row_missing_during_live_scan'});continue;}
if(row.msgRowOpaqueData&&typeof row.msgRowOpaqueData==='object'&&'_data' in row.msgRowOpaqueData)
throw Error('Opaque payload still encrypted');
const m=serializer.messageFromDbRow(row),id=m.id;
const out={id:key,message_id:id?.id??null,chat_id:asString(id?.remote),from_me:id?.fromMe??null,
type:m.type??null,timestamp:m.t??null,body_length:typeof m.body==='string'?m.body.length:null,
caption_length:typeof m.caption==='string'?m.caption.length:null};
if(!options.redact){out.body=typeof m.body==='string'?m.body:null;out.caption=typeof m.caption==='string'?m.caption:null;}
messages.push(out);
}catch(e){messages.push({id:key,error:'row_decryption_or_decoding_failed',error_type:e?.name??'Error'});}
}
return {keys,messages,last:keys.length?keys[keys.length-1]:options.after};
}finally{db.close();}
})()'''
def desktop_ports():
# Read process metadata only; never start a process with debugging flags.
script = r'''$p=@(Get-CimInstance Win32_Process -Filter "Name='WhatsApp.Root.exe' OR Name='msedgewebview2.exe'");$ids=@($p | Where-Object Name -eq 'WhatsApp.Root.exe' | ForEach-Object ProcessId);for($i=0;$i -lt 8;$i++){$ids+=@($p | Where-Object {$_.ParentProcessId -in $ids} | ForEach-Object ProcessId);$ids=@($ids | Select-Object -Unique)};$r=@($p | Where-Object {$_.ProcessId -in $ids -and $_.CommandLine -match '--remote-debugging-port=(\d+)'} | ForEach-Object {[int]$Matches[1]});ConvertTo-Json -InputObject $r -Compress'''
result=subprocess.run(['powershell.exe','-NoProfile','-NonInteractive','-Command',script],capture_output=True,text=True,check=True)
return set(json.loads(result.stdout or '[]'))
def evaluate(ws, expression, ident):
ws.send(json.dumps({'id':ident,'method':'Runtime.evaluate','params':{'expression':expression,'returnByValue':True,'awaitPromise':True}}))
while True:
response=json.loads(ws.recv(timeout=120))
if response.get('id')!=ident:continue
if response.get('error') or response.get('result',{}).get('exceptionDetails'):
raise RuntimeError('WhatsApp database query failed; session may be locked or modules unavailable')
return response['result']['result']['value']
def expression(options):
return PROBE.replace('__OPTIONS__',json.dumps(options))
def export(cdp, output, batch=100, redact=False):
output=Path(output)
if output.exists() or output.with_suffix(output.suffix+'.partial').exists():
raise RuntimeError('Output already exists; choose a new --output path')
return _export(cdp,output,batch,redact)
def _export(cdp, output, batch=100, redact=False):
from websockets.sync.client import connect
ports=desktop_ports()
if not ports:
raise RuntimeError('Running WhatsApp Desktop exposes no CDP interface. ReadProcessMemory cannot execute its database decryption methods; Desktop was not restarted or modified.')
if cdp is None:
if len(ports)!=1:raise RuntimeError('Multiple Desktop CDP ports; supply --cdp')
cdp='http://127.0.0.1:'+str(next(iter(ports)))
parsed=urllib.parse.urlsplit(cdp)
if (parsed.port or (443 if parsed.scheme=='https' else 80)) not in ports:
raise RuntimeError('Endpoint does not belong to a detected WhatsApp Desktop debugging port')
with urllib.request.urlopen(cdp+'/json/list',timeout=10) as response:targets=json.load(response)
pages=[t for t in targets if t.get('type')=='page' and urllib.parse.urlsplit(t.get('url','')).hostname=='web.whatsapp.com']
if len(pages)!=1:raise RuntimeError('Expected one WhatsApp Desktop page target')
url=pages[0]['webSocketDebuggerUrl']
if urllib.parse.urlsplit(url).hostname not in ('127.0.0.1','::1'):raise RuntimeError('Non-loopback WebSocket refused')
output=Path(output)
partial=output.with_suffix(output.suffix+'.partial')
report_path=output.with_suffix(output.suffix+'.report.json')
if output.exists() or partial.exists():raise RuntimeError('Output already exists; choose a new --output path')
output.parent.mkdir(parents=True,exist_ok=True)
report={'status':'running','rows':0,'row_errors':0,'snapshot_atomic':False,
'scope':'All encountered local message rows up to initial upper primary key; no phone sync',
'rea_evidence':'ev_8be2c4d68d5e268d7782a8098715202eaeda83625aa5b7f25277b09929f6995e'}
try:
with connect(url,max_size=16_000_000,open_timeout=10) as ws:
bounds=evaluate(ws,expression({'mode':'bounds'}),1)
report['initial_row_count']=bounds['count']
upper,after,ident=bounds['upper'],None,2
with partial.open('x',encoding='utf-8') as stream:
while upper is not None and after!=upper:
result=evaluate(ws,expression({'mode':'batch','upper':upper,'after':after,'batch':batch,'redact':redact}),ident)
ident+=1
if not result['keys']:break
if result['last']==after:raise RuntimeError('Cursor made no progress')
if len(result['messages'])!=len(result['keys']):raise RuntimeError('Incomplete batch response')
for message in result['messages']:
stream.write(json.dumps(message,ensure_ascii=False)+'\n')
report['rows']+=1
report['row_errors']+=int('error' in message)
stream.flush();after=result['last']
print(json.dumps({'exported_rows':report['rows'],'row_errors':report['row_errors']}),flush=True)
report['final_row_count']=evaluate(ws,expression({'mode':'bounds'}),ident)['count']
report['status']='completed_with_row_errors' if report['row_errors'] else 'completed_live_local_scan'
partial.rename(output)
report['output']=str(output.resolve())
except Exception:
report['status']='failed_partial_export'
raise
finally:
report_path.write_text(json.dumps(report,indent=2),encoding='utf-8')
return report
def main(argv=None):
p=argparse.ArgumentParser(description=__doc__)
p.add_argument('--cdp',type=endpoint)
p.add_argument('--batch-size',type=int,default=100)
p.add_argument('--redact-text',action='store_true')
p.add_argument('--output',type=Path,default=Path('whatsapp-local-history.jsonl'))
args=p.parse_args(argv)
if not 1<=args.batch_size<=500:p.error('--batch-size must be 1..500')
try:report=export(args.cdp,args.output,args.batch_size,args.redact_text)
except (RuntimeError,OSError) as error:print(str(error));return 2
print(json.dumps(report));return 0 if not report['row_errors'] else 1
if __name__=='__main__':raise SystemExit(main())
+43
View File
@@ -0,0 +1,43 @@
"""Bounded oldest-message reading through the app's JavaScript database API."""
PROBE = r'''(async () => {
if(location.origin!=='https://web.whatsapp.com')throw Error('Unexpected origin');
const table=require('WAWebSchemaMessage').getMessageTable();
const serializer=require('WAWebDBMessageSerialization');
if(!(await indexedDB.databases()).some(d=>d.name==='model-storage'))throw Error('Database absent');
const db=await new Promise((resolve,reject)=>{
const r=indexedDB.open('model-storage');
r.onupgradeneeded=()=>{r.transaction.abort();reject(Error('Refusing database upgrade'));};
r.onsuccess=()=>resolve(r.result);r.onerror=()=>reject(Error('Database open failed'));
});
let scanned=0;
try {
const oldest=await new Promise((resolve,reject)=>{
const tx=db.transaction('message','readonly'),store=tx.objectStore('message'),rows=[];
const r=store.openCursor();
r.onerror=()=>reject(Error('Cursor failed'));tx.onabort=()=>reject(Error('Transaction aborted'));
r.onsuccess=()=>{
const c=r.result;if(!c){resolve(rows);return;}
if(++scanned>100000) {reject(Error('History scan exceeds 100000 rows'));return;}
const t=c.value.t;
if(typeof t==='number'&&Number.isFinite(t)&&t>0){
rows.push({key:c.primaryKey,t});rows.sort((a,b)=>a.t-b.t);if(rows.length>5)rows.pop();
}
c.continue();
};
});
const messages=[];
for(const candidate of oldest){
const row=await table.get(candidate.key);
if(!row)throw Error('Selected row disappeared');
if(row.msgRowOpaqueData&&typeof row.msgRowOpaqueData==='object'&&'_data' in row.msgRowOpaqueData)
throw Error('Payload remains encrypted');
const m=serializer.messageFromDbRow(row);
messages.push({id:candidate.key,timestamp:m.t,type:m.type,
body:typeof m.body==='string'?m.body:null,caption:typeof m.caption==='string'?m.caption:null,
selected_timestamp:candidate.t});
}
return {scanned,sample_limit:5,source:'WAWebSchemaMessage.getMessageTable().get + WAWebDBMessageSerialization.messageFromDbRow',
cdp:false,messages};
}finally{db.close();}
})()'''
+110
View File
@@ -0,0 +1,110 @@
"""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())
+589
View File
@@ -0,0 +1,589 @@
"""Standalone fixed arithmetic and capability tests in the supported WhatsApp WebView.
Windows x64 and 64-bit Python; standard library only. The bundled native DLL is
hash-verified and cached in the workspace. Capability tests use fixed queries,
empty-input reads and simple getters. Explicit message-json and contacts
suites serialize the fixed search results or a five-contact sample.
There is no arbitrary JavaScript CLI and settings setters are never invoked.
"""
from __future__ import annotations
import argparse
import ctypes
from ctypes import wintypes as w
import hashlib
import json
import math
import os
from pathlib import Path
import tempfile
import time
import uuid
import sys
from .common import BASE, verified_helper
from .desktop import visible_ui_threads, detect_desktop_pid, check_core_build, SUPPORTED_BUILD, CORE_SHA256
WAKE = 0x8000 + 0x498
PROTOCOL = 0x4d415432
# SHA-256 of assets/math_probe.dll.
EMBEDDED_SHA256 = '136620d96f4745a4c41edfe1f4a9272ef25fc060ddace1c0eec1027bf252e02a'
CASES = (
('(17 * 23) - 19', 372),
('2 ** 10', 1024),
('Math.sqrt(144) + Math.cbrt(27)', 15),
('Math.hypot(3, 4)', 5),
('Math.sin(Math.PI / 2)', 1),
('[1,2,3,4,5].reduce((sum,n) => sum + n*n, 0)', 55),
('0.1 + 0.2', 0.3),
('(12345678901234567890n * 9n).toString()', '111111110111111111010'),
)
# Fixed probes only. Availability checks never invoke the selected member.
CAPABILITIES = (
('search.entry', 'search', 'WAWebMsgCollection', 'MsgCollection.search', 'search'),
('search.local', 'search', 'WAWebDBMessageFindLocal', 'msgFindSearch', 'presence'),
('search.fts', 'search', 'WAWebFtsClient', 'ftsClient.search', 'presence'),
('messages.by_id', 'messages', 'WAWebMsgCollection', 'MsgCollection.getMessagesById', 'empty_messages'),
('messages.hydrate', 'messages', 'WAWebMsgCollection', 'MsgCollection.hydrateOrGetMessages', 'empty_array'),
('messages.before', 'messages', 'WAWebDBMessageFindLocal', 'msgFindBefore', 'presence'),
('messages.after', 'messages', 'WAWebDBMessageFindLocal', 'msgFindAfter', 'presence'),
('messages.media', 'messages', 'WAWebDBMessageFindLocal', 'msgFindMedia', 'presence'),
('messages.serialization', 'messages', 'WAWebDBMessageSerialization', 'messageFromDbRow', 'presence'),
('storage.message_table', 'storage', 'WAWebSchemaMessage', 'getMessageTable', 'table_empty'),
('storage.indexeddb', 'storage', 'WAWebIndexedDB', 'default.open', 'presence'),
('storage.class', 'storage', 'WAWebStorage', 'Storage', 'presence'),
('storage.permanent', 'storage', 'WAWebPermanentStorage', 'default.getItem', 'presence'),
('settings.theme', 'settings', 'WAWebUserPrefsGeneral', 'getTheme', 'theme'),
('settings.theme_setter', 'settings', 'WAWebUserPrefsGeneral', 'setTheme', 'presence'),
('settings.dark_theme', 'settings', 'WAWebThemeSession', 'getIsDarkTheme', 'boolean'),
('settings.notifications', 'settings', 'WAWebUserPrefsNotifications', 'getGlobalNotifications', 'flag'),
('settings.notifications_setter', 'settings', 'WAWebUserPrefsNotifications', 'setGlobalNotifications', 'presence'),
('settings.privacy', 'settings', 'WAWebUserPrefsGeneral', 'getUserPrivacySettings', 'privacy_shape'),
('settings.auto_download', 'settings', 'WAWebUserPrefsGeneral', 'getAutoDownloadPhotos', 'boolean'),
('settings.archive', 'settings', 'WAWebSettingsGetters', 'getArchive', 'presence'),
('settings.apply', 'settings', 'WAWebSettingsBridgeApi', 'SettingsBridgeApi.applyAppSetting', 'presence'),
('settings.sync', 'settings', 'WAWebSettingsSyncBridge', 'sendSettingChange', 'presence'),
('chats.collection', 'chats', 'WAWebChatCollection', 'ChatCollection.get', 'presence'),
('contacts.collection', 'contacts', 'WAWebContactCollection', 'ContactCollection.get', 'presence'),
('bridge.ready', 'bridge', 'WAWebWindowsHybridBridgeFactory', 'getIsBridgeReady', 'boolean'),
('bridge.get', 'bridge', 'WAWebWindowsHybridBridgeFactory', 'getWindowsBridge', 'presence'),
('bridge.frontend', 'bridge', 'WAWebBackendApi', 'frontendSendAndReceive', 'presence'),
('bridge.worker', 'bridge', 'WAWebWorkerSafeBackendApi', 'workerSafeSendAndReceive', 'presence'),
)
def capability_expressions(probe_id):
key = '__whatsapp_fixed_capability_' + probe_id
specs = json.dumps(CAPABILITIES, separators=(',', ':'))
start = r'''(() => {
const key=__KEY__,id=__ID__,specs=__SPECS__;
const state={probe_id:id,state:'pending',checks:[]};
window[key]=state;
if(typeof require!=='function'){
state.state='error';state.error='WhatsApp module loader unavailable';return {probe_id:id,state:'started'};
}
async function bounded(fn){
let timer;
try{return await Promise.race([Promise.resolve().then(fn),new Promise((_,reject)=>{
timer=setTimeout(()=>reject(Error('Fixed getter/query timeout')),2500);
})]);}finally{clearTimeout(timer);}
}
(async()=>{
const loaded=new Map();
for(const [test,area,module,path,action] of specs){
const row={id:test,area,module,member:path,probe:action,invoked:false,passed:false};
try{
let exports=loaded.get(module);
if(!exports){exports=require(module);loaded.set(module,exports);}
const parts=path.split('.');let owner=exports;
// Meta's global require may unwrap a default-only module.
if(parts[0]==='default'&&parts.length>1&&exports?.default===undefined&&
typeof exports?.[parts[parts.length-1]]==='function'){
parts.shift();row.import_shape='default-export-unwrapped';
}
for(const part of parts.slice(0,-1))owner=owner?.[part];
const value=owner?.[parts[parts.length-1]];
row.available=typeof value==='function';
if(!row.available){
row.status='unavailable';
row.summary={module_type:typeof exports,default_type:typeof exports?.default,
direct_member_type:typeof exports?.[parts[parts.length-1]],
export_names:exports&&typeof exports==='object'?Object.keys(exports).slice(0,12):[]};
state.checks.push(row);continue;
}
if(action==='presence'){row.passed=true;row.status='available';}
else{
row.invoked=true;
if(action==='search'){
const result=await bounded(()=>value.call(owner,'meow',1,3));
row.summary={query:'meow',matches:Array.isArray(result?.messages)?result.messages.length:null,
canceled:result?.canceled===true,eof:result?.eof===true};
row.passed=Array.isArray(result?.messages)&&result?.canceled!==true;
}else if(action==='empty_messages'||action==='empty_array'){
const result=await bounded(()=>value.call(owner,[]));
const list=action==='empty_array'?result:result?.messages;
row.summary={input_count:0,returned_count:Array.isArray(list)?list.length:null};
row.passed=Array.isArray(list)&&list.length===0;
}else if(action==='table_empty'){
const table=value.call(owner);
row.summary={methods:['get','bulkGet','all','count'].filter(name=>typeof table?.[name]==='function')};
if(typeof table?.bulkGet!=='function')throw Error('Message table bulkGet unavailable in this context');
const result=await bounded(()=>table.bulkGet([]));
row.summary.input_count=0;row.summary.returned_count=Array.isArray(result)?result.length:null;
row.passed=Array.isArray(result)&&result.length===0;
}else{
const result=await bounded(()=>value.call(owner));
if(action==='privacy_shape'){
row.summary={type:typeof result,fields:result&&typeof result==='object'?Object.keys(result).sort():[]};
row.passed=result!==null&&typeof result==='object'&&!Array.isArray(result);
}else if(action==='theme'){
row.summary={type:typeof result};
if(['light','dark','system','auto'].includes(result))row.summary.value=result;
row.passed=typeof result==='string';
}else{
row.summary={type:typeof result};
row.passed=action==='boolean'?typeof result==='boolean':
result==null||typeof result==='boolean'||typeof result==='number';
}
}
row.status=row.passed?'passed':'unexpected-result';
}
}catch(error){row.status='error';row.error=String(error).slice(0,400);}
state.checks.push(row);
}
state.state='done';
})().catch(error=>{state.state='error';state.error=String(error).slice(0,400);});
return {probe_id:id,state:'started'};
})()'''.replace('__KEY__', json.dumps(key)).replace('__ID__', json.dumps(probe_id)).replace('__SPECS__', specs)
poll = '''(() => {const key=__KEY__,state=window[key];
if(!state)return {probe_id:__ID__,state:'error',error:'Probe state missing'};
if(state.state==='done'||state.state==='error')delete window[key];
return state;})()'''.replace('__KEY__', json.dumps(key)).replace('__ID__', json.dumps(probe_id))
remove = 'delete window[' + json.dumps(key) + ']'
if len(start.encode('utf-16-le')) // 2 >= 8192:
raise ValueError('Fixed capability probe exceeds native input buffer')
return start, poll, remove
def validate_capabilities(parsed, probe_id):
if parsed.get('probe_id') != probe_id or parsed.get('state') != 'done':
raise ValueError('Capability probe incomplete: ' + str(parsed.get('error', parsed.get('state'))))
checks = parsed.get('checks', [])
if [row.get('id') for row in checks] != [spec[0] for spec in CAPABILITIES]:
raise ValueError('Capability response does not match the fixed test list')
return checks
def data_expressions(probe_id, kind):
"""Fixed meow search or loaded-contact sample; no arbitrary query/code input."""
key = '__whatsapp_fixed_data_' + probe_id
start = r'''(() => {
const key=__KEY__,id=__ID__,kind=__KIND__;
const state={probe_id:id,state:'pending'};window[key]=state;
function snapshot(model){
if(typeof model?.serialize!=='function')throw Error('App model serializer unavailable');
return model.serialize();
}
Promise.resolve().then(async()=>{
if(typeof require!=='function')throw Error('WhatsApp module loader unavailable');
let payload;
if(kind==='message-json'){
const collection=require('WAWebMsgCollection').MsgCollection;
const result=await collection.search('meow',1,3);
if(!Array.isArray(result?.messages)||result?.canceled)throw Error('Search did not complete');
payload={probe_id:id,kind,query:'meow',matched_count:result.messages.length,
eof:result.eof===true,serializer:'BaseModel.serialize',messages:result.messages.map(snapshot)};
}else if(kind==='history-read'){
payload={probe_id:id,kind,...await __HISTORY_PROBE__};
}else{
const collection=require('WAWebContactCollection').ContactCollection;
if(typeof collection?.getModelsArray!=='function')throw Error('Contact getter unavailable');
const contacts=collection.getModelsArray();
if(!Array.isArray(contacts))throw Error('Contact getter returned an unexpected type');
payload={probe_id:id,kind,loaded_count:contacts.length,sample_limit:5,
serializer:'BaseModel.serialize',contacts:contacts.slice(0,5).map(snapshot)};
}
const seen=new WeakMap();let reference=0;
state.json=JSON.stringify(payload,function(name,value){
if(value===undefined)return {$type:'undefined'};
if(typeof value==='bigint')return {$type:'BigInt',decimal:value.toString()};
if(typeof value==='function')return {$type:'function',name:value.name};
if(value&&typeof value==='object'){
if(seen.has(value))return {$ref:seen.get(value)};
seen.set(value,++reference);
if(value instanceof ArrayBuffer)return {$type:'ArrayBuffer',bytes:Array.from(new Uint8Array(value))};
if(ArrayBuffer.isView(value))return {$type:value.constructor.name,bytes:Array.from(new Uint8Array(value.buffer,value.byteOffset,value.byteLength))};
if(value instanceof Map)return {$type:'Map',entries:Array.from(value.entries())};
if(value instanceof Set)return {$type:'Set',values:Array.from(value.values())};
}
return value;
});
state.state='ready';
}).catch(error=>{state.state='error';state.error=String(error).slice(0,400);});
return {probe_id:id,state:'started'};
})()'''.replace('__KEY__', json.dumps(key)).replace('__ID__', json.dumps(probe_id)).replace('__KIND__', json.dumps(kind))
from .history_read import PROBE as history_probe
start = start.replace('__HISTORY_PROBE__', history_probe)
poll = '''(() => {const value=window[__KEY__];if(!value)return {probe_id:__ID__,state:'error',error:'Data probe state missing'};
return {probe_id:value.probe_id,state:value.state,error:value.error,length:value.json?.length};})()'''.replace('__KEY__', json.dumps(key)).replace('__ID__', json.dumps(probe_id))
return start, poll, key, 'delete window[' + json.dumps(key) + ']'
def data_chunk_expression(key, offset):
return '''(() => {const value=window[__KEY__];if(value?.state!=='ready')throw Error('Data not ready');
const start=__OFFSET__;let end=Math.min(start+4096,value.json.length);
if(end<value.json.length&&value.json.charCodeAt(end-1)>=0xd800&&value.json.charCodeAt(end-1)<=0xdbff)end++;
return {probe_id:value.probe_id,offset:start,next:end,chunk:value.json.slice(start,end)};})()'''.replace('__KEY__', json.dumps(key)).replace('__OFFSET__', str(offset))
def extract_helper():
return verified_helper('math_probe.dll', EMBEDDED_SHA256, '.whatsapp-math-native', 'math_probe.dll')
def fixed_expression(probe_id):
values = ','.join('{expression:' + json.dumps(expression) + ',value:(' + expression + ')}'
for expression, _ in CASES)
return '({probe_id:' + json.dumps(probe_id) + ',cases:[' + values + ']})'
def validate_result(raw, probe_id):
parsed = json.loads(raw)
if parsed.get('probe_id') != probe_id or len(parsed.get('cases', [])) != len(CASES):
raise ValueError('Arithmetic response does not match this run')
results = []
for (expression, expected), observed in zip(CASES, parsed['cases']):
value = observed.get('value')
passed = observed.get('expression') == expression
if isinstance(expected, float):
passed = passed and type(value) in (int, float) and math.isclose(
value, expected, rel_tol=1e-12, abs_tol=1e-12)
else:
passed = passed and type(value) is type(expected) and value == expected
results.append(dict(expression=expression, expected=expected, actual=value, passed=passed))
return results
class Packet(ctypes.Structure):
_fields_ = [('state', w.LONG), ('hr', w.LONG), ('thread', w.DWORD),
('protocol', w.DWORD), ('script', w.WCHAR * 8192),
('result', w.WCHAR * 32768)]
def windows_api():
kernel = ctypes.WinDLL('kernel32', use_last_error=True)
user = ctypes.WinDLL('user32', use_last_error=True)
declarations = {
'CreateFileMappingW': ([w.HANDLE, ctypes.c_void_p, w.DWORD, w.DWORD, w.DWORD, w.LPCWSTR], w.HANDLE),
'MapViewOfFile': ([w.HANDLE, w.DWORD, w.DWORD, w.DWORD, ctypes.c_size_t], ctypes.c_void_p),
'UnmapViewOfFile': ([ctypes.c_void_p], w.BOOL),
'CloseHandle': ([w.HANDLE], w.BOOL),
'LoadLibraryW': ([w.LPCWSTR], w.HMODULE),
'FreeLibrary': ([w.HMODULE], w.BOOL),
'GetProcAddress': ([w.HMODULE, ctypes.c_char_p], ctypes.c_void_p),
'OpenProcess': ([w.DWORD, w.BOOL, w.DWORD], w.HANDLE),
'WaitForSingleObject': ([w.HANDLE, w.DWORD], w.DWORD),
}
for name, (arguments, returns) in declarations.items():
function = getattr(kernel, name)
function.argtypes = arguments
function.restype = returns
user.SetWindowsHookExW.argtypes = [ctypes.c_int, ctypes.c_void_p, w.HMODULE, w.DWORD]
user.SetWindowsHookExW.restype = w.HANDLE
user.UnhookWindowsHookEx.argtypes = [w.HANDLE]
user.UnhookWindowsHookEx.restype = w.BOOL
user.PostThreadMessageW.argtypes = [w.DWORD, w.UINT, w.WPARAM, w.LPARAM]
user.PostThreadMessageW.restype = w.BOOL
return kernel, user
def wait_for_state(packet, desired, seconds, wake, alive, phase):
deadline = time.monotonic() + seconds
while time.monotonic() < deadline:
if not alive():
raise RuntimeError('WhatsApp exited during ' + phase)
wake()
time.sleep(.025)
if packet.state == desired:
return
if packet.state == -1:
raise RuntimeError(phase + ': native error 0x' + format(packet.hr & 0xffffffff, '08x'))
if phase == 'startup':
raise TimeoutError('Helper startup not acknowledged within ' + str(seconds) +
's; no JavaScript requested. Check DLL path access in packaged WhatsApp.')
raise TimeoutError(phase + ' timed out (native state ' + str(packet.state) + ')')
def helper_loaded(pid, path):
"""Observe Windows' asynchronous hook-DLL unloading using module metadata."""
class ModuleEntry(ctypes.Structure):
_fields_ = [('dwSize', w.DWORD), ('th32ModuleID', w.DWORD),
('th32ProcessID', w.DWORD), ('GlblcntUsage', w.DWORD),
('ProccntUsage', w.DWORD), ('modBaseAddr', ctypes.c_void_p),
('modBaseSize', w.DWORD), ('hModule', w.HMODULE),
('szModule', w.WCHAR * 256), ('szExePath', w.WCHAR * 260)]
kernel = ctypes.WinDLL('kernel32', use_last_error=True)
kernel.CreateToolhelp32Snapshot.argtypes = [w.DWORD, w.DWORD]
kernel.CreateToolhelp32Snapshot.restype = w.HANDLE
kernel.CloseHandle.argtypes = [w.HANDLE]
kernel.CloseHandle.restype = w.BOOL
for name in ('Module32FirstW', 'Module32NextW'):
function = getattr(kernel, name)
function.argtypes = [w.HANDLE, ctypes.POINTER(ModuleEntry)]
function.restype = w.BOOL
snapshot = kernel.CreateToolhelp32Snapshot(8 | 16, pid)
if snapshot == ctypes.c_void_p(-1).value:
raise ctypes.WinError(ctypes.get_last_error())
try:
entry = ModuleEntry(dwSize=ctypes.sizeof(ModuleEntry))
present = kernel.Module32FirstW(snapshot, ctypes.byref(entry))
while present:
if os.path.normcase(entry.szExePath) == os.path.normcase(str(path)):
return True
present = kernel.Module32NextW(snapshot, ctypes.byref(entry))
error = ctypes.get_last_error()
if error != 18: # ERROR_NO_MORE_FILES
raise ctypes.WinError(error)
return False
finally:
kernel.CloseHandle(snapshot)
def run_math(pid=None, suite='math', _operation=None):
scopes = {'math': 'fixed-arithmetic-only', 'capabilities': 'fixed-capability-probes',
'all': 'fixed-arithmetic-and-capability-probes',
'message-json': 'fixed-meow-message-models', 'contacts': 'loaded-contact-sample',
'history-read': 'oldest-local-messages-through-javascript-no-cdp',
'history-export': 'all-local-message-rows-through-javascript-no-cdp',
'send-self': 'one-text-message-to-own-account-through-javascript',
'contacts-export': 'all-local-contact-records-through-javascript'}
report = {'passed': False, 'scope': scopes.get(suite, 'invalid-suite'),
'suite': suite, 'frida_used': False,
'startup_acknowledged': False, 'javascript_executed': False,
'cleanup': {'acknowledged': False, 'hook_removed': False},
'supported_build': SUPPORTED_BUILD}
kernel = user = None
module = hook = mapping = view = process = None
packet = None
remove_expression = None
execute = None
try:
if suite not in scopes:
raise ValueError('Unknown suite; choose math, capabilities, all, message-json or contacts')
if os.name != 'nt' or ctypes.sizeof(ctypes.c_void_p) != 8:
raise ValueError('Windows and 64-bit Python are required')
selected = detect_desktop_pid(pid)
thread = next(iter(visible_ui_threads(selected)))
report.update(pid=selected, ui_thread_id=thread, pid_auto_detected=pid is None)
kernel, user = windows_api()
process = kernel.OpenProcess(0x100000, False, selected) # SYNCHRONIZE only
if not process:
raise ctypes.WinError(ctypes.get_last_error())
dll, cache = extract_helper()
report.update(native_helper_path=str(dll), cache=cache)
ctypes.set_last_error(0)
mapping = kernel.CreateFileMappingW(w.HANDLE(-1), None, 4, 0, ctypes.sizeof(Packet),
'Local\\WhatsAppMathProbe-v2-' + str(selected))
mapping_error = ctypes.get_last_error()
if not mapping:
raise ctypes.WinError(mapping_error)
if mapping_error == 183:
raise ValueError('Another math probe owns this WhatsApp instance; no hook installed')
view = kernel.MapViewOfFile(mapping, 0xf001f, 0, 0, ctypes.sizeof(Packet))
if not view:
raise ctypes.WinError(ctypes.get_last_error())
packet = Packet.from_address(view)
packet.thread = thread
packet.protocol = PROTOCOL
packet.state = 3 # Startup only: native helper must acknowledge before any JS.
module = kernel.LoadLibraryW(str(dll))
if not module:
raise ctypes.WinError(ctypes.get_last_error())
callback = kernel.GetProcAddress(module, b'ProbeHook')
if not callback:
raise ctypes.WinError(ctypes.get_last_error())
hook = user.SetWindowsHookExW(3, callback, module, thread)
if not hook:
raise ctypes.WinError(ctypes.get_last_error())
def wake():
if not user.PostThreadMessageW(thread, WAKE, 0, 0):
raise ctypes.WinError(ctypes.get_last_error())
def alive():
status = kernel.WaitForSingleObject(process, 0)
if status == 0xffffffff:
raise ctypes.WinError(ctypes.get_last_error())
return status == 258
started = time.monotonic()
wait_for_state(packet, 4, 5, wake, alive, 'startup')
report.update(startup_acknowledged=True, startup_seconds=round(time.monotonic() - started, 3))
probe_id = uuid.uuid4().hex
report['probe_id'] = probe_id
def execute(expression, phase):
if len(expression.encode('utf-16-le')) // 2 >= 8192:
raise ValueError('Fixed probe exceeds native input buffer')
packet.script = expression
packet.result = ''
packet.hr = 0
packet.state = 0
wait_for_state(packet, 2, 20, wake, alive, phase)
report['javascript_executed'] = True
return packet.result
if suite in ('history-export', 'send-self', 'contacts-export'):
if _operation is None:
raise ValueError('Use the export-zip command for history export')
_operation(execute, probe_id, report)
report['operation_passed'] = True
if suite in ('math', 'all'):
report['tests'] = validate_result(execute(fixed_expression(probe_id), 'arithmetic'), probe_id)
report['math_passed'] = all(result['passed'] for result in report['tests'])
if suite in ('capabilities', 'all'):
start, poll, remove_expression = capability_expressions(probe_id)
initial = json.loads(execute(start, 'capability startup'))
if initial.get('probe_id') != probe_id or initial.get('state') != 'started':
raise ValueError('Capability startup response does not match this run')
deadline = time.monotonic() + 80
while time.monotonic() < deadline:
parsed = json.loads(execute(poll, 'capability poll'))
if parsed.get('state') != 'pending':
checks = validate_capabilities(parsed, probe_id)
report['capabilities'] = checks
report['capability_summary'] = {
'total': len(checks), 'passed': sum(row['passed'] for row in checks),
'invoked': sum(row['invoked'] for row in checks),
'unavailable': sum(row.get('status') == 'unavailable' for row in checks),
'errors': sum(row.get('status') == 'error' for row in checks),
}
report['capabilities_passed'] = all(row['passed'] for row in checks)
remove_expression = None # Poll deletes completed state in the page.
report['page_probe_state_removed'] = True
break
time.sleep(.1)
else:
raise TimeoutError('Fixed capability suite did not finish within 80 seconds')
if suite in ('message-json', 'contacts', 'history-read'):
start, poll, key, remove_expression = data_expressions(probe_id, suite)
initial = json.loads(execute(start, 'data startup'))
if initial.get('probe_id') != probe_id or initial.get('state') != 'started':
raise ValueError('Data probe startup does not match this run')
deadline = time.monotonic() + 30
while time.monotonic() < deadline:
metadata = json.loads(execute(poll, 'data poll'))
if metadata.get('probe_id') != probe_id:
raise ValueError('Data probe session mismatch')
if metadata.get('state') == 'error':
raise ValueError(str(metadata.get('error')))
if metadata.get('state') == 'ready':
break
time.sleep(.1)
else:
raise TimeoutError('Fixed data probe did not become ready')
length = metadata.get('length')
if type(length) is not int or not 0 < length <= 16 * 1024 * 1024:
raise ValueError('Data probe exceeds the bounded JSON transport size')
pieces = []
offset = 0
while offset < length:
part = json.loads(execute(data_chunk_expression(key, offset), 'data chunk'))
following = part.get('next')
if (part.get('probe_id') != probe_id or part.get('offset') != offset
or type(following) is not int or not offset < following <= length
or not isinstance(part.get('chunk'), str)):
raise ValueError('Invalid JSON transport chunk')
if len(part['chunk'].encode('utf-16-le', errors='surrogatepass')) // 2 != following - offset:
raise ValueError('JSON transport chunk length mismatch')
pieces.append(part['chunk'])
offset = following
data = json.loads(''.join(pieces))
if data.get('probe_id') != probe_id or data.get('kind') != suite:
raise ValueError('JSON payload does not match this data probe')
report['data'] = data
report['data_passed'] = isinstance(data.get('contacts' if suite == 'contacts' else 'messages'), list)
if suite == 'history-read':
report['data_passed'] = report['data_passed'] and bool(data['messages']) and all(
isinstance(row.get('timestamp'), (int, float)) and row['timestamp'] == row.get('selected_timestamp')
for row in data['messages'])
report['serialization_notes'] = (
'Declared model properties and nested collections through app serialize(); '
'undefined/BigInt/binary values are tagged. Repeated object references use traversal-order $ref IDs. '
'This is a model snapshot, not prototype methods or every private heap field.'
)
execute(remove_expression, 'data state cleanup')
remove_expression = None
report['page_probe_state_removed'] = True
except Exception as error:
report['error'] = str(error)
finally:
cleanup = report['cleanup']
if remove_expression and execute and packet is not None and packet.state in (2, 4, -1):
try:
execute(remove_expression, 'capability state cleanup')
report['page_probe_state_removed'] = True
except Exception as error:
cleanup['page_state_error'] = str(error)
if hook and packet is not None:
try:
packet.state = 6
wait_for_state(packet, 7, 5, wake, alive, 'cleanup')
cleanup['acknowledged'] = True
cleanup['async_hresult'] = '0x' + format(packet.hr & 0xffffffff, '08x')
if packet.hr < 0:
cleanup['error'] = 'Native cancel/close failed: ' + cleanup['async_hresult']
except Exception as error:
cleanup['error'] = str(error)
cleanup['hook_removed'] = bool(user.UnhookWindowsHookEx(hook))
if not cleanup['hook_removed']:
cleanup['unhook_error'] = str(ctypes.WinError(ctypes.get_last_error()))
elif not hook:
cleanup['not_started'] = True
for name, handle, function in [('module', module, 'FreeLibrary'),
('mapping_view', view, 'UnmapViewOfFile'),
('mapping_handle', mapping, 'CloseHandle'),
('process_handle', process, 'CloseHandle')]:
if handle:
ok = bool(getattr(kernel, function)(handle))
cleanup[name + '_released'] = ok
if not ok:
cleanup[name + '_error'] = str(ctypes.WinError(ctypes.get_last_error()))
if hook and cleanup['hook_removed']:
try:
deadline = time.monotonic() + 3
while helper_loaded(selected, dll) and time.monotonic() < deadline:
time.sleep(.05)
cleanup['native_helper_unloaded'] = not helper_loaded(selected, dll)
if not cleanup['native_helper_unloaded']:
cleanup['unload_error'] = 'Windows has not unloaded the hook DLL yet'
except Exception as error:
cleanup['unload_error'] = str(error)
suites_ok = ((suite not in ('math', 'all') or report.get('math_passed', False))
and (suite not in ('capabilities', 'all') or report.get('capabilities_passed', False))
and (suite not in ('message-json', 'contacts', 'history-read') or report.get('data_passed', False))
and (suite not in ('history-export', 'send-self', 'contacts-export') or report.get('operation_passed', False)))
report['passed'] = (suites_ok and 'error' not in report and cleanup['acknowledged']
and cleanup['hook_removed'] and not any(
key.endswith('error') for key in cleanup))
return report
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--suite', choices=('math', 'capabilities', 'all', 'message-json', 'contacts', 'history-read'), default='math',
help='Fixed tests (default: math); message-json prints meow matches, contacts prints a five-contact sample')
parser.add_argument('--pid', type=int, help='Optional supported Desktop host PID')
parser.add_argument('--output', type=Path)
args = parser.parse_args(argv)
if args.output is None:
args.output = BASE / ('whatsapp-' + args.suite + '-report.json')
report = run_math(args.pid, args.suite)
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(report, indent=2) + '\n', encoding='utf-8')
print(json.dumps(report, indent=2))
return 0 if report['passed'] else 1
if __name__ == '__main__':
raise SystemExit(main())
+183
View File
@@ -0,0 +1,183 @@
"""Bounded live native WebView search backend."""
import os
import json
from pathlib import Path
from .common import BASE, verified_helper
from .desktop import detect_desktop_pid, visible_ui_threads
EMBEDDED_PROBE_SHA256 = '911beb9f6bb52390cde8a75562ca1d6c46f4629f641426a5250286903de60895'
def build_native_probe():
"""Extract the bundled Windows x64 helper; no compiler or downloads."""
return verified_helper('search_probe.dll', EMBEDDED_PROBE_SHA256, '.whatsapp-native', 'probe.dll')[0]
def native_webview_test(pid, output, config, search_query=None, limit=50):
"""Python/Windows backend; no Frida import, server, agent or runtime."""
import ctypes
from ctypes import wintypes as w
import time
import hashlib
automatic_pid = pid is None
if automatic_pid:
pid = detect_desktop_pid()
threads = visible_ui_threads(pid)
if len(threads)!=1:
raise ValueError('Cannot identify one Desktop UI thread')
thread = next(iter(threads))
class Packet(ctypes.Structure):
_fields_=[('state',w.LONG),('hr',w.LONG),('thread',w.DWORD),('reserved',w.DWORD),
('script',w.WCHAR*8192),('result',w.WCHAR*32768)]
kernel=ctypes.WinDLL('kernel32',use_last_error=True)
user=ctypes.WinDLL('user32',use_last_error=True)
declarations = {
'CreateFileMappingW':([w.HANDLE,ctypes.c_void_p,w.DWORD,w.DWORD,w.DWORD,w.LPCWSTR],w.HANDLE),
'MapViewOfFile':([w.HANDLE,w.DWORD,w.DWORD,w.DWORD,ctypes.c_size_t],ctypes.c_void_p),
'UnmapViewOfFile':([ctypes.c_void_p],w.BOOL),
'CloseHandle':([w.HANDLE],w.BOOL),
'LoadLibraryW':([w.LPCWSTR],w.HMODULE),
'FreeLibrary':([w.HMODULE],w.BOOL),
'GetProcAddress':([w.HMODULE,ctypes.c_char_p],ctypes.c_void_p),
}
for name,(arguments,returns) in declarations.items():
function=getattr(kernel,name);function.argtypes=arguments;function.restype=returns
user.SetWindowsHookExW.argtypes=[ctypes.c_int,ctypes.c_void_p,w.HMODULE,w.DWORD]
user.SetWindowsHookExW.restype=w.HANDLE
user.UnhookWindowsHookEx.argtypes=[w.HANDLE];user.UnhookWindowsHookEx.restype=w.BOOL
user.PostThreadMessageW.argtypes=[w.DWORD,w.UINT,w.WPARAM,w.LPARAM]
user.PostThreadMessageW.restype=w.BOOL
# PID discovery checks the shipped native library hash before any target injection.
if pid != detect_desktop_pid():
raise ValueError('Explicit PID is not the validated active Desktop instance')
dll=build_native_probe()
module=hook=mapping=view=None
report={'pid':pid,'pid_auto_detected':automatic_pid,'backend':'python-win32-native',
'frida_used':False,'mode':config['mode'],'passed':False,'javascript_executed':False,
'ui_thread_id':thread,'native_helper_path':str(dll)}
report.update({'test_count':len(config.get('cases',[]))} if search_query is None else {'query':search_query,'limit':limit})
try:
mapping=kernel.CreateFileMappingW(w.HANDLE(-1),None,4,0,ctypes.sizeof(Packet),
'Local\\WhatsAppLocalProbe-'+str(pid))
mapping_error=ctypes.get_last_error()
if not mapping:raise ctypes.WinError(mapping_error)
if mapping_error==183:raise ValueError('Another native probe already owns this Desktop instance')
view=kernel.MapViewOfFile(mapping,0xf001f,0,0,ctypes.sizeof(Packet))
if not view:raise ctypes.WinError(ctypes.get_last_error())
packet=Packet.from_address(view);packet.thread=thread;packet.state=3
module=kernel.LoadLibraryW(str(dll))
if not module:raise ctypes.WinError(ctypes.get_last_error())
callback=kernel.GetProcAddress(module,b'ProbeHook')
if not callback:raise ctypes.WinError(ctypes.get_last_error())
hook=user.SetWindowsHookExW(3,callback,module,thread)
if not hook:raise ctypes.WinError(ctypes.get_last_error())
deadline=time.monotonic()+30
def execute(expression):
if len(expression.encode('utf-16-le'))//2>=8192:raise ValueError('Probe expression too long')
packet.script=expression;packet.result='';packet.hr=0;packet.state=0
while time.monotonic()<deadline:
if not user.PostThreadMessageW(thread,0x8000+0x497,0,0):
raise ctypes.WinError(ctypes.get_last_error())
time.sleep(.025)
if packet.state==1:report['javascript_executed']=True
if packet.state==-1:raise ValueError('Native HRESULT 0x'+format(packet.hr&0xffffffff,'08x'))
if packet.state==2:
report['javascript_executed']=True
return packet.result
if packet.state==0:
raise TimeoutError('Native hook did not acknowledge the request; check helper path accessibility in the packaged Desktop process')
raise TimeoutError('Timed out waiting for native WebView2 completion (state='+str(packet.state)+')')
result=execute(config['start'])
if config['mode']=='arithmetic':
if config.get('cases'):
import math
parsed=json.loads(result)
actual=parsed.get('cases',[])
checks=[]
if parsed.get('probe_id')!=config['probe_id'] or len(actual)!=len(config['cases']):
raise ValueError('Arithmetic result does not match this test session')
for expected,observed in zip(config['cases'],actual):
value=observed.get('value')
target=expected['expected']
same=observed.get('expression')==expected['expression']
if isinstance(target,float):
same=same and isinstance(value,(int,float)) and not isinstance(value,bool) and math.isclose(value,target,rel_tol=1e-12,abs_tol=1e-12)
else:
same=same and type(value) is type(target) and value==target
checks.append({'expression':expected['expression'],'expected':target,'actual':value,'passed':same})
report.update({'probe_id':config['probe_id'],'tests':checks,'passed':all(c['passed'] for c in checks)})
else:
report.update({'result':result,'passed':result=='2'})
else:
parsed=json.loads(result)
deadline = time.monotonic() + 30
while parsed.get('state') in ('started','pending'):
if time.monotonic() >= deadline:
raise TimeoutError('Desktop search did not finish within 30 seconds')
time.sleep(.2);parsed=json.loads(execute(config['poll']))
report.update({'search':parsed,'passed':parsed.get('state')=='done'})
except Exception as error:
report['error']=str(error)
finally:
if hook and view:
packet.state=6
close_deadline=time.monotonic()+3
while packet.state!=7 and time.monotonic()<close_deadline:
user.PostThreadMessageW(thread,0x8000+0x497,0,0);time.sleep(.025)
if packet.state!=7:report['cleanup_error']='Owner-thread cleanup did not acknowledge completion'
if hook:user.UnhookWindowsHookEx(hook)
if module:kernel.FreeLibrary(module)
if view:kernel.UnmapViewOfFile(view)
if mapping:kernel.CloseHandle(mapping)
output.parent.mkdir(parents=True,exist_ok=True)
output.write_text(json.dumps(report,indent=2)+'\n',encoding='utf-8')
print(json.dumps(report,indent=2))
if report.get('cleanup_error'):
report['passed'] = False
output.write_text(json.dumps(report,indent=2)+'\n',encoding='utf-8')
return 0 if report.get('passed') else 1
def run_live_probe(pid, output, search_query=None, limit=50):
"""Run the bounded native WebView2 probe without Frida."""
import uuid
if search_query is None:
raise ValueError('A search query is required; use the live math suite for arithmetic')
if not search_query.strip() or len(search_query) > 200 or not 1 <= limit <= 50:
raise ValueError('Search requires 1..200 characters and a limit of 1..50')
key = '__local_search_probe_' + uuid.uuid4().hex
# JSON serialization keeps the keyword a JavaScript string literal.
query_literal, key_literal = json.dumps(search_query), json.dumps(key)
start = r'''(() => {
const key=__KEY__,query=__QUERY__,limit=__LIMIT__;
if(typeof require!=='function')return {state:'error',error:'WhatsApp module loader unavailable'};
window[key]={state:'pending'};
Promise.resolve().then(()=>{
const collection=require('WAWebMsgCollection').MsgCollection;
if(!collection||typeof collection.search!=='function')throw Error('MsgCollection.search unavailable');
return collection.search(query,1,limit);
}).then(result=>{
const messages=Array.isArray(result.messages)?result.messages:[];
window[key]={state:'done',query,entry:'WAWebMsgCollection.MsgCollection.search',
returned:messages.length,eof:result.eof,canceled:result.canceled,
messages:messages.slice(0,limit).map(message=>{
const body=typeof message.body==='string'?message.body:'';
const caption=typeof message.caption==='string'?message.caption:'';
return {id:message.id?.toString()??null,chat_id:message.id?.remote?.toString()??null,
timestamp:message.t??null,type:message.type??null,
body_length:body.length,caption_length:caption.length,
contains_query:(body+'\n'+caption).toLowerCase().includes(query.toLowerCase())};
})};
}).catch(error=>{window[key]={state:'error',error:String(error),query};});
return {state:'started',query};
})()'''.replace('__KEY__',key_literal).replace('__QUERY__',query_literal).replace('__LIMIT__',str(limit))
poll = r'''(() => {
const key=__KEY__,value=window[key];
if(!value)return {state:'error',error:'Search state missing'};
if(value.state==='done'||value.state==='error')delete window[key];
return value;
})()'''.replace('__KEY__',key_literal)
config = {'mode': 'search', 'start': start, 'poll': poll}
return native_webview_test(pid, output, config, search_query, limit)
+128
View File
@@ -0,0 +1,128 @@
"""Request a real WhatsApp mobile-linking QR in an isolated background browser.
The official WhatsApp Web client creates the keys and requests the server ref.
This script reads the displayed QR payload and renders it locally. It does not
extract credentials from the existing Desktop account or implement fake pairing.
Requires: pip install websockets 'qrcode[pil]'
"""
from __future__ import annotations
import argparse, base64, datetime as dt, hashlib, json, os
from pathlib import Path
import socket, subprocess, sys, time, urllib.parse, urllib.request
import http.server,threading
from .common import BASE as ROOT
READ_QR=r'''(() => {
const element=Array.from(document.querySelectorAll('[data-ref]')).find(e=>{const v=e.getAttribute('data-ref');return v&&v.split(',').length>=4;});
const payload=element?.getAttribute('data-ref')??null;
let linked=false;
try {linked=!!require('WAWebUserPrefsMeUser').getMaybeMePnUser();}catch{}
let refreshRequested=false;
if(!linked&&!payload){const button=document.querySelector('[data-testid="link_device_qr_expired_refresh_button"]');if(button){button.click();refreshRequested=true;}}
return {payload,linked,refreshRequested,title:document.title,origin:location.origin};
})()'''
def chrome_path(explicit):
if explicit:return Path(explicit).resolve()
candidates=[Path(os.environ.get('PROGRAMFILES','C:/Program Files'))/'Google/Chrome/Application/chrome.exe',Path(os.environ.get('PROGRAMFILES(X86)','C:/Program Files (x86)'))/'Google/Chrome/Application/chrome.exe',Path(os.environ.get('LOCALAPPDATA',''))/'Google/Chrome/Application/chrome.exe']
return next((p for p in candidates if p.is_file()),None)
def call(url,expression):
from websockets.sync.client import connect
with connect(url,open_timeout=10,max_size=1_000_000) as ws:
ws.send(json.dumps({'id':1,'method':'Runtime.evaluate','params':{'expression':expression,'returnByValue':True}}))
while True:
result=json.loads(ws.recv(timeout=15))
if result.get('id')!=1:continue
if result.get('error') or result.get('result',{}).get('exceptionDetails'):raise RuntimeError('Page probe failed')
return result['result']['result'].get('value')
def describe(payload):
parts=payload.split(',')
lengths=[]
for part in parts[1:]:
try:lengths.append(len(base64.b64decode(part,validate=True)))
except Exception:lengths.append(None)
return {'field_count':len(parts),'payload_length':len(payload),'sha256':hashlib.sha256(payload.encode()).hexdigest(),'base64_field_byte_lengths':lengths}
def write_qr(payload,out):
import qrcode
qr=qrcode.QRCode(error_correction=qrcode.constants.ERROR_CORRECT_M,box_size=10,border=4)
qr.add_data(payload);qr.make(fit=True)
qr.make_image(fill_color='black',back_color='white').save(out/'pairing-qr.png')
# No raw QR payload, identity secret or session key is written to the report.
report={'timestamp_utc':dt.datetime.now(dt.timezone.utc).isoformat(),'status':'waiting_for_mobile_scan','source':'Official WhatsApp Web QR DOM in isolated headless Chrome','qr_metadata':describe(payload),'note':'QR rotates and requires the matching browser session to remain running.'}
(out/'pairing-status.json').write_text(json.dumps(report,indent=2),encoding='utf-8')
(out/'pairing-qr.html').write_text('<!doctype html><html><head><meta charset="utf-8"><meta http-equiv="refresh" content="3"><title>WhatsApp test pairing</title></head><body style="font-family:Arial;text-align:center"><h1>Link your own WhatsApp phone</h1><p>WhatsApp → Linked devices → Link a device.</p><img src="pairing-qr.png" style="max-width:90vw"><p>This QR links the isolated Python test session. The image updates while the script runs.</p></body></html>',encoding='utf-8')
def serve_viewer(out):
class Handler(http.server.SimpleHTTPRequestHandler):
def __init__(self,*a,**kw):super().__init__(*a,directory=str(out),**kw)
def log_message(self,*a):pass
def do_GET(self):
if urllib.parse.urlsplit(self.path).path not in ('/pairing-qr.html','/pairing-qr.png','/pairing-status.json'):
self.send_error(404);return
super().do_GET()
server=http.server.ThreadingHTTPServer(('127.0.0.1',0),Handler)
threading.Thread(target=server.serve_forever,daemon=True).start()
return server,f'http://127.0.0.1:{server.server_port}/pairing-qr.html'
def main(argv=None):
p=argparse.ArgumentParser(description=__doc__)
p.add_argument('--browser',type=Path);p.add_argument('--profile',type=Path,default=ROOT/'whatsapp-pairing-profile');p.add_argument('--output-dir',type=Path,default=ROOT/'whatsapp-pairing');p.add_argument('--watch-seconds',type=int,default=300);p.add_argument('--startup-seconds',type=int,default=90)
args=p.parse_args(argv);browser=chrome_path(args.browser)
if not browser or not browser.is_file():p.error('Chrome executable missing; supply --browser')
if not 10<=args.watch_seconds<=3600:p.error('--watch-seconds must be 10..3600')
profile=args.profile.resolve();out=args.output_dir.resolve()
if '5319275a.whatsappdesktop' in str(profile).lower():p.error('Refusing the existing WhatsApp Desktop profile')
marker=profile/'python-pairing-profile.json'
if profile.exists() and any(profile.iterdir()) and not marker.exists():p.error('Profile must be empty or created by this script')
profile.mkdir(parents=True,exist_ok=True);out.mkdir(parents=True,exist_ok=True)
marker.write_text(json.dumps({'purpose':'isolated official WhatsApp Web pairing test'}),encoding='utf-8')
with socket.socket() as sock:sock.bind(('127.0.0.1',0));port=sock.getsockname()[1]
command=[str(browser),'--headless=new','--no-first-run','--no-default-browser-check','--remote-debugging-address=127.0.0.1',f'--remote-debugging-port={port}',f'--user-data-dir={profile}','--window-size=1200,1000','https://web.whatsapp.com/']
flags=subprocess.CREATE_NO_WINDOW if sys.platform=='win32' else 0
proc=subprocess.Popen(command,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,creationflags=flags)
viewer,viewer_url=serve_viewer(out)
endpoint=f'http://127.0.0.1:{port}';(out/'pairing-runtime.json').write_text(json.dumps({'browser_pid':proc.pid,'cdp_endpoint':endpoint,'viewer_url':viewer_url,'profile':str(profile)},indent=2),encoding='utf-8')
start=time.monotonic();last_payload=None;generated=0;linked=False
print(f'Isolated background session started; QR output: {out}',flush=True)
print(f'REA observation endpoint: {endpoint}',flush=True)
print(f'QR viewer (auto-refresh): {viewer_url}',flush=True)
try:
while time.monotonic()-start<args.watch_seconds:
if proc.poll() is not None:raise RuntimeError('Background browser exited')
try:
with urllib.request.urlopen(endpoint+'/json/list',timeout=3) as response:targets=json.load(response)
target=next((t for t in targets if t.get('type')=='page' and urllib.parse.urlsplit(t.get('url','')).hostname=='web.whatsapp.com'),None)
if not target:time.sleep(1);continue
state=call(target['webSocketDebuggerUrl'],READ_QR)
except (OSError,StopIteration):time.sleep(1);continue
if not state or state.get('origin')!='https://web.whatsapp.com':
if time.monotonic()-start>args.startup_seconds:raise RuntimeError('WhatsApp page did not commit before startup timeout')
time.sleep(1);continue
if state.get('linked'):
linked=True;(out/'pairing-status.json').write_text(json.dumps({'status':'linked','timestamp_utc':dt.datetime.now(dt.timezone.utc).isoformat(),'profile':str(profile),'note':'Browser is kept alive until the watch timeout; credentials remain only in its isolated profile.'},indent=2),encoding='utf-8')
if last_payload is not None:print('Mobile linking confirmed.',flush=True);last_payload=None
elif state.get('payload') and state['payload']!=last_payload:
last_payload=state['payload'];write_qr(last_payload,out);generated+=1
print(f'QR refreshed ({generated}); scan pairing-qr.png from your own phone.',flush=True)
elif state.get('refreshRequested'):
print('Expired QR: requested a fresh reference through the official reload button.',flush=True)
elif not generated and time.monotonic()-start>args.startup_seconds:raise RuntimeError('No pairing QR appeared before startup timeout')
time.sleep(2)
return 0 if generated or linked else 1
except KeyboardInterrupt:return 0
finally:
viewer.shutdown();viewer.server_close()
try:
with urllib.request.urlopen(endpoint+'/json/version',timeout=3) as response:version=json.load(response)
with connect(version['webSocketDebuggerUrl'],open_timeout=3) as ws:ws.send(json.dumps({'id':1,'method':'Browser.close'}))
except Exception:proc.terminate()
try:proc.wait(timeout=10)
except subprocess.TimeoutExpired:proc.kill()
(out/'pairing-runtime.json').write_text(json.dumps({'running':False,'linked':linked,'qr_refreshes':generated},indent=2),encoding='utf-8')
if not linked and (out/'pairing-status.json').exists():
report=json.loads((out/'pairing-status.json').read_text());report['status']='expired_browser_closed';(out/'pairing-status.json').write_text(json.dumps(report,indent=2),encoding='utf-8')
if __name__=='__main__':raise SystemExit(main())
+91
View File
@@ -0,0 +1,91 @@
"""Find WhatsApp Desktop pairing QR payload candidates using read-only process RAM.
No screenshots, browser, debug connection or injected code. Finding a payload
does not establish that its server reference is active; no session is created.
"""
import argparse, base64, ctypes, datetime as dt, hashlib, json, re, struct, sys
from pathlib import Path
from .ram import Memory, discover
REA_EVIDENCE = 'ev_6c3a7930e51a95d162c3634ecdc0b73652cb640663681a7cb9962a5b45a9c1da'
PATTERN = re.compile(r'(?:https://wa\.me/settings/linked_devices#)?[A-Za-z0-9+/=_:-]{8,512},[A-Za-z0-9+/]{43}=,[A-Za-z0-9+/]{43}=,[A-Za-z0-9+/]{43}=,8(?:,https://faq\.whatsapp\.com/r/ld)?')
def valid_payload(value):
fields = value.removeprefix('https://wa.me/settings/linked_devices#').split(',')
if len(fields) not in (5, 6) or fields[4] != '8': return False
if len(fields) == 6 and fields[5] != 'https://faq.whatsapp.com/r/ld': return False
try: return all(len(base64.b64decode(f, validate=True)) == 32 for f in fields[1:4])
except ValueError: return False
def scan(mem, budget_mb):
scanned, seen = 0, set()
budget = budget_mb * 1024 * 1024
for region in mem.regions():
for offset in range(0, region['size'], 1024 * 1024):
if scanned >= budget: return
address = region['base'] + offset
size = min(1024 * 1024, region['size'] - offset, budget - scanned)
data = mem.read(address, min(size + 2048, region['size'] - offset))
scanned += size
variants = [(data.decode('latin-1'), 1, 0)]
if b',\x008\x00' in data:
for alignment in (0, 1):
tail = len(data) - (len(data) - alignment) % 2
variants.append((data[alignment:tail].decode('utf-16-le', errors='surrogatepass'), 2, alignment))
for text, width, alignment in variants:
for match in PATTERN.finditer(text):
payload = match.group()
if not valid_payload(payload): continue
start = address + alignment + match.start() * width
header = mem.read(start - 12, 12)
if len(header) != 12: continue
map_tag, _, length = struct.unpack('<III', header)
if not map_tag & 1 or length != len(payload) or (start - 12) % 4: continue
raw = payload.encode('latin-1' if width == 1 else 'utf-16-le')
if mem.read(start, len(raw)) != raw: continue
digest = hashlib.sha256(payload.encode()).hexdigest()
if digest in seen: continue
seen.add(digest)
yield payload, {'pid': mem.pid, 'string_address': hex(start - 12),
'encoding': 'Latin-1' if width == 1 else 'UTF-16LE',
'payload_sha256': digest, 'server_validity': 'unverified',
'validation': 'field lengths, candidate V8 string header, repeat memory read'}
def main(argv=None):
p = argparse.ArgumentParser(description=__doc__)
p.add_argument('--output-dir', type=Path, default=Path.cwd() / 'whatsapp-desktop-pairing-memory')
p.add_argument('--max-mb', type=int, default=512)
args = p.parse_args(argv)
import qrcode
if sys.platform != 'win32' or ctypes.sizeof(ctypes.c_void_p) != 8: p.error('64-bit Windows Python required')
if not 16 <= args.max_mb <= 4096: p.error('--max-mb must be 16..4096')
targets = discover()
candidates, diagnostics = [], []
args.output_dir.mkdir(parents=True, exist_ok=True)
for target in targets:
pid = target['ProcessId']
try: mem = Memory(pid)
except OSError as error:
diagnostics.append({'pid': pid, 'error': str(error)}); continue
try:
for payload, metadata in scan(mem, args.max_mb):
path = args.output_dir / ('pairing-candidate-' + metadata['payload_sha256'][:16] + '.png')
qrcode.make(payload).save(path)
metadata['file'] = str(path.resolve())
candidates.append(metadata)
diagnostics.append({'pid': pid, 'bytes_read': mem.read_bytes, 'debug_flags_present': target['DebugFlagsPresent']})
finally: mem.close()
report = {'timestamp_utc': dt.datetime.now(dt.timezone.utc).isoformat(),
'mode': 'ReadProcessMemory; running Desktop renderer descendants only',
'rea_static_evidence': REA_EVIDENCE, 'candidates': candidates, 'diagnostics': diagnostics,
'limitations': ['Payloads can be expired or unreachable heap objects; active registration is not verified.',
'Bounded scan of private readable memory; Cons/external strings or separately stored fields are not reconstructed.',
'No new server reference is requested; a linked app may have no pairing payload.',
'PNG candidates contain temporary pairing credentials. JSON omits raw payload and private keys.']}
path = args.output_dir / 'report.json'
path.write_text(json.dumps(report, indent=2), encoding='utf-8')
print(json.dumps({'status': 'unverified_candidates_saved' if candidates else 'no_pairing_payload_found',
'candidates': len(candidates), 'targets': len(targets), 'report': str(path.resolve())}))
return 0 if candidates else 2
if __name__ == '__main__': raise SystemExit(main())
+133
View File
@@ -0,0 +1,133 @@
"""Save the pairing QR displayed by the already-running WhatsApp Desktop app.
No browser, debug port, process injection, restart, or logout. Requires Pillow
and zxing-cpp. The official Desktop app must be displaying its linking QR.
"""
import argparse
import ctypes as C
from ctypes import wintypes as W
import hashlib
import json
from pathlib import Path
import subprocess
import time
user = C.WinDLL('user32', use_last_error=True)
gdi = C.WinDLL('gdi32', use_last_error=True)
user.GetDC.argtypes = [W.HWND]; user.GetDC.restype = W.HDC
user.ReleaseDC.argtypes = [W.HWND, W.HDC]
user.GetWindowThreadProcessId.argtypes = [W.HWND, C.POINTER(W.DWORD)]
user.GetWindowRect.argtypes = [W.HWND, C.POINTER(W.RECT)]
user.PrintWindow.argtypes = [W.HWND, W.HDC, W.UINT]
user.IsWindowVisible.argtypes = [W.HWND]
gdi.CreateCompatibleDC.argtypes = [W.HDC]; gdi.CreateCompatibleDC.restype = W.HDC
gdi.CreateCompatibleBitmap.argtypes = [W.HDC, C.c_int, C.c_int]; gdi.CreateCompatibleBitmap.restype = W.HBITMAP
gdi.SelectObject.argtypes = [W.HDC, W.HGDIOBJ]; gdi.SelectObject.restype = W.HGDIOBJ
gdi.DeleteObject.argtypes = [W.HGDIOBJ]
gdi.DeleteDC.argtypes = [W.HDC]
class Header(C.Structure):
_fields_ = [('size', W.DWORD), ('width', W.LONG), ('height', W.LONG),
('planes', W.WORD), ('bits', W.WORD), ('compression', W.DWORD),
('image_size', W.DWORD), ('xppm', W.LONG), ('yppm', W.LONG),
('used', W.DWORD), ('important', W.DWORD)]
class Info(C.Structure):
_fields_ = [('header', Header), ('colors', W.DWORD * 3)]
gdi.GetDIBits.argtypes = [W.HDC, W.HBITMAP, W.UINT, W.UINT, C.c_void_p, C.POINTER(Info), W.UINT]
def desktop_windows():
query = "@(Get-CimInstance Win32_Process -Filter \"Name='WhatsApp.Root.exe'\" | Select-Object -ExpandProperty ProcessId) | ConvertTo-Json -Compress"
result = subprocess.run(['powershell.exe', '-NoProfile', '-NonInteractive', '-Command', query], capture_output=True, text=True, check=True)
data = json.loads(result.stdout) if result.stdout.strip() else []
pids = set(data if isinstance(data, list) else [data])
windows = []
callback_type = C.WINFUNCTYPE(W.BOOL, W.HWND, W.LPARAM)
@callback_type
def callback(hwnd, _):
pid = W.DWORD()
user.GetWindowThreadProcessId(hwnd, C.byref(pid))
if pid.value in pids and user.IsWindowVisible(hwnd):
windows.append((hwnd, pid.value))
return True
user.EnumWindows.argtypes = [callback_type, W.LPARAM]
user.EnumWindows(callback, 0)
return windows
def capture(hwnd):
from PIL import Image
rect = W.RECT()
if not user.GetWindowRect(hwnd, C.byref(rect)):
return None
width, height = rect.right - rect.left, rect.bottom - rect.top
if not (100 <= width <= 10000 and 100 <= height <= 10000):
return None
screen = user.GetDC(hwnd)
dc = gdi.CreateCompatibleDC(screen)
bitmap = gdi.CreateCompatibleBitmap(screen, width, height)
old = gdi.SelectObject(dc, bitmap)
try:
if not user.PrintWindow(hwnd, dc, 2):
return None
gdi.SelectObject(dc, old)
info = Info(Header(C.sizeof(Header), width, -height, 1, 32, 0, 0, 0, 0, 0, 0))
pixels = C.create_string_buffer(width * height * 4)
if gdi.GetDIBits(dc, bitmap, 0, height, pixels, C.byref(info), 0) != height:
return None
return Image.frombytes('RGB', (width, height), pixels.raw, 'raw', 'BGRX')
finally:
gdi.SelectObject(dc, old)
gdi.DeleteObject(bitmap)
gdi.DeleteDC(dc)
user.ReleaseDC(hwnd, screen)
def pairing_payload(text):
import base64
parts = text.split(',')
if len(parts) not in (5, 6):
return False
try:
return bool(parts[0]) and all(len(base64.b64decode(p, validate=True)) == 32 for p in parts[1:4]) and parts[4].isdigit()
except ValueError:
return False
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--output', type=Path, default=Path.cwd() / 'whatsapp-desktop-qr.png')
parser.add_argument('--wait-seconds', type=int, default=0)
args = parser.parse_args(argv)
import zxingcpp
if not 0 <= args.wait_seconds <= 3600:
parser.error('--wait-seconds must be 0..3600')
deadline = time.monotonic() + args.wait_seconds
while True:
windows = desktop_windows()
for hwnd, pid in windows:
image = capture(hwnd)
if image is None:
continue
for code in zxingcpp.read_barcodes(image):
if code.format != zxingcpp.BarcodeFormat.QRCode or not pairing_payload(code.text):
continue
points = [code.position.top_left, code.position.top_right, code.position.bottom_left, code.position.bottom_right]
margin = 25
box = (max(0, min(p.x for p in points)-margin), max(0, min(p.y for p in points)-margin),
min(image.width, max(p.x for p in points)+margin), min(image.height, max(p.y for p in points)+margin))
cropped = image.crop(box)
if not any(c.text == code.text for c in zxingcpp.read_barcodes(cropped)):
continue
args.output.parent.mkdir(parents=True, exist_ok=True)
cropped.save(args.output, format='PNG')
print(json.dumps({'status': 'saved', 'source': 'running WhatsApp Desktop window', 'pid': pid,
'file': str(args.output.resolve()), 'payload_sha256': hashlib.sha256(code.text.encode()).hexdigest()}))
return 0
if time.monotonic() >= deadline:
print(json.dumps({'status': 'no_active_desktop_qr', 'windows_found': len(windows),
'note': 'Desktop must display its linking QR. A linked account has no login QR; GPU rendering or a minimized window can also prevent capture. No QR file was created or overwritten.'}))
return 2
time.sleep(2)
if __name__ == '__main__':
raise SystemExit(main())
+454
View File
@@ -0,0 +1,454 @@
"""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())
+78
View File
@@ -0,0 +1,78 @@
"""Search loaded WhatsApp Desktop message objects via read-only process RAM.
Does not invoke WhatsApp's native FTS engine or load older history. The query
matches Unicode-normalized, case-insensitive literal text in body/caption.
"""
from .common import normalized as normalize
import argparse, ctypes, datetime as dt, json, sys, unicodedata
from pathlib import Path
from .ram import Memory, discover, scan_markers, recover
EVIDENCE='ev_fd70aa06525b6f2259bf6d98bc879adb2472f63f2407a2d0d1186cc9e27b2f21'
def search_models(models,query,chat_id=None,redact=False):
needle=normalize(query)
if not needle.strip():raise ValueError('Query must contain non-whitespace text')
matches=[]
for model in models:
# Reject schema-only candidates without verified collection membership.
if not model.get('in_message_collection'):continue
if chat_id is not None and model.get('chat_id')!=chat_id:continue
fields=[field for field in ('body','caption') if isinstance(model.get(field),str) and needle in normalize(model[field])]
if not fields:continue
result=dict(model);result['matched_fields']=fields
if redact:
for field in ('body','caption'):
value=result.pop(field,None)
result[field+'_length']=len(value) if isinstance(value,str) else None
matches.append(result)
return matches
def main(argv=None):
p=argparse.ArgumentParser(description=__doc__)
p.add_argument('query')
p.add_argument('--chat-id')
p.add_argument('--max-mb',type=int,default=512)
p.add_argument('--max-models',type=int,default=10000)
p.add_argument('--redact-text',action='store_true')
p.add_argument('--output',type=Path,default=Path('whatsapp-process-search.json'))
args=p.parse_args(argv)
if sys.platform!='win32' or ctypes.sizeof(ctypes.c_void_p)!=8:p.error('64-bit Windows Python required')
if not args.query.strip():p.error('Query must contain non-whitespace text')
if not 16<=args.max_mb<=4096:p.error('--max-mb must be 16..4096')
if not 1<=args.max_models<=10000:p.error('--max-models must be 1..10000')
if args.output.exists():p.error('Output already exists; choose a new --output path')
targets=discover();results=[]
for target in targets:
pid=target['ProcessId']
try:mem=Memory(pid)
except OSError as error:
results.append({'pid':pid,'error':str(error),'matches':[]});continue
try:
regions=list(mem.regions())
markers=scan_markers(mem,regions,args.max_mb)
models,diagnostics=recover(mem,regions,markers,args.max_mb,args.max_models)
verified=[m for m in models if m.get('in_message_collection')]
matches=search_models(verified,args.query,args.chat_id,args.redact_text)
results.append({'pid':pid,'loaded_models_examined':len(verified),'matches':matches,
'diagnostics':diagnostics,'bytes_read':mem.read_bytes,
'model_limit_reached':len(models)>=args.max_models,
'debug_flags_present':target['DebugFlagsPresent']})
finally:mem.close()
report={'timestamp_utc':dt.datetime.now(dt.timezone.utc).isoformat(),
'mode':'ReadProcessMemory; literal search of collection-verified loaded Msg objects',
'whatsapp_native_search_invoked':False,'rea_search_evidence':EVIDENCE,
'results':results,'limitations':['Loaded message objects only; not the full IndexedDB or native FTS index.',
'No process execution, writes, injection, debugger, UI control, or new history query.',
'Live heap may move; the reader checks object layout and collection membership but is not an atomic snapshot.',
'Search uses NFC/casefold literal matching; it does not reproduce WhatsApp tokenizer, prefix matching or ranking.',
'No match is not evidence of absence from account history.']}
args.output.parent.mkdir(parents=True,exist_ok=True)
args.output.write_text(json.dumps(report,indent=2,ensure_ascii=False),encoding='utf-8')
print(json.dumps({'targets':len(results),'loaded_models_examined':sum(r.get('loaded_models_examined',0) for r in results),
'matches':sum(len(r['matches']) for r in results),'native_search_invoked':False,
'report':str(args.output.resolve())}))
return 0 if any(r.get('loaded_models_examined') for r in results) else 2
if __name__=='__main__':raise SystemExit(main())
+51
View File
@@ -0,0 +1,51 @@
"""Execute regression tests and write a machine-readable result."""
import argparse
import contextlib
import io
import json
from pathlib import Path
import time
import unittest
from .common import BASE, _BUNDLE
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--live', action='store_true', help='Also run fixed arithmetic probes in running WhatsApp Desktop')
parser.add_argument('--capabilities', action='store_true', help='Also run fixed Desktop capability checks (implies --live)')
parser.add_argument('--pid', type=int, help='Desktop host PID for live checks')
parser.add_argument('--output', type=Path, default=BASE / 'analysis/whatsapp-suite/results.json')
args = parser.parse_args(argv)
if args.pid is not None and not (args.live or args.capabilities):
parser.error('--pid requires --live or --capabilities')
started = time.monotonic()
if _BUNDLE:
from . import _regression
tests = unittest.defaultTestLoader.loadTestsFromModule(_regression)
else:
tests = unittest.defaultTestLoader.discover(str(BASE / 'tests'), pattern='test_whatsapp_*.py')
result = unittest.TextTestRunner(verbosity=2).run(tests)
report = {
'passed': result.wasSuccessful() and result.testsRun > 0,
'offline': {'tests': result.testsRun, 'failures': len(result.failures),
'errors': len(result.errors), 'skipped': len(result.skipped),
'failure_details': [{'test': str(test), 'detail': detail} for test, detail in result.failures + result.errors],
'skip_details': [{'test': str(test), 'reason': reason} for test, reason in result.skipped]},
'live': {'status': 'not_requested'},
}
if args.live or args.capabilities:
from .live import run_math
# Keep complete live probe data in the JSON report, with a compact console summary.
with contextlib.redirect_stdout(io.StringIO()):
try:
report['live'] = run_math(args.pid, 'all' if args.capabilities else 'math')
except Exception as error:
report['live'] = {'passed': False, 'error': str(error)}
report['passed'] = report['passed'] and report['live'].get('passed', False)
report['duration_seconds'] = round(time.monotonic() - started, 3)
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(report, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
print(json.dumps({'passed': report['passed'], 'tests': result.testsRun,
'skipped': len(result.skipped), 'report': str(args.output)}))
return 0 if report['passed'] else 1
+43
View File
@@ -0,0 +1,43 @@
"""Temporary app-scoped WebView2 debugging for the installed Desktop session."""
from contextlib import contextmanager
import json, socket, subprocess, time, urllib.request
KEY=r'Software\Policies\Microsoft\Edge\WebView2\AdditionalBrowserArguments'
APP='WhatsApp.Root.exe'
ACTIVATE=r"Get-Process -Name WhatsApp.Root -ErrorAction SilentlyContinue | Stop-Process; Start-Process -FilePath explorer.exe -ArgumentList 'shell:AppsFolder\5319275A.WhatsAppDesktop_cv1g1gvanyjgm!App' -WindowStyle Hidden"
def restart():
subprocess.run(['powershell.exe','-NoProfile','-NonInteractive','-Command',ACTIVATE],capture_output=True,text=True,check=True)
@contextmanager
def temporary_desktop_cdp():
import winreg
with socket.socket() as sock:
sock.bind(('127.0.0.1',0));port=sock.getsockname()[1]
key=winreg.CreateKeyEx(winreg.HKEY_CURRENT_USER,KEY,0,winreg.KEY_READ|winreg.KEY_SET_VALUE)
try:
try:old=winreg.QueryValueEx(key,APP)
except FileNotFoundError:old=None
flags=f'--remote-debugging-address=127.0.0.1 --remote-debugging-port={port}'
if old and old[1]!=winreg.REG_SZ:raise RuntimeError('Unexpected existing WebView2 policy value type')
if old and '--remote-debugging' in old[0]:raise RuntimeError('Existing debug policy requires explicit endpoint selection')
winreg.SetValueEx(key,APP,0,winreg.REG_SZ,((old[0]+' ') if old else '')+flags)
try:
print('Restarting WhatsApp Desktop with a temporary loopback execution interface.',flush=True)
restart();endpoint=f'http://127.0.0.1:{port}'
deadline=time.monotonic()+60
while time.monotonic()<deadline:
try:
with urllib.request.urlopen(endpoint+'/json/list',timeout=2) as response:targets=json.load(response)
if any(t.get('type')=='page' and t.get('url','').startswith('https://web.whatsapp.com/') for t in targets):
yield endpoint
return
except OSError:pass
time.sleep(1)
raise RuntimeError('Desktop did not expose its WhatsApp page within 60 seconds')
finally:
if old is None:winreg.DeleteValue(key,APP)
else:winreg.SetValueEx(key,APP,0,old[1],old[0])
restart()
print('Temporary setting restored; Desktop restarted normally.',flush=True)
finally:winreg.CloseKey(key)
+58
View File
@@ -0,0 +1,58 @@
"""Send one fixed test message exclusively to the signed-in account itself."""
import argparse
import datetime as dt
import json
from pathlib import Path
import uuid
from .live import run_math
from .zip_export import evaluate
PROBE = r'''(async () => {
const prefs=require('WAWebUserPrefsMeUser');
const me=prefs.getMaybeMeLidUser()??prefs.getMaybeMePnUser();
if(!me||!prefs.isMeAccount(me))throw Error('Own account identity unavailable');
const {chat}=await require('WAWebFindChatAction').findOrCreateLatestChat(me,'sendSelfTest');
if(!chat||!prefs.isMeAccount(chat.id))throw Error('Refusing to send outside own account');
const action=require('WAWebSendTextMsgChatAction');
const body=__BODY__;
const data=await action.createTextMsgData(chat,body,{});
if(!data?.id||!prefs.isMeAccount(data.id.remote))throw Error('Outgoing message is not addressed to self');
const id=data.id.toString();
await action.addAndSendTextMsg(chat,data);
const model=chat.msgs.get(data.id)??require('WAWebMsgCollection').MsgCollection.get(data.id);
const row=await require('WAWebSchemaMessage').getMessageTable().get(id);
const decoded=row?require('WAWebDBMessageSerialization').messageFromDbRow(row):null;
return {recipient_is_self:true,message_id:id,ack:model?.ack??null,
send_completed:true,persisted:!!row,plaintext_matches:decoded?.body===body,
timestamp:decoded?.t??null,body_length:body.length,cdp:false,ui_automation:false};
})()'''
def send_self(output, pid=None):
output=Path(output)
if output.exists():
raise ValueError('Receipt exists; choose a new output. Sending is never retried automatically.')
output.parent.mkdir(parents=True,exist_ok=True)
body='WhatsApp JS-Sendetest an mich selbst — '+dt.datetime.now().isoformat(timespec='seconds')+' — '+uuid.uuid4().hex[:8]
# Reserve the receipt before sending; a failed/uncertain attempt must be inspected before retrying.
output.write_text(json.dumps({'status':'reserved','test_message':body}),encoding='utf-8')
def operation(execute,session,report):
result=evaluate(execute,PROBE.replace('__BODY__',json.dumps(body)),session)
report['send_result']=result
if not (result.get('recipient_is_self') and result.get('send_completed') and result.get('persisted') and result.get('plaintext_matches')):
raise RuntimeError('Self-message send/readback verification failed; do not retry without checking the receipt')
report=run_math(pid,'send-self',_operation=operation)
report['test_message']=body
output.write_text(json.dumps(report,indent=2),encoding='utf-8')
return report
def main(argv=None):
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--pid',type=int)
parser.add_argument('--output',type=Path,default=Path('analysis/whatsapp-suite')/('self-message-'+dt.datetime.now().strftime('%Y%m%d-%H%M%S')+'.json'))
args=parser.parse_args(argv)
report=send_self(args.output,args.pid)
print(json.dumps({'passed':report['passed'],'error':report.get('error'),'send_result':report.get('send_result'),'receipt':str(args.output)}))
return 0 if report['passed'] else 1
+89
View File
@@ -0,0 +1,89 @@
"""Isolated WebView2 test host."""
import json
import pathlib
import shutil
import subprocess
import urllib.request
import zipfile
from .common import BASE
ROOT = BASE / "webview2-harmless-test"
VERSION = "1.0.2903.40"
SOURCE = r'''
using System;
using System.IO;
using System.Windows.Forms;
using Microsoft.Web.WebView2.Core;
using Microsoft.Web.WebView2.WinForms;
class Probe : Form {
WebView2 view = new WebView2();
string report;
Timer deadline = new Timer();
bool completed;
Probe(string path) {
report = path; ShowInTaskbar = false; Opacity = 0;
Controls.Add(view); view.Dock = DockStyle.Fill;
deadline.Interval = 45000;
deadline.Tick += (s,e) => Finish("{\"passed\":false,\"error\":\"timeout\"}", 2);
Shown += async (s,e) => {
deadline.Start();
try {
var env = await CoreWebView2Environment.CreateAsync(null,
Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "test-profile"));
await view.EnsureCoreWebView2Async(env);
var result = await view.CoreWebView2.ExecuteScriptAsync(
"({arithmetic:1+1,marker:'isolated-webview2-test',url:location.href})");
bool ok = result.Contains("\"arithmetic\":2") && result.Contains("isolated-webview2-test");
Finish("{\"passed\":" + (ok ? "true" : "false") +
",\"method\":\"CoreWebView2.ExecuteScriptAsync\",\"external_process_injection\":false," +
"\"result\":" + result + "}", ok ? 0 : 1);
} catch(Exception ex) {
File.WriteAllText(report + ".error.txt", ex.ToString());
Finish("{\"passed\":false,\"error\":\"See .error.txt\"}", 1);
}
};
}
void Finish(string data, int code) {
if(completed) return; completed=true; deadline.Stop();
File.WriteAllText(report,data); Environment.ExitCode=code;
view.Dispose(); Close();
}
[STAThread] static void Main(string[] args) {
Application.EnableVisualStyles(); Application.Run(new Probe(args[0]));
}
}
'''
def run_webview_test():
ROOT.mkdir(exist_ok=True)
package = ROOT / ('webview2-' + VERSION + '.nupkg')
if not package.exists():
urllib.request.urlretrieve(
'https://api.nuget.org/v3-flatcontainer/microsoft.web.webview2/' + VERSION +
'/microsoft.web.webview2.' + VERSION + '.nupkg', package)
with zipfile.ZipFile(package) as archive:
for name in ('lib/net462/Microsoft.Web.WebView2.Core.dll',
'lib/net462/Microsoft.Web.WebView2.WinForms.dll',
'runtimes/win-x64/native/WebView2Loader.dll'):
with archive.open(name) as src, (ROOT / pathlib.Path(name).name).open('wb') as dest:
shutil.copyfileobj(src, dest)
source = ROOT / 'Probe.cs'
source.write_text(SOURCE, encoding='utf-8')
exe = ROOT / 'Probe.exe'
compiler = r'C:\Windows\Microsoft.NET\Framework64\v4.0.30319\csc.exe'
subprocess.run([compiler, '/nologo', '/target:winexe', '/platform:x64',
'/out:' + str(exe), '/r:System.Windows.Forms.dll',
'/r:System.Drawing.dll',
'/r:' + str(ROOT / 'Microsoft.Web.WebView2.Core.dll'),
'/r:' + str(ROOT / 'Microsoft.Web.WebView2.WinForms.dll'),
str(source)], check=True, creationflags=subprocess.CREATE_NO_WINDOW)
report = ROOT / 'result.json'
report.unlink(missing_ok=True)
run = subprocess.run([str(exe), str(report)], cwd=ROOT, timeout=60,
creationflags=subprocess.CREATE_NO_WINDOW)
if report.exists():
print(json.dumps(json.loads(report.read_text(encoding='utf-8-sig')), indent=2))
else:
raise RuntimeError('Test host exited without a report: ' + str(run.returncode))
return run.returncode
+143
View File
@@ -0,0 +1,143 @@
"""Export all local message rows to ZIP via the native WebView JavaScript bridge.
No CDP, UI automation, app restart, or phone history sync. The ZIP contains
messages.jsonl and manifest.json; media binaries are not downloaded.
"""
import argparse
import datetime as dt
import json
import os
from pathlib import Path
import time
import zipfile
from .history import expression
from .live import data_chunk_expression, run_math
def evaluate(execute, value, session):
"""Run the fixed async database probe and stream its result across the bridge."""
key = '__whatsapp_zip_' + session
literal = json.dumps(key)
start = '''(() => {
const key=__KEY__,id=__ID__,state={probe_id:id,state:'pending'};window[key]=state;
Promise.resolve(__VALUE__).then(value=>{
state.json=JSON.stringify(value);state.state='ready';
}).catch(error=>{state.state='error';state.error=String(error).slice(0,500);});
return {probe_id:id,state:'started'};
})()'''.replace('__KEY__', literal).replace('__ID__', json.dumps(session)).replace('__VALUE__', value)
poll = '''(() => {const v=window[__KEY__];return {probe_id:v?.probe_id,state:v?.state,error:v?.error,length:v?.json?.length};})()'''.replace('__KEY__', literal)
try:
initial = json.loads(execute(start, 'export batch startup'))
if initial.get('probe_id') != session or initial.get('state') != 'started':
raise ValueError('Export startup response mismatch')
deadline = time.monotonic() + 60
while True:
meta = json.loads(execute(poll, 'export batch poll'))
if meta.get('probe_id') != session:
raise ValueError('Export session mismatch')
if meta.get('state') == 'error':
raise RuntimeError(meta.get('error', 'JavaScript export failed'))
if meta.get('state') == 'ready':
break
if time.monotonic() >= deadline:
raise TimeoutError('JavaScript export batch timed out')
time.sleep(.05)
length = meta.get('length')
if type(length) is not int or not 0 < length <= 16 * 1024 * 1024:
raise ValueError('Export batch exceeds 16 MiB transport bound; reduce --batch-size')
offset, parts = 0, []
while offset < length:
part = json.loads(execute(data_chunk_expression(key, offset), 'export batch chunk'))
following = part.get('next')
if (part.get('probe_id') != session or part.get('offset') != offset
or type(following) is not int or not offset < following <= length
or not isinstance(part.get('chunk'), str)
or len(part['chunk'].encode('utf-16-le', errors='surrogatepass')) // 2 != following-offset):
raise ValueError('Export transport chunk mismatch')
parts.append(part['chunk'])
offset = following
return json.loads(''.join(parts))
finally:
execute('delete window[' + literal + ']', 'export JavaScript state cleanup')
def write_messages(read, archive, batch, redact):
"""Stream a bounded keyset scan, preserving a record for every row error."""
bounds = read({'mode': 'bounds'})
count, upper = bounds['count'], bounds['upper']
after, rows, errors, seen = None, 0, 0, set()
with archive.open('messages.jsonl', 'w', force_zip64=True) as stream:
while upper is not None and after != upper:
page = read({'mode': 'batch', 'upper': upper, 'after': after, 'batch': batch, 'redact': redact})
keys, messages = page['keys'], page['messages']
if not keys:
raise RuntimeError('History cursor ended before the initial upper bound')
if len(keys) != len(messages) or page['last'] != keys[-1] or page['last'] == after:
raise RuntimeError('Invalid or nonprogressing history page')
for key, message in zip(keys, messages):
if key in seen or message.get('id') != key:
raise RuntimeError('Duplicate key or mismatched history message')
seen.add(key)
stream.write((json.dumps(message, ensure_ascii=True) + '\n').encode('utf-8'))
rows += 1
errors += int('error' in message)
after = page['last']
print(json.dumps({'exported_rows': rows, 'initial_rows': count, 'row_errors': errors}), flush=True)
final = read({'mode': 'bounds'})
return {'rows': rows, 'row_errors': errors, 'initial_row_count': count,
'final_row_count': final['count'], 'initial_upper_key': upper,
'reached_initial_upper': after == upper, 'snapshot_atomic': False,
'count_matches_initial': rows == count}
def export_zip(output, pid=None, batch=100, redact=False):
output = Path(output).resolve()
if not 1 <= batch <= 500:
raise ValueError('Batch size must be 1..500')
partial = output.with_suffix(output.suffix + '.partial')
report_path = output.with_suffix(output.suffix + '.report.json')
if output.exists() or partial.exists() or report_path.exists():
raise ValueError('Output or partial/report exists; select a new --output')
output.parent.mkdir(parents=True, exist_ok=True)
summary = {'cdp': False, 'ui_automation': False, 'transport': 'native WebView ExecuteScriptAsync',
'source': 'WhatsApp JavaScript message table getter and decoder',
'scope': 'All local message rows up to initial upper primary key; no phone sync or media download',
'created_utc': dt.datetime.now(dt.timezone.utc).isoformat(), 'text_redacted': redact}
def operation(execute, session, bridge):
with zipfile.ZipFile(partial, 'x', compression=zipfile.ZIP_DEFLATED, allowZip64=True) as archive:
summary.update(write_messages(lambda options: evaluate(execute, expression(options), session), archive, batch, redact))
bridge = run_math(pid, 'history-export', _operation=operation)
summary['bridge'] = bridge
summary['passed'] = bool(bridge['passed'] and summary.get('reached_initial_upper') and not summary.get('row_errors'))
summary['status'] = 'completed' if summary['passed'] else 'completed_with_errors' if bridge['passed'] else 'failed_partial_export'
try:
if bridge['passed']:
with zipfile.ZipFile(partial, 'a', compression=zipfile.ZIP_DEFLATED) as archive:
archive.writestr('manifest.json', json.dumps(summary, indent=2))
with zipfile.ZipFile(partial) as archive:
bad = archive.testzip()
if bad:
raise RuntimeError('ZIP integrity failed: ' + bad)
# Publish without overwriting an output created during the live scan.
os.link(partial, output)
partial.unlink()
summary['output'] = str(output)
else:
summary['partial_output'] = str(partial)
except Exception as error:
summary.update(passed=False, status='failed_partial_export', error=str(error), partial_output=str(partial))
report_path.write_text(json.dumps(summary, indent=2) + '\n', encoding='utf-8')
return summary
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--output', type=Path, default=Path('exports') / ('whatsapp-' + dt.datetime.now().strftime('%Y%m%d-%H%M%S') + '.zip'))
parser.add_argument('--pid', type=int)
parser.add_argument('--batch-size', type=int, default=100)
parser.add_argument('--redact-text', action='store_true')
args = parser.parse_args(argv)
report = export_zip(args.output, args.pid, args.batch_size, args.redact_text)
print(json.dumps({k: report[k] for k in ('passed', 'status', 'rows', 'row_errors', 'output', 'partial_output', 'error') if k in report}))
return 0 if report['passed'] else 1
+5
View File
@@ -0,0 +1,5 @@
"""Run the consolidated WhatsApp suite (same CLI as python -m whatsapp_suite)."""
from whatsapp_suite.cli import main
if __name__ == '__main__':
raise SystemExit(main())