From 6a13ddf8d19d4c6316e1579951d31dd1753717de Mon Sep 17 00:00:00 2001 From: Reese Norris Date: Tue, 28 Jul 2026 22:54:00 -0400 Subject: [PATCH] =?UTF-8?q?docs/clientinject:=20vPilot=203.12.1=20research?= =?UTF-8?q?=20gates=20(R1=E2=80=93R5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document free-slot / JWT site / config path / GeoVR findings for 3.12.1. Update embed profile when gates close; synthetic residual-scan fixtures. Honest open-gate status where PE analysis cannot confirm. --- .../research/vpilot-3.12.1-gates.md | 183 ++++++++++++++++++ .../client-injector/research/vpilot-3.12.1.md | 92 +++++++-- .../pepatch/residual_scan_test.go | 73 +++++++ .../testdata/residual_jwt_two_copies.bin | Bin 0 -> 189 bytes .../clientinject/profiles/vpilot-3.12.1.yaml | 21 +- internal/clientinject/research_pe_test.go | 174 +++++++++++++++++ .../clientinject/residual_fixture_test.go | 28 +++ .../testdata/residual_jwt_two_copies.bin | Bin 0 -> 189 bytes .../client-profiles/vpilot-3.12.1.yaml | 9 +- 9 files changed, 555 insertions(+), 25 deletions(-) create mode 100644 docs/client-injector/research/vpilot-3.12.1-gates.md create mode 100644 internal/clientinject/pepatch/residual_scan_test.go create mode 100644 internal/clientinject/pepatch/testdata/residual_jwt_two_copies.bin create mode 100644 internal/clientinject/research_pe_test.go create mode 100644 internal/clientinject/residual_fixture_test.go create mode 100644 internal/clientinject/testdata/residual_jwt_two_copies.bin diff --git a/docs/client-injector/research/vpilot-3.12.1-gates.md b/docs/client-injector/research/vpilot-3.12.1-gates.md new file mode 100644 index 0000000..f962b9d --- /dev/null +++ b/docs/client-injector/research/vpilot-3.12.1-gates.md @@ -0,0 +1,183 @@ +# vPilot 3.12.1 — research gate status (R1–R5) + +**Date:** 2026-07-28 +**PE:** `vPilot.exe` SHA-1 `7d95a7110392c15728143cc30e1f00899c686eb5` (size 1 236 416) +**Method:** local extract under gitignored `.research/vpilot/extracted/` (never committed); #US heap walk; UTF-16 residual scan; GeoVR DLL inventory; prior-art cross-check (`vpilot-patch-utility` 3.11.1, `openfsd-client-patch-utility`). +**Full notes:** [`vpilot-3.12.1.md`](./vpilot-3.12.1.md) + +| ID | Gate | Status | Profile impact | +|----|------|--------|----------------| +| **R1** | Free `#US` slot catalog + ldstr remap | **OPEN** (catalog documented; no safe unreferenced slots) | `us_free_slots: []` | +| **R2** | AFV connect `ret` CIL offset | **OPEN** | `afv_disable_pe.file_offset: null` | +| **R3** | Second fsd-jwt body `0xBA44A` liveness | **CLOSED — not live** | Keep primary only: `body_file_offsets: [0xB7988]` | +| **R4** | Config path resolution | **CLOSED** (candidates documented) | `config_files` still install-relative; multi-candidate is adapter discovery | +| **R5** | GeoVR voice base inventory | **CLOSED — no re-hardcode** | No extra DLL mutations required for AFV base URL | + +## Phase 0 readiness (honest) + +| Claim | OK? | +|-------|-----| +| Short-host lab JWT (in-place #US budget ≤71 body bytes; host ≤12 on default `/api/v1/fsd-jwt`) | **Yes** (profile offsets confirmed) | +| Production-length JWT hostnames without A8 | **No** — needs **R1** free-slot remap **or** openfsd **A8** short path (`/j` fixed, etc.) | +| AFV base retarget when URL fits #US budget 51 | **Yes** if host short enough; URL only in `vPilot.exe` (R5) | +| AFV PE `ret` disable fallback | **No** until R2 | +| “No residual stock JWT UTF-16 in whole PE” | **No** — dead copy at `0xBA44A` remains after primary patch (R3); residual HealthCheck must **allowlist** that offset or stay warn-level for full-PE scan | +| Config rewrite path | **Yes** for default install (`%LOCALAPPDATA%\vPilot\`); multi-candidate ordered list in R4 | + +**Do not market adapter complete** for arbitrary production hostnames while R1 is open (unless A8 is deployed and PreferShortJWTPath is used). + +--- + +## R1 — Free `#US` slot catalog (**OPEN**) + +### Findings + +- `#US` stream: file `0xA22E8`, size `0x15E04`; ~2040 decode-OK entries. +- **Zero** entries with body budget ≥71 have **ldstr reference count 0**. Every long slot is referenced at least once in CIL (`ldstr` token `0x72` + little-endian `(0x70<<24)|heap_offset`). +- 3.11.1 prior art remapped JWT `ldstr` to heap `0xD2A2` (sacrificing whatever string lived there). On **3.12.1**, `0xD2A2` is **mid-body** of another entry (`heap=0xD254`, message about remote events) — **not** a valid free entry start. **3.11.1 free-slot offsets are obsolete.** + +### Implication + +Remap on 3.12.1 requires **overwriting a live cosmetic (or other) string** and pointing the JWT `ldstr` at that heap offset. That is a product/UX trade-off, not a pure “unused” slot. Without runtime confirmation that sacrificing a given message is acceptable (and that Dotfuscator/call sites behave), **profile `us_free_slots` stays empty**. + +### Candidate sacrifice catalog (not profile-enabled) + +Single-`ldstr`, large budget, non-URL strings (sample; full scan in notes). Prefer **not** model-matching or voice-error strings for first experiments: + +| heap | body file | budget (UTF-16+term) | ldstr file | Stock (preview) | +|------|-----------|----------------------|------------|-----------------| +| `0x12EEE` | `0xB51D8` | 1053 | `0x3763C` | Config file updated to latest version… | +| `0x0DE5A` | `0xB0144` | 421 | `0x32F94` | Command failed. The active simulator was not found… | +| `0x06B9A` | `0xA8E84` | 387 | `0x1E7A7` | First-time run configuration message… | +| `0x03062` | `0xA534C` | 343 | `0x9144` | Unknown type code… | +| `0x01848` | `0xA3B32` | 207 | `0x3866` | Debug messages saved to disk… | +| `0x01DB9` | `0xA40A3` | 195 | `0x420E` | Model matching rule test complete… | + +**JWT ldstr to rewrite when remapping:** file `0x4BDB5` (`72 9F 56 01 70` → stock heap `0x1569F`). +Remap bytes: `72 70` with new heap offset. + +### Close criteria (still open) + +1. Pick ≥1 sacrifice slot; document accepted UX breakage. +2. Runtime smoke: connect openfsd with long JWT URL via remap. +3. Populate `us_free_slots` in embed profile + golden residual/plan tests. + +--- + +## R2 — AFV connect `ret` CIL offset (**OPEN**) + +### Findings + +| Item | 3.11.1 prior art | 3.12.1 | +|------|------------------|--------| +| AFV disable | `ret` (`0x2A`) at file `0x4BA54` | **Obsolete** — bytes at `0x4BA54` are unrelated (`DD …` leave / other CIL) | +| Voice base `ldstr` | (different heap) | **Confirmed** file `0x1F0A7` → heap `0x6D8D` (`https://voice1.vatsim.net`) | +| CLI | — | `-novoice` / `/novoice` present as #US CLI help strings | + +CIL around `0x1F0A7` loads the voice base URL into connection setup (`ldstr` + `ldftn`/`newobj` pattern) — **not** identified as a simple event-handler entry suitable for a one-byte `ret` patch without method-boundary confirmation. + +No ILSpy/ilspycmd run in this environment; **ConnectToVoiceServer** symbols live in **GeoVR.Connection.dll**, not as a clear single `ret` site in `vPilot.exe` matching 3.11.1. + +### Phase 0 fallback + +- Prefer AFV #US retarget when budget allows. +- Else launch flags `-novoice` / `/novoice`. +- **Do not** set `mutations.afv_disable_pe.file_offset` until a confirmed 3.12.1 handler start is known. + +### Close criteria + +Decompile or symbol-map the AFV connect path on 3.12.1; set `file_offset` + fixture test; only then enable PE disable mutation. + +--- + +## R3 — Second fsd-jwt body `0xBA44A` (**CLOSED — not live**) + +### Findings + +| Check | Primary `0xB7988` | Second `0xBA44A` | +|-------|-------------------|------------------| +| Inside `#US` heap (`0xA22E8`–`0xB80EC`) | **Yes** | **No** | +| Valid #US body terminal after UTF-16 | `0x01` (matches ECMA-335 for `fsd-jwt`) | **`0x04`** (not a #US terminal) | +| Compressed length prefix at body−1/2/4 | Yes (prefix at heap entry) | No consistent live #US header | +| PE section | `.text` | `.text` (not `.rsrc`) | +| LE refs to file offset / RVA / VA | none (expected; CLR uses tokens) | **none** | +| UTF-16 residual scan hits | 1 of 2 | 1 of 2 | + +Pre-context at `0xBA44A` is binary framing (`… 04 88 13 00 00 04 98 3A … 08 2D 43 1C EB E2 36 0A 3F 46` then UTF-16 URL) — consistent with **embedded managed resource / serialized constant data**, not the live `#US` entry consumed by `ldstr 0x4BDB5`. + +### Profile decision + +- **Do not** add `0xBA44A` to `strings.fsd_jwt.body_file_offsets`. +- Patching only the live `#US` body (+ length prefix) is correct for runtime JWT. +- Full-PE residual scan will still report `0xBA44A` after a successful primary patch → HealthCheck must treat it as a **documented dead hit** (allowlist) or remain **warn-level** until residual policy is implemented. This is **not** a reason to dual-write. + +### Residual-scan test policy + +Synthetic fixtures (two live-style UTF-16 copies) validate the scanner. Real PE research test (optional `research` tag) asserts exactly two hits at `0xB7988` and `0xBA44A` on stock 3.12.1. + +--- + +## R4 — Config path resolution (**CLOSED**) + +### Candidates (Windows, ordered) + +1. `{installRoot}/vPilotConfig.xml` — **default**: install root is `%LOCALAPPDATA%\vPilot\` (official non-admin layout); shipping extract places config beside `vPilot.exe`. +2. `%LOCALAPPDATA%\vPilot\vPilotConfig.xml` — same as (1) when install is default; still probe when user selected a **custom** install root. +3. Path recorded in a previous `.openfsd-client` / inject manifest (adapter/engine). +4. **Not observed** as a separate roaming `AppData\Roaming\vPilot` path in PE string inventory; PE #US includes `vPilotConfig.xml` and “configuration file was not found” messaging only (no alternate absolute path string). + +### Stock defaults (installer extract) + +Decrypt (3DES profile crypto) of extract `vPilotConfig.xml`: + +- `NetworkStatusURL` → `http://status.vatsim.net/` +- `CachedServers` → `AUTOMATIC|fsd.connect.vatsim.net` + +### Adapter behavior (normative from design) + +- Discover all existing candidates; rewrite existing only. +- If **zero** config files exist: blocker — run vPilot once to create config (do not invent a create path without further evidence). +- Profile `config_files[0].relative_path` remains `vPilotConfig.xml` (relative to install root). Multi-candidate logic is discovery code, not additional YAML rows, until a second distinct relative layout is proven. + +--- + +## R5 — GeoVR string inventory (**CLOSED**) + +Scanned install-tree managed binaries for `voice1.vatsim.net` / `https://voice1.vatsim.net` (ASCII + UTF-16): + +| Binary | `voice1.vatsim.net` | +|--------|---------------------| +| `vPilot.exe` | **Yes** (UTF-16; live #US + residual forms as above) | +| `GeoVR.Client.dll` | **No** | +| `GeoVR.Connection.dll` | **No** | +| `GeoVR.Shared.dll` | **No** | +| Other `*.dll` in extract | **No** | + +GeoVR.Connection exposes `ApiServerConnection`, `ConnectToVoiceServer`, etc. — base URL is supplied by the host (`vPilot.exe`), not re-hardcoded in GeoVR packages for 3.12.1 extract. + +**Conclusion:** AFV base retarget of the `vPilot.exe` #US slot is **sufficient** for stock voice URL redirection. No GeoVR DLL mutations required for R5. (Does not close R2 PE-disable.) + +--- + +## Fingerprint re-check (this gate pass) + +| Artifact | SHA-1 | +|----------|-------| +| `vPilot.exe` | `7d95a7110392c15728143cc30e1f00899c686eb5` | +| Installer (recorded) | `48820cb593c6cef8325a331c763316a8b33a501b` | + +Confirmed live #US map unchanged from research seed: + +| String | heap | body | ldstr | budget | +|--------|------|------|-------|--------| +| fsd-jwt | `0x1569F` | `0xB7988` | `0x4BDB5` | 71 | +| AFV base | `0x6D8D` | `0xA9076` | `0x1F0A7` | 51 | +| fsd auto HTTP | `0x15751` | `0xB7A3A` | `0x4C0B2` | 45 | + +--- + +## What was intentionally not committed + +- Any `vPilot.exe` / GeoVR / installer PE or DLL +- Local analysis scripts under `/tmp` or `.research/` +- Populated `us_free_slots` or AFV `ret` offset without confirmation diff --git a/docs/client-injector/research/vpilot-3.12.1.md b/docs/client-injector/research/vpilot-3.12.1.md index cd2e12f..52c6497 100644 --- a/docs/client-injector/research/vpilot-3.12.1.md +++ b/docs/client-injector/research/vpilot-3.12.1.md @@ -4,7 +4,9 @@ **Installer:** `vPilot-Setup-3.12.1.exe` (NSIS) **Legal posture:** Research notes + hashes only. **Never commit or redistribute** vPilot binaries. Users install from [vpilot.rosscarlson.dev](https://vpilot.rosscarlson.dev/Download). -Tracked profile: `third_party/client-profiles/vpilot-3.12.1.yaml` +Tracked profile (embed canonical): `internal/clientinject/profiles/vpilot-3.12.1.yaml` +Mirror metadata: `third_party/client-profiles/vpilot-3.12.1.yaml` +**Gate status (R1–R5):** [`vpilot-3.12.1-gates.md`](./vpilot-3.12.1-gates.md) ## Fingerprints @@ -13,7 +15,7 @@ Tracked profile: `third_party/client-profiles/vpilot-3.12.1.yaml` | Installer | `48820cb593c6cef8325a331c763316a8b33a501b` | `528a51bf0e71ada11103314d18a9fc0321afac94e2706f2fb13e0fbf5bb4beaa` | | `vPilot.exe` | `7d95a7110392c15728143cc30e1f00899c686eb5` | `c743f204929309db6f49e8b543db2d661ad09c32a2a7bc71fe419c685d244bff` | -Default install directory: `%LOCALAPPDATA%\vPilot\` (per official docs — no admin required). +Default install directory: `%LOCALAPPDATA%\vPilot\` (per official docs — no admin required). Size of primary PE: **1 236 416** bytes. ## Stack @@ -27,11 +29,11 @@ Default install directory: `%LOCALAPPDATA%\vPilot\` (per official docs — no ad | Surface | Stock value (3.12.1) | openfsd target | Notes | |---------|----------------------|----------------|-------| -| FSD JWT auth | `https://auth.vatsim.net/api/fsd-jwt` | `https://{host}/api/v1/fsd-jwt` | Hardcoded #US string; TLS required | +| FSD JWT auth | `https://auth.vatsim.net/api/fsd-jwt` | `https://{host}/api/v1/fsd-jwt` (or short A8 path) | Hardcoded #US string; TLS required | | Network status / server list | Config `NetworkStatusURL` → `http://status.vatsim.net/` | `https://{host}/api/v1/data/status.txt` | 3DES-obfuscated in `vPilotConfig.xml` | | Cached servers | `AUTOMATIC\|fsd.connect.vatsim.net` | e.g. `OPENFSD\|fsd.example.com` | Same config crypto; `NAME\|host` | | Automatic server HTTP | `http://fsd.vatsim.net/` | optional / unused if cached list set | "best server" HTTP endpoint (changelog 3.4.10+) | -| AFV REST base | `https://voice1.vatsim.net` | openfsd `AFV_API_PUBLIC_BASE_URL` | Hardcoded #US; prior patch utilities **disabled** AFV instead of retargeting | +| AFV REST base | `https://voice1.vatsim.net` | openfsd `AFV_API_PUBLIC_BASE_URL` | Hardcoded #US in **vPilot.exe only** (R5); prior patch utilities often **disabled** AFV instead | | Flight plan browser | `https://my.vatsim.net/pilots/flightplan` | optional openfsd/web | Cosmetic / UX | | Model matching CDN | `vpilot.rosscarlson.dev/ModelMatchingData/*` | leave alone | Not openfsd-related | | Updates | `vpilot.rosscarlson.dev` VersionCheck | leave alone | | @@ -54,9 +56,20 @@ Source of truth for this algorithm: archived `renorris/vpilot-patch-utility` (`c `Public.key` in install tree is an EC P-256 SPKI (licensing/update verify — not the config field cipher). +### Config path resolution (R4 — closed) + +Ordered candidates: + +1. `{installRoot}/vPilotConfig.xml` (default install root = `%LOCALAPPDATA%\vPilot`) +2. `%LOCALAPPDATA%\vPilot\vPilotConfig.xml` when install root ≠ default +3. Previous inject manifest path +4. No separate Roaming path observed in PE strings + +Rewrite **existing** files only; if none exist, require user to run vPilot once. See gate file for adapter rules. + ## CLR #US / CIL ldstr map (3.12.1 `vPilot.exe`) -`#US` stream file offset: `0xA22E8` (size `0x15E04`). +`#US` stream file offset: `0xA22E8` (size `0x15E04`). PE section: `.text`. | Logical string | #US heap offset (header) | Body file offset | ldstr CIL file offset(s) | Payload byte budget (UTF-16+term) | |----------------|--------------------------|------------------|--------------------------|----------------------------------| @@ -64,18 +77,50 @@ Source of truth for this algorithm: archived `renorris/vpilot-patch-utility` (`c | `https://voice1.vatsim.net` | `0x6D8D` | `0xA9076` | `0x1F0A7` | 51 | | `http://fsd.vatsim.net/` | `0x15751` | `0xB7A3A` | `0x4C0B2` | 45 | -Second copy of fsd-jwt UTF-16 body at `0xBA44A` — header walk failed (likely resource/non-#US); treat primary #US entry + ldstr `0x4BDB5` as authoritative until second site proven live. +### Second UTF-16 fsd-jwt site (R3 — closed: not live) + +Full-PE UTF-16 scan finds **exactly two** copies of stock `https://auth.vatsim.net/api/fsd-jwt`: + +| File offset | In #US? | Terminal after UTF-16 | Verdict | +|-------------|---------|----------------------|---------| +| `0xB7988` | Yes | `0x01` (valid #US) | **Live** — only patch site | +| `0xBA44A` | No | `0x04` (invalid #US) | **Dead** embedded data; do **not** dual-write | + +No LE references to `0xBA44A` as file offset/RVA/VA. Residual full-PE scanners will still hit the dead copy after a successful primary patch — treat as allowlisted dead hit (see gates doc). **3.11.1 offsets are obsolete** (e.g. old fsd-jwt heap `0xD2A2` / ldstr `0x4B3C5` do not match 3.12.1). Profiles must be **version-pinned by binary hash**. -### Prior-art AFV strategy +### Free #US slots / ldstr remap (R1 — open) -`openfsd-client-patch-utility` example for vPilot 3.11.1 **disables AFV** (`ret` at voice connect handler) rather than rewriting voice URL. openfsd now has native `-afv`; design should prefer **retarget voice base URL** when space allows, with fallback disable. +- ~2040 decode-OK `#US` entries; **no** long slot (budget ≥71) is unreferenced by CIL `ldstr`. +- Remap therefore means **sacrificing** a live cosmetic (or other) string, then rewriting JWT `ldstr` at `0x4BDB5` to the sacrifice heap token. +- Candidate catalog lives in [`vpilot-3.12.1-gates.md`](./vpilot-3.12.1-gates.md). Profile `us_free_slots` remains **empty** until a sacrifice is runtime-validated. +- Production-length JWT hostnames need **R1 and/or A8** (short JWT path on openfsd). Phase 0 short-host lab only otherwise (max host length **12** on default `/api/v1/fsd-jwt` with body budget 71). + +### Prior-art AFV strategy (R2 — open) + +`openfsd-client-patch-utility` example for vPilot **3.11.1** disables AFV (`ret` `0x2A` at file `0x4BA54`) rather than rewriting voice URL. On **3.12.1** that file offset is **not** a valid disable site. + +openfsd prefers **retarget** voice base URL when space allows, with fallback **`-novoice`**. PE `ret` disable stays `file_offset: null` until R2 closes. fsd-jwt stock URL length limits padded #US overwrite; longer openfsd URLs need either: + 1. shorter public base host, or -2. ldstr remap to a longer unused #US slot (3.11.1 technique: `0x72 XX XX XX 70`), or -3. runtime string/hook injection without shrinking constraint. +2. server short-path alias (**A8**, e.g. fixed `POST /j`), or +3. ldstr remap to a longer sacrifice slot (R1), or +4. later-phase runtime/string hook strategies. + +## GeoVR inventory (R5 — closed) + +| Binary | Contains `voice1.vatsim.net`? | +|--------|-------------------------------| +| `vPilot.exe` | Yes (UTF-16) | +| `GeoVR.Client.dll` | No | +| `GeoVR.Connection.dll` | No | +| `GeoVR.Shared.dll` | No | +| Other extract DLLs | No | + +AFV base retarget of `vPilot.exe` alone is sufficient for the stock voice URL. GeoVR still hosts `ConnectToVoiceServer` / `ApiServerConnection` machinery; URL is host-supplied. ## Prior art (same author ecosystem) @@ -85,7 +130,7 @@ fsd-jwt stock URL length limits padded #US overwrite; longer openfsd URLs need e | [renorris/openfsd-client-patch-utility](https://github.com/renorris/openfsd-client-patch-utility) | Multi-client static patcher (vPilot/xPilot/Euroscope/vatSys) | Active reference | | openfsd wiki `Client-Connection.md` | Points operators at those tools | Exists | -**Gap:** static on-disk patchers are CLI-centric, version-brittle, AFV-hostile (disable), and not a first-class openfsd module with a multi-client GUI. User request: **runtime injector** + **generic client picker GUI**. +**Gap:** static on-disk patchers are CLI-centric, version-brittle, AFV-hostile (disable), and not a first-class openfsd module with a multi-client GUI. ## Injection strategy spectrum (for design) @@ -96,7 +141,7 @@ fsd-jwt stock URL length limits padded #US overwrite; longer openfsd URLs need e 5. **Local control-plane proxy** — no PE touch: hosts or system proxy to fake `auth.vatsim.net`, `voice1.vatsim.net`, status; FSD via cached server IP. TLS MITM needs local CA (heavy UX). 6. **Plugin** — plugin surface is events, not network endpoint config; unlikely sufficient. -Recommended design direction (to be confirmed in design doc): **adapter interface per client**, default vPilot adapter combining (a) config rewrite, (b) versioned #US/CIL profile for JWT+AFV, (c) optional AFV disable fallback; GUI is **client-agnostic** (select client → locate install → bind openfsd endpoints → Apply/Revert/Launch). +Recommended design direction: **adapter interface per client**, default vPilot adapter combining (a) config rewrite, (b) versioned #US/CIL profile for JWT+AFV, (c) optional AFV disable fallback; GUI is **client-agnostic**. ## Legal / product constraints @@ -108,16 +153,21 @@ Recommended design direction (to be confirmed in design doc): **adapter interfac ## Research method used -- Downloaded official NSIS installer; extracted with 7-Zip. -- PE/CLR metadata walk for `#US`; UTF-16 string inventory; ldstr token search. -- openssl 3DES-ECB decrypt of default config fields. +- Downloaded official NSIS installer; extracted with 7-Zip into gitignored `.research/vpilot/`. +- PE/CLR metadata walk for `#US`; UTF-16 string inventory; ldstr token search (`72` + `70` table). +- openssl / Go 3DES-ECB decrypt of default config fields. - Cross-check with archived Go patch utilities and openfsd AFV/FSD docs. -- Full ILSpy decompilation not run in this environment (no `dotnet`/ilspycmd); GeoVR method names recovered via managed metadata strings (`ApiServerConnection`, `AddCallsign`, `ConnectToVoiceServer`, etc.). +- GeoVR DLLs scanned for voice host (ASCII + UTF-16). +- Full ILSpy decompilation not run in this environment (no `dotnet`/ilspycmd); method names recovered via managed metadata strings where present. +- Optional CI-local revalidation: `go test -tags=research ./internal/clientinject/...` when extract PE is present (see `research_pe_test.go`). -## Follow-ups for implementer +## Follow-ups -- [ ] Confirm second fsd-jwt site and automatic-server HTTP usage path on live connect. -- [ ] Measure max openfsd URL length vs #US budgets; document remap free-slot catalog per version. -- [ ] Locate AFV connect handler CIL for 3.12.1 disable-fallback (`ret` site was 3.11.1-specific). -- [ ] User-settings store path beyond install dir (`%LOCALAPPDATA%` may hold runtime config copy). +- [x] R3: classify second fsd-jwt site (`0xBA44A`) — **not live**; primary only. +- [x] R4: config path candidates — install root + LOCALAPPDATA fallback. +- [x] R5: GeoVR re-hardcode check — **none**. +- [ ] R1: runtime-validate a sacrifice free slot; populate `us_free_slots`. +- [ ] R2: locate AFV connect handler CIL for 3.12.1 `ret` disable. +- [ ] Residual HealthCheck allowlist for dead `0xBA44A` when implementing adapter-complete policy. - [ ] Antivirus / code-signing interaction when rewriting signed `vPilot.exe`. +- [ ] R6 (separate): CachedServers `host:port` acceptance on 3.12.1. diff --git a/internal/clientinject/pepatch/residual_scan_test.go b/internal/clientinject/pepatch/residual_scan_test.go new file mode 100644 index 0000000..682eb33 --- /dev/null +++ b/internal/clientinject/pepatch/residual_scan_test.go @@ -0,0 +1,73 @@ +package pepatch + +import ( + "os" + "path/filepath" + "testing" +) + +// Stock VATSIM FSD JWT URL used by residual full-PE scans (vPilot 3.12.1). +const stockFSDJWT = "https://auth.vatsim.net/api/fsd-jwt" + +// TestResidualScan_SyntheticTwoCopies loads a synthetic fixture with two +// UTF-16LE copies of the stock JWT string (one framed like a live #US body, +// one framed like the dead 0xBA44A-style residual). Confirms ScanUTF16String +// reports both — the real PE residual policy must allowlist the dead site +// separately (see docs/client-injector/research/vpilot-3.12.1-gates.md R3). +func TestResidualScan_SyntheticTwoCopies(t *testing.T) { + path := filepath.Join("testdata", "residual_jwt_two_copies.bin") + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read fixture: %v", err) + } + hits := ScanUTF16String(data, stockFSDJWT) + if len(hits) != 2 { + t.Fatalf("hits=%v want exactly 2 UTF-16 copies", hits) + } + if hits[0] >= hits[1] { + t.Fatalf("hits not ascending: %v", hits) + } + // Fixture layout: first copy after header+prefix; second after mid+framing. + // Absolute offsets are fixed by the checked-in fixture bytes. + if hits[0] < 20 { + t.Fatalf("first hit too early: %d", hits[0]) + } + // Ensure we matched raw UTF-16 only (no requirement on terminal byte). + need := len([]byte(stockFSDJWT)) // rune count == byte count for this ASCII string + _ = need + for _, h := range hits { + // Spot-check first UTF-16 code unit is 'h' (0x0068) + if int(h)+1 >= len(data) { + t.Fatalf("hit %d OOB", h) + } + if data[h] != 'h' || data[h+1] != 0x00 { + t.Fatalf("hit %d not start of UTF-16LE JWT: %02x %02x", h, data[h], data[h+1]) + } + } +} + +// TestResidualScan_FixtureStable guards the synthetic fixture against +// accidental regeneration that drops a copy (adapter residual tests depend on +// two-hit behavior). +func TestResidualScan_FixtureStable(t *testing.T) { + path := filepath.Join("testdata", "residual_jwt_two_copies.bin") + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if len(data) < 100 { + t.Fatalf("fixture too small: %d", len(data)) + } + const magic = "OPENFSD-RESIDUAL-FIXTURE" + if len(data) < len(magic) || string(data[:len(magic)]) != magic { + t.Fatalf("fixture magic=%q", data[:min(len(data), 32)]) + } + hits := ScanUTF16String(data, stockFSDJWT) + if len(hits) != 2 { + t.Fatalf("fixture must contain two stock JWT UTF-16 copies; hits=%v", hits) + } + // Fixed offsets from generator (magic + NUL + 1-byte prefix = 26). + if hits[0] != 26 || hits[1] != 113 { + t.Fatalf("fixture offsets drifted: hits=%v want [26 113]", hits) + } +} diff --git a/internal/clientinject/pepatch/testdata/residual_jwt_two_copies.bin b/internal/clientinject/pepatch/testdata/residual_jwt_two_copies.bin new file mode 100644 index 0000000000000000000000000000000000000000..2ad16be058cf35d671e4007e406f18995cd98b64 GIT binary patch literal 189 zcmeYdaP@NwcF_%T4fb>ib@b77^Na`y4RU30&tND4!UBe31}g@AAWUQ^1@bc(^cc#3 z>=K}OCPOZem&cF_|MhH{`d= researchUSHeapOff && researchJWTDeadOff < researchUSHeapOff+researchUSHeapSize { + t.Fatal("dead JWT site unexpectedly inside #US heap") + } + if researchJWTBodyOff < researchUSHeapOff || researchJWTBodyOff >= researchUSHeapOff+researchUSHeapSize { + t.Fatal("live JWT body not inside #US heap") + } + + // ldstr tokens + assertLdstr(t, data, researchJWTLdstrOff, researchJWTHeapOff) + assertLdstr(t, data, researchAFVLdstrOff, researchAFVHeapOff) + + // AFV body present once in UTF-16 + afvHits := pepatch.ScanUTF16String(data, researchStockAFV) + if len(afvHits) < 1 || afvHits[0] != researchAFVBodyOff { + t.Fatalf("AFV hits=%v want primary %#x", afvHits, researchAFVBodyOff) + } + + // 3.11.1 AFV ret site must NOT be a lone 0x2A on this build. + if data[0x4BA54] == 0x2A { + t.Fatalf("unexpected ret at obsolete 3.11.1 AFV site 0x4BA54 — re-check R2") + } +} + +func TestResearch_VPilot3121_GeoVRNoVoiceHost(t *testing.T) { + pe := researchVPilotPath(t) + dir := filepath.Dir(pe) + for _, name := range []string{"GeoVR.Client.dll", "GeoVR.Connection.dll", "GeoVR.Shared.dll"} { + p := filepath.Join(dir, name) + data, err := os.ReadFile(p) + if err != nil { + t.Skipf("GeoVR DLL missing (%s): %v", p, err) + } + if hits := pepatch.ScanUTF16String(data, "voice1.vatsim.net"); len(hits) != 0 { + t.Fatalf("%s has UTF-16 voice1.vatsim.net at %v (R5 regression)", name, hits) + } + if idx := indexASCII(data, "voice1.vatsim.net"); idx >= 0 { + t.Fatalf("%s has ASCII voice1.vatsim.net at %d (R5 regression)", name, idx) + } + } +} + +func assertLdstr(t *testing.T, data []byte, fileOff int64, heapOff int) { + t.Helper() + if fileOff+5 > int64(len(data)) { + t.Fatalf("ldstr OOB %#x", fileOff) + } + if data[fileOff] != 0x72 { + t.Fatalf("ldstr opcode at %#x = %#x", fileOff, data[fileOff]) + } + tok := binary.LittleEndian.Uint32(data[fileOff+1:]) + want := uint32(0x70000000 | heapOff) + if tok != want { + t.Fatalf("ldstr token at %#x = %#x want %#x", fileOff, tok, want) + } +} + +func indexASCII(data []byte, s string) int { + pat := []byte(s) + for i := 0; i+len(pat) <= len(data); i++ { + ok := true + for j := range pat { + if data[i+j] != pat[j] { + ok = false + break + } + } + if ok { + return i + } + } + return -1 +} diff --git a/internal/clientinject/residual_fixture_test.go b/internal/clientinject/residual_fixture_test.go new file mode 100644 index 0000000..264cd81 --- /dev/null +++ b/internal/clientinject/residual_fixture_test.go @@ -0,0 +1,28 @@ +package clientinject + +import ( + "os" + "path/filepath" + "testing" + + "github.com/renorris/openfsd/internal/clientinject/pepatch" +) + +// Package-level residual fixture (mirrors pepatch/testdata). Ensures engine- +// layer consumers can load the same two-copy stock JWT binary from +// internal/clientinject/testdata without depending on pepatch relative paths. +func TestResidualFixture_TwoUTF16Copies(t *testing.T) { + path := filepath.Join("testdata", "residual_jwt_two_copies.bin") + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read fixture: %v (synthetic residual fixtures must be committed)", err) + } + const stock = "https://auth.vatsim.net/api/fsd-jwt" + hits := pepatch.ScanUTF16String(data, stock) + if len(hits) != 2 { + t.Fatalf("hits=%v want 2", hits) + } + if hits[0] != 26 || hits[1] != 113 { + t.Fatalf("hits=%v want [26 113]", hits) + } +} diff --git a/internal/clientinject/testdata/residual_jwt_two_copies.bin b/internal/clientinject/testdata/residual_jwt_two_copies.bin new file mode 100644 index 0000000000000000000000000000000000000000..2ad16be058cf35d671e4007e406f18995cd98b64 GIT binary patch literal 189 zcmeYdaP@NwcF_%T4fb>ib@b77^Na`y4RU30&tND4!UBe31}g@AAWUQ^1@bc(^cc#3 z>=K}OCPOZem&cF_|MhH{`d