diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..1ae256c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,190 @@ +# OpenFUT Bridge — Claude Code project context + +Read this first. It's the standing context for every task in this project. Each +working session will give you ONE bounded task plus a verification clause; this +file is the background that stays true across all of them. + +## What this project is + +OpenFUT is an **offline FIFA 23 FUT (Ultimate Team) emulator** for single-player +game preservation. EA permanently shut down FIFA 23's online servers in October +2025, which removed FUT. This project lets a legitimately-owned copy run FUT +against a **local emulated backend** instead of EA's (dead) servers. + +It has two repos: +- **OpenFUT Core** — the game-independent FUT economy backend (already largely built). +- **OpenFUT Bridge** — the FIFA 23 integration / reverse-engineering layer (this work). + +## Hard constraints (do not violate) + +- **Clean-room only.** Everything is derived from observing the running client's + own behaviour. NEVER use, reference, or reproduce leaked EA source code (e.g. + the 2021 FIFA 21 / Blaze leak). If a task seems to need it, stop and say so. + Clean-room provenance is the legal foundation of the whole project. +- **Mark uncertainty, don't invent.** Several things are genuinely unconfirmed: + the Fire2 packet framing, ProtoSSL non-blocking return values, and FIFA 23's + Blaze component/command IDs. When you don't know a value, leave a clearly + labelled `TODO/CONFIRM` rather than guessing a confident-looking number. +- **Verify against the client.** The dead servers mean responses are *synthesized + and tested against the live offline client*, never captured from EA. The client + is the oracle: a gate is "done" when the client advances to the next command. + +## Architecture + +``` +FIFA 23 client (offline, native Windows) + | ProtoSSLConnect / ProtoSSLSend / ProtoSSLRecv (in-process) +openfut_hook.dll — fakes the connection, captures requests, injects responses + | plain localhost TCP, length-prefixed frames (no TLS) +blaze_brain (Rust) — decodes requests, crafts Blaze responses <-- iteration surface + | +OpenFUT Core — supplies real FUT economy data for the FUT gates +``` + +Why in-process (not a localhost TLS server): FIFA 23's ProtoSSL uses modern TLS +with cert pinning, so a fake server gets its cert rejected. Hooking ABOVE the +crypto (ProtoSSLSend/Recv take/return plaintext) sidesteps TLS entirely. + +## The entry sequence (the gates to reach FUT) + +The client runs these in order; each must be satisfied before the next fires: + +1. **LSX** (port 3216, plaintext loopback) — launcher→game bootstrap. +2. **Blaze redirector** — returns a server host/port. +3. **Blaze preauth** (UTIL) — config / component list. +4. **Blaze login** (AUTHENTICATION) — persona, session key, UID. +5. **Blaze postauth** (UTIL) — telemetry/ticker; "fully online". +6. **FUT entry check** — FIFA-specific eligibility/entitlement wall. +7. **FUT hub load** — club/squad over REST; hand off to OpenFUT Core. + +Gates 2–5 are largely static/replayable (the client validates little). Gates 6–7 +need internally-consistent data and connect to OpenFUT Core. + +## Environment + +- Native Windows boot (chosen for predictable PE/hooking behaviour over Proton). +- FIFA 23, with EA Anticheat in its offline/neutralized state. Do NOT assume a + task is safe to hook unless EAAC is confirmed neutralized. +- Rust is the primary language. Hook = `cdylib`, Windows target. Brain = portable. +- Self-hosted Gitea (git.aleshym.co, org Quaternions). Self-hosted CI runner. + +## How to work here + +- One rung at a time. Don't run ahead into a gate that depends on an unconfirmed + earlier answer. +- When decoding a frame, work from REAL captured bytes the user pastes in — not + from a guessed layout. Ask for the bytes if they're not provided. +- End each change with how the user verifies it on the client. + + +# OpenFUT Bridge — Task prompts (run in order) + +Each task is its own Claude Code session. Paste the task, attach the named +starting file(s), and don't move to the next until the verification clause passes. +The project CLAUDE.md is assumed to be in context. + +--- + +## TASK 1 — Confirm the transport (read-only memory scan) + +**Goal:** prove whether FIFA 23 uses EA DirtySDK / ProtoSSL before we build any +hook around it. Read-only; no hooking, patching, or injection. + +**Do this:** +Write a standalone Windows console program in Rust (target +`x86_64-pc-windows-msvc` or `-gnu`) that: +1. Finds the running FIFA 23 process by name (and accepts a PID argument). +2. Enumerates its modules and committed memory regions (e.g. `EnumProcessModules` + / `VirtualQueryEx`), readable regions only. +3. Reads memory with `ReadProcessMemory` in chunks (with small overlap so a + marker spanning a chunk boundary is still found). +4. Searches for these ASCII markers and prints each hit with its absolute address: + `protossl:`, `ProtoSSLSend`, `ProtoSSLRecv`, `ProtoSSLConnect`, + `gosredirector`, `DirtySDK`, `blaze`. +5. Prints a summary: which markers were found, and a clear verdict line + ("ProtoSSL present" vs "no ProtoSSL markers found"). + +Keep it dependency-light (the `windows` crate is fine). Comment it for a Rust +beginner. It must only read. + +**Verification:** with FIFA 23 running and sitting at the main menu, the program +prints whether the ProtoSSL markers are present. If `protossl:` and the +send/recv strings appear, the transport is confirmed and we proceed. If nothing +appears after the menu has loaded, STOP — we rethink the transport, don't build +the hook. + +**Note:** reach the main menu before scanning; the networking code/strings may +not be paged in at the title screen. + +--- + +## TASK 2 — Loadable DLL shell (prove we can get code in) + +**Goal:** a `version.dll` proxy that loads into FIFA 23 and runs our code, before +any hook logic exists. This isolates "can we inject at all" from "is the hook +correct". + +**Do this:** +Create a Rust `cdylib` (Windows target) that: +1. Exports the functions a real `version.dll` exports, forwarding each to the + system `version.dll` (proxy/sideload pattern). List the needed exports and + implement the forwarding. +2. In `DllMain`, on `DLL_PROCESS_ATTACH`, spawns a thread (NOT work in DllMain + itself — loader lock) that writes a line to a log file in a known path, e.g. + `C:\openfut\hook.log`, with a timestamp. +3. Provide the `Cargo.toml` (`crate-type = ["cdylib"]`) and the exact build + command. + +Clean-room: this is generic proxy/loader scaffolding, nothing EA-derived. + +**Verification:** drop the built DLL next to the FIFA 23 executable, launch the +game, and confirm `hook.log` gets the timestamped line. Game still reaches the +menu normally. If it crashes or the line never appears, fix the proxy/forwarding +before continuing. + +**Caution:** confirm EAAC is in its offline/neutralized state before loading the +DLL. Do not load it into an anticheat-active session. + +--- + +## TASK 3 — One landing hook on ProtoSSLSend + +**Goal:** prove we can hook a ProtoSSL function and see real outbound frames. +Just one function, and it calls the original (passive observation). + +**Do this:** +Extend the Task 2 DLL: +1. Add the `retour` crate. Resolve the `ProtoSSLSend` address — first pass: use + the absolute address Task 1 reported, converted to a module-relative offset + plus the live module base. (We'll switch to a byte-pattern scan later for + patch-resilience; leave a TODO for that.) +2. Install a `retour` inline detour on `ProtoSSLSend` with signature + `extern "C" fn(*mut c_void, *const u8, i32) -> i32` (x64 = one calling + convention; `extern "C"` is correct). +3. In the detour: log `length` and the first ~32 bytes of the buffer as hex, + then CALL THE ORIGINAL and return its result (do not block or alter traffic + yet). + +Mark the ProtoSSL return-value conventions and the eventual pattern-scan as +`TODO/CONFIRM` per the project rules. + +**Verification:** launch the game, attempt to go online / load FUT, and confirm +`hook.log` shows ProtoSSLSend calls with non-trivial byte dumps — these are the +client's real outbound Blaze frames (still TLS-bound on the wire, but plaintext +here, which is the whole point). Seeing real frames = hooking works, and we now +have actual bytes to decode in later tasks. Paste a few of those captured frames +into the next session. + +--- + +## What to attach to each task + +- Task 1: nothing (fresh program), or the Linux `protossl_scan.rs` as a logic + reference to port. +- Task 2: nothing, or your existing `version.dll` proxy shell if you have one. +- Task 3: the Task 2 DLL, plus the `openfut_hook_sketch.rs` as the structural + reference, plus the address Task 1 reported. + +Once Task 3 yields real captured frames, later tasks (the brain handlers per +gate) should always include the actual pasted bytes — decoding real frames is far +more reliable than guessing the Fire2 layout. diff --git a/openfut-hook/Cargo.lock b/openfut-hook/Cargo.lock new file mode 100644 index 0000000..31df026 --- /dev/null +++ b/openfut-hook/Cargo.lock @@ -0,0 +1,336 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "cc" +version = "1.2.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "generic-array" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libudis86-sys" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139bbf9ddb1bfc90c1ac64dd2923d9c957cd433cee7315c018125d72ab08a6b0" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + +[[package]] +name = "mmap-fixed-fixed" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0681853891801e4763dc252e843672faf32bcfee27a0aa3b19733902af450acc" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openfut-hook" +version = "0.1.0" +dependencies = [ + "retour", + "windows", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "region" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6b6ebd13bc009aef9cd476c1310d49ac354d36e240cf1bd753290f3dc7199a7" +dependencies = [ + "bitflags", + "libc", + "mach2", + "windows-sys", +] + +[[package]] +name = "retour" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9af44d40e2400b44d491bfaf8eae111b09f23ac4de6e92728e79d93e699c527" +dependencies = [ + "cfg-if", + "generic-array", + "libc", + "libudis86-sys", + "mmap-fixed-fixed", + "once_cell", + "region", + "slice-pool2", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "slice-pool2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a3d689654af89bdfeba29a914ab6ac0236d382eb3b764f7454dde052f2821f8" + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core", + "windows-targets", +] + +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-result", + "windows-strings", + "windows-targets", +] + +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result", + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" diff --git a/openfut-hook/Cargo.toml b/openfut-hook/Cargo.toml new file mode 100644 index 0000000..b0d9820 --- /dev/null +++ b/openfut-hook/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "openfut-hook" +version = "0.1.0" +edition = "2021" +description = "FIFA 23 version.dll proxy + ProtoSSL plaintext capture hook" + +[lib] +# Output is `version.dll` (the proxy/sideload name FIFA 23 loads). +name = "version" +crate-type = ["cdylib"] + +[dependencies] +# Inline-hooking library (installs the detour on _ProtoSSLSendPacket). +retour = "0.3" + +# Win32 bindings for the loader/threading/module APIs the hook needs. +windows = { version = "0.58", features = [ + "Win32_Foundation", + "Win32_Security", + "Win32_Networking_WinSock", + "Win32_System_LibraryLoader", + "Win32_System_ProcessStatus", + "Win32_System_Threading", + "Win32_System_SystemInformation", + "Win32_System_SystemServices", +] } + +# Own workspace root so Cargo doesn't attach this to the openfut-bridge package +# in the parent directory. +[workspace] diff --git a/openfut-hook/src/lib.rs b/openfut-hook/src/lib.rs new file mode 100644 index 0000000..98a76f2 --- /dev/null +++ b/openfut-hook/src/lib.rs @@ -0,0 +1,612 @@ +//! openfut-hook — FIFA 23 `version.dll` proxy + ProtoSSL plaintext capture. +//! +//! This DLL is built as `version.dll` and dropped next to `FIFA23.exe`. The +//! game loads it (DLL search-order / sideload), at which point we: +//! +//! 1. (Task 2) Forward every real `version.dll` export to the genuine system +//! DLL, so the game keeps working. We prove load with a log line. +//! 2. (Task 3) Pattern-scan FIFA23.exe for `_ProtoSSLSendPacket` (the DirtySDK +//! TLS record assembler we located at FIFA23.exe+0xEFA530) and install an +//! inline detour. At its entry the record payload is still PLAINTEXT, so we +//! log the content type + the source buffers, then call the original. +//! +//! Everything is written to `C:\openfut\hook.log` with timestamps, in stages, +//! so the log alone tells us how far initialization got. +//! +//! Clean-room: this is generic proxy/loader/hook scaffolding plus a byte +//! pattern derived from observing the binary the user owns. No EA source. + +use core::ffi::c_void; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Mutex; + +use retour::RawDetour; +use windows::core::{PCSTR, PCWSTR}; +use windows::Win32::Foundation::{BOOL, HMODULE}; +use windows::Win32::Networking::WinSock::{WSAGetLastError, AF_INET, SOCKADDR, SOCKADDR_IN}; +use windows::Win32::System::LibraryLoader::{ + DisableThreadLibraryCalls, GetModuleHandleW, GetProcAddress, LoadLibraryW, +}; +use windows::Win32::System::ProcessStatus::{GetModuleInformation, MODULEINFO}; +use windows::Win32::System::SystemInformation::GetLocalTime; +use windows::Win32::System::SystemServices::DLL_PROCESS_ATTACH; +use windows::Win32::System::Threading::{ + CreateThread, GetCurrentProcess, Sleep, THREAD_CREATION_FLAGS, +}; + +// --------------------------------------------------------------------------- +// Task 2: version.dll export forwarding +// --------------------------------------------------------------------------- + +/// Resolved addresses of the real system `version.dll` exports. Each proxy stub +/// below tail-jumps to its slot. AtomicUsize (not `static mut`) keeps this sound +/// and lets the naked stubs read the raw pointer via a simple memory load. +static REAL: [AtomicUsize; 17] = [const { AtomicUsize::new(0) }; 17]; + +/// The 17 exports a real `version.dll` provides, in the SAME order as the proxy +/// stubs' indices below. +const EXPORTS: [&str; 17] = [ + "GetFileVersionInfoA", + "GetFileVersionInfoByHandle", + "GetFileVersionInfoExA", + "GetFileVersionInfoExW", + "GetFileVersionInfoSizeA", + "GetFileVersionInfoSizeExA", + "GetFileVersionInfoSizeExW", + "GetFileVersionInfoSizeW", + "GetFileVersionInfoW", + "VerFindFileA", + "VerFindFileW", + "VerInstallFileA", + "VerInstallFileW", + "VerLanguageNameA", + "VerLanguageNameW", + "VerQueryValueA", + "VerQueryValueW", +]; + +/// Generate an exported, naked proxy stub that tail-jumps to `REAL[idx]`. +/// A tail `jmp` leaves every register/stack arg untouched, so it forwards any +/// signature correctly regardless of how many arguments the real function takes. +macro_rules! proxy_stub { + ($idx:literal, $name:ident) => { + #[no_mangle] + #[unsafe(naked)] + pub unsafe extern "system" fn $name() { + core::arch::naked_asm!( + "jmp qword ptr [rip + {base} + {off}]", + base = sym REAL, + off = const $idx * 8, + ); + } + }; +} + +proxy_stub!(0, GetFileVersionInfoA); +proxy_stub!(1, GetFileVersionInfoByHandle); +proxy_stub!(2, GetFileVersionInfoExA); +proxy_stub!(3, GetFileVersionInfoExW); +proxy_stub!(4, GetFileVersionInfoSizeA); +proxy_stub!(5, GetFileVersionInfoSizeExA); +proxy_stub!(6, GetFileVersionInfoSizeExW); +proxy_stub!(7, GetFileVersionInfoSizeW); +proxy_stub!(8, GetFileVersionInfoW); +proxy_stub!(9, VerFindFileA); +proxy_stub!(10, VerFindFileW); +proxy_stub!(11, VerInstallFileA); +proxy_stub!(12, VerInstallFileW); +proxy_stub!(13, VerLanguageNameA); +proxy_stub!(14, VerLanguageNameW); +proxy_stub!(15, VerQueryValueA); +proxy_stub!(16, VerQueryValueW); + +/// Load the genuine `version.dll` by full path (so we don't re-load ourselves) +/// and fill `REAL[]` with each export's address. Done synchronously in DllMain +/// so the slots are ready before the game calls any version function. +unsafe fn resolve_exports() { + let path = wide("C:\\Windows\\System32\\version.dll"); + let module = match LoadLibraryW(PCWSTR(path.as_ptr())) { + Ok(m) => m, + Err(e) => { + log(&format!("FATAL: could not load real version.dll: {e:?}")); + return; + } + }; + let mut missing = 0; + for (i, name) in EXPORTS.iter().enumerate() { + let cname = std::ffi::CString::new(*name).unwrap(); + let addr = GetProcAddress(module, PCSTR(cname.as_ptr() as *const u8)) + .map(|f| f as usize) + .unwrap_or(0); + REAL[i].store(addr, Ordering::SeqCst); + if addr == 0 { + missing += 1; + log(&format!("warn: real version.dll missing export {name}")); + } + } + log(&format!("forwarded {} version.dll exports", 17 - missing)); +} + +// --------------------------------------------------------------------------- +// Task 3: the _ProtoSSLSendPacket detour +// --------------------------------------------------------------------------- + +/// Trampoline to the original `_ProtoSSLSendPacket`, stored after we hook. +static ORIG: AtomicUsize = AtomicUsize::new(0); + +/// Signature derived from the disassembly (Windows x64 ABI): +/// `_ProtoSSLSendPacket(pState, contentType, bufA, lenA, bufB, lenB) -> i32`. +type SendPacket = + unsafe extern "system" fn(*mut c_void, u32, *const u8, i32, *const u8, i32) -> i32; + +/// Byte pattern for the `_ProtoSSLSendPacket` prologue. The four `0x00` bytes at +/// the masked positions are the stack-cookie `mov rax,[rip+disp32]` displacement +/// (position-dependent), so they're wildcarded via `MASK`. +const PATTERN: [u8; 36] = [ + 0x40, 0x53, 0x55, 0x56, 0x57, 0x41, 0x54, 0x41, 0x55, 0x41, 0x57, 0x48, 0x81, 0xEC, 0xB0, + 0x00, 0x00, 0x00, 0x48, 0x8B, 0x05, 0x00, 0x00, 0x00, 0x00, 0x48, 0x33, 0xC4, 0x48, 0x89, + 0x84, 0x24, 0xA0, 0x00, 0x00, 0x00, +]; +/// `false` = wildcard byte (don't compare). Indices 21..=24 are the disp32. +const MASK: [bool; 36] = { + let mut m = [true; 36]; + m[21] = false; + m[22] = false; + m[23] = false; + m[24] = false; + m +}; + +/// Our detour. At entry the record payload is still plaintext, so we log the +/// content type and source buffers, then forward to the original unchanged. +unsafe extern "system" fn hooked( + p_state: *mut c_void, + content_type: u32, + buf_a: *const u8, + len_a: i32, + buf_b: *const u8, + len_b: i32, +) -> i32 { + log_frame(content_type as u8, buf_a, len_a, buf_b, len_b); + + let orig = ORIG.load(Ordering::SeqCst); + if orig != 0 { + let orig: SendPacket = core::mem::transmute(orig); + orig(p_state, content_type, buf_a, len_a, buf_b, len_b) + } else { + // Should never happen (we store ORIG before the game can call us), but + // never crash the game if it does. + 0 + } +} + +/// Background init: pattern-scan FIFA23.exe for the function, then detour it. +/// Retries for a while in case the image isn't fully paged in at load. +unsafe extern "system" fn init_thread(_: *mut c_void) -> u32 { + // Connection capture first. Listen on the LSX port (gate 1, so the launcher + // bootstrap succeeds) and on the redirect port (for external TLS/Blaze). + start_listener(LSX_PORT, "LSX"); + start_listener(LOCAL_PORT, "BLZ"); + hook_dns(); + hook_connect(); + + // Then the plaintext-capture detour on _ProtoSSLSendPacket. + for _ in 0..60 { + if let Some(addr) = find_send_packet() { + install_hook(addr); + return 0; + } + Sleep(1000); + } + log("ERROR: _ProtoSSLSendPacket pattern not found after 60s"); + 0 +} + +/// Scan the FIFA23.exe image for the `_ProtoSSLSendPacket` prologue pattern. +unsafe fn find_send_packet() -> Option { + let module = GetModuleHandleW(PCWSTR::null()).ok()?; // null => the EXE itself + let base = module.0 as usize; + + let mut info = MODULEINFO::default(); + GetModuleInformation( + GetCurrentProcess(), + module, + &mut info, + core::mem::size_of::() as u32, + ) + .ok()?; + let size = info.SizeOfImage as usize; + let hay = core::slice::from_raw_parts(base as *const u8, size); + + let plen = PATTERN.len(); + if hay.len() < plen { + return None; + } + for i in 0..=hay.len() - plen { + // Cheap first-byte gate before the full compare. + if hay[i] != PATTERN[0] { + continue; + } + let mut ok = true; + for j in 1..plen { + if MASK[j] && hay[i + j] != PATTERN[j] { + ok = false; + break; + } + } + if ok { + return Some(base + i); + } + } + None +} + +/// Install the inline detour on the resolved function address. +unsafe fn install_hook(addr: usize) { + let detour = match RawDetour::new(addr as *const (), hooked as *const ()) { + Ok(d) => d, + Err(e) => { + log(&format!("ERROR: could not create detour: {e:?}")); + return; + } + }; + if let Err(e) = detour.enable() { + log(&format!("ERROR: could not enable detour: {e:?}")); + return; + } + // Leak the detour so it lives forever (dropping it would un-hook), and + // publish its trampoline so `hooked` can call the original. + let detour: &'static RawDetour = Box::leak(Box::new(detour)); + ORIG.store(detour.trampoline() as *const () as usize, Ordering::SeqCst); + log(&format!( + "hook installed: _ProtoSSLSendPacket @ 0x{addr:X} (FIFA23.exe+0x{:X})", + addr - module_base(), + )); +} + +// --------------------------------------------------------------------------- +// Connection capture: log every connect() and redirect external attempts to a +// local listener, so the client's TCP succeeds and it starts sending TLS. +// --------------------------------------------------------------------------- + +/// Local port our in-DLL listener binds; redirected connections land here. +const LOCAL_PORT: u16 = 7777; + +/// The EA launcher LSX port. The game connects here for launcher↔game bootstrap +/// (gate 1). We listen so the connect succeeds and we can see the LSX protocol. +const LSX_PORT: u16 = 3216; + +/// Trampoline to the original ws2_32 `connect`. +static ORIG_CONNECT: AtomicUsize = AtomicUsize::new(0); + +type ConnectFn = unsafe extern "system" fn(usize, *const SOCKADDR, i32) -> i32; + +/// Our `connect` detour: log the real destination, and for any non-loopback +/// IPv4 target, rewrite it to 127.0.0.1:LOCAL_PORT before calling the original. +unsafe extern "system" fn hooked_connect(s: usize, name: *const SOCKADDR, namelen: i32) -> i32 { + let orig: ConnectFn = core::mem::transmute(ORIG_CONNECT.load(Ordering::SeqCst)); + + if !name.is_null() && (*name).sa_family == AF_INET { + let sin = name as *const SOCKADDR_IN; + let port = u16::from_be((*sin).sin_port); + let octets = (*sin).sin_addr.S_un.S_addr.to_ne_bytes(); + let is_loopback = octets[0] == 127; + let dst = format!("{}.{}.{}.{}:{}", octets[0], octets[1], octets[2], octets[3], port); + + if !is_loopback { + // Build a fresh 127.0.0.1:LOCAL_PORT address and connect there. + let mut local: SOCKADDR_IN = core::mem::zeroed(); + local.sin_family = AF_INET; + local.sin_port = LOCAL_PORT.to_be(); + local.sin_addr.S_un.S_addr = u32::from_ne_bytes([127, 0, 0, 1]); + let ret = orig( + s, + &local as *const SOCKADDR_IN as *const SOCKADDR, + core::mem::size_of::() as i32, + ); + let err = if ret != 0 { WSAGetLastError().0 } else { 0 }; + log(&format!("CONNECT -> {dst} [redirected->7777] ret={ret} err={err}")); + return ret; + } else { + let ret = orig(s, name, namelen); + let err = if ret != 0 { WSAGetLastError().0 } else { 0 }; + log(&format!("CONNECT -> {dst} ret={ret} err={err}")); + return ret; + } + } + + orig(s, name, namelen) +} + +// --- DNS logging: what hostnames does the client try to resolve? ---------- + +static ORIG_GAIW: AtomicUsize = AtomicUsize::new(0); +static ORIG_GAI: AtomicUsize = AtomicUsize::new(0); + +type GaiWFn = unsafe extern "system" fn(PCWSTR, PCWSTR, *const c_void, *mut *mut c_void) -> i32; +type GaiFn = unsafe extern "system" fn(PCSTR, PCSTR, *const c_void, *mut *mut c_void) -> i32; + +unsafe extern "system" fn hooked_gaiw( + node: PCWSTR, + svc: PCWSTR, + hints: *const c_void, + res: *mut *mut c_void, +) -> i32 { + let name = if node.is_null() { + "".to_string() + } else { + node.to_string().unwrap_or_else(|_| "".into()) + }; + let orig: GaiWFn = core::mem::transmute(ORIG_GAIW.load(Ordering::SeqCst)); + let ret = orig(node, svc, hints, res); + log(&format!("DNS GetAddrInfoW(\"{name}\") ret={ret}")); + ret +} + +unsafe extern "system" fn hooked_gai( + node: PCSTR, + svc: PCSTR, + hints: *const c_void, + res: *mut *mut c_void, +) -> i32 { + let name = if node.is_null() { + "".to_string() + } else { + node.to_string().unwrap_or_else(|_| "".into()) + }; + let orig: GaiFn = core::mem::transmute(ORIG_GAI.load(Ordering::SeqCst)); + let ret = orig(node, svc, hints, res); + log(&format!("DNS getaddrinfo(\"{name}\") ret={ret}")); + ret +} + +/// Generic: resolve `name` in `module`, install a detour to `detour`, store the +/// trampoline in `slot`. +unsafe fn install_detour( + module: HMODULE, + name: &[u8], + detour: *const (), + slot: &AtomicUsize, + label: &str, +) { + let addr = match GetProcAddress(module, PCSTR(name.as_ptr())) { + Some(f) => f as usize, + None => { + log(&format!("ERROR: {label} not found")); + return; + } + }; + let d = match RawDetour::new(addr as *const (), detour) { + Ok(d) => d, + Err(e) => { + log(&format!("ERROR: {label} detour create: {e:?}")); + return; + } + }; + if d.enable().is_err() { + log(&format!("ERROR: {label} detour enable failed")); + return; + } + let d: &'static RawDetour = Box::leak(Box::new(d)); + slot.store(d.trampoline() as *const () as usize, Ordering::SeqCst); + log(&format!("{label} hook installed")); +} + +/// Detour the DNS resolvers so we see every hostname lookup. +unsafe fn hook_dns() { + let ws2 = match LoadLibraryW(PCWSTR(wide("ws2_32.dll").as_ptr())) { + Ok(m) => m, + Err(e) => { + log(&format!("ERROR: load ws2_32 for DNS: {e:?}")); + return; + } + }; + install_detour(ws2, b"GetAddrInfoW\0", hooked_gaiw as *const (), &ORIG_GAIW, "GetAddrInfoW"); + install_detour(ws2, b"getaddrinfo\0", hooked_gai as *const (), &ORIG_GAI, "getaddrinfo"); +} + +/// Resolve and detour ws2_32 `connect`. +unsafe fn hook_connect() { + let ws2 = match LoadLibraryW(PCWSTR(wide("ws2_32.dll").as_ptr())) { + Ok(m) => m, + Err(e) => { + log(&format!("ERROR: could not load ws2_32.dll: {e:?}")); + return; + } + }; + let addr = match GetProcAddress(ws2, PCSTR(b"connect\0".as_ptr())) { + Some(f) => f as usize, + None => { + log("ERROR: connect not found in ws2_32.dll"); + return; + } + }; + let detour = match RawDetour::new(addr as *const (), hooked_connect as *const ()) { + Ok(d) => d, + Err(e) => { + log(&format!("ERROR: could not create connect detour: {e:?}")); + return; + } + }; + if let Err(e) = detour.enable() { + log(&format!("ERROR: could not enable connect detour: {e:?}")); + return; + } + let detour: &'static RawDetour = Box::leak(Box::new(detour)); + ORIG_CONNECT.store(detour.trampoline() as *const () as usize, Ordering::SeqCst); + log("connect hook installed (external IPv4 -> 127.0.0.1:7777)"); +} + +/// Spawn a TCP listener on `127.0.0.1:port`, tagging logged traffic with `tag`. +/// Accepts connections and logs what the client sends — as readable text (for +/// the text-based LSX protocol) plus a hex prefix (for binary TLS/Blaze). +fn start_listener(port: u16, tag: &'static str) { + std::thread::spawn(move || { + let listener = match std::net::TcpListener::bind(("127.0.0.1", port)) { + Ok(l) => l, + Err(e) => { + log(&format!("ERROR: listener[{tag}] bind {port} failed: {e}")); + return; + } + }; + let bound = listener + .local_addr() + .map(|a| a.to_string()) + .unwrap_or_default(); + log(&format!("listener[{tag}] up, bound {bound}, accepting")); + + // Explicit accept loop so accept errors are visible (not swallowed). + loop { + match listener.accept() { + Ok((stream, peer)) => { + log(&format!("ACCEPT[{tag}] from {peer}")); + std::thread::spawn(move || handle_conn(stream, tag, peer.to_string())); + } + Err(e) => { + log(&format!("ACCEPT[{tag}] error: {e}")); + std::thread::sleep(std::time::Duration::from_millis(250)); + } + } + } + }); +} + +fn handle_conn(mut stream: std::net::TcpStream, tag: &'static str, peer: String) { + use std::io::Read; + let mut buf = [0u8; 2048]; + let mut total = 0usize; + loop { + match stream.read(&mut buf) { + Ok(0) | Err(_) => break, + Ok(n) => { + total += n; + let ascii = ascii_render(&buf[..n.min(400)]); + let hexp = hex(&buf[..n.min(24)]); + log(&format!("RECV[{tag}] {n}B | hex: {hexp} | text: {ascii}")); + } + } + } + log(&format!("CLOSE[{tag}] from {peer} after {total}B total")); +} + +/// Render bytes as printable ASCII (non-printable -> '.'), for text protocols. +fn ascii_render(bytes: &[u8]) -> String { + bytes + .iter() + .map(|&b| { + if (0x20..=0x7e).contains(&b) || b == b'\n' || b == b'\r' || b == b'\t' { + b as char + } else { + '.' + } + }) + .collect() +} + +// --------------------------------------------------------------------------- +// Logging +// --------------------------------------------------------------------------- + +fn log_frame(content_type: u8, buf_a: *const u8, len_a: i32, buf_b: *const u8, len_b: i32) { + let label = match content_type { + 0x14 => "ccs", + 0x15 => "alert", + 0x16 => "handshake", + 0x17 => "appdata", + _ => "?", + }; + let mut line = format!("SEND type=0x{content_type:02X}({label}) lenA={len_a} lenB={len_b}"); + if !buf_a.is_null() && len_a > 0 { + let n = (len_a as usize).min(48); + let bytes = unsafe { core::slice::from_raw_parts(buf_a, n) }; + line.push_str(&format!(" | A: {}", hex(bytes))); + } + if !buf_b.is_null() && len_b > 0 { + let n = (len_b as usize).min(16); + let bytes = unsafe { core::slice::from_raw_parts(buf_b, n) }; + line.push_str(&format!(" | B: {}", hex(bytes))); + } + log(&line); +} + +fn hex(bytes: &[u8]) -> String { + bytes + .iter() + .map(|b| format!("{b:02X}")) + .collect::>() + .join(" ") +} + +/// Serializes log writes so concurrent threads don't corrupt each other's lines. +static LOG_LOCK: Mutex<()> = Mutex::new(()); + +/// Append a timestamped line to `C:\openfut\hook.log`. +fn log(msg: &str) { + use std::io::Write; + let _guard = LOG_LOCK.lock(); + let _ = std::fs::create_dir_all("C:\\openfut"); + if let Ok(mut f) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open("C:\\openfut\\hook.log") + { + let _ = writeln!(f, "[{}] {}", now(), msg); + } +} + +fn now() -> String { + let st = unsafe { GetLocalTime() }; + format!( + "{:04}-{:02}-{:02} {:02}:{:02}:{:02}", + st.wYear, st.wMonth, st.wDay, st.wHour, st.wMinute, st.wSecond + ) +} + +// --------------------------------------------------------------------------- +// Helpers + entry point +// --------------------------------------------------------------------------- + +fn wide(s: &str) -> Vec { + s.encode_utf16().chain(std::iter::once(0)).collect() +} + +/// FIFA23.exe base address (the main module), for pretty logging. +fn module_base() -> usize { + unsafe { + GetModuleHandleW(PCWSTR::null()) + .map(|h| h.0 as usize) + .unwrap_or(0) + } +} + +#[no_mangle] +pub extern "system" fn DllMain(module: HMODULE, reason: u32, _reserved: *mut c_void) -> BOOL { + if reason == DLL_PROCESS_ATTACH { + unsafe { + let _ = DisableThreadLibraryCalls(module); + + let host = std::env::current_exe() + .map(|p| p.display().to_string()) + .unwrap_or_default(); + log(&format!( + "openfut-hook loaded | pid {} | host {host}", + std::process::id() + )); + + // Forward exports synchronously (must be ready before any call)... + resolve_exports(); + + // ...then do the pattern scan + hook on a background thread (never + // do real work directly in DllMain — loader lock). + let _ = CreateThread( + None, + 0, + Some(init_thread), + None, + THREAD_CREATION_FLAGS(0), + None, + ); + } + } + BOOL(1) +} diff --git a/tools/protossl-scan/Cargo.lock b/tools/protossl-scan/Cargo.lock new file mode 100644 index 0000000..1e8cc8a --- /dev/null +++ b/tools/protossl-scan/Cargo.lock @@ -0,0 +1,196 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "iced-x86" +version = "1.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c447cff8c7f384a7d4f741cfcff32f75f3ad02b406432e8d6c878d56b1edf6b" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "protossl-scan" +version = "0.1.0" +dependencies = [ + "iced-x86", + "memchr", + "windows", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core", + "windows-targets", +] + +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-result", + "windows-strings", + "windows-targets", +] + +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result", + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" diff --git a/tools/protossl-scan/Cargo.toml b/tools/protossl-scan/Cargo.toml new file mode 100644 index 0000000..e6c894c --- /dev/null +++ b/tools/protossl-scan/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "protossl-scan" +version = "0.1.0" +edition = "2021" +description = "Task 1: read-only memory scan to confirm FIFA 23 uses EA DirtySDK / ProtoSSL" + +[[bin]] +name = "protossl-scan" +path = "src/main.rs" + +[dependencies] +# SIMD-accelerated substring search. Orders of magnitude faster than a naive +# byte-by-byte scan when sweeping the game's multi-GB address space. +memchr = "2" + +# Pure-Rust x86/x64 disassembler. Lets us read the game's own code around a +# code anchor (clean-room: we only disassemble the binary the user owns). +iced-x86 = "1" + +# The official Microsoft `windows` crate gives us safe-ish bindings to the +# Win32 APIs we need. We only turn on the few feature modules we use, to keep +# build times and binary size down. +windows = { version = "0.58", features = [ + "Win32_Foundation", + "Win32_System_Threading", + "Win32_System_Memory", + "Win32_System_Diagnostics_ToolHelp", + "Win32_System_Diagnostics_Debug", +] } + +# This tool lives inside the openfut-bridge repo but is its OWN crate. Declaring +# an empty [workspace] here makes Cargo treat this directory as a standalone +# workspace root, so it does not try to attach to the openfut-bridge package +# in the parent directory (which would error). +[workspace] diff --git a/tools/protossl-scan/src/main.rs b/tools/protossl-scan/src/main.rs new file mode 100644 index 0000000..dd405dd --- /dev/null +++ b/tools/protossl-scan/src/main.rs @@ -0,0 +1,786 @@ +//! protossl-scan — OpenFUT Bridge, Task 1: confirm the transport (read-only). +//! +//! GOAL +//! ---- +//! Before we build any hook around FIFA 23's networking, we want to *prove* +//! that the game uses EA's DirtySDK / ProtoSSL stack, and then locate the +//! actual `ProtoSSLSend` function so a later task can hook it. We do that by +//! reading the running game's memory (read-only!). +//! +//! This program ONLY READS memory. It never writes, patches, injects, or hooks +//! anything. It is completely passive and safe to run against the live client. +//! +//! TWO MODES +//! --------- +//! protossl-scan [pid|name] +//! Marker scan. Searches memory for DirtySDK/ProtoSSL/Blaze strings and +//! prints a verdict on whether the transport is present. +//! +//! protossl-scan xref [pid|name] +//! Cross-reference scan. Given the address of something (e.g. the +//! "ProtoSSLSend" string the marker scan found), finds: +//! (a) absolute 8-byte pointers to it (likely a name/function table in +//! .rdata) — and dumps the neighbouring pointers so we can spot the +//! matching *function* pointer; and +//! (b) RIP-relative references to it from executable code (e.g. a +//! `lea reg, [rip+disp]` that loads the string). +//! This is how we turn the *string* address into the *function* address. +//! +//! USAGE EXAMPLES +//! -------------- +//! protossl-scan # marker scan of "FIFA23.exe" +//! protossl-scan 8088 # marker scan of PID 8088 +//! protossl-scan xref 0x147D198B9 # who references that address? +//! protossl-scan xref 0x147D198B9 8088 # ...in PID 8088 +//! +//! NOTE: reach the game's MAIN MENU before scanning (networking strings/code +//! may not be paged in at the title screen). Run the terminal "as +//! administrator" so you're allowed to read the game's memory, and make sure EA +//! Anticheat is in its offline/neutralized state. + +use core::ffi::c_void; +use std::collections::HashSet; + +use memchr::memmem; + +use windows::Win32::Foundation::{CloseHandle, HANDLE}; +use windows::Win32::System::Diagnostics::Debug::ReadProcessMemory; +use windows::Win32::System::Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, Module32FirstW, Module32NextW, Process32FirstW, Process32NextW, + MODULEENTRY32W, PROCESSENTRY32W, TH32CS_SNAPMODULE, TH32CS_SNAPMODULE32, TH32CS_SNAPPROCESS, +}; +use windows::Win32::System::Memory::{ + VirtualQueryEx, MEMORY_BASIC_INFORMATION, MEM_COMMIT, PAGE_EXECUTE_READ, + PAGE_EXECUTE_READWRITE, PAGE_EXECUTE_WRITECOPY, PAGE_GUARD, PAGE_READONLY, PAGE_READWRITE, + PAGE_WRITECOPY, +}; +use windows::Win32::System::Threading::{OpenProcess, PROCESS_QUERY_INFORMATION, PROCESS_VM_READ}; + +/// The ASCII strings the marker scan hunts for. Finding any ProtoSSL* / +/// `protossl:` marker is strong evidence the game speaks DirtySDK/ProtoSSL. +const MARKERS: &[&[u8]] = &[ + b"protossl:", + b"ProtoSSLSend", + b"ProtoSSLRecv", + b"ProtoSSLConnect", + b"gosredirector", + b"DirtySDK", + b"blaze", +]; + +/// How much memory we read per `ReadProcessMemory` call. Larger chunks mean +/// fewer syscalls when sweeping a multi-GB address space. +const CHUNK: usize = 4 << 20; // 4 MiB + +/// One module loaded in the target process: its name, base address and size. +struct ModuleInfo { + name: String, + base: usize, + size: usize, +} + +fn main() { + let args: Vec = std::env::args().skip(1).collect(); + + // Decide the mode from the first argument. + match args.first().map(|s| s.as_str()) { + // xref-str [pid|name] + // Convenience: find the CURRENT address of a string ourselves, then + // xref it. This is ASLR-proof — no copy-pasting absolute addresses. + Some("xref-str") => { + let text = match args.get(1) { + Some(t) => t.clone(), + None => { + eprintln!("Usage: protossl-scan xref-str [pid|name]"); + eprintln!("Example: protossl-scan xref-str ProtoSSLSend"); + std::process::exit(1); + } + }; + let pid = resolve_pid(args.get(2).map(|s| s.as_str())); + let process = open_for_read(pid); + let modules = enumerate_modules(pid); + + let addrs = find_string_addresses(process, text.as_bytes()); + if addrs.is_empty() { + println!("String \"{text}\" not found in PID {pid}. Did you reach the menu?"); + } else { + println!("Found \"{text}\" at {} location(s):", addrs.len()); + for a in addrs { + println!(); + // Show the surrounding bytes and find the TRUE start of the + // C-string containing the hit. A pointer/reference targets + // the string's start, not a substring offset, so we xref + // that start rather than the raw hit address. + let start = dump_string_context(process, a, &modules); + run_xref(process, &modules, start); + } + } + unsafe { + let _ = CloseHandle(process); + } + } + // disasm [pid|name] + // Disassemble the function enclosing an address, annotating string + // loads and call targets. Use this on a code anchor (e.g. the lea that + // loads the "_ProtoSSLSendPacket" string) to read the real code. + Some("disasm") => { + let target = match args.get(1).and_then(|s| parse_hex(s)) { + Some(t) => t, + None => { + eprintln!("Usage: protossl-scan disasm [pid|name]"); + eprintln!("Example: protossl-scan disasm 0x140EFA631"); + std::process::exit(1); + } + }; + let pid = resolve_pid(args.get(2).map(|s| s.as_str())); + let process = open_for_read(pid); + let modules = enumerate_modules(pid); + run_disasm(process, &modules, target); + unsafe { + let _ = CloseHandle(process); + } + } + // xref [pid|name] (power-user form, exact absolute address) + Some("xref") => { + let target = match args.get(1).and_then(|s| parse_hex(s)) { + Some(t) => t, + None => { + eprintln!("Usage: protossl-scan xref [pid|name]"); + eprintln!("Example: protossl-scan xref 0x147D198B9"); + eprintln!("Tip: pass the FULL absolute address, not the +offset."); + eprintln!("Or just use: protossl-scan xref-str ProtoSSLSend"); + std::process::exit(1); + } + }; + let pid = resolve_pid(args.get(2).map(|s| s.as_str())); + let process = open_for_read(pid); + let modules = enumerate_modules(pid); + run_xref(process, &modules, target); + unsafe { + let _ = CloseHandle(process); + } + } + // Default: marker scan. An optional argument selects the process. + _ => { + let pid = resolve_pid(args.first().map(|s| s.as_str())); + let process = open_for_read(pid); + let modules = enumerate_modules(pid); + run_marker_scan(process, &modules); + unsafe { + let _ = CloseHandle(process); + } + } + } +} + +/// Find every absolute address where the byte string `text` currently appears +/// in the target process. Used by `xref-str` so the user never has to copy an +/// ASLR'd address by hand. +fn find_string_addresses(process: HANDLE, text: &[u8]) -> Vec { + let mut hits: HashSet = HashSet::new(); + let overlap = text.len().saturating_sub(1); + let finder = memmem::Finder::new(text); + walk_regions(process, false, overlap, |chunk_base, bytes| { + for off in finder.find_iter(bytes) { + hits.insert(chunk_base + off); + } + }); + let mut v: Vec = hits.into_iter().collect(); + v.sort_unstable(); + v +} + +// --------------------------------------------------------------------------- +// Mode 1: marker scan +// --------------------------------------------------------------------------- + +fn run_marker_scan(process: HANDLE, modules: &[ModuleInfo]) { + println!("== protossl-scan : marker scan =="); + println!("Loaded modules: {}", modules.len()); + + // For each marker, the set of absolute addresses where we found it. A + // HashSet de-duplicates hits landing in the overlap between two chunks. + let mut hits: Vec> = vec![HashSet::new(); MARKERS.len()]; + let overlap = MARKERS.iter().map(|m| m.len()).max().unwrap_or(1) - 1; + + // Build one SIMD finder per marker, reused across every chunk. + let finders: Vec = MARKERS.iter().map(|m| memmem::Finder::new(m)).collect(); + + walk_regions(process, false, overlap, |chunk_base, bytes| { + for (i, finder) in finders.iter().enumerate() { + for off in finder.find_iter(bytes) { + hits[i].insert(chunk_base + off); + } + } + }); + + println!("\n-- Results --"); + let mut protossl_present = false; + for (i, marker) in MARKERS.iter().enumerate() { + let name = String::from_utf8_lossy(marker); + let addrs = &hits[i]; + if addrs.is_empty() { + println!(" {name:<16} : not found"); + continue; + } + if matches!( + *marker, + b"protossl:" | b"ProtoSSLSend" | b"ProtoSSLRecv" | b"ProtoSSLConnect" + ) { + protossl_present = true; + } + let mut sorted: Vec = addrs.iter().copied().collect(); + sorted.sort_unstable(); + println!(" {name:<16} : {} hit(s)", sorted.len()); + for addr in sorted.iter().take(8) { + println!(" {}", describe(*addr, modules)); + } + if sorted.len() > 8 { + println!(" ... and {} more", sorted.len() - 8); + } + } + + println!("\n-- Verdict --"); + if protossl_present { + println!("ProtoSSL present — transport confirmed."); + println!("Next: `protossl-scan xref ` to find the function."); + } else { + println!("No ProtoSSL markers found."); + println!("STOP: did you reach the MAIN MENU before scanning? If you did and"); + println!("still see nothing, the transport assumption is wrong — rethink it."); + } +} + +// --------------------------------------------------------------------------- +// Mode 2: cross-reference scan +// --------------------------------------------------------------------------- + +fn run_xref(process: HANDLE, modules: &[ModuleInfo], target: usize) { + println!("== protossl-scan : xref of 0x{target:X} =="); + println!("({})", describe(target, modules)); + + // (a) Absolute 8-byte pointers to `target`. These usually live in a + // read-only data table. If the table pairs names with functions, a + // neighbouring slot will hold the function pointer we actually want. + let needle = (target as u64).to_le_bytes(); + let ptr_finder = memmem::Finder::new(&needle); + let mut ptr_hits: HashSet = HashSet::new(); + walk_regions(process, false, needle.len() - 1, |chunk_base, bytes| { + for off in ptr_finder.find_iter(bytes) { + ptr_hits.insert(chunk_base + off); + } + }); + + println!("\n-- Absolute pointers to target --"); + if ptr_hits.is_empty() { + println!(" none"); + } else { + let mut sorted: Vec = ptr_hits.iter().copied().collect(); + sorted.sort_unstable(); + for at in sorted.iter().take(8) { + println!(" pointer stored at {}", describe(*at, modules)); + // Dump the neighbouring pointer-sized slots so a name/function + // table becomes visible. A neighbour resolving to a LOW FIFA23.exe + // offset (the .text/code section) is a strong function candidate; + // the string itself lives at a HIGH offset (.rdata). + dump_neighbours(process, *at, modules); + } + if sorted.len() > 8 { + println!(" ... and {} more", sorted.len() - 8); + } + } + + // (b) RIP-relative references from executable code. For x64, an instruction + // like `lea rcx, [rip+disp32]` encodes a 4-byte signed displacement + // relative to the address of the *next* instruction. So if the 4 bytes + // at address P are `disp`, the referenced target is `P + 4 + disp`. + // We scan executable pages for any P where that equals our target. + let mut code_hits: HashSet = HashSet::new(); + walk_regions(process, true, 3, |chunk_base, bytes| { + if bytes.len() < 4 { + return; + } + for i in 0..=bytes.len() - 4 { + let disp = i32::from_le_bytes([bytes[i], bytes[i + 1], bytes[i + 2], bytes[i + 3]]); + let after = chunk_base + i + 4; // address just past the disp field + let referenced = after.wrapping_add(disp as i64 as usize); + if referenced == target { + code_hits.insert(chunk_base + i); + } + } + }); + + println!("\n-- RIP-relative code references --"); + if code_hits.is_empty() { + println!(" none"); + } else { + let mut sorted: Vec = code_hits.iter().copied().collect(); + sorted.sort_unstable(); + for at in sorted.iter().take(12) { + // The instruction opcode starts a few bytes before the disp field + // (e.g. `48 8D 05 ` => opcode begins 3 bytes earlier). + println!( + " disp field at {} (instruction begins ~3 bytes earlier)", + describe(*at, modules) + ); + } + if sorted.len() > 12 { + println!(" ... and {} more", sorted.len() - 12); + } + } + + println!("\n-- Next --"); + println!("Look for a neighbour pointer (or a code reference) that resolves to a LOW"); + println!("FIFA23.exe offset — that is the candidate ProtoSSLSend function address."); + println!("TODO/CONFIRM: validate the candidate by disassembling around it before hooking."); +} + +/// Read and print the pointer-sized slots immediately around `at`, resolving +/// each stored value to module+offset. Reveals name/function tables. +fn dump_neighbours(process: HANDLE, at: usize, modules: &[ModuleInfo]) { + // 4 slots before through 4 slots after (8 bytes each). + for k in -4i64..=4 { + let slot = (at as i64 + k * 8) as usize; + match read_u64(process, slot) { + Some(val) => { + let marker = if k == 0 { " <- string ptr" } else { "" }; + println!( + " [{:+}] 0x{:016X} -> {}{}", + k, + val, + describe(val as usize, modules), + marker + ); + } + None => {} + } + } +} + +// --------------------------------------------------------------------------- +// Mode 3: disassemble the enclosing function +// --------------------------------------------------------------------------- + +fn run_disasm(process: HANDLE, modules: &[ModuleInfo], target: usize) { + use iced_x86::{ + Decoder, DecoderOptions, Formatter, Instruction, Mnemonic, NasmFormatter, OpKind, + }; + + println!("== protossl-scan : disasm around 0x{target:X} =="); + println!("({})\n", describe(target, modules)); + + // Read a window of code: enough before the anchor to capture the function + // prologue, and enough after to see the body. + const BACK: usize = 0x400; + const FWD: usize = 0x300; + let win_start = target.saturating_sub(BACK); + let buf = match read_bytes(process, win_start, BACK + FWD) { + Some(b) => b, + None => { + println!("Could not read code memory around 0x{target:X}."); + return; + } + }; + let target_off = target - win_start; + + // Find the start of the enclosing function. MSVC pads the gap between + // functions with int3 (0xCC) bytes, so the nearest 0xCC before the anchor + // marks the end of the previous function; ours starts right after it. + let mut p = target_off.min(buf.len()); + while p > 0 && buf[p - 1] != 0xCC { + p -= 1; + } + let func_start_addr = win_start + p; + if p == 0 { + println!("(warning: no int3 padding found in window — disassembly may begin mid-function)\n"); + } else { + println!( + "Enclosing function starts at {}\n", + describe(func_start_addr, modules) + ); + } + + let code = &buf[p..]; + let mut decoder = Decoder::with_ip(64, code, func_start_addr as u64, DecoderOptions::NONE); + let mut formatter = NasmFormatter::new(); + let mut instr = Instruction::default(); + let mut text = String::new(); + + let mut count = 0; + while decoder.can_decode() && count < 220 { + decoder.decode_out(&mut instr); + count += 1; + + let ip = instr.ip() as usize; + text.clear(); + formatter.format(&instr, &mut text); + + // Raw instruction bytes. + let idx = ip - func_start_addr; + let len = instr.len(); + let raw: String = code + .get(idx..idx + len) + .unwrap_or(&[]) + .iter() + .map(|b| format!("{b:02X}")) + .collect::>() + .join(" "); + + // Annotate RIP-relative data loads (strings!) and call/jmp targets. + let mut note = String::new(); + if instr.is_ip_rel_memory_operand() { + let tgt = instr.ip_rel_memory_address() as usize; + match read_string(process, tgt, 48) { + Some(s) => note = format!(" ; -> \"{s}\""), + None => note = format!(" ; -> {}", describe(tgt, modules)), + } + } else if matches!(instr.mnemonic(), Mnemonic::Call | Mnemonic::Jmp) + && matches!( + instr.op0_kind(), + OpKind::NearBranch64 | OpKind::NearBranch32 | OpKind::NearBranch16 + ) + { + let tgt = instr.near_branch_target() as usize; + note = format!(" ; -> {}", describe(tgt, modules)); + } + + let here = if ip == target { " <== anchor" } else { "" }; + println!(" 0x{ip:012X} {raw:<30} {text}{note}{here}"); + + // Stop at the function's terminating `ret` (followed by int3 padding). + if instr.mnemonic() == Mnemonic::Ret { + let next = idx + len; + if next >= code.len() || code[next] == 0xCC { + break; + } + } + } + println!("\n-- Next --"); + println!("Look for the public ProtoSSLSend: it's the function that CALLS this one"); + println!("with plaintext. Use `protossl-scan callers 0x{func_start_addr:X}` (coming next)"); + println!("or scan upward for a nearby function that calls into here."); +} + +/// Read a printable ASCII C-string at `addr` (up to `max` bytes). Returns None +/// if there isn't at least a short run of printable characters. +fn read_string(process: HANDLE, addr: usize, max: usize) -> Option { + let bytes = read_bytes(process, addr, max)?; + let mut s = String::new(); + for &b in &bytes { + if (0x20..=0x7e).contains(&b) { + s.push(b as char); + } else { + break; + } + } + if s.len() >= 3 { + Some(s) + } else { + None + } +} + +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- + +/// Turn an optional process selector (numeric PID or name) into a PID, exiting +/// with a helpful message if it can't be resolved. No selector => "FIFA23.exe". +fn resolve_pid(arg: Option<&str>) -> u32 { + match arg { + Some(a) if a.chars().all(|c| c.is_ascii_digit()) => { + a.parse::().expect("PID should be a valid number") + } + Some(name) => find_process_by_name(name).unwrap_or_else(|| { + eprintln!("Could not find a running process named '{name}'."); + std::process::exit(1); + }), + None => find_process_by_name("FIFA23.exe").unwrap_or_else(|| { + eprintln!("Could not find 'FIFA23.exe'. Is the game running?"); + eprintln!("Tip: pass a process name or PID, e.g. `protossl-scan 8088`."); + std::process::exit(1); + }), + } +} + +/// Open the target process with read-only rights, or exit with guidance. +fn open_for_read(pid: u32) -> HANDLE { + match unsafe { OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, false, pid) } { + Ok(h) => { + println!("Target PID: {pid}"); + h + } + Err(e) => { + eprintln!("OpenProcess failed for PID {pid}: {e}"); + eprintln!("Try running this terminal as administrator, and make sure"); + eprintln!("EA Anticheat is in its offline/neutralized state."); + std::process::exit(1); + } + } +} + +/// Parse a hex address that may or may not have a "0x" prefix. +fn parse_hex(s: &str) -> Option { + let trimmed = s.trim_start_matches("0x").trim_start_matches("0X"); + usize::from_str_radix(trimmed, 16).ok() +} + +/// Read a single u64 from the target process at `addr`. Returns None if the +/// memory can't be read (e.g. unmapped). +fn read_u64(process: HANDLE, addr: usize) -> Option { + let b = read_bytes(process, addr, 8)?; + if b.len() == 8 { + Some(u64::from_le_bytes(b.try_into().unwrap())) + } else { + None + } +} + +/// Read up to `len` bytes from the target process at `addr`. Returns however +/// many bytes were actually readable (possibly fewer than `len`), or None. +fn read_bytes(process: HANDLE, addr: usize, len: usize) -> Option> { + let mut buf = vec![0u8; len]; + let mut got = 0usize; + let ok = unsafe { + ReadProcessMemory( + process, + addr as *const c_void, + buf.as_mut_ptr() as *mut c_void, + len, + Some(&mut got), + ) + }; + if ok.is_ok() && got > 0 { + buf.truncate(got); + Some(buf) + } else { + None + } +} + +/// Print the bytes around a string hit (full containing C-string + a hex/ASCII +/// dump) and return the address of the TRUE start of that C-string. This tells +/// us whether the hit is a standalone string or embedded in a larger blob. +fn dump_string_context(process: HANDLE, hit: usize, modules: &[ModuleInfo]) -> usize { + const BACK: usize = 256; + const FWD: usize = 256; + let win_start = hit.saturating_sub(BACK); + let buf = match read_bytes(process, win_start, BACK + FWD) { + Some(b) => b, + None => { + println!(" (could not read memory around 0x{hit:X})"); + return hit; + } + }; + let hit_off = hit - win_start; // index of the hit within the window + + // A printable ASCII byte (the alphabet C-strings are made of here). + let printable = |b: u8| (0x20..=0x7e).contains(&b); + + // Walk backward/forward to the NUL (or non-printable) boundaries. + let mut s = hit_off; + while s > 0 && printable(buf[s - 1]) { + s -= 1; + } + let mut e = hit_off; + while e < buf.len() && printable(buf[e]) { + e += 1; + } + + let start_addr = win_start + s; + let full = String::from_utf8_lossy(&buf[s..e]); + println!(" containing string: \"{full}\""); + println!(" string starts at : {}", describe(start_addr, modules)); + if start_addr != hit { + println!(" (hit was a substring; xref-ing the string start instead)"); + } + + // Hex + ASCII dump of ~96 bytes centred on the hit, for structural insight + // (e.g. is this a NUL-separated table of names, or one long message?). + println!(" context:"); + let dump_start = hit_off.saturating_sub(32) & !0xF; // 16-byte aligned + for row in 0..6 { + let off = dump_start + row * 16; + if off >= buf.len() { + break; + } + let end = (off + 16).min(buf.len()); + let slice = &buf[off..end]; + let mut hex = String::new(); + let mut asc = String::new(); + for &b in slice { + hex.push_str(&format!("{b:02X} ")); + asc.push(if printable(b) { b as char } else { '.' }); + } + println!(" 0x{:016X} {:<48} {}", win_start + off, hex, asc); + } + + start_addr +} + +/// Format an address as "module+0xOFFSET" or note it's outside any module. +fn describe(addr: usize, modules: &[ModuleInfo]) -> String { + for m in modules { + if addr >= m.base && addr < m.base + m.size { + return format!("0x{addr:016X} ({}+0x{:X})", m.name, addr - m.base); + } + } + format!("0x{addr:016X} (outside any module)") +} + +/// Walk every committed, readable region of the process. If `exec_only`, only +/// executable regions are visited. Each readable chunk is passed to `f` as +/// `(absolute_base_of_chunk, bytes)`, in overlapping `CHUNK`-sized pieces. +fn walk_regions( + process: HANDLE, + exec_only: bool, + overlap: usize, + mut f: F, +) { + let mut buf = vec![0u8; CHUNK]; + let mut address: usize = 0; + + loop { + let mut mbi = MEMORY_BASIC_INFORMATION::default(); + let written = unsafe { + VirtualQueryEx( + process, + Some(address as *const c_void), + &mut mbi, + core::mem::size_of::(), + ) + }; + if written == 0 { + break; + } + + let region_base = mbi.BaseAddress as usize; + let region_size = mbi.RegionSize; + + let wanted = if exec_only { + is_executable(mbi.Protect.0) + } else { + is_readable(mbi.Protect.0) + }; + if mbi.State == MEM_COMMIT && wanted { + // Read this region in overlapping chunks. + let end = region_base.saturating_add(region_size); + let mut pos = region_base; + while pos < end { + let want = CHUNK.min(end - pos); + let mut got: usize = 0; + let ok = unsafe { + ReadProcessMemory( + process, + pos as *const c_void, + buf.as_mut_ptr() as *mut c_void, + want, + Some(&mut got), + ) + }; + if ok.is_err() || got == 0 { + pos = pos.saturating_add(want.max(1)); + continue; + } + f(pos, &buf[..got]); + if pos + got >= end { + break; + } + let step = if got > overlap { got - overlap } else { got }; + pos += step; + } + } + + match region_base.checked_add(region_size) { + Some(next) if next > address => address = next, + _ => break, + } + } +} + +/// Find a running process by executable name (case-insensitive). Returns PID. +fn find_process_by_name(target: &str) -> Option { + unsafe { + let snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0).ok()?; + let mut entry = PROCESSENTRY32W { + dwSize: core::mem::size_of::() as u32, + ..Default::default() + }; + let mut found = None; + if Process32FirstW(snapshot, &mut entry).is_ok() { + loop { + if wide_to_string(&entry.szExeFile).eq_ignore_ascii_case(target) { + found = Some(entry.th32ProcessID); + break; + } + if Process32NextW(snapshot, &mut entry).is_err() { + break; + } + } + } + let _ = CloseHandle(snapshot); + found + } +} + +/// List every module (EXE + DLLs) in the target process with base and size. +fn enumerate_modules(pid: u32) -> Vec { + let mut modules = Vec::new(); + unsafe { + let snapshot = + match CreateToolhelp32Snapshot(TH32CS_SNAPMODULE | TH32CS_SNAPMODULE32, pid) { + Ok(s) => s, + Err(_) => return modules, + }; + let mut entry = MODULEENTRY32W { + dwSize: core::mem::size_of::() as u32, + ..Default::default() + }; + if Module32FirstW(snapshot, &mut entry).is_ok() { + loop { + modules.push(ModuleInfo { + name: wide_to_string(&entry.szModule), + base: entry.modBaseAddr as usize, + size: entry.modBaseSize as usize, + }); + if Module32NextW(snapshot, &mut entry).is_err() { + break; + } + } + } + let _ = CloseHandle(snapshot); + } + modules +} + +/// Readable page? Accept read/write/execute-read variants; reject NOACCESS, +/// plain EXECUTE (not readable), and guard pages. +fn is_readable(protect: u32) -> bool { + if protect & PAGE_GUARD.0 != 0 { + return false; + } + let base = protect & 0xFF; + base == PAGE_READONLY.0 + || base == PAGE_READWRITE.0 + || base == PAGE_WRITECOPY.0 + || base == PAGE_EXECUTE_READ.0 + || base == PAGE_EXECUTE_READWRITE.0 + || base == PAGE_EXECUTE_WRITECOPY.0 +} + +/// Executable AND readable page? (We can only scan code we can also read.) +fn is_executable(protect: u32) -> bool { + if protect & PAGE_GUARD.0 != 0 { + return false; + } + let base = protect & 0xFF; + base == PAGE_EXECUTE_READ.0 + || base == PAGE_EXECUTE_READWRITE.0 + || base == PAGE_EXECUTE_WRITECOPY.0 +} + +/// Convert a fixed-size, NUL-terminated UTF-16 (wide) buffer into a String. +fn wide_to_string(wide: &[u16]) -> String { + let len = wide.iter().position(|&c| c == 0).unwrap_or(wide.len()); + String::from_utf16_lossy(&wide[..len]) +}