Compare commits

..

1 Commits

Author SHA1 Message Date
funman300 bc6612697a feat: add ProtoSSL RE tooling and FIFA 23 in-process hook
Add the reverse-engineering work for FIFA 23 online integration:

- tools/protossl-scan: read-only memory scanner that confirms the
  DirtySDK/ProtoSSL transport, cross-references strings to code
  (xref/xref-str), and disassembles the enclosing function (disasm).
  Located _ProtoSSLSendPacket at FIFA23.exe+0xEFA530 (plaintext TLS
  record assembler) via the "_ProtoSSLSendPacket" debug string xref.

- openfut-hook: version.dll proxy (forwards all 17 real exports) that
  injects into FIFA 23 and installs an inline detour on
  _ProtoSSLSendPacket, plus connect/DNS (getaddrinfo) capture and local
  LSX/redirect listeners. Logs to C:\openfut\hook.log.

- CLAUDE.md: bridge project context + the gate-sequence task roadmap.

Findings so far: with the anadius offline emulator the game never
attempts the online Blaze connection (local auth gate), so capture of
live Blaze frames is blocked pending an online-session fake.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 17:09:26 -07:00
7 changed files with 2185 additions and 0 deletions
+190
View File
@@ -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 25 are largely static/replayable (the client validates little). Gates 67
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.
+336
View File
@@ -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"
+30
View File
@@ -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]
+612
View File
@@ -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<usize> {
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::<MODULEINFO>() 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::<SOCKADDR_IN>() 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() {
"<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() {
"<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::<Vec<_>>()
.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<u16> {
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)
}
+196
View File
@@ -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"
+35
View File
@@ -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]
+786
View File
@@ -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 <hex-addr> [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<String> = std::env::args().skip(1).collect();
// Decide the mode from the first argument.
match args.first().map(|s| s.as_str()) {
// xref-str <text> [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 <text> [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 <hex-addr> [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 <hex-addr> [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 <hex-addr> [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 <hex-addr> [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<usize> {
let mut hits: HashSet<usize> = 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<usize> = 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<HashSet<usize>> = 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<memmem::Finder> = 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<usize> = 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 <ProtoSSLSend-string-addr>` 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<usize> = 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<usize> = 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<usize> = 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<usize> = 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 <disp32>` => 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::<Vec<_>>()
.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<String> {
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::<u32>().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<usize> {
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<u64> {
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<Vec<u8>> {
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<F: FnMut(usize, &[u8])>(
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::<MEMORY_BASIC_INFORMATION>(),
)
};
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<u32> {
unsafe {
let snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0).ok()?;
let mut entry = PROCESSENTRY32W {
dwSize: core::mem::size_of::<PROCESSENTRY32W>() 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<ModuleInfo> {
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::<MODULEENTRY32W>() 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])
}