From afdbb364caf4e82784389d884c2c5808346a9b15 Mon Sep 17 00:00:00 2001 From: funman300 Date: Wed, 5 Aug 2026 19:24:37 -0700 Subject: [PATCH] fifa17-recon: pack opening reversed end to end, and there is no pack-inventory endpoint A twelve-agent pass over the parts of pack opening we did not understand, run against the live client (CardsDLL slide proven, not assumed) plus static CardsDLL. Findings below survived an adversarial verification round that corrected several of them; where a verifier and a finder disagreed, the verifier won. THE HEADLINE IS A NEGATIVE, and it deletes work rather than creating it. There is no pack-inventory endpoint in FIFA 17 and there never was. Proven three independent ways: the 48-entry UTAS route template array at 0x18021df80, a regex for "ut/" over the whole PE, and the 125-row client action table at 0x1802caa20, which is the complete set of requests the client can originate. "Serve the pack inventory" comes off the backlog. The unclaimed-pack tile and My Packs are two fields on responses we already build. Corrections to ENDPOINT_MAP.md, both freeze-risky as written: * duplicateItemIdList is an array of OBJECTS (element parser 0x180138e10: itemId 0x16d, duplicateItemId 0xeb, itemLoans 0x16f, duplicateItemLoans 0xed), not the int list documented at :1095 and :218. Control that this is not a misread: dreamSquads 0xe9 in FutMoveCard genuinely is a bare int array and parses with no inner object loop. We serve [], so this is a docs bug today and a live freeze the moment somebody implements it from the map as written. * FutDiscardCardServerResponse is {"items":[{"id":N}],"totalCredits":N}. There is no top-level id. :968-971 is wrong twice over. packContentInfo is DECORATIVE. It is read only into a store-tile view model, and nothing compares the declared counts against the delivered itemList, so open_pack() does not have to honour the distribution. The reveal is entirely CLIENT-SIDE. Walkout, tiering, colours and ordering are arithmetic over fields we already send. Genuine outstanding server work reduces to three items: duplicates, quick-sell credit, unopenedPacks. Perishable intel captured: the real FIFA 17 retail pack catalogue, 41 SKUs with Origin offer ids, recovered from the client heap as a parsed copy of data/store/storecfg.xml. It is in no file on disk, only in a running process. futmem/ is a standalone read-only Rust crate for this kind of work (maps, find, strings, read). Read-only by construction: it opens /proc//mem with File::open and there is no code path in it that can write to another process, because a live game session depends on that. Its own [workspace] table keeps it out of the parent workspace. Chunked scanning overlaps by pattern_len-1 so a match spanning a chunk boundary is still found. utas_server.py gains FUT_PORT/FUT_LOG so a throwaway instance can be started without bouncing the one the live client is using. Defaults unchanged (8099, /tmp/utas_server.log). Noted for the record: this edit came from a research agent that had been told not to touch server code. It is benign and useful, but it was out of scope. Not committed: the doc proposes ENDPOINT_MAP.md changes as pasteable text rather than applying them, and every proposed server change defaults off per the house rule. Nothing in this commit changes a response the client sees. Co-Authored-By: Claude Opus 5 (1M context) --- .../docs/plan-2026-08-05-pack-opening.md | 1030 +++++++++++++++++ fifa17-recon/futmem/.gitignore | 1 + fifa17-recon/futmem/Cargo.lock | 16 + fifa17-recon/futmem/Cargo.toml | 27 + fifa17-recon/futmem/README.md | 249 ++++ fifa17-recon/futmem/src/cli.rs | 125 ++ fifa17-recon/futmem/src/dump.rs | 33 + fifa17-recon/futmem/src/image.rs | 201 ++++ fifa17-recon/futmem/src/main.rs | 576 +++++++++ fifa17-recon/futmem/src/maps.rs | 168 +++ fifa17-recon/futmem/src/mem.rs | 102 ++ fifa17-recon/futmem/src/scan.rs | 376 ++++++ .../tools/ghidra_queries/q_pack_buy_1.py | 136 +++ .../tools/ghidra_queries/q_pack_buy_2.py | 163 +++ .../tools/ghidra_queries/q_pack_buy_3.py | 160 +++ .../tools/ghidra_queries/q_pack_buy_4.py | 121 ++ .../tools/ghidra_queries/q_pack_buy_5.py | 116 ++ .../tools/ghidra_queries/q_pack_buy_6.py | 53 + .../tools/ghidra_queries/q_pack_buy_7.py | 59 + .../tools/ghidra_queries/q_pack_content_1.py | 142 +++ .../tools/ghidra_queries/q_pack_content_2.py | 146 +++ .../tools/ghidra_queries/q_pack_content_3.py | 148 +++ .../tools/ghidra_queries/q_pack_content_4.py | 136 +++ .../tools/ghidra_queries/q_pack_content_5.py | 91 ++ .../tools/ghidra_queries/q_pack_content_6.py | 109 ++ .../tools/ghidra_queries/q_pack_content_7.py | 101 ++ .../tools/ghidra_queries/q_pack_content_8.py | 78 ++ .../tools/ghidra_queries/q_pack_content_9.py | 133 +++ .../tools/ghidra_queries/q_pack_disp_1.py | 125 ++ .../tools/ghidra_queries/q_pack_disp_10.py | 66 ++ .../tools/ghidra_queries/q_pack_disp_11.py | 35 + .../tools/ghidra_queries/q_pack_disp_12.py | 57 + .../tools/ghidra_queries/q_pack_disp_13.py | 51 + .../tools/ghidra_queries/q_pack_disp_2.py | 108 ++ .../tools/ghidra_queries/q_pack_disp_3.py | 112 ++ .../tools/ghidra_queries/q_pack_disp_4.py | 68 ++ .../tools/ghidra_queries/q_pack_disp_5.py | 58 + .../tools/ghidra_queries/q_pack_disp_6.py | 74 ++ .../tools/ghidra_queries/q_pack_disp_7.py | 73 ++ .../tools/ghidra_queries/q_pack_disp_8.py | 84 ++ .../tools/ghidra_queries/q_pack_disp_9.py | 68 ++ .../tools/ghidra_queries/q_pack_inv_1.py | 112 ++ .../tools/ghidra_queries/q_pack_inv_10.py | 53 + .../tools/ghidra_queries/q_pack_inv_2.py | 125 ++ .../tools/ghidra_queries/q_pack_inv_3.py | 99 ++ .../tools/ghidra_queries/q_pack_inv_4.py | 133 +++ .../tools/ghidra_queries/q_pack_inv_5.py | 137 +++ .../tools/ghidra_queries/q_pack_inv_6.py | 94 ++ .../tools/ghidra_queries/q_pack_inv_7.py | 123 ++ .../tools/ghidra_queries/q_pack_inv_8.py | 95 ++ .../tools/ghidra_queries/q_pack_inv_9.py | 54 + .../tools/ghidra_queries/q_pack_reveal_1.py | 88 ++ .../tools/ghidra_queries/q_pack_reveal_2.py | 79 ++ .../tools/ghidra_queries/q_pack_reveal_3.py | 107 ++ .../tools/ghidra_queries/q_pack_reveal_4.py | 47 + .../tools/ghidra_queries/q_pack_reveal_5.py | 96 ++ .../tools/ghidra_queries/q_pack_reveal_6.py | 79 ++ .../tools/ghidra_queries/q_pack_reveal_7.py | 62 + .../tools/ghidra_queries/q_pack_reveal_8.py | 66 ++ .../tools/ghidra_queries/q_pack_v2_1.py | 124 ++ .../tools/ghidra_queries/q_pack_v2_2.py | 155 +++ .../tools/ghidra_queries/q_pack_v2_3.py | 132 +++ .../tools/ghidra_queries/q_pack_v2_4.py | 117 ++ .../tools/ghidra_queries/q_pack_v2_5.py | 83 ++ .../tools/ghidra_queries/q_pack_v3_1.py | 153 +++ .../tools/ghidra_queries/q_pack_v3_2.py | 123 ++ .../tools/ghidra_queries/q_pack_v3_3.py | 136 +++ .../tools/ghidra_queries/q_pack_v3_4.py | 146 +++ .../tools/ghidra_queries/q_pack_v3_5.py | 137 +++ .../tools/ghidra_queries/q_verify_1.py | 64 + .../tools/ghidra_queries/q_verify_2.py | 97 ++ .../tools/ghidra_queries/q_verify_3.py | 73 ++ fifa17-recon/tools/utas_server.py | 9 +- 73 files changed, 8971 insertions(+), 2 deletions(-) create mode 100644 fifa17-recon/docs/plan-2026-08-05-pack-opening.md create mode 100644 fifa17-recon/futmem/.gitignore create mode 100644 fifa17-recon/futmem/Cargo.lock create mode 100644 fifa17-recon/futmem/Cargo.toml create mode 100644 fifa17-recon/futmem/README.md create mode 100644 fifa17-recon/futmem/src/cli.rs create mode 100644 fifa17-recon/futmem/src/dump.rs create mode 100644 fifa17-recon/futmem/src/image.rs create mode 100644 fifa17-recon/futmem/src/main.rs create mode 100644 fifa17-recon/futmem/src/maps.rs create mode 100644 fifa17-recon/futmem/src/mem.rs create mode 100644 fifa17-recon/futmem/src/scan.rs create mode 100644 fifa17-recon/tools/ghidra_queries/q_pack_buy_1.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_pack_buy_2.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_pack_buy_3.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_pack_buy_4.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_pack_buy_5.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_pack_buy_6.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_pack_buy_7.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_pack_content_1.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_pack_content_2.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_pack_content_3.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_pack_content_4.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_pack_content_5.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_pack_content_6.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_pack_content_7.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_pack_content_8.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_pack_content_9.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_pack_disp_1.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_pack_disp_10.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_pack_disp_11.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_pack_disp_12.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_pack_disp_13.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_pack_disp_2.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_pack_disp_3.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_pack_disp_4.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_pack_disp_5.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_pack_disp_6.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_pack_disp_7.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_pack_disp_8.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_pack_disp_9.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_pack_inv_1.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_pack_inv_10.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_pack_inv_2.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_pack_inv_3.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_pack_inv_4.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_pack_inv_5.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_pack_inv_6.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_pack_inv_7.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_pack_inv_8.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_pack_inv_9.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_pack_reveal_1.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_pack_reveal_2.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_pack_reveal_3.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_pack_reveal_4.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_pack_reveal_5.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_pack_reveal_6.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_pack_reveal_7.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_pack_reveal_8.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_pack_v2_1.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_pack_v2_2.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_pack_v2_3.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_pack_v2_4.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_pack_v2_5.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_pack_v3_1.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_pack_v3_2.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_pack_v3_3.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_pack_v3_4.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_pack_v3_5.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_verify_1.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_verify_2.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_verify_3.py diff --git a/fifa17-recon/docs/plan-2026-08-05-pack-opening.md b/fifa17-recon/docs/plan-2026-08-05-pack-opening.md new file mode 100644 index 0000000..990a01d --- /dev/null +++ b/fifa17-recon/docs/plan-2026-08-05-pack-opening.md @@ -0,0 +1,1030 @@ +# The pack opening system, end to end + +Written 2026-08-05, after six parallel reversing passes over the store, the pack +reveal and the card-disposition path, plus two adversarial verification rounds +that overturned five of the original claims. FIFA 17 was running throughout as +pid 4048 and was read strictly read-only. Nothing proposed here has been in +front of the game yet. + +Slide used for every live read, re-derived and re-proven in this document's own +final check: `live = static - 0x180000000 + 0x6ffffc140000`. Controls were the +FNV hasher prologue at `0x180180d00` and the 64-byte `RS4:FutSquadSaveServerResponse` +literal at `0x18022c618`, both taken from the on-disk PE rather than asserted. +Both matched. These bases die with the process; the static addresses do not. + +--- + +## 1. What we now know that we did not know this morning + +**There is no pack-inventory endpoint in FIFA 17, and there never was.** The +client cannot list, claim or open an already-owned pack over HTTP, because no +route exists for it and no client action composes one. Three independent proofs: + +- The UTAS API template array in `.rdata` at `0x18021df80..0x18021e280` holds 48 + `(path, token)` pairs. The only pack-relevant entries are `ut/%s/store`, + `ut/v2/%s/store`, `ut/%s/purchased` and `ut/%s/item`. A regex for `ut/` over + all 3,179,952 bytes of the PE returns 59 literals and adds nothing. +- The client action table at `0x1802caa20` (125 populated rows of 0x30 bytes, + `{name, urlIndex, TAG, ...}`) is the complete set of requests the client can + originate. Its pack rows are `PurchasePack` (urlIndex 0x1a), `PurchasedItems` + (0x1a), `PurchaseItems` (0x1b), `StorePackTypes` (0x1b) and + `StorePackQuantities` (0x1b). No claim, no list, no open. +- The pending-pack surface that *does* exist is entirely inside responses we + already serve. `userInfo.unopenedPacks` is a counter pair that lights a game-hub + tile, and "My Packs" is a filter over the ordinary store catalogue: entries + whose `displayGroup.value` string equals `mypacks` (`FUN_1800150d0`, literal at + `0x1801ec008`). Neither issues a request. + +That is a negative result and it is the most valuable thing in this document, +because it deletes work rather than creating it. "Serve the pack inventory" has +been an implicit item behind the unclaimed-pack tile and the My Packs screen for +two rounds. It is not an endpoint we are missing. It is two fields on responses +we already build. The whole pack system reduces to four routes, all of which are +live today. + +Four other findings change what we should do next, in descending order: + +**`packOpeningAnimationEnabled` is already ON, and this contaminates yesterday's +settings-gate plan.** One reversing pass traced the flag from settings switch arm +`0x20e` through `FUN_18011dc50` to `FutDataManagerImpl+0x1fd45`, concluded the byte +defaults to zero, and recommended shipping a `/settings` row to turn it on. I read +the byte out of pid 4048 myself, at a moment when `/proc/3740/environ` carries no +`FUT_*` variables and `/tmp/utas.log` records `GET /ut/game/fifa17/settings` at +14:39:21 answered with `{"configs": []}`: + +``` +slot +0x2b0 -> 0x18011c500 disp 0x1fd3a (friendlySeasonsEnabled) value = 1 +slot +0x2c8 -> 0x18011c4b0 disp 0x1fd3d (enableDraftMode) value = 1 +slot +0x2e0 -> 0x18011c590 disp 0x1fd45 (packOpeningAnimationEnabled) value = 1 +``` + +A whole-`.text` disassembly scan shows `FUN_18011dc50` is the only writer of any +byte in `0x1fd28..0x1fd50`, so the applier ran and the settings struct it was +handed simply defaults those fields to 1. Two consequences. The proposed +`packOpeningAnimationEnabled` row is a no-op, so if the reveal animation is not +playing the cause is one of the other four terms of the gate. And +`docs/plan-2026-08-05-settings-gate.md` section 1 asserts that +`IS_FRIENDLY_SEASON_ENABLED` and `IS_DRAFT_MODE_ENABLED` "have never been set to +true by anything, on any run". Measured, both are 1. That premise is false for +this struct, and the Seasons diagnosis built on it needs redoing before that +launch is spent. + +**`duplicateItemIdList` is an array of objects, and both places the docs describe +it are wrong in a freeze-risky direction.** Element parser `0x180138e10` reads +`itemId` (0x16d), `duplicateItemId` (0xeb), `itemLoans` (0x16f) and +`duplicateItemLoans` (0xed) into 0x20-byte records. `ENDPOINT_MAP.md:1095` and +`:218` both call it an int list. The same binary provides the control that this is +not a misread: `dreamSquads` (0xe9) in `FutMoveCard` genuinely is a bare int array, +parsed by a `while (tok != 0xd)` loop calling the int getter directly, with no +inner object loop. We currently serve `[]`, which is safe, so this is a documentation +bug rather than a live bug. It becomes a live bug the moment somebody implements +duplicates from the map as written. + +**`FutDiscardCardServerResponse` is `{"items":[{"id":N}], "totalCredits":N}`, and +quick sell currently pays nothing.** `items` (0x171) is an array of OBJECTS; there +is no top-level `id` key. `ENDPOINT_MAP.md:968-971` documents +`{"items":[123456789], "totalCredits":15000, "id":123456789}`, which is the wrong +shape twice over. Separately, the client sends a bulk discard as +`{"itemId":[id, id, ...]}` (atom 0x16d) and a single discard with the id in the URL +as `/%llu`, and `quick_sell_route()` in `utas_server.py` parses neither: it looks +for `itemData` and `itemIds`. The route regex `/ut/delete/game/[^/]+/item` is +unanchored, so a URL-suffix discard still *matches* the route; the id is simply +never extracted. `ids` comes out empty, `STORE.quick_sell([])` sells nothing, and +the response is a bare `{}` with no `items` array, so the client's own local removal +(model slot +0xa30, driven by the ids it parses back) never fires either. + +**Two readings collide here and I am not going to smooth it over.** The static +reading above says a quick sell today is a complete no-op. The docstring on +`quick_sell_route()` records a *live observation* from 2026-08-04 saying the +opposite: "nothing was credited, so a quick sell destroyed the cards for 0 coins." +Both cannot be true of the code as it now stands. I would bet on the static reading, +for two reasons: the code path is short enough to read end to end and it cannot +remove a card it has no id for, and the docstring is describing the state that +*prompted* the handler to be written, when the path was unmapped and answered with a +generic body. But "the cards visibly disappeared from the reveal screen" is also +exactly what a client-side removal looks like before a reload puts them back, and +nobody has checked the save file after a quick sell. Section 7 step 4 settles it for +the cost of one menu action, and until it does, treat "quick sell destroys nothing" +as the more likely of two live possibilities rather than as established. + +**The FIFA 17 retail pack catalogue is recoverable from the running client and +nowhere else.** 41 complete `` records from +`data/store/storecfg.xml` are sitting in the heap at `0x3dabe000..0x3dac7000`, +each with a numeric server id, a display name, an Origin offer id and an +entitlement id of the form `FIFA17FUTPACKnnn`. The text is not in CardsDLL, not in +the packed exe, not in `stp-origin_emu.dll`, and not in any of 256 files totalling +3.24 GiB scanned under the game install. It will be unrecoverable when this process +exits. The full table is in section 5. + +--- + +## 2. The pack opening system, end to end + +Everything below is graded CONFIRMED (read from the binary or observed on the +wire), INFERRED (a chain of reasoning with no single decisive observation), or +UNKNOWN. Addresses are static CardsDLL VAs at base `0x180000000`. + +### 2.1 Catalogue + +**CONFIRMED.** `GET ut/%s/store/purchasegroup/all?ppInfo=true`, built by +`FUN_180123430` from three literal appends. Response class +`FutStoreGetPackTypesServerResponse`, root deser `0x1801234e0`, which reads +`purchase` (0x260, array) and `timestamp` (0x31b, int). + +Each element goes to the pack deser `0x18013af30`, which fills a 0x158-byte record. +**Only the first 100 elements survive**: at `0x18013bb02` a `CMP EDX,0x64` after +dividing the vector span by 0x158 jumps past both push_back paths straight to the +record destructor at `0x18013bb49`. Elements 101 and up are fully parsed and thrown +away, silently. + +The dispatch is a pre-table ladder plus an MSVC jump table at `0x18013b792` +(`LEA EAX,[R15-0x20f]` / `CMP EAX,0x89`, byte index table at `0x18013bcb4`, seven +targets at `0x18013bc98`). Decoding that table settles the "these atoms are skipped" +question independently of any decompiler. Field map, all offsets relative to the +0x158-byte record: + +| key | atom | getter | offset | note | +|---|---|---|---|---| +| `assetId` | 0x23 | INT | +0x74 | the real pack identity | +| `id` | 0x15c | INT via `0x1800d7b10` | +0x70 | **u16 store**, values above 65535 truncate | +| `description` | 0xd1 | STR | | tile caption | +| `sortPriority` | 0x2cb | INT | +0x7c | | +| `firstPartyStoreId` | 0x127 | **STR then `atoi()`** | +0x78 | int here is the documented freeze | +| `displayGroup` | 0xd9 | **flat OBJECT** | | `value` (0x377, STR) to +0x00, `priority` (0x250, INT) to +0x34 | +| `currencies` | 0xc5 | ARRAY | +0x40..+0x48 | 0x30-byte records via `0x180138bd0` | +| `extPrice` | 0x119 | OBJECT | | see below | +| `packContentInfo` | 0x20c | flat OBJECT | +0x144..+0x154 | exactly five children | +| `state` | 0x2eb | STR vs `"active"` | +0xb0 | ctor default 0, i.e. not active | +| `start` | 0x2e3 | INT, non-negative clamp | +0xb4 | **top level, not in packContentInfo** | +| `end` | 0x102 | INT | +0xb8 | | +| `quantity` | 0x26b | INT | +0xbc | a sent 0 is rewritten to -1 | +| `purchaseLimit` | 0x265 | INT | +0xc0 | | +| `purchaseCount` | 0x261 | INT | +0xc4 | | +| `saleType` | 0x298 | STR to enum | +0xc8 | `NONE` 0, `QUANTITY` 1, `TIME` 2, `TIME_QUANTITY` 5, anything else 0 | +| `dealType` | 0xcc | STR, lowercased | +0xcf/+0xd0 | compared against `promo` and a 4-char literal | +| `useDefaultImage` | 0x36a | BOOL, **stored inverted** | +0xcc | | +| `unopened` | 0x35d | BOOL, raw | +0xcd | **top level, not in packContentInfo** | +| `isPremium` | 0x176 | BOOL | +0xce | | +| `packType` | 0x20f | STR | +0x108 | ctor default `"INVALID"` | +| `visible` | 0x37d | **no getter at all** | +0x138 | one instruction, `MOV byte [RBP+0x88],1`; presence sets it, the value is never read | +| `points` | 0x240 | INT | +0x140 | | + +`packContentInfo` has exactly five children and nothing else: +`itemQuantity` 0x170 to +0x144, `goldQuantity` 0x149 to +0x148, `silverQuantity` +0x2c6 to +0x14c, `bronzeQuantity` 0x63 to +0x150, `rareQuantity` 0x273 to +0x154. +Any other key inside it reaches the safe SKIP at `0x180135ff0`. Our +`_pack_body()` sends `"unopened": false` inside it, which is therefore inert. + +`extPrice` is documented wrong. `finalPrice` (`0x180139070`) and `originalPrice` +(`0x18013aae0`) are 152 instructions each and contain exactly one atom comparison +apiece, `CMP r,0x11a`. Neither reads `amount` (0x1b) or `currency` (0xc4). The one +key each reads is `externalPriceId` (0x11a, INT). `finalPrice` also writes its value +straight into the pack's `firstPartyStoreId` slot at +0x78, so `extPrice.finalPrice` +and the top-level `firstPartyStoreId` are the same slot reached two ways. + +Currency elements (`0x180138bd0`, stride 0x30): `name` 0x1d0 STR, `funds` 0x134 INT, +`finalFunds` 0x124 INT, both ints through the non-negative clamp `0x1800d7b30` (a +negative price silently becomes 0). The tile adapter takes the **coin** price from +`finalFunds` and ignores `funds`; it takes the mtx price from `funds` and then +overwrites it with the platform-store price anyway. + +**Consumer, and the verdict on packContentInfo: DECORATIVE. CONFIRMED.** +Exactly one function in CardsDLL reads those five slots, `FUN_18002c3c0`, a +record-to-tile adapter with a single code caller, `FUN_1800150d0`. It is a straight +MOV-to-MOV copy into view model +0xc0..+0xd0 with no arithmetic and no comparison. +An operand-text census over 13,308 functions and 469,335 instructions finds exactly +three functions carrying all five offsets: that adapter, the vector copy +`0x180133210` and the copy-assign `0x1801340e0`. Nothing anywhere compares the +declared quantities against a delivered item list. `open_pack()` does not have to +honour the declared distribution. It should eventually, because the numbers are +rendered on the tile, but it is cosmetic and can be parked. + +### 2.2 Purchase + +This is the part where the static reports and the wire disagreed, and the wire wins. + +**CONFIRMED, observed live this session.** At 14:39:28 on every boot the client +sends: + +``` +PUT /ut/v2/game/fifa17/store/transaction/0 +body: {"state":"TRANSACTIONCANCEL"} +``` + +That is unmistakably the `PurchaseItems` request serializer `FUN_180126440`: it +always emits `state` (0x2eb) from the 9-entry table at `0x1802d02c0`, and the URL +builder `FUN_180126720` appends `/transaction` and then `/` as `%lld` +whenever the state enum exceeds 1 (`TRANSACTIONCANCEL` is 7, the transaction id is +0). One reversing pass concluded that this first-party checkout path "an offline +emulator with no platform store will never see". It fires on every boot. The +`state == "TRANSACTIONCANCEL"` branch in `store_buy()` is live code, not dead code, +and it is the reason no phantom pack opens at startup. + +**Two distinct classes, chosen client-side. CONFIRMED.** `CreatePack` (ctor +`0x1801623d0`, call id 0x4b, vtable `0x180228270`) and `PurchaseItems` (ctor +`0x1801263a0`, call id 0x4c, vtable `0x1802202f8`) are separate ServerCall classes +with separate response factories and deserializers. The two vtables differ at slots +6, 9, 12 and 16. Nothing in any server response selects between them; there is no +fork for us to drive. + +The `CreatePack` body is produced by `FUN_180162530` and has exactly four keys, all +derived from one mode integer at `this+0x20`: + +``` +packId (0x20b) = int +useCredits (0x369) = (mode == 0) +usePreOrder (0x36b) = (mode == 4) +currency (0xc4) = "MTX" if mode==1, "POINTS" if mode==2, else "COINS"; omitted entirely when mode==4 +``` + +**Where the coin buy goes: INFERRED, high.** No static pass could find the URL +builder for `CreatePack`. Two independent sources agree that it is +`POST ut/%s/purchased`: the action table row `PurchasePack` carries urlIndex 0x1a, +which resolves to `ut/%s/purchased`, and the live ground truth recorded in +`utas_server.py:2557` is that FIFA sends `{"packId":1,"useCredits":1,"usePreOrder":0, +"currency":"COINS"}` and then immediately polls `GET /purchased`. So the coin buy is +`POST /purchased` with a `CreatePack` body, and `store/transaction` is the +first-party path. + +**Consequence, and this one matters: the `createPackResponse` envelope has never +actually been parsed by the client. INFERRED, high.** `store_buy()` only builds it +when the body carries an integer `packId`, and the only bodies the client sends to +`store/transaction` are state bodies, so that branch never fires. Meanwhile +`POST /purchased` answers with the *request* shape (`packId`, `firstPartyStoreId`, +`groupName`, `productId`, `purchasePackType`), and `FutCreatePackServerResponse`'s +deser `0x180162880` recognises only four atoms (`duplicateItemIdList` 0xec, +`itemList` 0x16e, `numberItems` 0x1dd, `purchasedPackId` 0x264), so all of it is +skipped. The reveal gets its cards from the follow-up `GET /purchased` instead. +That works because **both responses write the same container**: the shared item +manager reached as `FUN_18011a830()` then vtable slot +0x160. `FutCreatePack` +fills it and `FutGetPurchasedItems` fills it, and we happen to be using the second. + +`ENDPOINT_MAP.md` calls the `createPackResponse` body "already handled, VERIFIED +byte-exact". It is verified against `store_buy()`, not against the client. Nothing +has ever read it. + +> **RESOLVED, 2026-08-05 evening, by `tools/ghidra_queries/q_envelope_{1,2,3}.py`. +> Reading A is correct. The envelope is structurally required. Its name is never +> checked. Reading B rested on a factual error, corrected below.** +> +> The token enum, decoded from the class table at `DAT_18023dd40` and the switch in +> the classifier `FUN_1801c67a0` (container state 2 = object, 3 = array, which is what +> the push/pop arms key off): +> +> | token | meaning | set by | +> |---|---|---| +> | 9 | START_OBJECT | case 0x64, pushes state 2 | +> | 10 | END_OBJECT | case 0x65, pops state 2 | +> | 11 | FIELD_NAME | confirmed independently by `FUN_18013bd40`, which tests `+0xd0 == 0xb` then atom-hashes `+0xf8` | +> | 12 | START_ARRAY | case 0x66, pushes state 3 | +> | 13 | END_ARRAY | case 0x67, pops state 3 | +> | 1 | error | the `caseD_78` sink | +> +> So the three unconditional tokens are **`{`, the first FIELD_NAME, and the token that +> opens that field's value**. For `{"createPackResponse":{...}}` that is 9, 11, 9, and +> the loop then reads the inner object's keys and exits on 10. The wrapper key's name is +> consumed as token 2 and never hashed, which is why the ladder has no arm for atom +> `0xbe` and does not need one. Coverage for that absence: the ladder has exactly four +> arms (`0xec`, `0x16e`, `0x1dd`, `0x264`) and `0xbe` does not occur anywhere in the +> full 4702-char decompile, printed in full. +> +> **The error in reading B.** It claimed the `/purchased` root "spends the same three +> tokens". `FUN_180124ee0` spends **two**, then hands off to `FUN_18013bd40`, which +> spends the third. The count is the same in total but it is split across two functions, +> and the sub-parser is the one that loops on FIELD_NAME. So `/purchased` never was a +> counterexample. It is `{"itemData":[...]}`, whose three tokens are 9, 11, 12, and the +> array case is handled by the sub-parser rather than by the key ladder. +> +> **The general rule, and the thing to be careful about.** A census over all 86 +> begin-object callers puts 67 at exactly three tokens before the first key read. For +> those roots the first key/value pair of the body is consumed **blind**: the key is not +> hashed and not dispatched. That is harmless when the first value is an object, because +> the loop then walks that object. It silently discards data when the first value is a +> scalar. Any flat multi-key body served to a three-token root therefore loses its first +> key. `GET /hub` returns `{"clubPlayers":205,"auctionCount":0}` and is the obvious thing +> to check against this, but I could not close it here: there is no +> `RS4:FutGetHubServerResponse` literal in the image, and `clubPlayers` and +> `auctionCount` appear only as atom-table entries with no code xref, so `/hub` is not +> parsed by a generated root at all and the rule above may simply not apply to it. +> That is the next thing to look at, and it is UNKNOWN, not "fine". + +**The envelope rule itself, as it stood before the resolution above: UNKNOWN, and two readings were live.** `FUN_180162880` +calls the next-token primitive three times unconditionally at `0x180162942`, +`0x18016294b` and `0x180162954`, tests only the third against token 10, and has no +arm for `createPackResponse` (atom 0xbe) anywhere in its ladder. So it descends +exactly one wrapper level without ever checking the wrapper key's name: the envelope +is structurally required and its *name* is irrelevant. The competing reading is the +verifier's, that the token count decides nothing, because `FutCreateUser` +(`0x18014cc60`), `FutDiscardCard`, `FutMoveCard` and the `/purchased` root +(`0x180124ee0` plus `0x18013bd40`) all spend the same three tokens, and we serve +`POST /user` and `GET /purchased` unwrapped and both work. I would bet on the first +reading for `CreatePack` specifically, because the three calls there are +unconditional and only the third is tested, which is a descend and not a probe. But +the two readings cannot both be right for `/purchased`, and I am not going to +pretend the contradiction is settled. Section 4 gives the one decompile that +settles it. + +Incidental but useful: `200` with `{}` is genuinely safe on the buy, not +accidentally safe. For a body of `{}` the three unconditional tokens see `{`, `}`, +end-of-input, the third returns 10 and the function jumps straight to its exit with +the constructor's zeroed vectors intact. + +**HTTP status. CONFIRMED.** `FUN_1801844c0` is a single status-to-error table +reached through ServerCall vtable slot 12. Only 200 maps to success. 204 maps to 99. +**Any other 2xx, including 201 and 202, falls through to 999.** 461 maps to 3, which +is what our insufficient-coins refusal already uses; most 4xx and 5xx map to 998. +`PurchaseItems` alone overrides slot 12 with `FUN_1801267b0`, which turns HTTP 409 +plus a body containing the exact substring `User already has a transaction` into +code 0x70, a pending code the transaction state machine re-polls rather than +failing. Audit result: `utas_server.py` returns only 200, 404 and 461 anywhere, so +this is already clean. + +**FIFA Points: out of scope, CONFIRMED.** The tile price formatter `FUN_18002cc90` +opens with `if (*(int *)(param_1 + 0x6c) == -1) return;` and then queries the +platform store catalogue for the real-money price. `tile+0x6c` is the pack's +`firstPartyStoreId`, whose constructor default is -1. Offline there is no platform +catalogue, so the points path is dead by construction and needs nothing from us. + +### 2.3 Reveal + +**CONFIRMED.** `FUN_1800aa440` is the whole reveal brain, registered as the handler +for UI message 9 on the pack-open sublevel (`FUN_18001eb90` loads +`gmLoadFUTPackOpenSublevel`); message `0x2742` is the teardown that broadcasts +`gmUnloadFUTPackOpenAnimation`. It walks the shared item container (model vtable ++0x160), keeps only items whose `cardtype` is 1, and picks a headline. **Neither +branch issues any HTTP request**: the whole 17,996-character decompile contains no +URL builder and no request enqueue. + +The five-way AND that chooses flow transition 0x33 (play the reveal) over 0x34 +(skip it): + +1. a best item was found, +2. `(item->resourceId & 0xffffff) != 0`, +3. `item->cardtype == 1`, +4. the data provider has an `ASSET_ID`, +5. `FutDataManagerImpl+0x1fd45` (`packOpeningAnimationEnabled`) is non-zero. + +Term 5 is measured at 1 right now. If the animation is not playing, look at terms +2 to 4. Term 2 is satisfied by what `open_pack()` already sends, since our +`resourceId` is `(version << 24) | asset`. + +Selection, all client-side arithmetic over fields we already control: + +- **Ranking key is `discardValue` (item+0x38, atom 0xd7), not rating.** When it is + zero or absent the client computes its own quick-sell price into item+0x3c from + the local `fcc_discardcoins` table keyed on `cardtype`, `level` and `rare`, scaled + by rating and divided by 100 with round-half-up. Sending a non-zero `discardValue` + therefore overrides EA's own economy table and directly dictates which card gets + the walkout. `level` (item+0x54) has no JSON arm at all and is always 0. +- **Presentation tier** is `FUN_1800a9fe0(rareflag, rating)` returning 1, 2 or 3 + from a hardcoded table: rareflag 5 or 6 always 3; rareflag 0xb, 0xc and 0x15 have + their own thresholds; everything else is rating > 89 for 3, and rating > 83 or + exactly 74 or exactly 64 for 2. The 74 and 64 cases are the tops of the silver and + bronze bands. +- **A special headline predicate outranks discardValue.** `FUN_1800aa330` returns + true when `loans < 1` and (`rating > 87` or the playerid appears in the local + `fcc_GrandStandPlayers` table). The first predicate-true item takes the headline + regardless of value, and only another predicate-true item with a strictly greater + value can displace it. +- **Wire order is a tiebreak only, for the summary strip.** `FUN_1800a96a0` is a + stable descending sort on `discardValue`; the best-item scan uses strict + greater-than so the earliest item wins ties. The underlying vector keeps wire + order, so `random.shuffle()` in `open_pack()` is a real ordering decision for any + screen that renders the vector directly, and a tiebreak for the strip. + +`NUM_ITEMS_IN_PACK` and its four siblings are pushed to the data provider by +`FUN_180015d80` from the pack **definition** at +0xc0..+0xd0, not from the opened +item list. Whether those definition fields come off the wire is **UNKNOWN**: the +read offsets are confirmed but the sole caller `FUN_1800147f0` was not traced, and +nine unrelated functions write the same displacement group. + +The community term "walkout" does not exist in this client. The single `Walkout` +string in 3.5 GiB of process memory sits between `Lineup` and `Handshake` in a +match-presentation cutscene enum. + +### 2.4 Disposition + +**CONFIRMED.** `duplicateItemIdList` is not inert. At the end of the createPack +parse, `FUN_180162880` runs a post-pass that walks the reveal collection and, for +every card whose id matches an entry's `itemId`, writes that entry's +`duplicateItemId` into the card object at +0x10. The reveal controller +`FUN_18009bc40` reads exactly that field: non-zero means duplicate, and the screen +fires the UI event `GotoNewItems` and issues **no request**. Zero means the normal +path, which builds a one-element `{id, pile=7, swap=0, tradeId=0}` record and submits +it through model vtable slot +0xc0. The swap flow behind the duplicate branch is +`FutMoveCard` with a per-item `swap` key (atom 0x2fe) carrying the second id. + +**Quick Sell is called Discard.** There is no `quickSell` byte sequence anywhere in +CardsDLL, in any casing. Two shapes: + +- single: `ut/delete/%s/item` plus `/%llu` appended by `FUN_180127570`, no body; +- bulk: `ut/delete/%s/item` with `{"itemId":[id, id, ...]}` (atom 0x16d) built by + `FUN_180126f40`, one request for the whole batch, clamped client-side at 64. + +Response `FutDiscardCardServerResponse` (deser `0x180127300`, also reachable as slot ++0x08 of the response vtable `0x180220488`): `items` (0x171) is an array of objects +each carrying `id` (0x15c), and `totalCredits` (0x326) is an int. Each parsed id is +also passed to model slot +0xa30, which removes the item from the client's local +model. There is no top-level `id`. Nothing in CardsDLL reads `totalCredits` back out +of +0x28: the response vtable's apply slot `0x180122420` is literally `return;` and +the only two xrefs to the vtable are its own constructors. So the coin figure is +server-authored and parsed, but the balance almost certainly moves via a +`GET ut/%s/user` refresh, which we already serve correctly. + +**Send to Transfer List is the same route as Send to Club.** `PUT ut/%s/item`, +`FutMoveCard`, with `pile` set to `trade` instead of `club`. The pile vocabulary is +exactly three strings, decoded by `FUN_180142650`: `club` 7, `purchased` 6, `trade` +5, anything else 0. + +`FutMoveCard` (`0x180128600`, 6193 characters, whole function read) parses only +`itemData` at the top level. Element keys are `id`, `pile`, `success`, `reason` and +`dreamSquads`. **It does not parse `chemistry` (0x81), and neither does +`FutMoveCardByRes` (`0x180128e30`).** The `"chemistry": true` in ENDPOINT_MAP rows 10 +and 11 is a no-op on both. The string `Destination Full` under `reason` is +special-cased to error code 0xf. + +The three `ByRes` actions (`ApplyCardByRes`, `DiscardCardByRes`, `MoveCardByRes`) all +carry urlIndex 0x0e, which resolves to `ut/%s/item/resource`, not to `ut/%s/item`. + +### 2.5 Inventory counters + +**CONFIRMED.** `userInfo.unopenedPacks` (0x35e) is an object whose only recognised +children are `preOrderPacks` (0x24b) and `recoveredPacks` (0x27b). `count` (0xbc) is +not read there, though the same class does read it under a different parent (atom +0x59). The two are summed and passed to model vtable slot +0x4e0 (`0x18011e120`), +which stores the total at `model+0x20950` and broadcasts message `0x273d`, registered +as the string `RELOAD_CENTRAL_PANEL`. That rebuilds the FUT game hub central panel, +where tile type 0x1c renders `CentralUnclaimedPack` with destination +`GOTO_STORE_MYPACK`. + +Measured live in pid 4048: `model+0x20950 = 0`, consistent with never having served +the field. + +`FutUserCreditsServerResponse` (`0x180122c50`) also carries `unopenedPacks` with the +same two children nested inside it, and `FutSBCSubmitChallengeServerResponse` +(`0x180161b00`) carries `preOrderPacks` and `recoveredPacks` at the top level. So an +SBC that awards a pack reports it by bumping counters, not by returning a pack object. + +Atom 0x20d `packList` is **not** a wire key: the only parser that dispatches on it +reads the local file `packs/dreamsquad/dreamsquadpacklist.json`. Graded MEDIUM, +because the uniqueness half was not independently reproduced (see section 4). + +--- + +## 3. Server-authoritative versus client-side + +Strict reading: a thing is server-authoritative only if the response we send +determines it. "The client parses it" is not enough; "the client renders it" is not +enough either if the client would compute the same thing from something else. + +| Mechanism | Owner | Emulator has to reimplement? | +|---|---|---| +| Pack catalogue: which packs exist, ids, captions, sort order | SERVER | Yes, done | +| Coin price of a pack (`currencies[].finalFunds`) | SERVER | Yes, done | +| Coin balance and the debit | SERVER | Yes, done | +| Which cards a pack contains (`itemList`) | SERVER | Yes, done | +| Number of cards revealed | SERVER (the list length; `numberItems` is written and never read) | No extra work: send the list | +| Duplicate detection (`duplicateItemIdList`) | SERVER | Yes, not built. Requires the createPack envelope path | +| Quick-sell payout on the wire (`totalCredits`) | SERVER | Yes, not built. Currently pays nothing | +| Which items a discard removes | SERVER | Yes, partly: our accounting works, our request parsing does not | +| Move verdicts (`itemData[].success`, `reason`) | SERVER | Yes, done for `club`; `trade` and `purchased` untested | +| Pile vocabulary (`club`/`purchased`/`trade`) | SERVER (we choose the string, client maps it) | Already correct | +| `unopenedPacks` counters and the unclaimed-pack tile | SERVER | Yes, one field. Zero today | +| "My Packs" grouping (`displayGroup.value == "mypacks"`) | SERVER | Yes, one string. Not sent today | +| Pack availability: `state`, `saleType`, `quantity`, `purchaseLimit`, `purchaseCount`, `start`, `end` | SERVER (all parsed and copied to the tile) | Optional. The predicate that greys a tile is in the packed exe and unread | +| HTTP status to FUT error code | SERVER | Yes, done: only ever answer 200 | +| Transaction state machine (`state`, `transactionId`, 409 pending) | SERVER, on the first-party path only | Only the cancel, which we already answer with 200 `{}` | +| `packContentInfo` tier quantities | SERVER, but purely decorative | Cosmetic only. Nothing compares them to delivery | +| Reveal animation on/off (`packOpeningAnimationEnabled`) | SERVER in principle, via `/settings` | No. The byte is already 1 | +| Which card gets the walkout | CLIENT, from `discardValue` we send | No logic. Only field values | +| Presentation tier / card colours (`FUN_1800a9fe0`, `FUN_1800aa060`) | CLIENT, from `rareflag` and `rating` | No | +| Grandstand headline override | CLIENT, from `loans`, `rating`, local table | No | +| Summary strip ordering | CLIENT, stable sort on `discardValue` | No | +| Quick-sell price shown on a card when `discardValue` is 0 | CLIENT, from `fcc_discardcoins` | No. Omitting the field is the EA-authentic behaviour | +| `cardtype` from `cardsubtypeid` (`FUN_1800d8330`) | CLIENT | No | +| Store tile rendering, grouping, backgrounds | CLIENT | No | +| Game-hub tile types and captions (`FUN_1800b2680`) | CLIENT | No | +| FIFA Points pricing | CLIENT plus platform store | No. Out of scope offline | +| 64-item clamp on bulk operations | CLIENT | No, but accept a list of up to 64 | +| 100-pack cap on the catalogue | CLIENT | No, but never send a 101st pack | +| DIME entitlement mapping (`FIFA17FUTPACKnnn`) | CLIENT plus Origin | No. UTAS never serves it | + +The row that decides how much work exists: almost everything expensive-looking about +pack opening (the walkout, the tiering, the colours, the ordering, the animation) is +client-side arithmetic over fields we already send. The genuine outstanding +server-side work is three items: duplicates, quick-sell credit, and the +`unopenedPacks` counter. + +--- + +## 4. What is still unknown, and the cheapest way to settle it + +### Needs more decompiling (cheap, no launch, no risk) + +1. ~~**The envelope rule.**~~ **DONE 2026-08-05 evening.** See the resolution box in + section 2. Token enum decoded, reading A confirmed, `/purchased` shown not to be a + counterexample. `POST /user` and `GET /purchased` are already correctly wrapped and + need no change. The follow-up it opened, which is now the live question: **does the + three-token blind-first-pair rule apply to `GET /hub`, which we answer with a flat + two-key body?** There is no `FutGetHubServerResponse` class and no code xref to + either atom, so `/hub` may not go through a generated root at all. UNKNOWN, and + worth one query. +2. **`NUM_*_IN_PACK` authority.** Decompile `FUN_1800147f0`, the sole caller of + `FUN_180015d80`, and see whether its `param_4` is the JSON-filled pack record. One + function. Until then those five numbers are UNKNOWN, not SERVER. +3. **Whether anything reads `FutCreatePackServerResponse+0x28` (`numberItems`).** + The original evidence was a misread of an adjacent vtable: `0x180228260` is a + two-slot response vtable, `{0x1801624e0 dtor, 0x180162880 deser}`, and + `0x180228270` is the separate ServerCall vtable. The conclusion survives trivially + (a two-slot vtable has no accessor) but the non-virtual path is unproven and the + offset is disp8-encodable, so no byte scan bounds it. +4. **The three unread consumers of the `mypacks` literal**, `FUN_180014df0`, + `FUN_180014580` and `FUN_1800147f0`. Only `FUN_1800150d0` was read. +5. **Atom 0x20d uniqueness.** The dream-squad identification is solid; the "only + parser" half was not independently reproduced, because a raw immediate scan cannot + see running-sum ladder dispatch and its own controls failed. +6. **`class_deser()` is currently broken** in the rebuilt Ghidra projects: it returns + `[]` for all three documented control classes, because it searches for + `"\0"` while the only literal is `"RS4:ServerResponse"`. Fix + it to take the full name or delete it. Until then, any absence claim resting on an + empty result from it is worthless. + +### Needs a live memory probe (cheap, read-only, no launch) + +7. **Nothing about pack records can be checked live until the store screen is + opened.** Two independent signatures found zero resident pack records this + session: a scan of 3.2 GiB of writable memory for the constructor's own + `CREATEPACK` literal, and a scan for the `FUT Vector` / `FUT String` pointer + triple. So every runtime offset in section 2.1 is static-only. Re-running the + probe with the store open would confirm the whole tile field map in one pass. +8. **`data/store/dimecfg.xml`.** The `file://` path string is live at `0x79049a0` + but the parsed content was not located. It is the sibling of `storecfg.xml` and + probably carries the group/category structure that `displayGroup` maps onto, which + is exactly the field that froze the store once. Worth a targeted hunt before this + process exits. +9. **The `FIFA17.exe` side of the reveal gate.** CardsDLL exposes + `packOpeningAnimationEnabled` as a vtable slot; whether the packed exe also reads + it is unknown, and would need a sweep of the decrypted arena at + `0x1450f3000..0x14b1a3000`. Low value now that the byte is known to be 1. + +### Needs a live in-game test the user has to drive (expensive, keep short) + +Ordered by value. Only the first three are worth a launch. + +10. **What request the reveal's Quick Sell actually sends.** Single or bulk, URL + suffix or body, and what the body looks like. This costs one menu action, needs + **no server change at all**, and it is the difference between a quick sell that + pays and one that silently pays zero. +11. **Whether `finalFunds` or `funds` is the tile price.** Two ints we already send, + given different values. Settles a documented ambiguity and doubles as the + positive control for everything else in the run. Zero freeze risk. +12. **Whether the reveal animation plays at all.** The gate byte is 1, so this + observation now tests terms 2 to 4 of the AND rather than the flag. +13. Lower value, do not spend a launch on these alone: whether a populated + `displayGroup` is safe; whether `unopenedPacks` alone lights the unclaimed-pack + tile or whether the store must also return a `mypacks` group; whether + `purchaseLimit` plus `purchaseCount` is what greys a tile out. + +### Coverage, stated honestly + +- The live string sweep covered roughly 98% of the anonymous and W+X scope. Between + the two passes, 64 MiB and 88 MiB respectively came back short on `pread` and were + never retried or identified. The 549 MiB of readable file-backed non-W+X mappings + were deliberately not swept. Any absence claim over live memory carries that hole, + including "walkout does not exist". +- The provenance of the `storecfg.xml` content is unresolved. It is not in CardsDLL, + not in the packed exe, not in `stp-origin_emu.dll`, not in the repo, and not in 256 + files totalling 3.24 GiB under the install. But `Data/` is 30 GiB of compressed + archives that a raw grep cannot see into, and the file-scan skipped everything over + 512 MiB. It is client-owned commerce data, not something UTAS sends; which channel + delivered it is unknown. +- The terminal consumer of every store-tile field lives in Denuvo-packed + `FIFA17.exe`. We can prove data reaches the tile; we cannot read the predicate that + turns it into a greyed-out tile. Every "how to make a pack sold out" statement in + this document is a ranked candidate, not a recipe. +- The absence proof for "nothing compares packContentInfo against itemList" has one + hole that no byte scan or operand census can close: a pre-biased pointer, for + example `LEA rax,[rcx+0x140]` then `MOV edx,[rax+4]`. Closing it needs symbolic + data-flow, which nobody attempted. +- One reversing pass reported the retail catalogue's heap-pool table with + `FIFA17FUTPACK1100 -> Origin.OFR.50.0001484`. The XML records disagree and give + that offer id to 1101. My own re-parse of the artifact initially reproduced the + same class of skew, because the dumped XML contains `<>` markers where pages + were not resident and a naive regex spans them. The table in section 5 is the + gap-aware parse and is the one to trust; the eleven earliest SKUs, which survive + only in the pool and not as XML records, have not been paired to that standard. + +--- + +## 5. Proposed changes to ENDPOINT_MAP.md + +Do not apply these blind. They are correct about what the parser reads, which this +project has learned twice is not the same statement as "safe to send". Everything +below is a documentation change only; the server changes it implies are in section 6. + +One formatting note. The removal lines in these diffs are quoted from +`ENDPOINT_MAP.md` character for character, and the new subsection headers follow its +existing `### N. Name - confidence: X` convention. That file uses em dashes, so the +quoted and paste-ready blocks below contain them. They are reproduced deliberately, +so the diffs apply cleanly and the pasted sections match their neighbours. The prose +in this document does not use them. + +### 5.1 Line 1042, `displayGroup` is an object, not an array + +```diff +- | `displayGroup` | 0xd9 | **ARRAY** | nested (freeze-risk) | ++ | `displayGroup` | 0xd9 | **flat OBJECT** | `value`(0x377,STR)→rec+0x00, `priority`(0x250,INT)→rec+0x34. NOT recursive, NOT an array. Case 0xd9 loops on `tok != 10` (object end); the neighbouring `currencies` case loops on `tok != 0xd` (array end), which is the in-function control. An array here is the 2026-08-04 store freeze. | +``` + +### 5.2 Line 1046, `extPrice` reads neither `amount` nor `currency` + +```diff +- | `extPrice` | 0x119 | **OBJECT** | → `finalPrice`(0x125,obj `0x180139070`) + `originalPrice`(0x205,obj `0x18013aae0`); inner uses `amount`(0x1b)/`currency`(0xc4) (freeze-risk) | ++ | `extPrice` | 0x119 | **OBJECT** | → `finalPrice`(0x125,obj `0x180139070`) + `originalPrice`(0x205,obj `0x18013aae0`). Each inner object reads **exactly one key, `externalPriceId`(0x11a,INT)**; `amount`(0x1b) and `currency`(0xc4) appear nowhere in either function (152 instructions each, one atom compare apiece at `0x180139245` / `0x18013acb4`). `finalPrice` also writes pack+0x78, i.e. **it is the same slot as top-level `firstPartyStoreId`** (freeze-risk) | +``` + +### 5.3 Line 1047, `start` and `unopened` are not children of `packContentInfo` + +```diff +- | `packContentInfo` | 0x20c | **OBJECT** | → `bronzeQuantity`(0x63), `silverQuantity`(0x2c6), `goldQuantity`(0x149), `rareQuantity`(0x273), `itemQuantity`(0x170), `start`(0x2e3), `unopened`(0x35d,bool) (freeze-risk) | ++ | `packContentInfo` | 0x20c | **flat OBJECT, exactly five children** | `itemQuantity`(0x170)→+0x144, `goldQuantity`(0x149)→+0x148, `silverQuantity`(0x2c6)→+0x14c, `bronzeQuantity`(0x63)→+0x150, `rareQuantity`(0x273)→+0x154. Anything else inside reaches SKIP `0x180135ff0`. **`start`(0x2e3) and `unopened`(0x35d) are TOP-LEVEL siblings, not children** (freeze-risk) | ++ | `start` | 0x2e3 | INT | rec+0xb4, non-negative clamp `0x1800d7b30` | ++ | `end` | 0x102 | INT | rec+0xb8 | ++ | `unopened` | 0x35d | BOOL | rec+0xcd, stored raw (contrast `useDefaultImage` 0x36a → rec+0xcc, stored **inverted**) | ++ | `state` | 0x2eb | STR | compared against `"active"` → rec+0xb0. Constructor default is 0, i.e. **not active** | ++ | `quantity` | 0x26b | INT | rec+0xbc. A sent **0 is rewritten to -1** | ++ | `purchaseLimit` | 0x265 | INT | rec+0xc0 | ++ | `purchaseCount` | 0x261 | INT | rec+0xc4 | ++ | `saleType` | 0x298 | STR→enum | `NONE`0, `QUANTITY`1, `TIME`2, `TIME_QUANTITY`5, **anything else 0**. `"promo"` is not in the enum | ++ | `packType` | 0x20f | STR | rec+0x108, ctor default `"INVALID"` | ++ | `isPremium` | 0x176 | BOOL | rec+0xce | ++ | `id` | 0x15c | INT | rec+0x70 as a **u16**; values above 65535 truncate | ++ | `points` | 0x240 | INT | rec+0x140 | ++ | `visible` | 0x37d | **no getter** | one instruction, `MOV byte,1`. Presence sets it; the value is never read, so `false` reads as `true` | ++ | `firstPartyStoreId` | 0x127 | **STR then `atoi()`** | rec+0x78. Sending a JSON int here is a documented freeze | +``` + +### 5.4 Line 1051, delete the "none of these atoms exist" paragraph + +```diff +- - `store_catalog()` currently emits `id, packType, quantity, purchaseLimit, purchaseCount, isPremium, saleType` — **none of these atoms exist in the pack deser** (`id`=0x15c, `quantity`=0x26b, `saleType`=0x298, `packType`=0x20f, `isPremium`=0x176 are all routed to SKIP `0x180135ff0`). They are harmless no-ops but do nothing. ++ - **CORRECTION 2026-08-05.** Every one of `id`, `quantity`, `saleType`, `packType`, `isPremium`, `purchaseLimit` and `purchaseCount` has a real dispatch arm and a real store. Proven three ways: the decompile, the raw sub-ladder at `0x18013b62e`, and a decode of the MSVC jump table at `0x18013b792` (byte index table `0x18013bcb4`, seven targets `0x18013bc98`), which shows six atoms getting real arms and the other 132 indices falling to the shared SKIP. They are not no-ops. Being parsed is still not the same as being safe to change. ++ - The catalogue is **capped at 100 packs**. `CMP EDX,0x64` at `0x18013bb02` jumps past both push_back paths to the record destructor. Elements 101 and up are parsed and silently discarded. ++ - **`packContentInfo` is DECORATIVE.** Exactly one function reads the five quantity slots (`FUN_18002c3c0`, single code caller `FUN_1800150d0`) and it is a MOV-to-MOV copy into the tile view model with no arithmetic and no comparison. An operand-text census over 13,308 functions finds no code anywhere that compares the declared counts against the delivered `itemList`. `open_pack()` does not have to honour the declared distribution; the numbers are rendered, so agreement is cosmetic. ++ - **The coin price the tile shows is `finalFunds`, not `funds`** (`FUN_18002c3c0`: `tile+0xa0 = currencyRecord+0x24`). `funds` feeds the mtx slot and is then overwritten by the platform-store lookup. Both ints pass the non-negative clamp `0x1800d7b30`, so a negative price silently becomes 0. UNTESTED live; see the plan's §7 control. +``` + +### 5.5 Lines 920 to 923, the item-lifecycle rows + +```diff +-| 8 | FutDiscardCard | `0x180127300` | DELETE `ut/delete/%s/item` (CardsDiscardCard) | `items`(0x171) → **array[int ids]** [FREEZE-RISK]; `totalCredits`(0x326) → int; `id`(0x15c) → int | GAP | HIGH | ++| 8 | FutDiscardCard | `0x180127300` | POST-tunnelled DELETE `ut/delete/%s/item` (single: id in URL as `/%llu`; bulk: body `{"itemId":[…]}`, atom 0x16d) | `items`(0x171) → **array of OBJECTS**, each `{id:(0x15c,int)}` [FREEZE-RISK]; `totalCredits`(0x326) → int. **No top-level `id`.** Each parsed id is also passed to model slot +0xa30 (local removal) | GAP | HIGH | +-| 9 | FutDiscardCardByRes | `0x1801279c0` | DELETE `ut/delete/%s/item` (by res) | `totalCredits`(0x326) → int | GAP | HIGH | ++| 9 | FutDiscardCardByRes | `0x1801279c0` | DELETE `ut/%s/item/resource` (urlIndex 0x0e) | `totalCredits`(0x326) → int, and nothing else | GAP | HIGH | +-| 10 | FutMoveCard | `0x180128600` | PUT `ut/%s/item` (move) | `itemData`(0x16b) → **array** [FREEZE-RISK]; `chemistry`(0x81) → bool | GAP | HIGH | ++| 10 | FutMoveCard | `0x180128600` | PUT `ut/%s/item` (move) | `itemData`(0x16b) → **array** [FREEZE-RISK], elements `id`(0x15c,int64) · `pile`(0x226,STR: club/purchased/trade only) · `success`(0x2fa,bool) · `reason`(0x279,STR; `"Destination Full"` → code 0xf) · `dreamSquads`(0xe9, genuine **array of bare ints**). **`chemistry`(0x81) is NOT parsed** (whole 6193-char function read) | ack live-proven | HIGH | +-| 11 | FutMoveCardByRes | `0x180128e30` | PUT `ut/%s/item` (move by res) | `itemData`(0x16b) → **array** [FREEZE-RISK]; `chemistry`(0x81) → bool (+ 2 str/1 int minor) | GAP | HIGH | ++| 11 | FutMoveCardByRes | `0x180128e30` | PUT `ut/%s/item/resource` (urlIndex 0x0e) | atoms are exactly `id`, `itemData`, `pile`, `reason`, `success`. **`chemistry`(0x81) is NOT parsed here either** (3040-char function read in full) | GAP | HIGH | +``` + +### 5.6 Lines 968 to 975, the minimal known-good bodies + +```diff + // 8 DiscardCard - DELETE ut/delete/game/fifa17/item +-{ "items": [ 123456789 ], "totalCredits": 15000, "id": 123456789 } ++{ "items": [ { "id": 123456789 } ], "totalCredits": 15000 } + + // 9 DiscardCardByRes - DELETE ut/delete/game/fifa17/item + { "totalCredits": 15000 } + + // 10 MoveCard / 11 MoveCardByRes - PUT ut/game/fifa17/item +-{ "itemData": [ /* moved item */ ], "chemistry": true } ++{ "itemData": [ { "id": 123456789, "pile": "club", "success": true } ] } +``` + +### 5.7 Line 1095 and line 218, `duplicateItemIdList` + +```diff +- | `duplicateItemIdList` | 0xec | **ARRAY** (int list) | freeze-risk | ++ | `duplicateItemIdList` | 0xec | **ARRAY of OBJECTS** (element deser `0x180138e10`) | `itemId`(0x16d,int64) · `duplicateItemId`(0xeb,int64) · `itemLoans`(0x16f,int32) · `duplicateItemLoans`(0xed,int32). NOT a list of ints (freeze-risk). Post-pass in `0x180162880` writes `duplicateItemId` into the matched card at +0x10; the reveal controller `0x18009bc40` reads it as "is duplicate" and diverts to `GotoNewItems` **without issuing any request** | +``` + +At line 218 change `duplicateItemIdList` → **array** to `duplicateItemIdList` → +**array of objects**, and note the contrast with `dreamSquads` (0xe9), which really +is an array of bare ints. + +### 5.8 New subsection, ready to paste after Store §5 + +```markdown +### 5a. Where the coin buy actually goes - confidence: HIGH (request), MEDIUM-HIGH (response binding) + +Two distinct ServerCall classes post into the store area and they are chosen +client-side, before any request is sent. Nothing in a server response selects +between them. + +| Call | id | Request builder | URL | Body | +|---|---|---|---|---| +| CreatePack (`PurchasePack`) | 0x4b | `0x180162530` | `ut/%s/purchased`, urlIndex 0x1a | `packId`(0x20b) · `useCredits`(0x369) · `usePreOrder`(0x36b) · `currency`(0xc4: COINS/MTX/POINTS, omitted when usePreOrder) | +| PurchaseItems | 0x4c | `0x180126440` | `ut/v2/%s/store` + `/transaction` + `/` when state > 1 | always `state`(0x2eb) from the 9-entry table at `0x1802d02c0`, plus per-state extras | + +`ut/v2/%s/store/transaction/0` with `{"state":"TRANSACTIONCANCEL"}` is **observed on +every boot** (2026-08-05 14:39:28). The PurchaseItems state machine is not +first-party-only from our point of view; the cancel reaches us and must be answered +`200 {}`. + +State table (`0x1802d02c0`, read from the image and live, nine entries, no tenth): +`NOTRANSACTION` -1, `TRANSACTIONCREATED` 1, `PURCHASESTARTED` 2, `PURCHASECOMPLETE` 3, +`PURCHASECONSUMABLECOUNT` 4, `PURCHASECONSUME` 5, `TRANSACTIONCOMPLETE` 6, +`TRANSACTIONCANCEL` 7, `NONE` -1. The out-of-band refusal is the literal +`PURCHASEERROR`, which sets error 998 at response+0x1c before the enum map runs, and +it exists only on PurchaseItems. `FutCreatePackServerResponse` has no `state` and no +`reason`. + +**HTTP status is a single table, `FUN_1801844c0`, reached through ServerCall vtable +slot 12.** Only 200 is success. 204 → 99. **Any other 2xx, 201 and 202 included, +falls through to 999.** 461 → 3. Most other 4xx/5xx → 998. PurchaseItems alone +overrides slot 12 (`0x1801267b0`) so that 409 plus the body substring +`User already has a transaction` becomes 0x70, a pending code the client re-polls. + +**`FutPurchaseItemsServerResponse` field corrections** (deser `0x1801269f0`): +`transactionId`(0x33a) is an 8-byte store at +0x28; `firstPartyStoreId`(0x127) is read +with the **INT** getter here (contrast the pack element, which uses STR + `atoi`); +`packId`(0x20b) is stored as a **u16** at +0x98; and +0xa0/+0xa4/+0xa8 are not credit +fields, they are `useCount`(0x368), `useTime`(0x375) and `useAuth`(0x367). +**`useAuth` is read with the INT getter `0x1801c79d0` and then coerced by +`TEST/SETNZ`, not with the BOOL getter.** If we ever serve it, it must be a JSON +number, never `true`/`false`. +``` + +### 5.9 New subsection, the recovered retail catalogue + +```markdown +### 5b. The real FIFA 17 pack SKUs - confidence: HIGH, source is perishable + +Recovered 2026-08-05 from the live client heap of pid 4048 (parsed copy of +`data/store/storecfg.xml`, buffer `0x3dabe000..0x3dac7000`). Not present in +CardsDLL, in the packed exe, or in any readable file under the install. This is the +id space the FIFA 17 store was built around. Our catalogue uses assetId 1, 5 and 6, +which are not members of it; see the plan's risk register for why that is a lead and +not an instruction. + +| serverId | name | Origin offer | entitlement | uniqueId | +|---|---|---|---|---| +| 504 | SILVER PLAYERS PACK | Origin.OFR.50.0001350 | FIFA17FUTPACK504 | 0f17f013 | +| 506 | SILVER PLAYERS PREMIUM | Origin.OFR.50.0001351 | FIFA17FUTPACK506 | 0f17f03b | +| 600-608 | FIFA Points 100 / 250 / 500 / 750 / 1050 / 1600 / 2200 / 4600 / 12000 | none | FP0600-FP0608 | 0f17f000-008 | +| 809 | Draft Entry | Origin.OFR.50.0001335 | FIFA17FUTPACK809 | 0f17f809 | +| 900 | Gold 13 PACK | Origin.OFR.50.0001359 | FIFA17FUTPACK900 | 0f17f042 | +| 901 | Premium Gold 13 Pack | Origin.OFR.50.0001362 | FIFA17FUTPACK901 | 0f17f043 | +| 902 | Jumbo Gold 26 Pack | Origin.OFR.50.0001360 | FIFA17FUTPACK902 | 0f17f044 | +| 903 | Jumbo Premium Gold 26 Pack | Origin.OFR.50.0001361 | FIFA17FUTPACK903 | 0f17f045 | +| 904 | VALUE PACK 5 | Origin.OFR.50.0001363 | FIFA17FUTPACK904 | 0f17f046 | +| 1101 | LIGUE 1 PREMIUM GOLD PACK | Origin.OFR.50.0001484 | FIFA17FUTPACK1101 | 0f17f04E | +| 1102 | SERIE A PREMIUM GOLD PACK | Origin.OFR.50.0001485 | FIFA17FUTPACK1102 | 0f17f04F | +| 1103 | LIGA BBVA PREMIUM GOLD PACK | Origin.OFR.50.0001486 | FIFA17FUTPACK1103 | 0f17f050 | +| 1104 | BUNDESLIGA PREMIUM GOLD PACK | Origin.OFR.50.0001487 | FIFA17FUTPACK1104 | 0f17f051 | +| 1110 | BPL PREMIUM PLAYERS PACK | Origin.OFR.50.0001488 | FIFA17FUTPACK1110 | 0f17f052 | +| 1111 | LIGUE 1 PREMIUM PLAYERS PACK | Origin.OFR.50.0001489 | FIFA17FUTPACK1111 | 0f17f053 | +| 1112 | SERIE A PREMIUM PLAYERS PACK | Origin.OFR.50.0001490 | FIFA17FUTPACK1112 | 0f17f054 | +| 1113 | LIGA BBVA PREMIUM PLAYERS PACK | Origin.OFR.50.0001491 | FIFA17FUTPACK1113 | 0f17f055 | +| 1114 | BUNDESLIGA PREMIUM PLAYERS PACK | (record truncated by a page gap) | | 0f17f056 | +| 1120 | BPL PRIME PLAYERS PACK | Origin.OFR.50.0001493 | FIFA17FUTPACK1120 | 0f17f057 | +| 1121 | LIGUE 1 PRIME PLAYERS PACK | Origin.OFR.50.0001494 | FIFA17FUTPACK1121 | 0f17f058 | +| 1122 | SERIE A PRIME PLAYERS PACK | Origin.OFR.50.0001495 | FIFA17FUTPACK1122 | 0f17f059 | +| 1123 | LIGA BBVA PRIME PLAYERS PACK | Origin.OFR.50.0001496 | FIFA17FUTPACK1123 | 0f17f05A | +| 1124 | BUNDESLIGA PRIME PLAYERS PACK | Origin.OFR.50.0001497 | FIFA17FUTPACK1124 | 0f17f05B | +| 1200 | BPL SQUAD PACK | Origin.OFR.50.0001498 | FIFA17FUTPACK1200 | 0f17f05C | +| 1201 | LIGUE 1 SQUAD PACK | Origin.OFR.50.0001499 | FIFA17FUTPACK1201 | 0f17f05D | +| 1202 | SERIE A SQUAD PACK | Origin.OFR.50.0001500 | FIFA17FUTPACK1202 | 0f17f05E | +| 1203 | LIGA BBVA SQUAD PACK | Origin.OFR.50.0001501 | FIFA17FUTPACK1203 | 0f17f05F | +| 1204 | BUNDESLIGA SQUAD PACK | Origin.OFR.50.0001502 | FIFA17FUTPACK1204 | 0f17f060 | +| 1210 | BPL TRIAL PACK | Origin.OFR.50.0001503 | FIFA17FUTPACK1210 | 0f17f061 | +| 1211 | LIGUE 1 TRIAL PACK | Origin.OFR.50.0001504 | FIFA17FUTPACK1211 | 0f17f062 | +| 1212 | SERIE A TRIAL PACK | Origin.OFR.50.0001505 | FIFA17FUTPACK1212 | 0f17f063 | +| 1213 | LIGA BBVA TRIAL PACK | Origin.OFR.50.0001507 | FIFA17FUTPACK1213 | 0f17f064 | +| 1214 | BUNDESLIGA TRIAL PACK | Origin.OFR.50.0001506 | FIFA17FUTPACK1214 | 0f17f065 | + +A second heap pool adds eleven earlier SKUs whose XML records did not survive: +102, 105, 200, 202, 203, 205, 300, 302, 304, 306, 400, plus names including +PREMIUM GOLD JUMBO, PREMIUM SILVER JUMBO, PREMIUM SILVER PACK, JUMBO SILVER PACK, +PREMIUM BRONZE JUMBO and JUMBO BRONZE PACK. Their SKU-to-offer pairing is **not** +established to the same standard and one published pairing for 1100/1101 was +off by one, so treat those eleven as names without ids. + +`CARDS_CB_ERR_PACK_NOT_IN_DIME` is **not** evidence of a client-side DIME lookup: it +is `case 0x22` of `FUN_1800d7b70`, a pure error-code-to-display-name table of 57 +`CARDS_*` strings. It is a code the server can return. +``` + +### 5.10 New subsection, the inventory counters + +```markdown +### 5c. unopenedPacks and the unclaimed-pack tile - confidence: HIGH + +`userInfo.unopenedPacks` (0x35e) is an OBJECT whose only recognised children are +`preOrderPacks`(0x24b, INT) and `recoveredPacks`(0x27b, INT). `count`(0xbc) is +**not** read there. The two are summed and stored at `model+0x20950` through model +vtable slot +0x4e0 (`0x18011e120`), which then broadcasts message `0x273d`, +registered as `RELOAD_CENTRAL_PANEL`. That rebuild is what can produce game-hub tile +type 0x1c, `CentralUnclaimedPack`, caption `FUT_GH_UNCLAIMED_PACK_0`, destination +`GOTO_STORE_MYPACK`. Nothing in the chain issues a request, and no request could +exist: the 48-entry template table has no pack-inventory route. + +The **My Packs** screen is a filter over the ordinary store catalogue. +`FUN_1800150d0` walks the same 0x158-byte pack array the store deserializer fills and +selects entries whose `displayGroup.value` equals the literal `"mypacks"` +(`0x1801ec008`), ordering them by `displayGroup.priority`. + +The same two counters also appear in `FutUserCreditsServerResponse` +(`0x180122c50`, nested inside `unopenedPacks`) and in +`FutSBCSubmitChallengeServerResponse` (`0x180161b00`, at the top level). An SBC that +awards a pack reports it by bumping counters, not by returning a pack object. + +**Minimal known-good** (inside `userInfo`): +`"unopenedPacks": {"preOrderPacks": 0, "recoveredPacks": 1}` +UNTESTED. Live value of `model+0x20950` measured at 0 on 2026-08-05. +``` + +--- + +## 6. Risk register + +House rule applies to all of it: default off behind an env flag until it has been in +front of the game once. The two worst regressions this project has had, the frozen +store and packs failing to open, both came from technically correct field +corrections shipped without a live test. + +| Change | Freeze risk | Type fidelity requirement | Default | +|---|---|---|---| +| **(A)** `quick_sell_route()` parses `{"itemId":[…]}` and the trailing `/%llu` in the URL | None on the wire (request parsing only) | n/a | **off**. It is not risk-free: today the handler matches nothing, so a quick sell destroys no cards and writes no save. With (A) it will delete cards and credit coins. A wrong id parse destroys real cards | +| **(B)** discard response becomes `{"items":[{"id":N}],"totalCredits":C}` | Low. `items` must be an array of OBJECTS; the currently documented array of bare ints would feed the int getter an object token and spin `0x1801c7f1a` | `items` array of objects, `id` and `totalCredits` plain ints | off | +| **(C)** move the `createPackResponse` envelope onto the `POST /purchased` response | Medium, and the danger is not a freeze. Both `FutCreatePack` and `FutGetPurchasedItems` write the **same** container (model vtable +0x160). Serving both could double-populate the reveal | `itemList` array, `numberItems` int, `purchasedPackId` int, `duplicateItemIdList` array of objects | off. Do not ship in the same round as anything else | +| **(D)** serve `duplicateItemIdList` with real entries | High if (C) is not in place, because the field only reaches the client through the createPack path. Array of OBJECTS or nothing | `itemId`/`duplicateItemId` int64, `itemLoans`/`duplicateItemLoans` int32 | off, and blocked on (C) | +| **(E)** serve `userInfo.unopenedPacks` with a non-zero pair | Low. Two ints inside an object; unknown keys inside it SKIP safely | object with two int children. `count` is not read there | off | +| **(F)** send `displayGroup` as `{"value":"mypacks"}` on some packs | Medium and **unquantified**. This is the first field we would send that selects a render path rather than a value, and the tile builder is in the packed exe. `FUT_STORE_DISPLAYGROUP` already exists for exactly this and is documented as untested | flat object; `value` STR, `priority` INT. An array here is the 2026-08-04 freeze | off, existing flag | +| **(G)** move `unopened` from inside `packContentInfo` to the top level | Low. It becomes a real BOOL at rec+0xcd, copied to tile+0x69. What the tile does with it is unread | JSON `true`/`false`; it is a genuine BOOL getter | off. Low value: today it is inert, tomorrow it does something unknown | +| **(H)** accept `pile` values `trade` and `purchased` in `move_items()` | Low. Same route, same verdict body that is already live-proven for `club` | `pile` must be one of exactly three strings; anything else maps to enum 0 client-side | on is defensible, since the handler already stamps whatever pile it is given | +| **(I)** set a pack's `finalFunds` different from `funds` | **None.** Both are plain ints we already send | ints, and negatives silently clamp to 0 | this is the §7 control, ship it for the test only | +| **(J)** change `assetId` to a real DIME serverId | **Do not.** Packs open today with 1/5/6, so nothing on the coin path validates against DIME, and the one string that suggested otherwise turned out to be an error-code table entry | n/a | never, until somebody decompiles the DIME consumable lookup and shows the coin path reaches it | +| **(K)** send `extPrice.finalPrice.externalPriceId` | **Do not.** It writes the same slot as `firstPartyStoreId`, and a non-negative value there sends the tile into a platform-store lookup that cannot succeed offline | int, but the correct action is to leave the current inert `{"amount":…,"currency":"mtx"}` alone or delete it | never for now | + +Things that are inert and should be left alone rather than "fixed": `saleType: +"promo"` (not in the client enum, lands on NONE), `limitType` (not an atom at all), +`visible` (the value is never read), `useDefaultImage` (stored inverted, so `true` +sets false). Churning accurate-but-inert fields costs a live test and buys nothing. + +--- + +## 7. The one-launch live test script + +Budget: **one server restart, one FIFA launch.** Steps 1 and 2 are the control and +they go first. Steps 3 and 4 need no server change at all and are the highest value +per unit of risk in the whole plan, because they read the client's own requests off +the wire. + +### Pre-flight, terminal, no launch + +```bash +cd fifa17-recon/tools +grep -nE '\breturn 2(01|02|04|06)\b' utas_server.py # expect: no matches +python3 -c "import fut_store as s; print([p['price'] for p in s.PACK_CATALOG])" +env | grep FUT_PACK_AUTOCLUB # expect: nothing +``` + +Only 200, 404 and 461 may ever be returned. Anything else in the 2xx range maps to +FUT error 999. Audited: the file returns only 200 (59 sites), 404 (once) and 461 +(three times), so this check is already clean and is here as a guard against +regression. + +**`FUT_PACK_AUTOCLUB` must be off, and the file lies about its own default.** +Line 728 reads `os.environ.get("FUT_PACK_AUTOCLUB", "0") == "1"`, so the code +default is off, but the comment at line 864 asserts "`FUT_PACK_AUTOCLUB=1` (the +default)". The comment is stale and the code is right. This matters more than a +stale comment normally would, because autoclub deposits a pack's cards into the club +before the reveal screen ever asks, and the file's own history at lines 725 to 727 +records the consequence: "the first live attempt at the correct response shape +produced no PUT /item at all because autoclub had already emptied the pile." If it +is on, steps 3 through 6 below produce no requests and the entire run reads as a +false negative. Check the environment, not the comment. + +Optionally re-read the gate byte on the client that is running right now, read-only, +no restart needed: + +```bash +python3 /tmp/.../packres/synth_check.py # expect: FNV control MATCH, +0x1fd45 value = 1 +``` + +### The change to ship + +**One variable.** Gold Pack only: set its price to `4321` and serve +`"currencies":[{"name":"coins","funds":5000,"finalFunds":4321}]`. Nothing else +changes. Two ints we already send, given different values, and the server debits the +same number the tile should show, so the state stays internally consistent. + +### In the GUI, in this order + +1. **Store. THE CONTROL.** Open it and read the Gold Pack price. +2. **Buy the Gold Pack.** Note the coin balance before and after. +3. **The reveal.** Record three things: whether an animation plays at all, how many + cards appear, and whether one card is singled out as the headline. +4. **Quick Sell exactly one card** from the reveal, then leave the rest. +5. **Send to Transfer List** exactly one card, if the option is offered. +6. **Send to Club** the remainder, which is the live-proven path and doubles as the + regression check. +7. Regression sweep: MY CLUB still 205 players, 189 gold, 8 silver, 8 bronze; + managers still 34 staff cards with no DB Error; store still lists three packs. + +Then, and this is the part that carries the round: + +```bash +grep -nE 'delete/game/[^/]+/item|store/transaction|POST /ut/game/[^/]+/purchased' -A6 /tmp/utas.log +``` + +### Reading the result + +| Step 1 price | Meaning | +|---|---| +| **4321** | The store body reached the tile **and** `finalFunds` is the coin price. Everything below is readable. Land the ENDPOINT_MAP correction at §5.4 as CONFIRMED-LIVE | +| **5000** | The body reached the tile but `funds` wins. Also readable, and it inverts the §5.4 correction. Record it and fix the map the other way | +| **unchanged from today, or blank** | The store body did not reach the tile. **Stop reading here.** Nothing in steps 2 to 6 is interpretable, and the failure is in delivery, not in any field | +| **store fails to open** | Two ints broke it, which would be new information of a very unwelcome kind. Revert the price, restart, and treat the currency record as far more fragile than believed | + +| Observation | Meaning | +|---|---| +| Step 3, animation plays | The five-way AND is fully satisfied today. Delete `packOpeningAnimationEnabled` from the backlog and from yesterday's settings plan | +| Step 3, no animation, byte is 1 | The blocker is term 2, 3 or 4: `resourceId & 0xffffff`, `cardtype == 1`, or `ASSET_ID` missing from the data provider. That is a card-field problem, not a settings problem, and it is cheap to bisect | +| Step 3, one card visibly singled out | Confirms the headline selector runs on the client-computed quick-sell value, since we send no `discardValue`. No server work | +| Step 4, log shows `ut/delete/.../item/` with no body | Single discard is URL-form. Ship (A) parsing the URL suffix | +| Step 4, log shows a body `{"itemId":[…]}` | Bulk form even for one card. Ship (A) parsing `itemId` | +| Step 4, log shows `itemData` or `itemIds` | The current handler was right all along and the static reading is wrong. Do not ship (A) | +| Step 4, no request at all | Quick Sell is client-only until a batch is confirmed elsewhere. Re-run with "Quick Sell All" | +| Step 4, coins increase | Impossible today, since the handler matches nothing. If it happens, our understanding of the route is wrong | +| Step 5, `PUT /item` with `"pile":"trade"` | Confirms transfer-list send is the same route. Ship (H) | +| Step 5, some other path | New route, capture it and stop guessing | +| Step 6 fails while it worked yesterday | Regression from the price change. Revert first, analyse second | + +The control is doing real work here. Without step 1 a silent step 4 is ambiguous +between "Quick Sell sends nothing" and "our store response never arrived, so the +buy never happened the way we think". With it, a silent step 4 is a fact. + +### What would make this whole plan wrong + +The reveal path could be fed by something we have not found. The argument that +`GET /purchased` is what populates the reveal container rests on the fact that +`FutGetPurchasedItems` writes model vtable slot +0x160 and that `POST /purchased` +currently answers with a body the createPack deserializer skips entirely. If the +cards actually arrive some third way, then section 2.2's account of the buy is wrong +and (C) is not just risky, it is pointless. + +`finalFunds` versus `funds` could be moot if the tile price comes from somewhere +else entirely, for instance the platform catalogue path when `firstPartyStoreId` is +not -1. We do not send `firstPartyStoreId`, so that should not apply, but the +formatter's early-return is the only thing standing between us and that branch. + +--- + +## Next + +**DONE. The envelope rule is settled, and it found the bug it was looking for, though +not the one that was predicted.** Full account in the resolution box in section 2. +Queries: `tools/ghidra_queries/q_envelope_{1,2,3}.py`. + +The prediction in the earlier draft of this section was that `starterPack`, `squad` and +`userData` were being swallowed on `POST /user`. **They are not.** The opposite is true, +and it is narrower and more interesting. + +`FutCreateUser` (`0x18014cc60`) is a three-token root with ladder arms for exactly the +five keys we send: `login` 0x1a5, `userData` 0x36d, `squad` 0x2cd, `starterPack` 0x2e5, +`bonusPacks` 0x5d. We serve them flat, in that order. The three tokens consume `{`, the +FIELD_NAME `login`, and the scalar `true`. The loop then dispatches `userData`, `squad`, +`starterPack` and `bonusPacks` normally at the outer level, which is exactly where the +ladder wants them. + +So four of the five keys are read, and **`login` is the single key that is silently +discarded**. Its name is eaten as the anonymous envelope and its value as the third +token. It has an arm, so the client does want it. It has never once been delivered. + +Two consequences, and the second is the one that matters more: + +1. `login: true` has never reached the client. Whether that is harmful is UNKNOWN, since + FUT plainly works without it. +2. **The key order of that dict is load-bearing and nothing in the code says so.** Any + reordering that puts `userData` first would swallow `userData` instead, which would be + a spectacular and very hard to diagnose failure. This is a landmine sitting in + `utas_server.py` right now. + +The likely correct shape is `{"": {login, userData, squad, starterPack, +bonusPacks}}`, wrapping all five one level down, since that is what the identical +three-token preamble means for `FutCreatePack`. That is a hypothesis with a mechanism, +not a proven fix, and it touches the login path, so it goes behind a flag defaulting to +off per the house rule. If it is right, `login` starts being read and nothing else +changes. If it is wrong, login desyncs and the game cannot enter FUT, so it is not a +change to make casually or to bundle with anything else. + +**The actual next thing to read is `GET /hub`.** We answer it with a flat two-key body, +`{"clubPlayers":205,"auctionCount":0}`, and under the confirmed rule a three-token root +would silently eat `clubPlayers`. I could not settle it here: there is no +`RS4:FutGetHubServerResponse` literal in the image and both atoms appear only as +atom-table entries with no code xref, so `/hub` may not be parsed by a generated root at +all. One query, no launch, no risk, and it is the same class of silent-discard bug that +this pass just found in the auth body. diff --git a/fifa17-recon/futmem/.gitignore b/fifa17-recon/futmem/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/fifa17-recon/futmem/.gitignore @@ -0,0 +1 @@ +/target diff --git a/fifa17-recon/futmem/Cargo.lock b/fifa17-recon/futmem/Cargo.lock new file mode 100644 index 0000000..c837564 --- /dev/null +++ b/fifa17-recon/futmem/Cargo.lock @@ -0,0 +1,16 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "futmem" +version = "0.1.0" +dependencies = [ + "memchr", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" diff --git a/fifa17-recon/futmem/Cargo.toml b/fifa17-recon/futmem/Cargo.toml new file mode 100644 index 0000000..6918931 --- /dev/null +++ b/fifa17-recon/futmem/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "futmem" +version = "0.1.0" +edition = "2021" +description = "Read-only live-memory inspector for the FIFA 17 process (preservation / reverse-engineering tooling)" +publish = false + +# An EMPTY [workspace] table makes this crate its own workspace root. +# Without it, cargo walks up the directory tree, finds +# /home/alex/Documents/OpenFUT/Cargo.toml, sees that futmem is not in its +# `members` list, and refuses to build. That parent manifest is untracked and +# must not be edited, so we opt out from this side instead. +[workspace] + +[dependencies] +# memchr is the ONLY dependency, and it earns its place. +# A `find` sweep covers roughly 3 GB of resident memory. The naive +# `windows(n).position(...)` search runs at a few hundred MB/s; memchr's +# memmem uses SIMD (AVX2 on this box) and runs an order of magnitude faster, +# which turns a multi-minute sweep into a few seconds. +# Everything else (argument parsing for four subcommands, /proc//maps +# parsing, hex dumping) is a few dozen lines of std and does not justify +# pulling in clap or a proc-maps crate. +memchr = "2" + +[profile.release] +opt-level = 3 diff --git a/fifa17-recon/futmem/README.md b/fifa17-recon/futmem/README.md new file mode 100644 index 0000000..8654fbb --- /dev/null +++ b/fifa17-recon/futmem/README.md @@ -0,0 +1,249 @@ +# futmem + +A small, read-only live-memory inspector for FIFA 17, built for the OpenFUT +preservation project. + +`FIFA17.exe` is Denuvo-packed: its `.text` and `.rdata` exist in plaintext only +inside the running process. Anything the packed executable owns can be reached +only through live memory. `CardsDLL_Win64_retail.dll`, which holds nearly all the +FUT logic, is unpacked but is loaded at a different address on every launch. +`futmem` answers both problems: it finds the process, tells you where everything +is loaded, and lets you search and dump it without touching a byte. + +``` +cargo build --release +./target/release/futmem maps +``` + +## Read only by construction + +A live game session may be running while this tool is used, and corrupting it +costs the user their session. The read-only property is therefore structural +rather than a matter of discipline: + +* `/proc//mem` is opened with `File::open`, i.e. `O_RDONLY`. The identifier + `OpenOptions` does not appear anywhere in this crate. +* `ProcMem` exposes `&self` read methods only. It hands out no `&mut File` and no + raw file descriptor, so no caller outside `mem.rs` can upgrade the handle. +* Nothing here calls `ptrace`, sends a signal, or stops the target. + +There is no code path in this crate that can write to another process. Even if +one were added by mistake, the kernel would reject the write on an `O_RDONLY` +descriptor. Keep it that way. + +## Subcommands + +``` +futmem maps [--pid N] +futmem find [--pid N] [--ascii|--utf16|--hex] [--module NAME] [--max N] +futmem strings [--pid N] [--min 6] [--range START-END] [--module NAME] [--utf16] + [--grep SUBSTR] [--max N] +futmem read [--pid N] +``` + +With no `--pid`, the target is resolved by scanning `/proc/*/comm` for exactly +`FIFA17.exe`. This matters: several processes in the Proton/umu tree carry +"fifa17" in their command line, including a convincing +`umu.exe /mnt/games/FIFA 17/_fifa17.exe` decoy, so a `pgrep -f` match is not good +enough. Only `comm` is authoritative. + +Addresses may be written `0x140000000` or `140000000`; bare values are read as +hex, which is how this project writes them. Lengths accept `0x100`, `256`, `16k`, +`2m`. + +## What `maps` gives you that `cat /proc/pid/maps` does not + +### The relocation slide, computed for you + +Every address in the project's Ghidra database is based at `0x180000000`. The +live module is somewhere else. `maps` prints the conversion directly: + +``` +CardsDLL_Win64_retail.dll PRESENT base 0x6ffffc140000 size 0x31d000 static 0x180000000 slide +0x6ffe7c140000 + +CardsDLL address conversion: live_va = static_va + 0x6ffe7c140000 +``` + +It derives this by reading `ImageBase` from the *on-disk* PE (where the module +wanted to load) and subtracting it from the live load address. The live header +cannot be used for this, because Wine rewrites its `ImageBase` field to the +actual load address. + +**Module bases move on every launch.** Never cache the slide across a restart. + +### The Wine mapping gotcha, made visible + +Wine keeps only a PE's 4 KiB header file-backed and copies every section into +anonymous memory. So this returns exactly one line: + +``` +$ grep CardsDLL /proc/4048/maps +6ffffc140000-6ffffc141000 r--p 00000000 00:37 2941670 /mnt/games/FIFA 17/CardsDLL_Win64_retail.dll +``` + +It is easy to misread that as "the module is barely mapped". A module table built +naively from path grouping reports CardsDLL as a 4 KiB module; it is really +`0x31d000` bytes. `futmem` reads `SizeOfImage` from the live PE header instead +and flags the discrepancy: + +``` +6ffffc140000 6ffffc45d000 3.11 MiB 1 CardsDLL_Win64_retail.dll [maps shows only 4.00 KiB; sections are anonymous] +``` + +This also drives address attribution. A hit inside CardsDLL's `.rdata` lands in +an anonymous region as far as the maps are concerned, so `find` checks module +image spans *before* the region list and reports +`CardsDLL_Win64_retail.dll+0x22c618` rather than `anon`. + +Only genuine PE images claim a range. `/dev/nvidia0` is mapped at many scattered +addresses, and letting its min..max span count as an "image" mis-attributed +gigabytes of unrelated anonymous memory to it. Non-PE mappings own only their +exact regions. + +### Honest degradation + +If the game has not loaded FUT yet, the difference is visible at a glance rather +than showing as an empty table: + +``` +KEY MODULES + FIFA17.exe PRESENT base 0x140000000 ... + CardsDLL_Win64_retail.dll ABSENT not in this process's maps (the game has not loaded it yet) +``` + +An explicit `--pid` that does not point at the game is called out too, so a +wrong-target mistake cannot pass unnoticed: + +``` +pid 26072 (comm "bash"), 39 mapped regions <-- NOT FIFA17.exe; this is not the game process +``` + +## Design notes + +### pread, not seek + read + +`FileExt::read_at` is `pread(2)`: the offset is an argument rather than a mutable +cursor on the file. A `&ProcMem` can therefore be shared across threads later +without a mutex and without one thread's seek corrupting another's read, and a +whole class of "forgot to seek" bugs disappears. + +### Partial sweeps are normal, and are reported + +Many regions marked readable in `/proc//maps` are not actually readable: +guard pages, Wine's special mappings, and pages Denuvo has not faulted in all +return `EIO`. A failed read is skipped and counted, never fatal, and every sweep +prints its counts: + +``` +1 hits; scanned 3552 regions (3.73 GiB), skipped 0 unreadable regions, 3 holes stepped over +``` + +That line is there so a zero-hit result is never mistaken for proof of absence. +When `find` returns nothing it says so explicitly. + +### Chunked reads and the `pattern_len - 1` overlap + +The target has roughly 3 GB resident, so regions are walked in 4 MiB chunks. The +classic bug in hand-rolled scanners is that a pattern straddling a chunk boundary +is never found: the tail of chunk N holds its first bytes and the head of chunk +N+1 holds the rest, and neither buffer contains the whole thing. + +Consecutive chunks therefore overlap by exactly `pattern_len - 1` bytes. That +number is neither too small nor too large. Let a chunk cover `[0, n)` and the +pattern have length `P`. A match starting at index `s` occupies `s ..= s + P - 1`, +so the last match wholly inside the chunk starts at `s = n - P`. Advancing by +`n - (P - 1)` starts the next chunk at `n - P + 1`, so: + +* nothing is missed: every straddling match starts at `s >= n - P + 1`, inside + the next chunk; +* nothing is double-reported: the overlap begins at `n - P + 1`, strictly past + `n - P`, the last index that can host a complete match in this chunk. The + windows of reportable match *starts* are disjoint even though the byte windows + overlap. + +Overlapping by `P` would report every boundary-straddling match twice; +overlapping by `P - 2` would miss one alignment. + +This is verified against the live process rather than merely asserted. Region +`0x144ed3000` is swept in 4 MiB chunks, so its first boundary falls at +`0x1452d3000`. A 16-byte pattern placed 8 bytes before it straddles the boundary, +and is found exactly once: + +``` +$ futmem read 0x1452d2ff8 16 +0001452d2ff8 a9 48 01 90 90 90 90 90 90 99 51 48 8d 0d 0c 74 |.H........QH...t| + +$ futmem find --hex a948019090909090909951488d0d0c74 --module fifa17 +0x0001452d2ff8 FIFA17.exe+0x52d2ff8 +1 hits +``` + +One hit, not zero and not two. + +String extraction uses a different mechanism for the same reason: it sweeps with +zero overlap and carries an unfinished run across contiguous chunks, so a string +spanning a boundary is still emitted whole. UTF-16 additionally carries a +dangling low byte when a chunk ends mid-pair. + +### Dependencies + +`memchr` is the only dependency. Its `memmem` uses SIMD and runs roughly an order +of magnitude faster than `windows(n).position(...)` over multiple gigabytes, +which is the difference between a several-minute sweep and a few seconds. +Everything else (argument parsing for four subcommands, maps parsing, PE header +parsing, hex dumping) is a few dozen lines of `std` and does not justify pulling +in `clap`. + +### Standalone workspace + +`Cargo.toml` carries an empty `[workspace]` table. Without it, cargo walks up the +directory tree, finds the untracked workspace manifest at the repo root, sees that +`futmem` is not in its `members` list, and refuses to build. Opting out from this +side avoids editing that manifest. + +## Performance + +Measured against pid 4048 with the game sitting at the main menu, release build, +best and worst of three runs each. These are wall clock, and they are dominated +by the `pread` syscalls rather than by the search itself. + +| Sweep | Scope | Wall clock | +|---|---|---| +| `strings --min 8 --grep pack` | 3.20 GiB, all anon private | 6.3 to 6.8 s | +| `find --ascii` (global) | 3.73 GiB, all readable | 5.3 to 7.0 s | +| `find --ascii --module cardsdll` | 3.11 MiB | 0.05 s | +| `maps` | n/a | 0.05 s | + +Scoping with `--module` is over a hundred times cheaper and should be the default +habit when the target is known to live in CardsDLL. A global sweep costs about +six seconds, which is cheap enough to use freely but not in a tight loop. + +## Worked example + +``` +$ futmem find --ascii 'RS4:FutSquadSave' --module cardsdll +scanning CardsDLL_Win64_retail.dll image span 0x6ffffc140000-0x6ffffc45d000 (3.11 MiB) + from /mnt/games/FIFA 17/CardsDLL_Win64_retail.dll +pattern 16 bytes, 7 candidate regions (3.11 MiB) + +0x6ffffc36c618 CardsDLL_Win64_retail.dll+0x22c618 + 6ffffc36c618 52 53 34 3a 46 75 74 53 71 75 61 64 53 61 76 65 |RS4:FutSquadSave| + 6ffffc36c628 53 65 72 76 65 72 52 65 73 70 6f 6e 73 65 00 00 |ServerResponse..| + 6ffffc36c638 5b 00 00 00 2c 25 64 00 5d 00 00 00 00 00 00 00 |[...,%d.].......| + 6ffffc36c648 63 61 70 74 61 69 6e 00 22 05 93 19 01 00 00 00 |captain.".......| + +1 hits; scanned 7 regions (3.11 MiB), skipped 0 unreadable regions, 0 holes stepped over +``` + +The `+0x22c618` offset converts straight back to the Ghidra address +`0x18022c618`. Note that the literal is `RS4:FutSquadSaveServerResponse`, not +`RS4:FutSquadSave` with a trailing NUL; read such patterns from the PE rather +than assuming them. + +## Scope + +This tool is client-side instrumentation. It establishes nothing about the UTAS +wire protocol and nothing a server emulator must reimplement. Its value is as the +addressing base that lets other work read server-authoritative logic out of +CardsDLL. Do not let addresses produced by this tool leak into a protocol +document as if they were protocol. diff --git a/fifa17-recon/futmem/src/cli.rs b/fifa17-recon/futmem/src/cli.rs new file mode 100644 index 0000000..ac2d0ce --- /dev/null +++ b/fifa17-recon/futmem/src/cli.rs @@ -0,0 +1,125 @@ +//! A deliberately tiny argument parser. +//! +//! Four subcommands do not justify a `clap` dependency and its build time. The +//! only subtlety is that some long options take a value (`--pid 165925`) and +//! some are bare booleans (`--utf16`). A parser cannot tell those apart from +//! the token stream alone, so each subcommand declares which of its options +//! take a value and we look the name up in that list. + +use std::collections::HashMap; + +pub struct Args { + opts: HashMap>, + pub positional: Vec, +} + +#[derive(Debug)] +pub struct ArgError(pub String); + +impl std::fmt::Display for ArgError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl Args { + /// `value_flags` lists the long option names that consume the following + /// token as their value. Everything else beginning with `--` is a boolean. + /// `--name=value` is always accepted regardless of the list. + pub fn parse>( + argv: I, + value_flags: &[&str], + ) -> Result { + let mut opts: HashMap> = HashMap::new(); + let mut positional = Vec::new(); + let mut it = argv.peekable(); + + while let Some(tok) = it.next() { + if let Some(rest) = tok.strip_prefix("--") { + if rest.is_empty() { + // A bare `--` ends option parsing; the rest is positional. + positional.extend(it.by_ref()); + break; + } + if let Some((name, value)) = rest.split_once('=') { + opts.insert(name.to_string(), Some(value.to_string())); + } else if value_flags.contains(&rest) { + let value = it + .next() + .ok_or_else(|| ArgError(format!("--{rest} needs a value")))?; + opts.insert(rest.to_string(), Some(value)); + } else { + opts.insert(rest.to_string(), None); + } + } else { + positional.push(tok); + } + } + + Ok(Args { opts, positional }) + } + + pub fn has(&self, name: &str) -> bool { + self.opts.contains_key(name) + } + + pub fn value(&self, name: &str) -> Option<&str> { + self.opts.get(name).and_then(|v| v.as_deref()) + } + + pub fn parse_value(&self, name: &str) -> Result, ArgError> { + match self.value(name) { + None => Ok(None), + Some(raw) => raw + .parse::() + .map(Some) + .map_err(|_| ArgError(format!("could not parse --{name} value {raw:?}"))), + } + } + + /// Reject typos instead of silently ignoring them. `futmem find --acii foo` + /// should not quietly scan for nothing. + pub fn reject_unknown(&self, known: &[&str]) -> Result<(), ArgError> { + for name in self.opts.keys() { + if !known.contains(&name.as_str()) { + return Err(ArgError(format!("unknown option --{name}"))); + } + } + Ok(()) + } +} + +/// Parse `0x1234`, `1234` (hex assumed when the `0x` prefix is present, +/// decimal otherwise) into a virtual address. +pub fn parse_addr(raw: &str) -> Result { + let cleaned = raw.replace('_', ""); + let parsed = match cleaned + .strip_prefix("0x") + .or_else(|| cleaned.strip_prefix("0X")) + { + Some(hex) => u64::from_str_radix(hex, 16), + // Bare addresses in this project are always written in hex + // (`6ffffc140000`), so try hex first and fall back to decimal only for + // values that are unambiguous. + None => u64::from_str_radix(&cleaned, 16).or_else(|_| cleaned.parse::()), + }; + parsed.map_err(|_| ArgError(format!("bad address {raw:?}"))) +} + +/// Parse a length: `4096`, `0x1000`, `16k`, `2m`. +pub fn parse_len(raw: &str) -> Result { + let lower = raw.to_ascii_lowercase(); + let (body, mult) = match lower.strip_suffix('k') { + Some(b) => (b, 1024u64), + None => match lower.strip_suffix('m') { + Some(b) => (b, 1024 * 1024), + None => (lower.as_str(), 1), + }, + }; + let n = match body.strip_prefix("0x") { + Some(hex) => u64::from_str_radix(hex, 16), + None => body.parse::(), + } + .map_err(|_| ArgError(format!("bad length {raw:?}")))?; + Ok(n * mult) +} diff --git a/fifa17-recon/futmem/src/dump.rs b/fifa17-recon/futmem/src/dump.rs new file mode 100644 index 0000000..7f6f937 --- /dev/null +++ b/fifa17-recon/futmem/src/dump.rs @@ -0,0 +1,33 @@ +//! Hex + ASCII rendering, shared by `read` and by `find`'s context blocks. + +use std::fmt::Write as _; +use std::io::{self, Write}; + +fn ascii_gutter(row: &[u8]) -> String { + row.iter() + .map(|&b| { + if (0x20..=0x7e).contains(&b) { + b as char + } else { + '.' + } + }) + .collect() +} + +/// Classic 16-bytes-per-line dump with absolute addresses in the left column. +pub fn hexdump(out: &mut impl Write, base: u64, data: &[u8], indent: &str) -> io::Result<()> { + for (i, row) in data.chunks(16).enumerate() { + let addr = base + (i * 16) as u64; + let mut hex = String::with_capacity(50); + for (j, b) in row.iter().enumerate() { + if j == 8 { + hex.push(' '); + } + // Writing into a String is infallible. + let _ = write!(hex, "{b:02x} "); + } + writeln!(out, "{indent}{addr:012x} {hex:<50}|{}|", ascii_gutter(row))?; + } + Ok(()) +} diff --git a/fifa17-recon/futmem/src/image.rs b/fifa17-recon/futmem/src/image.rs new file mode 100644 index 0000000..fa3f776 --- /dev/null +++ b/fifa17-recon/futmem/src/image.rs @@ -0,0 +1,201 @@ +//! Turning `/proc//maps` lines into a usable module table, and turning an +//! address back into `module+offset`. +//! +//! # The Wine mapping gotcha this module exists to work around +//! +//! Under Wine, only a PE's 4 KiB header stays file-backed. Wine copies every +//! section into ANONYMOUS memory. So `grep CardsDLL /proc//maps` returns +//! exactly one line, 4 KiB long, and a module table built naively from path +//! grouping will report CardsDLL as a 4 KiB module. It is really 0x31d000 bytes. +//! An agent who trusts the maps extent concludes the module is "barely mapped" +//! and gives up, or computes a wrong module size and mis-attributes every hit. +//! +//! The fix: read `SizeOfImage` out of the live PE header at the module base. +//! That field is authoritative for the module's real extent, and the header is +//! the one part of the image that is reliably readable. +//! +//! # Deriving the slide automatically +//! +//! Wine rewrites the `ImageBase` field of the *live* header to the actual load +//! address, so the live header cannot tell us where the module wanted to load. +//! The on-disk file still can, and the maps line gives us its path. Reading the +//! on-disk `ImageBase` and subtracting gives the relocation slide: +//! +//! ```text +//! slide = live_base - disk_image_base +//! live_va = static_va + slide +//! ``` +//! +//! For CardsDLL that is `0x6ffffc140000 - 0x180000000 = 0x6ffe7c140000`, the +//! number every Ghidra-derived address in this project has to be adjusted by. +//! Printing it removes the most error-prone manual step in the workflow. + +use crate::maps::Region; +use crate::mem::ProcMem; +use std::fs; + +#[derive(Debug, Clone)] +pub struct Module { + /// Bare file name, e.g. `CardsDLL_Win64_retail.dll`. + pub name: String, + pub path: String, + /// Lowest mapped address carrying this path. For a PE this is the header. + pub base: u64, + /// Highest address still carrying this path in the maps. Badly understates + /// the truth under Wine; see the module docs. + pub maps_end: u64, + /// Number of separate maps lines mentioning this path. + pub region_count: usize, + /// `SizeOfImage` from the live PE header, the real extent. + pub size_of_image: Option, + /// `ImageBase` from the on-disk file: where the module was linked to load. + pub disk_image_base: Option, +} + +impl Module { + /// Best available end address: PE-derived when we have it, maps otherwise. + pub fn end(&self) -> u64 { + match self.size_of_image { + Some(size) => self.base + size, + None => self.maps_end, + } + } + + /// The relocation slide: add this to a static (Ghidra) VA to get a live VA. + pub fn slide(&self) -> Option { + self.disk_image_base + .map(|disk| self.base as i128 - disk as i128) + } + + /// Is this actually a PE image, as opposed to a device node, font or `.nls` + /// data file that merely happens to be mapped? + pub fn is_pe(&self) -> bool { + self.size_of_image.is_some() + } + + /// Only PE images claim an address range. + /// + /// Without the `is_pe` guard this mis-attributes badly. `/dev/nvidia0` is + /// mapped at many scattered addresses, so its min..max span covers gigabytes + /// of unrelated anonymous memory, and every hit in there would be reported + /// as `nvidia0+0x...`. A non-PE mapping only ever owns the exact regions + /// listed for it in the maps, which `describe` handles as a fallback. + pub fn contains(&self, va: u64) -> bool { + self.is_pe() && va >= self.base && va < self.end() + } +} + +/// Little-endian scalar helpers. Returning `Option` keeps a truncated or +/// malformed header from panicking the whole run. +fn u16_at(buf: &[u8], off: usize) -> Option { + buf.get(off..off + 2) + .map(|s| u16::from_le_bytes([s[0], s[1]])) +} +fn u32_at(buf: &[u8], off: usize) -> Option { + buf.get(off..off + 4) + .map(|s| u32::from_le_bytes([s[0], s[1], s[2], s[3]])) +} +fn u64_at(buf: &[u8], off: usize) -> Option { + buf.get(off..off + 8) + .map(|s| u64::from_le_bytes([s[0], s[1], s[2], s[3], s[4], s[5], s[6], s[7]])) +} + +/// `SizeOfImage` and `ImageBase` from a PE header blob. +/// +/// Layout: `e_lfanew` at 0x3c points at the `PE\0\0` signature; the 20-byte +/// COFF header follows; the optional header starts at signature+24. Within the +/// optional header `SizeOfImage` sits at 0x38 for both PE32 and PE32+ (the +/// layouts diverge only between 0x18 and 0x20). `ImageBase` is 8 bytes at 0x18 +/// for PE32+ and 4 bytes at 0x1c for PE32. +fn parse_pe(buf: &[u8]) -> Option<(u64, u64)> { + if buf.get(0..2)? != b"MZ" { + return None; + } + let nt = u32_at(buf, 0x3c)? as usize; + if buf.get(nt..nt + 4)? != b"PE\0\0" { + return None; + } + let opt = nt + 24; + let magic = u16_at(buf, opt)?; + let size_of_image = u32_at(buf, opt + 0x38)? as u64; + let image_base = match magic { + 0x20b => u64_at(buf, opt + 0x18)?, // PE32+ + 0x10b => u32_at(buf, opt + 0x1c)? as u64, // PE32 + _ => return None, + }; + Some((size_of_image, image_base)) +} + +fn pe_from_disk(path: &str) -> Option<(u64, u64)> { + // 4 KiB is more than enough for MZ + PE + optional header on any real image. + let data = fs::read(path).ok()?; + parse_pe(&data[..data.len().min(4096)]) +} + +/// Build the module table. Modules are returned sorted by base address. +pub fn modules(regions: &[Region], mem: &ProcMem) -> Vec { + use std::collections::HashMap; + let mut by_path: HashMap<&str, (u64, u64, usize)> = HashMap::new(); + + for r in regions { + let Some(path) = r.path.as_deref() else { + continue; + }; + if r.pseudo() { + continue; + } + let entry = by_path.entry(path).or_insert((u64::MAX, 0, 0)); + entry.0 = entry.0.min(r.start); + entry.1 = entry.1.max(r.end); + entry.2 += 1; + } + + let mut out: Vec = by_path + .into_iter() + .map(|(path, (base, maps_end, region_count))| { + // The live header gives the true extent; the on-disk header gives + // the link-time base, which is what the slide is measured against. + let live = mem.read_partial(base, 4096); + let live_pe = parse_pe(&live); + let disk_pe = pe_from_disk(path); + Module { + name: path.rsplit('/').next().unwrap_or(path).to_string(), + path: path.to_string(), + base, + maps_end, + region_count, + size_of_image: live_pe.map(|(s, _)| s).or(disk_pe.map(|(s, _)| s)), + disk_image_base: disk_pe.map(|(_, b)| b), + } + }) + .collect(); + + out.sort_by_key(|m| m.base); + out +} + +/// Case-insensitive lookup by name substring, e.g. `cardsdll`. +pub fn find_module<'a>(mods: &'a [Module], needle: &str) -> Option<&'a Module> { + let needle = needle.to_ascii_lowercase(); + mods.iter() + .find(|m| m.name.to_ascii_lowercase().contains(&needle)) +} + +/// Describe an address as `module+0xoff`, falling back to the region kind. +/// +/// Checking module image spans BEFORE the region list is essential here: a hit +/// inside CardsDLL's `.rdata` lands in an anonymous region as far as the maps +/// are concerned, and would otherwise be reported as `anon`, throwing away the +/// single most useful piece of context. +pub fn describe(va: u64, mods: &[Module], regions: &[Region]) -> String { + if let Some(m) = mods.iter().find(|m| m.contains(va)) { + return format!("{}+{:#x}", m.name, va - m.base); + } + match regions.iter().find(|r| va >= r.start && va < r.end) { + Some(r) => match r.path.as_deref() { + Some(p) => format!("{}+{:#x}", p.rsplit('/').next().unwrap_or(p), va - r.start), + None => format!("anon:{:#x}({})", r.start, r.perms), + }, + None => "unmapped".to_string(), + } +} diff --git a/fifa17-recon/futmem/src/main.rs b/fifa17-recon/futmem/src/main.rs new file mode 100644 index 0000000..b44c115 --- /dev/null +++ b/fifa17-recon/futmem/src/main.rs @@ -0,0 +1,576 @@ +//! # futmem: a read-only live-memory inspector for FIFA 17 +//! +//! Preservation and interoperability tooling for the OpenFUT project. FIFA 17's +//! `FIFA17.exe` is Denuvo-packed, so its `.text` and `.rdata` exist in plaintext +//! only inside the running process. Anything the packed executable owns can be +//! reached only through live memory. This tool is how you reach it. +//! +//! ## READ ONLY BY CONSTRUCTION +//! +//! A live game session may be running while this tool is used, and corrupting it +//! costs the user their session. The read-only property is therefore structural +//! rather than a matter of discipline: +//! +//! * `/proc//mem` is opened with `File::open`, i.e. `O_RDONLY`. The string +//! `OpenOptions` does not appear anywhere in this crate. +//! * `ProcMem` exposes `&self` read methods only, hands out no `&mut File` and +//! no raw descriptor, so no caller can upgrade the handle to a writable one. +//! * Nothing here calls `ptrace`, sends a signal, or stops the target. +//! +//! There is no code path in this crate that can write to another process. Even +//! if one were added by mistake, the kernel would reject the write on an +//! `O_RDONLY` descriptor. +//! +//! ## Design notes +//! +//! * **pread, not seek+read.** `FileExt::read_at` takes the offset as an +//! argument instead of mutating a shared file cursor, so a `&ProcMem` can be +//! shared across threads later without a mutex, and a whole class of "forgot +//! to seek" bugs disappears. See `mem.rs`. +//! * **Partial sweeps are normal.** Many regions marked readable in +//! `/proc//maps` are not actually readable: guard pages, Wine's special +//! mappings, and pages Denuvo has not faulted in all return `EIO`. A failed +//! read is skipped and counted, never fatal, and the counts are printed so a +//! zero-hit result is never mistaken for proof of absence. See `scan.rs`. +//! * **Chunked reads with a `pattern_len - 1` overlap.** The target has roughly +//! 3 GB resident, so regions are walked in 4 MiB chunks. Consecutive chunks +//! overlap by exactly `pattern_len - 1` bytes so a pattern straddling a +//! boundary is still found, and not double-reported. `scan.rs` carries the +//! proof that this specific overlap is the correct one; it is the classic +//! off-by-one in scanners of this kind. +//! * **Minimal dependencies.** `memchr` is the only one, and it earns its place +//! on a multi-gigabyte sweep. Four subcommands do not justify `clap`. +//! +//! ## The Wine mapping gotcha +//! +//! Wine keeps only a PE's 4 KiB header file-backed and copies the sections into +//! anonymous memory. `grep CardsDLL /proc//maps` therefore returns exactly +//! one 4 KiB line. A module table built naively from the maps reports CardsDLL as +//! a 4 KiB module when it is really 0x31d000 bytes. `futmem maps` reads +//! `SizeOfImage` from the live PE header instead, and derives the relocation +//! slide by comparing the live load address against the on-disk `ImageBase`, so +//! the number needed to convert Ghidra addresses to live ones is printed rather +//! than recomputed by hand. + +mod cli; +mod dump; +mod image; +mod maps; +mod mem; +mod scan; + +use cli::{parse_addr, parse_len, ArgError, Args}; +use maps::{human, Region}; +use mem::ProcMem; +use std::io::{self, BufWriter, Write}; +use std::process::ExitCode; + +const COMM: &str = "FIFA17.exe"; +/// Modules this project always wants to know the status of. +const KEY_MODULES: [&str; 3] = [ + "FIFA17.exe", + "CardsDLL_Win64_retail.dll", + "powdll_Win64_retail.dll", +]; + +const USAGE: &str = "\ +futmem: read-only live-memory inspector for FIFA 17 (OpenFUT preservation tooling) + +USAGE + futmem maps [--pid N] + futmem find [--pid N] [--ascii|--utf16|--hex] [--module NAME] [--max N] + futmem strings [--pid N] [--min 6] [--range START-END] [--module NAME] [--utf16] + [--grep SUBSTR] [--max N] + futmem read [--pid N] + +COMMON + --pid N Target pid. Omitted, futmem resolves the process whose + /proc//comm is exactly \"FIFA17.exe\". Decoy processes in the + Proton tree match a pgrep -f on \"fifa17\", so comm is the authority. + +find + --ascii Pattern is ASCII text. This is the default. + --utf16 Widen the ASCII pattern to UTF-16LE, how Windows stores most UI + strings. + --hex Pattern is a hex byte string, e.g. 4883ec284885c9. Spaces ignored. + --module NAME Restrict the scan to a module's image span, matched case + insensitively on a substring of the file name, e.g. --module cardsdll. + --max N Stop after N hits. + +strings + --min N Minimum run length. Default 6. + --range A-B Scan exactly this address range, e.g. --range 0x1450f3000-0x14b1a3000. + --module NAME Scan a module's image span. + --utf16 Extract UTF-16LE strings instead of ASCII. + --grep S Only print strings containing S, matched case insensitively. + --max N Stop after N strings. + With none of --range or --module, the default scope is every anonymous private + region, which is where a packed executable's decrypted data lives. + +Addresses may be written 0x140000000 or 140000000; bare values are read as hex. +Lengths accept 0x100, 256, 16k, 2m. + +All operations are strictly read-only. See the crate docs for the guarantee. +"; + +fn main() -> ExitCode { + let argv: Vec = std::env::args().skip(1).collect(); + let Some(sub) = argv.first().cloned() else { + print!("{USAGE}"); + return ExitCode::FAILURE; + }; + let rest = argv.into_iter().skip(1); + + let stdout = io::stdout(); + let mut out = BufWriter::new(stdout.lock()); + + let result = match sub.as_str() { + "maps" => cmd_maps(&mut out, rest), + "find" => cmd_find(&mut out, rest), + "strings" => cmd_strings(&mut out, rest), + "read" => cmd_read(&mut out, rest), + "-h" | "--help" | "help" => { + print!("{USAGE}"); + return ExitCode::SUCCESS; + } + other => { + eprintln!("futmem: unknown subcommand {other:?}\n"); + eprint!("{USAGE}"); + return ExitCode::FAILURE; + } + }; + + // Flushing separately so a broken pipe (futmem strings | head) is not + // reported as a failure. + let flushed = out.flush(); + match (result, flushed) { + (Err(e), _) if e.kind() == io::ErrorKind::BrokenPipe => ExitCode::SUCCESS, + (_, Err(e)) if e.kind() == io::ErrorKind::BrokenPipe => ExitCode::SUCCESS, + (Err(e), _) => { + eprintln!("futmem: {e}"); + ExitCode::FAILURE + } + (Ok(()), Err(e)) => { + eprintln!("futmem: {e}"); + ExitCode::FAILURE + } + (Ok(()), Ok(())) => ExitCode::SUCCESS, + } +} + +fn arg_err(e: ArgError) -> io::Error { + new_invalid(e) +} + +/// Resolve the target pid from `--pid` or by scanning `/proc/*/comm`. +fn resolve_pid(args: &Args) -> io::Result { + match args.parse_value::("pid").map_err(arg_err)? { + Some(pid) => Ok(pid), + None => maps::find_pid(COMM), + } +} + +// ---------------------------------------------------------------- maps + +fn cmd_maps(out: &mut W, argv: impl Iterator) -> io::Result<()> { + let args = Args::parse(argv, &["pid"]).map_err(arg_err)?; + args.reject_unknown(&["pid"]).map_err(arg_err)?; + let pid = resolve_pid(&args)?; + let regions = maps::read_maps(pid)?; + let mem = ProcMem::open(pid)?; + let mods = image::modules(®ions, &mem); + + // Report the comm we actually found, not the one we hoped for: an explicit + // --pid may point anywhere, and silently labelling it "FIFA17.exe" would + // make a wrong-target mistake invisible. + let comm = maps::read_comm(pid); + let warn = if comm == COMM { + String::new() + } else { + format!(" <-- NOT {COMM}; this is not the game process") + }; + writeln!( + out, + "pid {pid} (comm {comm:?}), {} mapped regions{warn}", + regions.len() + )?; + writeln!(out)?; + + // -- key modules first, so "is FUT loaded yet?" is answerable at a glance. + writeln!(out, "KEY MODULES")?; + for want in KEY_MODULES { + match image::find_module(&mods, want) { + Some(m) => { + let slide = match m.slide() { + Some(s) if s >= 0 => format!("slide +{:#x}", s), + Some(s) => format!("slide -{:#x}", -s), + None => "slide unknown".to_string(), + }; + let static_base = m + .disk_image_base + .map(|b| format!("static {b:#x}")) + .unwrap_or_else(|| "static ?".to_string()); + writeln!( + out, + " {:<28} PRESENT base {:#x} size {:#x} {static_base} {slide}", + m.name, + m.base, + m.size_of_image.unwrap_or(m.maps_end - m.base), + )?; + } + None => writeln!( + out, + " {want:<28} ABSENT not in this process's maps (the game has not loaded it yet)" + )?, + } + } + if let Some(m) = image::find_module(&mods, "CardsDLL") { + if let Some(slide) = m.slide() { + writeln!(out)?; + writeln!( + out, + " CardsDLL address conversion: live_va = static_va + {slide:#x}" + )?; + writeln!( + out, + " (Ghidra static base {:#x} -> live base {:#x}. Valid for pid {pid} only; \ + module bases move on every launch.)", + m.disk_image_base.unwrap_or(0), + m.base + )?; + } + } + writeln!(out)?; + + // -- full module table + writeln!(out, "MODULES (file-backed, grouped by path)")?; + writeln!( + out, + " {:<14} {:<14} {:<12} {:>5} name", + "base", "end (PE)", "size", "regs" + )?; + for m in &mods { + let note = if !m.is_pe() { + // A device node, .nls table or font, not a loadable image. Its + // min..max span is meaningless, so say so rather than imply an extent. + " [non-PE mapping; span is min..max of scattered regions]".to_string() + } else if m.maps_end - m.base < m.end() - m.base { + // The Wine gotcha, made visible instead of silently misleading. + format!( + " [maps shows only {}; sections are anonymous]", + human(m.maps_end - m.base) + ) + } else { + String::new() + }; + writeln!( + out, + " {:<14x} {:<14x} {:<12} {:>5} {}{}", + m.base, + m.end(), + human(m.end() - m.base), + m.region_count, + m.name, + note + )?; + } + writeln!(out)?; + + // -- writable + executable regions: where packers put decrypted code. + let wx: Vec<&Region> = regions + .iter() + .filter(|r| r.writable() && r.executable()) + .collect(); + let wx_total: u64 = wx.iter().map(|r| r.size()).sum(); + writeln!( + out, + "WRITABLE + EXECUTABLE REGIONS ({} regions, {})", + wx.len(), + human(wx_total) + )?; + // Wine emits hundreds of 4 KiB per-thread stubs that are pure noise. + let mut small_wx = 0usize; + for r in &wx { + if r.size() <= 64 * 1024 { + small_wx += 1; + continue; + } + writeln!( + out, + " {:012x}-{:012x} {} {:>10} {}", + r.start, + r.end, + r.perms, + human(r.size()), + describe_region(r, &mods) + )?; + } + if small_wx > 0 { + writeln!( + out, + " (+{small_wx} regions of 64 KiB or less, Wine per-thread stubs, omitted)" + )?; + } + writeln!(out)?; + + // -- large anonymous private regions + let mut anon: Vec<&Region> = regions + .iter() + .filter(|r| r.anonymous() && r.private() && r.readable() && r.size() > 1024 * 1024) + .collect(); + anon.sort_by_key(|r| std::cmp::Reverse(r.size())); + let anon_total: u64 = anon.iter().map(|r| r.size()).sum(); + writeln!( + out, + "ANONYMOUS PRIVATE REGIONS OVER 1 MB ({} regions, {})", + anon.len(), + human(anon_total) + )?; + for r in &anon { + writeln!( + out, + " {:012x}-{:012x} {} {:>10} {}", + r.start, + r.end, + r.perms, + human(r.size()), + describe_region(r, &mods) + )?; + } + + Ok(()) +} + +/// Label a region with the module whose image span contains it, if any. +fn describe_region(r: &Region, mods: &[image::Module]) -> String { + if let Some(p) = r.path.as_deref() { + // The file offset matters for a packed executable: it says which part of + // the on-disk image this mapping still corresponds to. + let name = p.rsplit('/').next().unwrap_or(p); + return format!("{name} @fileoff {:#x}", r.offset); + } + match mods.iter().find(|m| m.contains(r.start)) { + Some(m) => format!("anon, inside {} image", m.name), + None => "anon".to_string(), + } +} + +// ---------------------------------------------------------------- find + +fn cmd_find(out: &mut W, argv: impl Iterator) -> io::Result<()> { + let known = ["pid", "ascii", "utf16", "hex", "module", "max"]; + let args = Args::parse(argv, &["pid", "module", "max"]).map_err(arg_err)?; + args.reject_unknown(&known).map_err(arg_err)?; + + let Some(raw) = args.positional.first() else { + return Err(new_invalid(ArgError("find needs a pattern".into()))); + }; + + let pattern: Vec = if args.has("hex") { + parse_hex(raw).map_err(arg_err)? + } else if args.has("utf16") { + // Widen ASCII to UTF-16LE: each byte followed by a zero high byte. + raw.bytes().flat_map(|b| [b, 0]).collect() + } else { + raw.as_bytes().to_vec() + }; + let max = args.parse_value::("max").map_err(arg_err)?; + + let pid = resolve_pid(&args)?; + let regions = maps::read_maps(pid)?; + let mem = ProcMem::open(pid)?; + let mods = image::modules(®ions, &mem); + + let module = match args.value("module") { + Some(name) => match image::find_module(&mods, name) { + Some(m) => Some(m.clone()), + None => { + return Err(new_invalid(ArgError(format!( + "no module matching {name:?} in pid {pid}; run `futmem maps` to list them" + )))) + } + }, + None => None, + }; + + if let Some(m) = &module { + writeln!( + out, + "scanning {} image span {:#x}-{:#x} ({})\n from {}", + m.name, + m.base, + m.end(), + human(m.end() - m.base), + m.path + )?; + } + + let targets = scan::scan_targets(®ions, module.as_ref(), false); + let target_bytes: u64 = targets.iter().map(|r| r.size()).sum(); + writeln!( + out, + "pattern {} bytes, {} candidate regions ({})", + pattern.len(), + targets.len(), + human(target_bytes) + )?; + writeln!(out)?; + + let mut hits: Vec = Vec::new(); + let stats = scan::find_pattern(&mem, &targets, &pattern, max, |va| hits.push(va)); + + for va in &hits { + let loc = image::describe(*va, &mods, ®ions); + writeln!(out, "{va:#014x} {loc}")?; + let ctx = mem.read_partial(*va, 64); + if !ctx.is_empty() { + dump::hexdump(out, *va, &ctx, " ")?; + } + } + + writeln!(out)?; + writeln!(out, "{} hits; {}", hits.len(), stats.summary())?; + if hits.is_empty() { + writeln!( + out, + "note: {} regions were unreadable, so an empty result is NOT proof of absence.", + stats.regions_skipped + )?; + } + Ok(()) +} + +fn parse_hex(raw: &str) -> Result, ArgError> { + let cleaned: String = raw + .chars() + .filter(|c| !c.is_whitespace() && *c != ':' && *c != ',') + .collect(); + let cleaned = cleaned.strip_prefix("0x").unwrap_or(&cleaned); + if !cleaned.len().is_multiple_of(2) { + return Err(ArgError(format!( + "hex pattern has an odd number of digits ({})", + cleaned.len() + ))); + } + (0..cleaned.len()) + .step_by(2) + .map(|i| { + u8::from_str_radix(&cleaned[i..i + 2], 16) + .map_err(|_| ArgError(format!("bad hex byte {:?}", &cleaned[i..i + 2]))) + }) + .collect() +} + +// ---------------------------------------------------------------- strings + +fn cmd_strings(out: &mut W, argv: impl Iterator) -> io::Result<()> { + let known = ["pid", "min", "range", "module", "utf16", "grep", "max"]; + let args = + Args::parse(argv, &["pid", "min", "range", "module", "grep", "max"]).map_err(arg_err)?; + args.reject_unknown(&known).map_err(arg_err)?; + + let min = args + .parse_value::("min") + .map_err(arg_err)? + .unwrap_or(6); + let max = args.parse_value::("max").map_err(arg_err)?; + let grep = args.value("grep"); + let utf16 = args.has("utf16"); + + let pid = resolve_pid(&args)?; + let regions = maps::read_maps(pid)?; + let mem = ProcMem::open(pid)?; + let mods = image::modules(®ions, &mem); + + let targets: Vec = if let Some(range) = args.value("range") { + let (a, b) = range + .split_once('-') + .ok_or_else(|| new_invalid(ArgError("--range wants START-END".into())))?; + let start = parse_addr(a).map_err(arg_err)?; + let end = parse_addr(b).map_err(arg_err)?; + if end <= start { + return Err(new_invalid(ArgError(format!( + "--range end {end:#x} is not above start {start:#x}" + )))); + } + writeln!(out, "scanning {start:#x}-{end:#x} ({})", human(end - start))?; + vec![Region { + start, + end, + perms: "r--p".to_string(), + offset: 0, + path: None, + }] + } else if let Some(name) = args.value("module") { + let m = image::find_module(&mods, name).ok_or_else(|| { + new_invalid(ArgError(format!( + "no module matching {name:?} in pid {pid}" + ))) + })?; + writeln!( + out, + "scanning {} image span {:#x}-{:#x} ({})\n from {}", + m.name, + m.base, + m.end(), + human(m.end() - m.base), + m.path + )?; + scan::scan_targets(®ions, Some(m), false) + } else { + // Default scope: anonymous private memory, where a packed executable's + // decrypted data lives. + let t = scan::scan_targets(®ions, None, true); + let bytes: u64 = t.iter().map(|r| r.size()).sum(); + writeln!( + out, + "scanning {} anonymous private regions ({})", + t.len(), + human(bytes) + )?; + t + }; + + let mut count = 0usize; + let stats = scan::find_strings(&mem, &targets, utf16, min, grep, max, |va, s| { + count += 1; + // Ignoring the write error here keeps the closure simple; a broken pipe + // is caught when the buffer is flushed in main. + let _ = writeln!(out, "{va:#014x} {}", s); + }); + + writeln!(out)?; + writeln!(out, "{count} strings; {}", stats.summary())?; + Ok(()) +} + +// ---------------------------------------------------------------- read + +fn cmd_read(out: &mut W, argv: impl Iterator) -> io::Result<()> { + let args = Args::parse(argv, &["pid"]).map_err(arg_err)?; + args.reject_unknown(&["pid"]).map_err(arg_err)?; + if args.positional.len() < 2 { + return Err(new_invalid(ArgError("read needs and ".into()))); + } + let va = parse_addr(&args.positional[0]).map_err(arg_err)?; + let len = parse_len(&args.positional[1]).map_err(arg_err)?; + if len == 0 || len > 64 * 1024 * 1024 { + return Err(new_invalid(ArgError(format!( + "length {len} out of range (1 .. 64 MiB)" + )))); + } + + let pid = resolve_pid(&args)?; + let regions = maps::read_maps(pid)?; + let mem = ProcMem::open(pid)?; + let mods = image::modules(®ions, &mem); + + writeln!(out, "{va:#x} {}", image::describe(va, &mods, ®ions))?; + let data = mem.read_exact(va, len as usize)?; + dump::hexdump(out, va, &data, "")?; + Ok(()) +} + +fn new_invalid(e: ArgError) -> io::Error { + io::Error::new(io::ErrorKind::InvalidInput, e.0) +} diff --git a/fifa17-recon/futmem/src/maps.rs b/fifa17-recon/futmem/src/maps.rs new file mode 100644 index 0000000..a1e7655 --- /dev/null +++ b/fifa17-recon/futmem/src/maps.rs @@ -0,0 +1,168 @@ +//! Parsing `/proc//maps` and finding the FIFA 17 process. + +use std::fs; +use std::io; + +#[derive(Debug, Clone)] +pub struct Region { + pub start: u64, + pub end: u64, + /// The raw four permission characters, e.g. `rwxp` or `r--s`. + pub perms: String, + /// File offset this mapping starts at, meaningless for anonymous regions. + pub offset: u64, + /// `None` for anonymous mappings. + pub path: Option, +} + +impl Region { + pub fn size(&self) -> u64 { + self.end - self.start + } + pub fn readable(&self) -> bool { + self.perms.as_bytes().first() == Some(&b'r') + } + pub fn writable(&self) -> bool { + self.perms.as_bytes().get(1) == Some(&b'w') + } + pub fn executable(&self) -> bool { + self.perms.as_bytes().get(2) == Some(&b'x') + } + pub fn private(&self) -> bool { + self.perms.as_bytes().get(3) == Some(&b'p') + } + pub fn anonymous(&self) -> bool { + self.path.is_none() + } + /// Pseudo-files the kernel exposes. Reading `[vvar]` through + /// `/proc/pid/mem` fails, and `[vsyscall]` is not interesting here. + pub fn pseudo(&self) -> bool { + matches!(self.path.as_deref(), Some(p) if p.starts_with('[')) + } +} + +pub fn read_maps(pid: i32) -> io::Result> { + let text = fs::read_to_string(format!("/proc/{pid}/maps")).map_err(|e| { + let hint = if fs::metadata(format!("/proc/{pid}")).is_err() { + format!("no process with pid {pid}") + } else { + format!("pid {pid} exists but its maps are unreadable (different user?)") + }; + io::Error::new(e.kind(), format!("reading /proc/{pid}/maps: {hint}")) + })?; + Ok(text.lines().filter_map(parse_line).collect()) +} + +/// The target's `comm`, so output can name what was actually inspected rather +/// than assuming an explicit `--pid` pointed at the game. +pub fn read_comm(pid: i32) -> String { + fs::read_to_string(format!("/proc/{pid}/comm")) + .map(|s| s.trim().to_string()) + .unwrap_or_else(|_| "?".to_string()) +} + +/// Pull the next whitespace-delimited field starting at `cursor`, advancing it. +fn next_field<'a>(line: &'a str, cursor: &mut usize) -> Option<&'a str> { + let bytes = line.as_bytes(); + while *cursor < bytes.len() && bytes[*cursor].is_ascii_whitespace() { + *cursor += 1; + } + let start = *cursor; + while *cursor < bytes.len() && !bytes[*cursor].is_ascii_whitespace() { + *cursor += 1; + } + if start == *cursor { + None + } else { + Some(&line[start..*cursor]) + } +} + +fn parse_line(line: &str) -> Option { + // Format: `start-end perms offset dev inode path` + // + // The path may contain spaces (`/mnt/games/FIFA 17/FIFA17.exe`) and may + // carry a ` (deleted)` suffix, so we consume exactly five leading fields by + // position and take the untouched remainder as the path. + // + // Doing this with `line.find(inode)` to locate the split point is a trap: + // the inode of an anonymous mapping is "0", and `find("0")` happily matches + // a zero digit inside the address range at the very start of the line. That + // silently turns half the address into a path. Hence the explicit cursor. + let mut cursor = 0usize; + let range = next_field(line, &mut cursor)?; + let perms = next_field(line, &mut cursor)?; + let offset = next_field(line, &mut cursor)?; + let _dev = next_field(line, &mut cursor)?; + let _inode = next_field(line, &mut cursor)?; + + let (start, end) = range.split_once('-')?; + let start = u64::from_str_radix(start, 16).ok()?; + let end = u64::from_str_radix(end, 16).ok()?; + + let tail = line[cursor..].trim(); + let path = if tail.is_empty() { + None + } else { + Some(tail.to_string()) + }; + + Some(Region { + start, + end, + perms: perms.to_string(), + offset: u64::from_str_radix(offset, 16).ok()?, + path, + }) +} + +/// Find the FIFA 17 process. +/// +/// `comm` is the authority, NOT `cmdline`. Under Proton there are a dozen +/// helper processes (bash, umu-run, srt-bwrap, pv-adverb, proton, umu.exe) +/// whose command lines mention fifa17, and at least one of them +/// (`umu.exe /mnt/games/FIFA 17/_fifa17.exe`) is a convincing decoy. Only the +/// real game has `comm == "FIFA17.exe"`. Its `/proc//exe` points at +/// wine64-preloader, which is expected and is not a reason to doubt the match. +pub fn find_pid(comm_name: &str) -> io::Result { + let mut hits = Vec::new(); + for entry in fs::read_dir("/proc")? { + let entry = entry?; + let name = entry.file_name(); + let Some(name) = name.to_str() else { continue }; + let Ok(pid) = name.parse::() else { + continue; + }; + if let Ok(comm) = fs::read_to_string(format!("/proc/{pid}/comm")) { + if comm.trim() == comm_name { + hits.push(pid); + } + } + } + match hits.len() { + 0 => Err(io::Error::new( + io::ErrorKind::NotFound, + format!("no process with comm == {comm_name:?}; is the game running? pass --pid to override"), + )), + 1 => Ok(hits[0]), + _ => Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("{} processes have comm == {comm_name:?}: {hits:?}; pass --pid to disambiguate", hits.len()), + )), + } +} + +pub fn human(bytes: u64) -> String { + const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"]; + let mut value = bytes as f64; + let mut unit = 0; + while value >= 1024.0 && unit < UNITS.len() - 1 { + value /= 1024.0; + unit += 1; + } + if unit == 0 { + format!("{bytes} B") + } else { + format!("{value:.2} {}", UNITS[unit]) + } +} diff --git a/fifa17-recon/futmem/src/mem.rs b/fifa17-recon/futmem/src/mem.rs new file mode 100644 index 0000000..1c34490 --- /dev/null +++ b/fifa17-recon/futmem/src/mem.rs @@ -0,0 +1,102 @@ +//! Read-only access to another process's address space. +//! +//! # The safety property this module exists to guarantee +//! +//! A live FIFA 17 session may be running while this tool is used. Corrupting it +//! costs the user their progress and their patience. So the guarantee here is +//! structural, not a matter of being careful: +//! +//! * `/proc//mem` is opened with [`File::open`], which is `O_RDONLY`. +//! There is no [`std::fs::OpenOptions`] anywhere in this crate. +//! * [`ProcMem`] exposes `&self` read methods only. It hands out no `&mut File` +//! and no raw fd, so no caller outside this module can upgrade the handle. +//! * Nothing in the crate calls `ptrace`, sends a signal, or writes to any +//! path under `/proc`. +//! +//! Even if a caller tried to write, the kernel would reject it on an `O_RDONLY` +//! descriptor. The type system and the open mode agree, which is the point. +//! +//! # Why pread and not seek + read +//! +//! [`FileExt::read_at`] is `pread(2)`: it takes the offset as an argument +//! instead of mutating a shared file cursor. That means a `&ProcMem` can be +//! shared across threads later without a mutex and without one thread's seek +//! corrupting another's read. It also removes a whole class of "forgot to seek" +//! bugs. There is never a reason to prefer seek+read here. + +use std::fs::File; +use std::io; +use std::os::unix::fs::FileExt; + +/// The page size we assume when stepping over an unreadable hole. Every x86-64 +/// mapping is a multiple of this, so it is a safe granularity for recovery. +pub const PAGE: u64 = 4096; + +/// A read-only handle on a process's memory. +pub struct ProcMem { + file: File, +} + +/// What a single chunk read produced. +pub enum ChunkRead { + /// `n` bytes landed in the buffer. May be shorter than requested when the + /// read ran into an unmapped hole partway through. + Got(usize), + /// Nothing readable at this address at all. + Hole, +} + +impl ProcMem { + /// Open the target read-only. See the module docs for why this is + /// `File::open` and must stay that way. + pub fn open(pid: i32) -> io::Result { + let file = File::open(format!("/proc/{pid}/mem")).map_err(|e| { + io::Error::new( + e.kind(), + format!("opening /proc/{pid}/mem: {e} (same-user or CAP_SYS_PTRACE required)"), + ) + })?; + Ok(Self { file }) + } + + /// Best-effort read. Never fatal: a hole reports [`ChunkRead::Hole`] rather + /// than propagating an error, because in a 3 GB sweep unreadable regions are + /// the normal case, not an exceptional one. + /// + /// Guard pages, Wine's special mappings and pages Denuvo has not faulted in + /// are all marked readable in `/proc//maps` yet return `EIO` here. The + /// caller counts these and reports the total so the user knows the sweep was + /// partial. + pub fn read_chunk(&self, va: u64, buf: &mut [u8]) -> ChunkRead { + match self.file.read_at(buf, va) { + Ok(0) | Err(_) => ChunkRead::Hole, + Ok(n) => ChunkRead::Got(n), + } + } + + /// Strict read for cases where a short read is genuinely an error, such as + /// an explicit `futmem read ` the user asked for by hand. + pub fn read_exact(&self, va: u64, len: usize) -> io::Result> { + let mut buf = vec![0u8; len]; + self.file.read_exact_at(&mut buf, va).map_err(|e| { + io::Error::new( + e.kind(), + format!("reading {len} bytes at {va:#x}: {e} (address may be unmapped)"), + ) + })?; + Ok(buf) + } + + /// Read up to `len` bytes, returning however many were actually available. + /// Used for printing context around a hit that sits near the end of a region. + pub fn read_partial(&self, va: u64, len: usize) -> Vec { + let mut buf = vec![0u8; len]; + match self.file.read_at(&mut buf, va) { + Ok(n) => { + buf.truncate(n); + buf + } + Err(_) => Vec::new(), + } + } +} diff --git a/fifa17-recon/futmem/src/scan.rs b/fifa17-recon/futmem/src/scan.rs new file mode 100644 index 0000000..6b4be46 --- /dev/null +++ b/fifa17-recon/futmem/src/scan.rs @@ -0,0 +1,376 @@ +//! Chunked sweeping of a remote address space, plus the two things we sweep +//! for: byte patterns and printable strings. +//! +//! # Why chunking, and the off-by-one that ruins scanners +//! +//! The target has roughly 3 GB resident. Reading a region in one allocation is +//! wasteful and can fail outright, so regions are walked in 4 MiB chunks. +//! +//! The classic bug in every hand-rolled scanner is that a pattern straddling a +//! chunk boundary is never found: the tail of chunk N holds the first few bytes +//! and the head of chunk N+1 holds the rest, and neither buffer contains the +//! whole thing. The fix is to overlap consecutive chunks by `pattern_len - 1` +//! bytes. +//! +//! That specific overlap is exactly right, and it is worth showing why it is +//! neither too small nor too large. Let a chunk cover `[0, n)` and the pattern +//! have length `P`. A match starting at index `s` occupies `s ..= s + P - 1`, so +//! the last match fully inside the chunk starts at `s = n - P`. Any match +//! starting at `s > n - P` runs off the end and must be caught by the next +//! chunk, so the next chunk has to begin at or before `n - P + 1`. Advancing by +//! `n - (P - 1)` starts it at precisely `n - P + 1`: +//! +//! * Nothing is missed: every straddling match starts at `s >= n - P + 1`, +//! which is inside the next chunk. +//! * Nothing is double-reported: the first index of the overlap is +//! `n - P + 1`, which is strictly greater than `n - P`, the last index that +//! can host a complete match in this chunk. The two windows of *reportable* +//! match starts are disjoint even though the byte windows overlap. +//! +//! Overlapping by `P` instead would report every boundary-straddling match +//! twice; overlapping by `P - 2` would miss one alignment. Hence `P - 1`. +//! +//! # Holes +//! +//! A region marked readable in `/proc//maps` is frequently not readable in +//! practice: guard pages, Wine's special mappings, and pages Denuvo has not +//! faulted in all return `EIO`. These are counted and stepped over a page at a +//! time, never propagated as errors, because in a sweep this size they are +//! routine. The counts are reported so the user knows the sweep was partial and +//! does not read a zero-hit result as proof of absence. + +use crate::image::Module; +use crate::maps::Region; +use crate::mem::{ChunkRead, ProcMem, PAGE}; + +pub const CHUNK: usize = 4 * 1024 * 1024; + +#[derive(Default, Debug)] +pub struct SweepStats { + pub regions_scanned: usize, + /// Regions from which not a single byte could be read. + pub regions_skipped: usize, + /// Individual chunk reads that hit an unreadable hole. + pub holes: usize, + pub bytes_read: u64, +} + +impl SweepStats { + pub fn summary(&self) -> String { + format!( + "scanned {} regions ({}), skipped {} unreadable regions, {} holes stepped over", + self.regions_scanned, + crate::maps::human(self.bytes_read), + self.regions_skipped, + self.holes + ) + } +} + +fn align_up(va: u64, align: u64) -> u64 { + va.div_ceil(align) * align +} + +/// Walk one region in chunks, invoking `f(chunk_va, bytes, contiguous)`. +/// +/// `contiguous` is true when this chunk's data continues directly from the +/// previous callback with no gap, which string extraction needs in order to +/// join a run that spans a boundary. `overlap` is `pattern_len - 1` for pattern +/// search and 0 for stateful scanners that track continuity themselves. +/// +/// Returns early (`false`) if `f` signals it has seen enough. +fn sweep_region( + mem: &ProcMem, + region: &Region, + overlap: usize, + buf: &mut [u8], + stats: &mut SweepStats, + f: &mut F, +) -> bool +where + F: FnMut(u64, &[u8], bool) -> bool, +{ + let mut pos = region.start; + let mut contiguous = false; + let mut read_anything = false; + + while pos < region.end { + let want = (buf.len() as u64).min(region.end - pos) as usize; + match mem.read_chunk(pos, &mut buf[..want]) { + ChunkRead::Hole => { + stats.holes += 1; + contiguous = false; + // Step to the next page; the current one is unreadable. + pos = align_up(pos + 1, PAGE); + } + ChunkRead::Got(n) => { + read_anything = true; + stats.bytes_read += n as u64; + if !f(pos, &buf[..n], contiguous) { + return false; + } + if pos + n as u64 >= region.end { + break; + } + if n < want { + // Short read: an unmapped hole begins at pos + n. No pattern + // can span a hole, so no overlap is needed here; resume on + // the next page boundary. + contiguous = false; + pos = align_up(pos + n as u64 + 1, PAGE); + } else { + if n <= overlap { + break; // cannot make forward progress + } + contiguous = true; + pos += (n - overlap) as u64; + } + } + } + } + + if read_anything { + stats.regions_scanned += 1; + } else { + stats.regions_skipped += 1; + } + true +} + +/// Which regions a sweep should touch. +pub fn scan_targets(regions: &[Region], module: Option<&Module>, anon_only: bool) -> Vec { + regions + .iter() + .filter(|r| r.readable() && !r.pseudo()) + .filter(|r| !anon_only || r.anonymous()) + .filter_map(|r| match module { + None => Some(r.clone()), + // Clip the region to the module's image span rather than dropping + // it: under Wine a module's sections live in large anonymous + // regions that may extend past the image. + Some(m) => { + let start = r.start.max(m.base); + let end = r.end.min(m.end()); + if start < end { + let mut clipped = (*r).clone(); + clipped.start = start; + clipped.end = end; + Some(clipped) + } else { + None + } + } + }) + .collect::>() +} + +/// Search every target region for `pattern`. Calls `hit(va)` per match. +pub fn find_pattern( + mem: &ProcMem, + targets: &[Region], + pattern: &[u8], + max: Option, + mut hit: F, +) -> SweepStats +where + F: FnMut(u64), +{ + let mut stats = SweepStats::default(); + if pattern.is_empty() { + return stats; + } + let finder = memchr::memmem::Finder::new(pattern); + let overlap = pattern.len() - 1; + // The buffer must comfortably exceed the overlap or progress stalls. + let mut buf = vec![0u8; CHUNK.max(pattern.len() * 4)]; + let mut found = 0usize; + + for region in targets { + let keep_going = sweep_region( + mem, + region, + overlap, + &mut buf, + &mut stats, + &mut |base, data, _contiguous| { + for off in finder.find_iter(data) { + hit(base + off as u64); + found += 1; + if max.is_some_and(|m| found >= m) { + return false; + } + } + true + }, + ); + if !keep_going { + break; + } + } + stats +} + +fn printable(b: u8) -> bool { + (0x20..=0x7e).contains(&b) +} + +/// Extracts printable runs, carrying an unfinished run across contiguous chunks +/// so a string straddling a boundary is still emitted whole. +struct StringScanner { + utf16: bool, + min: usize, + run: Vec, + run_start: u64, + open: bool, + /// UTF-16 only: a low byte at the very end of a chunk whose high byte will + /// arrive in the next one. + carry: Option<(u64, u8)>, +} + +impl StringScanner { + fn new(utf16: bool, min: usize) -> Self { + Self { + utf16, + min, + run: Vec::with_capacity(256), + run_start: 0, + open: false, + carry: None, + } + } + + fn flush(&mut self, emit: &mut F) { + if self.open && self.run.len() >= self.min { + // Runs are printable ASCII by construction, so this cannot fail. + if let Ok(s) = std::str::from_utf8(&self.run) { + emit(self.run_start, s); + } + } + self.run.clear(); + self.open = false; + } + + fn push(&mut self, va: u64, b: u8, emit: &mut F) { + if !self.open { + self.open = true; + self.run_start = va; + } + self.run.push(b); + // Guard against a pathological all-printable megabyte eating memory. + if self.run.len() >= 4096 { + self.flush(emit); + } + } + + fn feed( + &mut self, + base: u64, + data: &[u8], + contiguous: bool, + emit: &mut F, + ) { + if !contiguous { + self.flush(emit); + self.carry = None; + } + if self.utf16 { + self.feed_utf16(base, data, emit); + } else { + for (i, &b) in data.iter().enumerate() { + if printable(b) { + self.push(base + i as u64, b, emit); + } else { + self.flush(emit); + } + } + } + } + + fn feed_utf16(&mut self, base: u64, data: &[u8], emit: &mut F) { + let mut i = 0usize; + // A pair split across the chunk boundary: complete it if the high byte + // is the expected 0x00, otherwise the run ends here. + if let Some((addr, lo)) = self.carry.take() { + if data.first() == Some(&0) && printable(lo) { + self.push(addr, lo, emit); + i = 1; + } else { + self.flush(emit); + } + } + while i + 1 < data.len() { + let (lo, hi) = (data[i], data[i + 1]); + if hi == 0 && printable(lo) { + self.push(base + i as u64, lo, emit); + i += 2; + } else { + self.flush(emit); + i += 1; + } + } + if i < data.len() { + self.carry = Some((base + i as u64, data[i])); + } + } +} + +/// Extract strings from every target region. Calls `emit(va, text)`. +pub fn find_strings( + mem: &ProcMem, + targets: &[Region], + utf16: bool, + min: usize, + grep: Option<&str>, + max: Option, + mut emit: F, +) -> SweepStats +where + F: FnMut(u64, &str), +{ + let mut stats = SweepStats::default(); + let mut buf = vec![0u8; CHUNK]; + let grep_lower = grep.map(|g| g.to_ascii_lowercase()); + let mut count = 0usize; + + for region in targets { + let mut scanner = StringScanner::new(utf16, min); + let mut stop = false; + // overlap 0: the scanner tracks continuity itself via `contiguous`. + let keep_going = sweep_region( + mem, + region, + 0, + &mut buf, + &mut stats, + &mut |base, data, contiguous| { + scanner.feed(base, data, contiguous, &mut |va, s| { + let matches = match &grep_lower { + Some(g) => s.to_ascii_lowercase().contains(g.as_str()), + None => true, + }; + if matches { + emit(va, s); + count += 1; + if max.is_some_and(|m| count >= m) { + stop = true; + } + } + }); + !stop + }, + ); + scanner.flush(&mut |va, s| { + let matches = match &grep_lower { + Some(g) => s.to_ascii_lowercase().contains(g.as_str()), + None => true, + }; + if matches { + emit(va, s); + } + }); + if !keep_going || stop { + break; + } + } + stats +} diff --git a/fifa17-recon/tools/ghidra_queries/q_pack_buy_1.py b/fifa17-recon/tools/ghidra_queries/q_pack_buy_1.py new file mode 100644 index 0000000..0d2beb1 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_pack_buy_1.py @@ -0,0 +1,136 @@ +"""D5 Q1/Q4 recon: the store/transaction purchase fork. + +HYPOTHESIS: there are TWO distinct client server-calls that both POST to +"/transaction" -- PurchasePack (expects FutCreatePackServerResponse) and +PurchaseItems (expects FutPurchaseItemsServerResponse) -- and the fork is decided +CLIENT-SIDE before the request is sent, by which ServerCall object is constructed, +not by anything the server does. + +CONTROL: class_deser("FutSquadSaveServerResponse") must resolve to 0x180171a60 and +class_deser("FutCreateMatchServerResponse") to 0x180120380. If the controls come back +empty the whole run is untrustworthy. + +Outputs everything to /tmp/.../packres/d5_q1_*.txt with len() printed for every +decompile, so no absence is ever concluded from a truncation. +""" +import traceback, os + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres" + +def w(name, text): + p = os.path.join(OUT, name) + with open(p, "w") as f: + f.write(text) + print("WROTE %s (%d bytes)" % (p, len(text))) + +try: + buf = [] + def P(*a): + s = " ".join(str(x) for x in a) + print(s) + buf.append(s) + + # ---------- CONTROLS ---------- + P("=== CONTROLS ===") + for c, expect in (("FutSquadSaveServerResponse", 0x180171a60), + ("FutCreateMatchServerResponse", 0x180120380), + ("FutSquadListServerResponse", 0x180172140)): + try: + r = class_deser(c) + except Exception as e: + r = "ERR %s" % e + P(" %-34s -> %s (expect %#x)" % (c, r, expect)) + + # ---------- string xrefs ---------- + STRS = { + 0x1802203e8: "/transaction", + 0x18022fe80: "useCredits", + 0x18022fea0: "usePreOrder", + 0x18022fb78: "transaction", + 0x18022fb88: "transactionId", + 0x1802203c8: "User already has a transaction", + 0x180220400: "PURCHASEERROR", + 0x18021f2d8: "PURCHASEPACK", + 0x18021f2e8: "PurchaseItems", + 0x18021f2f8: "PURCHASEITEMS", + 0x1801f4e48: "PurchasePack", + 0x1801f4e78: "ValidateCoinPurchase", + 0x1801f4e90: "ValidatePointsPurchase", + 0x1801ff7c8: "PURCHASE_INSUFFICIENT_FUNDS", + 0x1802293a8: "NOTRANSACTION", + 0x1802293b8: "TRANSACTIONCREATED", + 0x1802293d0: "PURCHASESTARTED", + 0x1802293e0: "PURCHASECOMPLETE", + 0x180229420: "TRANSACTIONCOMPLETE", + 0x180229438: "TRANSACTIONCANCEL", + 0x180231010: "extPrice", + 0x180230b48: "currencies", + 0x1802310f0: "finalPrice", + 0x180231c78: "originalPrice", + 0x1802310e0: "finalFunds", + 0x180231110: "firstPartyStoreId", + 0x1802321a0: "purchaseLimit", + 0x180232160: "purchaseCount", + 0x1801ec120: "OnTransactionFailure", + 0x1801f0668: "OnServerTransactionIdResponse", + 0x18022ed10: "FUT_STORE_POINTS_", + 0x1801ff788: "PURCHASE_METHOD", + } + P("") + P("=== STRING XREFS ===") + funcs_of_interest = {} + for va, nm in sorted(STRS.items()): + try: + xs = xrefs_to(va) + except Exception as e: + P(" %-32s %#x ERR %s" % (nm, va, e)); continue + P(" %-32s %#x %d xrefs" % (nm, va, len(xs))) + for frm, typ, fn, ent in xs: + P(" from %#x %-14s in %s @ %#x" % (frm, typ, fn, ent)) + if ent: + funcs_of_interest.setdefault(ent, set()).add(nm) + + P("") + P("=== FUNCS OF INTEREST (%d) ===" % len(funcs_of_interest)) + for ent, names in sorted(funcs_of_interest.items()): + P(" %#x %-40s <- %s" % (ent, fname(ent), ",".join(sorted(names)))) + + w("d5_q1_xrefs.txt", "\n".join(buf) + "\n") + + # ---------- decompiles ---------- + TARGETS = { + "purchaseitems_deser": 0x180126a04, + "createpack_deser": 0x180162880, + "packtypes_deser": 0x1801234e0, + "pack_elem_deser": 0x18013af30, + "extprice_finalprice": 0x180139070, + "extprice_originalprice": 0x18013aae0, + "currencies_deser": 0x180122c50, + "packquantities_deser": 0x1801758c0, + "updatecredits_deser": 0x1801738b2, + } + for tag, va in sorted(TARGETS.items()): + try: + f = func(va) + src = dec(va) + except Exception as e: + src = "// ERR %s" % e + f = None + hdr = "// target %s va=%#x entry=%s name=%s len=%d\n" % ( + tag, va, ("%#x" % int(f.getEntryPoint().getOffset())) if f else "None", + f.getName() if f else "None", len(src)) + w("d5_q1_dec_%s.txt" % tag, hdr + src) + + # decompile every func-of-interest, full text + for ent, names in sorted(funcs_of_interest.items()): + try: + src = dec(ent) + except Exception as e: + src = "// ERR %s" % e + hdr = "// entry %#x name=%s strings=%s len=%d\n" % ( + ent, fname(ent), ",".join(sorted(names)), len(src)) + w("d5_q1_fn_%x.txt" % ent, hdr + src) + + print("DONE") +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_pack_buy_2.py b/fifa17-recon/tools/ghidra_queries/q_pack_buy_2.py new file mode 100644 index 0000000..5cf2a2b --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_pack_buy_2.py @@ -0,0 +1,163 @@ +"""D5 Q1/Q2/Q3/Q5: the store ServerCall classes, the currency element deser, +the transaction state enum table, and the client-side purchase validators. + +HYPOTHESES + H1 (fork): PurchasePack and PurchaseItems are two separate ServerCall classes, + both POSTing to a "/transaction"-suffixed URL; the fork is decided client-side + at construction time and the server has no say in it. + H2 (price): the pack currency record is + {std::string name; u32 funds; u32 finalFunds; u32 origExtPriceId; u32 finalExtPriceId} + and FUN_180138bd0 is the element parser that fills name/funds/finalFunds. + H3 (state enum): the 9-entry table at 0x1802d02c0 (u32 value, char* name) is the + complete `state` vocabulary of FutPurchaseItemsServerResponse. + +CONTROL: dec(0x180171a60) must be the FutSquadSave deserializer (a big SAX loop +calling FUN_1801c7f10); dec(0x180120380) the FutCreateMatch one. Both printed. +""" +import traceback, os + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres" + +def w(name, text): + p = os.path.join(OUT, name) + with open(p, "w") as f: + f.write(text) + print("WROTE %s (%d bytes)" % (p, len(text))) + +try: + buf = [] + def P(*a): + s = " ".join(str(x) for x in a) + print(s); buf.append(s) + + P("=== CONTROL: sizes of two known deserializers ===") + for c in (0x180171a60, 0x180120380): + s = dec(c) + P(" %#x %-24s len=%d has_sax_loop=%s" % (c, fname(c), len(s), "FUN_1801c7f10" in s)) + + # ---- H3: transaction state enum table ---- + P("") + P("=== state enum table @0x1802d02c0 (u32 value, char* name) x12 ===") + for i in range(12): + base = 0x1802d02c0 + i * 16 + try: + val = dword(base) + ptr = qword(base + 8) + nm = rd_str(ptr) if 0x180000000 <= ptr < 0x181000000 else "?" + except Exception as e: + P(" [%d] ERR %s" % (i, e)); continue + P(" [%2d] %#010x ptr=%#x %r" % (i, val, ptr, nm)) + + # ---- the atom name table, to prove the request key names live there ---- + P("") + P("=== atom-name pointer table around 0x1802d42a8 (useCredits) ===") + for off in range(-6, 7): + a = 0x1802d42a8 + off * 8 + try: + ptr = qword(a) + nm = rd_str(ptr) if 0x180000000 <= ptr < 0x181000000 else "?" + except Exception as e: + nm = "ERR %s" % e; ptr = 0 + P(" %#x -> %#x %r" % (a, ptr, nm)) + + # ---- who references FUN_180126720 (the /transaction URL builder) ---- + P("") + for tgt, tag in ((0x180126720, "url_builder_/transaction"), + (0x1801269f0, "purchaseitems_deser"), + (0x180162880, "createpack_deser"), + (0x1801267b0, "http409_handler"), + (0x1801669b0, "state_str_to_enum"), + (0x180138bd0, "currency_elem_deser"), + (0x1801234e0, "packtypes_deser")): + try: + xs = xrefs_to(tgt) + except Exception as e: + P("XREFS %s %#x ERR %s" % (tag, tgt, e)); continue + P("XREFS to %s %#x : %d" % (tag, tgt, len(xs))) + for frm, typ, fn, ent in xs: + P(" from %#x %-12s in %s @ %#x" % (frm, typ, fn, ent)) + + # ---- vtables around the store server-call classes ---- + P("") + P("=== scan .rdata for qword == 0x180126720 / 0x1801269f0 / 0x180162880 (vtable slots) ===") + import struct + for tgt in (0x180126720, 0x1801269f0, 0x180162880, 0x1801267b0, 0x1801234e0, 0x1801758c0): + pat = struct.pack(" target %#x" % (hit, tgt)) + for j in range(-4, 10): + a = hit + j * 8 + try: + q = qword(a) + except Exception: + continue + f = fm.getFunctionAt(addr(q)) if 0x180000000 <= q < 0x181000000 else None + s = "" + if 0x180000000 <= q < 0x181000000 and f is None: + try: + t = rd_str(q, 60) + if t and all(32 <= ord(c) < 127 for c in t): + s = repr(t) + except Exception: + pass + P(" %+3d %#x -> %#x %s %s" % (j, a, q, f.getName() if f else "", s)) + + w("d5_q2_notes.txt", "\n".join(buf) + "\n") + + # ---- decompiles ---- + TARGETS = { + "currency_elem_deser_180138bd0": 0x180138bd0, + "script_PurchasePack_18003f010": 0x18003f010, + "script_EnterStore_18003efd0": 0x18003efd0, + "script_ExitStore_18003eff0": 0x18003eff0, + "script_ValidateCoinPurchase_18003f060": 0x18003f060, + "script_ValidatePointsPurchase_18003f090": 0x18003f090, + "http409_1801267b0": 0x1801267b0, + "urlbuild_180126720": 0x180126720, + "state_enum_1801669b0": 0x1801669b0, + } + for tag, va in sorted(TARGETS.items()): + try: + f = func(va); src = dec(va) + except Exception as e: + f = None; src = "// ERR %s" % e + w("d5_q2_dec_%s.txt" % tag, + "// %s va=%#x entry=%s len=%d\n" % (tag, va, fname(va), len(src)) + src) + + # ---- dump every function in the store-service .text cluster ---- + P("") + P("=== functions in 0x180126400..0x180127400 ===") + cluster = [] + it = fm.getFunctions(addr(0x180126400), True) + while it.hasNext(): + f = it.next() + e = int(f.getEntryPoint().getOffset()) + if e > 0x180127400: + break + cluster.append(e) + P(" %d funcs: %s" % (len(cluster), ", ".join("%#x" % c for c in cluster))) + txt = [] + for e in cluster: + s = dec(e) + txt.append("// ===== %#x %s len=%d\n%s" % (e, fname(e), len(s), s)) + w("d5_q2_cluster_126400.txt", "\n".join(txt)) + + # createpack cluster + cluster2 = [] + it = fm.getFunctions(addr(0x180162400), True) + while it.hasNext(): + f = it.next() + e = int(f.getEntryPoint().getOffset()) + if e > 0x180162e00: + break + cluster2.append(e) + txt = [] + for e in cluster2: + s = dec(e) + txt.append("// ===== %#x %s len=%d\n%s" % (e, fname(e), len(s), s)) + w("d5_q2_cluster_162400.txt", "\n".join(txt)) + + w("d5_q2_notes.txt", "\n".join(buf) + "\n") + print("DONE") +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_pack_buy_3.py b/fifa17-recon/tools/ghidra_queries/q_pack_buy_3.py new file mode 100644 index 0000000..1d4b788 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_pack_buy_3.py @@ -0,0 +1,160 @@ +"""D5 Q3/Q4/Q5: who READS the pack availability fields and the currency funds, +what the two store ServerCall vtables look like, and what the HTTP error path does. + +HYPOTHESES + H4 (sold out): the pack record fields state(+0xB0), start(+0xB4), end(+0xB8), + quantity(+0xBC), purchaseLimit(+0xC0), purchaseCount(+0xC4), saleType(+0xC8) + are read together by one availability predicate in the store UI. + Offsets derived from the stack layout of FUN_18013af30 (base local_268, size 0x158). + H5 (error path): FUN_18016c060 is the generic HTTP-status -> FUT-error mapper and + FUN_1801267b0 only special-cases 409 + "User already has a transaction" -> 0x70. + +CONTROL: the same offset-scan run for offset 0x28 (a control offset that appears +everywhere) must return far more functions than the pack offsets, proving the scan +is not silently returning nothing. Also dec(0x180171a60) printed as a live control. +""" +import traceback, os, struct + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres" + +def w(name, text): + p = os.path.join(OUT, name) + with open(p, "w") as f: + f.write(text) + print("WROTE %s (%d bytes)" % (p, len(text))) + +try: + buf = [] + def P(*a): + s = " ".join(str(x) for x in a) + print(s); buf.append(s) + + s = dec(0x180171a60) + P("CONTROL dec(0x180171a60) len=%d sax=%s" % (len(s), "FUN_1801c7f10" in s)) + + # ---------- A. vtable dumps ---------- + def dumpvt(lo, hi, tag): + P("") + P("=== %s %#x..%#x ===" % (tag, lo, hi)) + a = lo + while a < hi: + try: + q = qword(a) + except Exception as e: + P(" %#x ERR %s" % (a, e)); a += 8; continue + f = fm.getFunctionAt(addr(q)) if 0x180000000 <= q < 0x181000000 else None + extra = "" + if f is None: + try: + raw = read_bytes(a, 8) + if all(32 <= b < 127 or b == 0 for b in raw) and raw[0] != 0: + extra = "inline-ascii %r" % raw + except Exception: + pass + if 0x180000000 <= q < 0x181000000: + try: + t = rd_str(q, 50) + if t and all(32 <= ord(c) < 127 for c in t): + extra += " ->str %r" % t + except Exception: + pass + P(" +%03x %#x -> %#x %s %s" % (a - lo, a, q, f.getName() if f else "", extra)) + a += 8 + + dumpvt(0x1802202f0, 0x1802203a8, "PurchaseItems ServerCall region") + dumpvt(0x180228250, 0x180228330, "CreatePack ServerCall region") + dumpvt(0x1801f0440, 0x1801f04b0, "first-party CARDPACK descriptor") + dumpvt(0x18021dd40, 0x18021de30, "StoreGetPackTypes region") + + # ---------- B. service / viewmodel strings ---------- + P("") + for va, nm in ((0x1802345d8, "FutComponentServicesImpl::FutStoreServiceImpl"), + (0x1801ee8d8, "futstoreviewmodel"), + (0x1801f4e48, "PurchasePack"), + (0x180205560, "PURCHASE_FAILED"), + (0x180205678, "PURCHASE_SUCCESS"), + (0x1802150e8, "PACK_EXISTS_IN_PURCHASED_PILE"), + (0x180215108, "PACK_PURCHASE_FAILED")): + try: + xs = xrefs_to(va) + except Exception as e: + P("XREF %s ERR %s" % (nm, e)); continue + P("XREFS %-46s %#x : %s" % (nm, va, [("%#x" % f, n, "%#x" % e2) for f, t, n, e2 in xs])) + + # ---------- C. who constructs the two ServerCalls ---------- + P("") + for vt, tag in ((0x180228270, "CreatePack call vtable"), + (0x1802202f8, "PurchaseItems call vtable"), + (0x18021dd90, "packtypes?"),): + pat = struct.pack(" %d funcs" % (o, len(tables[o]))) + + score = {} + for o in (0xB0, 0xBC, 0xC0, 0xC4, 0xC8): + for e in tables[o]: + score.setdefault(e, set()).add(o) + cands = sorted((e for e, s2 in score.items() if len(s2) >= 3), + key=lambda e: -len(score[e])) + P(" candidates with >=3 of {B0,BC,C0,C4,C8}: %d" % len(cands)) + for e in cands[:40]: + P(" %#x %-30s offs=%s" % (e, fname(e), sorted("%#x" % x for x in score[e]))) + + w("d5_q3_notes.txt", "\n".join(buf) + "\n") + + # ---------- E. decompiles ---------- + TG = {"http_status_mapper_18016c060": 0x18016c060, + "state_enum_to_str_180166a30": 0x180166a30, + "createpack_req_ser_180162530": 0x180162530, + "purchaseitems_req_ser_180126440": 0x180126440} + for tag, va in sorted(TG.items()): + try: + src = dec(va) + except Exception as e: + src = "// ERR %s" % e + w("d5_q3_dec_%s.txt" % tag, "// %s %#x len=%d\n" % (tag, va, len(src)) + src) + + txt = [] + for e in cands[:14]: + src = dec(e) + txt.append("// ===== %#x %s offs=%s len=%d\n%s" + % (e, fname(e), sorted("%#x" % x for x in score[e]), len(src), src)) + w("d5_q3_packreaders.txt", "\n".join(txt)) + + w("d5_q3_notes.txt", "\n".join(buf) + "\n") + print("DONE") +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_pack_buy_4.py b/fifa17-recon/tools/ghidra_queries/q_pack_buy_4.py new file mode 100644 index 0000000..998c15c --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_pack_buy_4.py @@ -0,0 +1,121 @@ +"""D5 finishing pass: CreatePack's URL/descriptor, the pack-record constructor +defaults, the generic HTTP-error path, and the FUT store service that picks the mode. + +HYPOTHESES + H6: FUN_1801342d0 is the pack-record constructor and its stores give the DEFAULT + value of every pack field when the server omits the key (critical for Q4: + what an omitted purchaseLimit/quantity/state means). + H7: the CreatePack ServerCall's URL + body builders live in a static descriptor + like the CARDPACK one at 0x1801f0458; find it by scanning .rdata/.data for the + qword 0x180162530. + H8: FUN_1801844c0 (reached from the generic slot-12 HTTP handler FUN_18016c060) + maps an HTTP status/body to a FUT error code; that is the whole error path. + +CONTROL: dec(0x180162880) must be the FutCreatePack deserializer (contains atom +0xbe / a SAX loop). Printed with its length. +""" +import traceback, os, struct + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres" + +def w(name, text): + p = os.path.join(OUT, name) + with open(p, "w") as f: + f.write(text) + print("WROTE %s (%d bytes)" % (p, len(text))) + +try: + buf = [] + def P(*a): + s = " ".join(str(x) for x in a) + print(s); buf.append(s) + + s = dec(0x180162880) + P("CONTROL dec(0x180162880) len=%d sax=%s" % (len(s), "FUN_1801c7f10" in s)) + + P("") + P("=== scan for descriptor qwords ===") + for tgt, tag in ((0x180162530, "createpack_req_ser"), + (0x180162770, "createpack_resp_factory"), + (0x180123480, "packtypes_resp_factory"), + (0x1801756d0, "packquantities_?"), + (0x180126440, "purchaseitems_req_ser")): + pat = struct.pack(" %d hits: %s" % (tag, tgt, len(hits), ["%#x" % h for h in hits])) + for h in hits: + for j in range(-8, 6): + a = h + j * 8 + try: + q = qword(a) + except Exception: + continue + f = fm.getFunctionAt(addr(q)) if 0x180000000 <= q < 0x181000000 else None + extra = "" + try: + raw = read_bytes(a, 8) + if raw[0] != 0 and all(32 <= b < 127 or b == 0 for b in raw): + extra = "ascii %r" % raw + except Exception: + pass + if f is None and 0x180000000 <= q < 0x181000000: + try: + t = rd_str(q, 40) + if t and all(32 <= ord(c) < 127 for c in t): + extra += " ->str %r" % t + except Exception: + pass + P(" %+3d %#x -> %#x %s %s" % (j, a, q, f.getName() if f else "", extra)) + P(" ---") + + P("") + P("=== url string xrefs ===") + for va, nm in ((0x18021e670, "ut/%s/store"), + (0x18021e860, "ut/v2/%s/store"), + (0x18021e650, "ut/%s/purchased"), + (0x18021de48, "/purchasegroup"), + (0x1802203e8, "/transaction"), + (0x180223110, "CREATEPACK"), + (0x18021f2f8, "PURCHASEITEMS")): + try: + xs = xrefs_to(va) + except Exception as e: + P(" %s ERR %s" % (nm, e)); continue + P(" %-18s %#x : %s" % (nm, va, [("%#x" % f, n) for f, t, n, e2 in xs])) + + w("d5_q4_notes.txt", "\n".join(buf) + "\n") + + TG = { + "pack_record_ctor_1801342d0": 0x1801342d0, + "pack_record_copy_1801340e0": 0x1801340e0, + "pack_helper_180133210": 0x180133210, + "pack_helper_18012c990": 0x18012c990, + "http_err_1801844c0": 0x1801844c0, + "storeservice_180199bf0": 0x180199bf0, + "purchase_ui_1800a5650": 0x1800a5650, + "purchase_ui_1800a5f90": 0x1800a5f90, + "packpile_1800dd300": 0x1800dd300, + "call_ctor_1801623d0": 0x1801623d0, + "call_ctor_1801263a0": 0x1801263a0, + "call_ctor_1801263f0": 0x1801263f0, + "vt9_180122420": 0x180122420, + "vt_18016ca60": 0x18016ca60, + "vt_18016c950": 0x18016c950, + "vt_1801631e0": 0x1801631e0, + "fp_store_18002dd40": 0x18002dd40, + "fp_store_18002ecb0": 0x18002ecb0, + "fp_store_18002fd40": 0x18002fd40, + "fp_store_18002fff0": 0x18002fff0, + "fp_store_18002dca0": 0x18002dca0, + } + for tag, va in sorted(TG.items()): + try: + src = dec(va) + except Exception as e: + src = "// ERR %s" % e + w("d5_q4_dec_%s.txt" % tag, "// %s %#x len=%d\n" % (tag, va, len(src)) + src) + + w("d5_q4_notes.txt", "\n".join(buf) + "\n") + print("DONE") +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_pack_buy_5.py b/fifa17-recon/tools/ghidra_queries/q_pack_buy_5.py new file mode 100644 index 0000000..73adb21 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_pack_buy_5.py @@ -0,0 +1,116 @@ +"""D5 last pass: the endpoint table (call-id -> URL), CreatePack's URL builder, +who sets the CreatePack purchase MODE, and the PurchaseItems response defaults. + +HYPOTHESES + H9: every FUT ServerCall carries a numeric id (CreatePack=0x4b, PurchaseItems=0x4c, + passed to FUN_18016be60) and a table near 0x18021e100 maps id -> URL format + string ("ut/%s/store", "ut/%s/purchased", ...). + H10: FUN_180124ad0 is CreatePack's URL builder and it emits the "store/transaction" + path we already see on the wire. + H11: the mode field at +0x20 of the CreatePack call (0=COINS,1=MTX,2=POINTS,4=preorder) + is set by whoever constructs it; callers of FUN_1801623d0 will show the choice. + +CONTROL: dec(0x180162530) reprinted (known: writes packId/useCredits/usePreOrder/currency). +""" +import traceback, os, struct + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres" + +def w(name, text): + p = os.path.join(OUT, name) + with open(p, "w") as f: + f.write(text) + print("WROTE %s (%d bytes)" % (p, len(text))) + +try: + buf = [] + def P(*a): + s = " ".join(str(x) for x in a) + print(s); buf.append(s) + + s = dec(0x180162530) + P("CONTROL dec(0x180162530) len=%d has_MTX=%s has_COINS=%s" + % (len(s), '"MTX"' in s, '"COINS"' in s)) + + P("") + P("=== endpoint table 0x18021e080..0x18021e900 ===") + a = 0x18021e080 + while a < 0x18021e900: + try: + q = qword(a) + except Exception as e: + P(" %#x ERR %s" % (a, e)); a += 8; continue + f = fm.getFunctionAt(addr(q)) if 0x180000000 <= q < 0x181000000 else None + extra = "" + if 0x180000000 <= q < 0x181000000 and f is None: + try: + t = rd_str(q, 60) + if t and all(32 <= ord(c) < 127 for c in t): + extra = "->str %r" % t + except Exception: + pass + P(" %#x -> %#x %s %s" % (a, q, f.getName() if f else "", extra)) + a += 8 + + P("") + P("=== createpack req vtable 0x180214d40..0x180214e10 ===") + a = 0x180214d40 + while a < 0x180214e10: + q = qword(a) + f = fm.getFunctionAt(addr(q)) if 0x180000000 <= q < 0x181000000 else None + P(" +%03x %#x -> %#x %s" % (a - 0x180214d40, a, q, f.getName() if f else "")) + a += 8 + + P("") + for callee, tag in ((0x1801623d0, "createpack_call_ctor"), + (0x1801263a0, "purchaseitems_call_ctor"), + (0x18016be60, "servercall_base_ctor"), + (0x180124ad0, "maybe_createpack_url")): + try: + cs = callers(callee) + except Exception as e: + P("CALLERS %s ERR %s" % (tag, e)); continue + P("CALLERS of %-26s %#x : %s" % (tag, callee, [("%#x" % c, n) for c, n in cs][:30])) + try: + xs = xrefs_to(callee) + P(" xrefs: %s" % [("%#x" % f2, t, n) for f2, t, n, e2 in xs][:30]) + except Exception: + pass + + w("d5_q5_notes.txt", "\n".join(buf) + "\n") + + TG = {"createpack_url_180124ad0": 0x180124ad0, + "createpack_x_180162c90": 0x180162c90, + "servercall_base_ctor_18016be60": 0x18016be60, + "purchaseitems_resp_ctor_18002d460": 0x18002d460, + "fp_18002ee50": 0x18002ee50, + "fp_18002ed20": 0x18002ed20, + "fp_18002f1b0": 0x18002f1b0, + "fp_18002f0b0": 0x18002f0b0, + "fp_18002f920": 0x18002f920, + "generic_180068320": 0x180068320, + "packtypes_url_180123430": 0x180123430} + for tag, va in sorted(TG.items()): + try: + src = dec(va) + except Exception as e: + src = "// ERR %s" % e + w("d5_q5_dec_%s.txt" % tag, "// %s %#x len=%d\n" % (tag, va, len(src)) + src) + + # who constructs the createpack call -> the mode + ctor_callers = set() + for c, n in callers(0x1801623d0): + ctor_callers.add(int(c)) + for frm, typ, fn, ent in xrefs_to(0x1801623d0): + if ent: + ctor_callers.add(int(ent)) + txt = [] + for e in sorted(ctor_callers): + src = dec(e) + txt.append("// ===== caller of createpack ctor %#x %s len=%d\n%s" % (e, fname(e), len(src), src)) + w("d5_q5_createpack_callers.txt", "\n".join(txt) if txt else "// none found\n") + + w("d5_q5_notes.txt", "\n".join(buf) + "\n") + print("DONE") +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_pack_buy_6.py b/fifa17-recon/tools/ghidra_queries/q_pack_buy_6.py new file mode 100644 index 0000000..54f6671 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_pack_buy_6.py @@ -0,0 +1,53 @@ +"""D5 final: CreatePack's URL suffix (undefined code at 0x180124ad0), the +FutStoreServiceImpl vtable and its PurchasePack / ValidateCoinPurchase / +ValidatePointsPurchase implementations -- i.e. where the purchase MODE is chosen. + +CONTROL: 0x180123430 (StoreGetPackTypes URL builder) is a known-good comparison; it +emits "/purchasegroup" + "?ppInfo=true". Printed alongside. +""" +import traceback, os + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres" + +def w(name, text): + p = os.path.join(OUT, name) + with open(p, "w") as f: + f.write(text) + print("WROTE %s (%d bytes)" % (p, len(text))) + +try: + buf = [] + def P(*a): + s = " ".join(str(x) for x in a) + print(s); buf.append(s) + + P("CONTROL 0x180123430:") + P(dec(0x180123430)) + + P("") + P("=== raw bytes + disasm at 0x180124ad0 ===") + b = read_bytes(0x180124ad0, 96) + P(" bytes: %s" % b.hex()) + a = addr(0x180124ad0) + for i in range(24): + ins = listing.getInstructionAt(a) + if ins is None: + try: + flat.disassemble(a) + except Exception: + pass + ins = listing.getInstructionAt(a) + if ins is None: + P(" %#x " % int(a.getOffset())) + break + P(" %#x %s" % (int(a.getOffset()), ins)) + a = ins.getMaxAddress().add(1) + + P("") + P("=== FutStoreServiceImpl ctor 0x1801998e0 ===") + P(dec(0x1801998e0)) + + w("d5_q6_notes.txt", "\n".join(buf) + "\n") + print("DONE") +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_pack_buy_7.py b/fifa17-recon/tools/ghidra_queries/q_pack_buy_7.py new file mode 100644 index 0000000..919e4d9 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_pack_buy_7.py @@ -0,0 +1,59 @@ +"""D5 Q3/Q4 readers: who consumes the pack currency record ("coins"/"mtx", funds vs +finalFunds) and who consumes the availability fields. + +HYPOTHESIS: the store tile / purchase validator looks the pack's currency vector up +by the literal name "coins" (0x1801efea4) or "mtx" (0x1801efea0) and then reads ++0x20 (funds) and/or +0x24 (finalFunds). Whichever offset the affordability compare +uses is the one the server must make authoritative. + +CONTROL: xrefs_to(0x1801efea0) must include FUN_18013af30, FUN_180139070 and +FUN_18013aae0, which we have already read and know reference "mtx". +""" +import traceback, os + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres" + +def w(name, text): + p = os.path.join(OUT, name) + with open(p, "w") as f: + f.write(text) + print("WROTE %s (%d bytes)" % (p, len(text))) + +try: + buf = [] + def P(*a): + s = " ".join(str(x) for x in a); print(s); buf.append(s) + + ents = {} + for va, nm in ((0x1801efea0, "mtx"), (0x1801efea4, "coins"), + (0x1801efeb0, "%.0f"), + (0x180232150, "purchase-atomname"), + (0x1801fd44c, "TIME"), (0x180223228, "QUANTITY"), + (0x180223238, "TIME_QUANTITY"), (0x180223214, "promo"), + (0x18022321c, "deal")): + try: + xs = xrefs_to(va) + except Exception as e: + P("XREF %s ERR %s" % (nm, e)); continue + P("XREFS %-20s %#x : %d" % (nm, va, len(xs))) + for frm, typ, fn, e2 in xs: + P(" %#x %-12s %s @ %#x" % (frm, typ, fn, e2)) + if e2: + ents.setdefault(e2, set()).add(nm) + + P("") + P("=== functions to inspect ===") + for e, s in sorted(ents.items()): + P(" %#x %-28s %s" % (e, fname(e), sorted(s))) + + w("d5_q7_notes.txt", "\n".join(buf) + "\n") + + txt = [] + for e in sorted(ents): + src = dec(e) + txt.append("// ===== %#x %s tags=%s len=%d\n%s" + % (e, fname(e), sorted(ents[e]), len(src), src)) + w("d5_q7_currency_readers.txt", "\n".join(txt)) + print("DONE") +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_pack_content_1.py b/fifa17-recon/tools/ghidra_queries/q_pack_content_1.py new file mode 100644 index 0000000..1fd59bd --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_pack_content_1.py @@ -0,0 +1,142 @@ +"""D3 Q1: where do the seven packContentInfo fields land, and what object owns them? + +HYPOTHESIS: the pack element deser 0x18013af30 dispatches atom 0x20c +(packContentInfo) into a nested object sub-deser, which writes seven scalars into +a struct. A prior note (docs/plan-2026-08-04-blockers.md:201) claims the slots are ++0x144..+0x154 and that nothing in cardsdll reads them back. Verify the offsets +first-hand and find the sub-deser. + +CONTROL: class_deser("FutSquadSave") must return 0x180171a60 and +class_deser("FutSquadList") must return 0x180172140. If those come back empty the +whole batch is suspect. + +OUTPUT: full decompiles (len printed, never truncated) + raw disassembly of the +pack element deser and of EVERY callee, so the store offsets are read off +instructions, not off the decompiler's guessed structure. Also scores each callee +by how many of the seven packContentInfo atom immediates (and their sub-ladder +deltas) it contains, so the nested sub-deser is identified mechanically. +""" +import traceback, sys, os + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres/" +os.makedirs(OUT, exist_ok=True) + +PCI_ATOMS = {0x63: "bronzeQuantity", 0x2c6: "silverQuantity", 0x149: "goldQuantity", + 0x273: "rareQuantity", 0x170: "itemQuantity", 0x2e3: "start", + 0x35d: "unopened"} +# running-sum sub/dec ladder deltas between consecutive sorted atoms +_s = sorted(PCI_ATOMS) +PCI_DELTAS = {_s[i + 1] - _s[i] for i in range(len(_s) - 1)} + + +def dump(tag, va, path, echo=True): + src = dec(va) + if echo: + print("=" * 78) + print("%s %#x fname=%s len(src)=%d (PRINTED IN FULL, NOT TRUNCATED)" + % (tag, va, fname(va), len(src))) + print("=" * 78) + print(src) + with open(path, "w") as fh: + fh.write("// %s %#x len=%d\n" % (tag, va, len(src))) + fh.write(src) + return src + + +def insns(va, limit=200000): + f = func(va) + out = [] + if f is None: + return out + it = listing.getInstructions(f.getBody(), True) + n = 0 + while it.hasNext() and n < limit: + ins = it.next() + out.append((int(ins.getAddress().getOffset()), str(ins))) + n += 1 + return out + + +def disasm(va, path): + lines = ["%#x %s" % (a, s) for a, s in insns(va)] + with open(path, "w") as fh: + fh.write("\n".join(lines)) + return lines + + +def scalars(va): + """set of every scalar immediate appearing in the function's instructions""" + out = set() + f = func(va) + if f is None: + return out + it = listing.getInstructions(f.getBody(), True) + while it.hasNext(): + ins = it.next() + for i in range(ins.getNumOperands()): + for o in ins.getOpObjects(i): + try: + out.add(int(o.getValue()) & 0xFFFFFFFF) + except Exception: + pass + return out + + +try: + print("### CONTROLS") + for c, expect in (("FutSquadSave", 0x180171a60), ("FutSquadList", 0x180172140), + ("FutCreateMatch", 0x180120380)): + r = class_deser(c) + print(" %-16s -> %s expect %#x %s" + % (c, [hex(x[0]) for x in r], expect, + "PASS" if any(x[0] == expect for x in r) else "FAIL/UNKNOWN")) + + PACK_DESER = 0x18013AF30 + src = dump("PACK ELEMENT DESER", PACK_DESER, OUT + "d3_pack_elem_deser.txt") + + print("\n### CALLERS OF PACK ELEMENT DESER") + for a, n in callers(PACK_DESER): + print(" %#x %s" % (a, n)) + + dl = disasm(PACK_DESER, OUT + "d3_pack_elem_deser.asm") + print("\n### DISASM %d instructions -> d3_pack_elem_deser.asm" % len(dl)) + + print("\n### CALLEES OF PACK ELEMENT DESER, scored for packContentInfo atoms") + cand = [] + for a, n in callees(PACK_DESER): + sc = scalars(a) + hit_atoms = sorted(x for x in sc if x in PCI_ATOMS) + hit_delta = sorted(x for x in sc if x in PCI_DELTAS) + score = len(hit_atoms) + len(hit_delta) + print(" %#x %-28s natoms=%d %s ndelta=%d %s" + % (a, n, len(hit_atoms), [hex(x) for x in hit_atoms], + len(hit_delta), [hex(x) for x in hit_delta])) + cand.append((score, a, n)) + dump("CALLEE", a, OUT + "d3_callee_%x.txt" % a, echo=False) + disasm(a, OUT + "d3_callee_%x.asm" % a) + cand.sort(reverse=True) + + print("\n### CALL SITES INSIDE PACK ELEM DESER (address -> target)") + for ad, s in dl: + if s.startswith("CALL"): + t = s.split()[-1] + try: + tv = int(t, 16) + print(" %#x %s -> %s" % (ad, s, fname(tv))) + except Exception: + print(" %#x %s" % (ad, s)) + + print("\n### TOP CANDIDATE SUB-DESERS") + for score, a, n in cand[:3]: + print(" score=%d %#x %s" % (score, a, n)) + if cand and cand[0][0] >= 3: + best = cand[0][1] + s2 = dump("PACKCONTENTINFO SUB-DESER (best candidate)", best, + OUT + "d3_pci_subdeser.txt") + d2 = disasm(best, OUT + "d3_pci_subdeser.asm") + print("\n### FULL DISASM OF %#x (%d instructions)" % (best, len(d2))) + for ln in d2: + print(" " + ln) +except Exception: + traceback.print_exc() + sys.stdout.flush() diff --git a/fifa17-recon/tools/ghidra_queries/q_pack_content_2.py b/fifa17-recon/tools/ghidra_queries/q_pack_content_2.py new file mode 100644 index 0000000..558f6f0 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_pack_content_2.py @@ -0,0 +1,146 @@ +"""D3 Q2/Q3/Q4: who READS the pack record's packContentInfo slots, `start` and +`unopened`, and does anything count the delivered itemList against them? + +ESTABLISHED IN RUN 1 (q_pack_content_1.py, d3_pack_elem_deser.asm), twice over -- +once from the decompiler's frame locals and once from raw disassembly: + pack element deser 0x18013af30, record base = RSP+0x50 = RBP-0xB0, record size 0x158 + itemQuantity 0x170 -> [RBP+0x94] -> rec +0x144 + goldQuantity 0x149 -> [RBP+0x98] -> rec +0x148 + silverQuantity 0x2c6 -> [RBP+0x9c] -> rec +0x14c + bronzeQuantity 0x63 -> [RBP+0xa0] -> rec +0x150 + rareQuantity 0x273 -> [RBP+0xa4] -> rec +0x154 + state 0x2eb -> [RBP+0x00] -> rec +0x0b0 + start 0x2e3 -> [RBP+0x04] -> rec +0x0b4 (INT via 0x1800d7b30) + useDefaultImage0x36a -> [RBP+0x1c] -> rec +0x0cc (inverted) + unopened 0x35d -> [RBP+0x1d] -> rec +0x0cd (BOOL, stored raw) +`start` and `unopened` are TOP-LEVEL pack keys, NOT packContentInfo children. + +HYPOTHESIS: nothing in CardsDLL reads +0x144..+0x154 back. + +WHY A BYTE SCAN IS SOUND HERE: every offset of interest is >= 0x80, so x86 cannot +encode it as a signed disp8. Any instruction touching one of these slots must carry +the literal disp32 little-endian bytes. So a raw .text scan for those 4 bytes is an +EXHAUSTIVE upper bound on the set of candidate accesses; each hit is then confirmed +by asking Ghidra for the instruction containing it and checking the scalar. + +POSITIVE CONTROL FOR THE SCAN: the record copy-assign 0x1801340e0 and the +push_back 0x180132180 must move all 0x158 bytes. If they copy field-by-field the +scan MUST list them; if the scan returns nothing at all for every offset including +theirs, the scan is broken, not the binary. Second control: the stride 0x158 must be +found in 0x18013af30 itself (the /0x158 count check) and in 0x180132180. +""" +import traceback, sys, os + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres/" +os.makedirs(OUT, exist_ok=True) + +REC = {0x144: "itemQuantity", 0x148: "goldQuantity", 0x14c: "silverQuantity", + 0x150: "bronzeQuantity", 0x154: "rareQuantity", + 0x0b0: "state", 0x0b4: "start", 0x0cc: "useDefaultImage", 0x0cd: "unopened", + 0x158: "STRIDE/record-size"} + + +def dump(tag, va, path, echo=True): + src = dec(va) + hdr = ("%s %#x fname=%s len(src)=%d (FULL, NOT TRUNCATED)" + % (tag, va, fname(va), len(src))) + if echo: + print("=" * 78) + print(hdr) + print("=" * 78) + print(src) + with open(path, "w") as fh: + fh.write("// " + hdr + "\n" + src) + return src + + +def scan_disp(val): + """every .text instruction carrying `val` as a literal 4-byte scalar""" + pat = bytes([val & 0xFF, (val >> 8) & 0xFF, (val >> 16) & 0xFF, (val >> 24) & 0xFF]) + seen = {} + for h in find_all(pat, blocks=(".text",)): + ins = None + for back in range(0, 12): + try: + i2 = listing.getInstructionContaining(addr(h - back)) + except Exception: + i2 = None + if i2 is not None: + ins = i2 + break + if ins is None: + continue + ok = False + for i in range(ins.getNumOperands()): + for o in ins.getOpObjects(i): + try: + if (int(o.getValue()) & 0xFFFFFFFF) == val: + ok = True + except Exception: + pass + if not ok: + continue + a = int(ins.getAddress().getOffset()) + seen[a] = (fname(a), str(ins)) + return seen + + +try: + print("### CONTROL A: does the RS4 machinery work in THIS project copy?") + for nm in ("FutSquadSaveServerResponse", "FutStoreGetPackTypesServerResponse"): + hits = find_all(b"RS4:" + nm.encode()) + print(" RS4:%-38s literal hits=%s" % (nm, [hex(x) for x in hits])) + for h in hits: + xs = xrefs_to(h) + print(" xrefs to literal %#x: %s" % (h, [(hex(f), t, n) for f, t, n, e in xs])) + for c, expect in (("FutSquadSave", 0x180171a60), ("FutSquadList", 0x180172140), + ("FutCreateMatch", 0x180120380)): + r = class_deser(c) + print(" class_deser(%-16s) -> %s expect %#x %s" + % (c, [hex(x[0]) for x in r], expect, + "PASS" if any(x[0] == expect for x in r) else "FAIL/UNKNOWN")) + + print("\n### RECORD LIFECYCLE FUNCTIONS (full decompiles -> files)") + for va, tag in ((0x1801342d0, "record ctor"), (0x1801340e0, "record copy-assign"), + (0x180132180, "vector push_back/grow"), (0x1801232a0, "record dtor"), + (0x1800d7af0, "int conv A (quantities)"), + (0x1800d7b30, "int conv B (start,bonus)"), + (0x1800d7b10, "int conv C (id, 16-bit)")): + s = dump(tag, va, OUT + "d3_life_%x.txt" % va, echo=False) + print(" %#x %-26s len=%d -> d3_life_%x.txt" % (va, tag, len(s), va)) + + print("\n### EXHAUSTIVE disp32 SCAN OF .text") + allhits = {} + for off in sorted(REC): + s = scan_disp(off) + allhits[off] = s + print("\n --- offset %#05x (%s): %d confirmed instruction(s)" + % (off, REC[off], len(s))) + byfn = {} + for a, (fn, txt) in sorted(s.items()): + byfn.setdefault(fn, []).append((a, txt)) + for fn in sorted(byfn): + print(" %s" % fn) + for a, txt in byfn[fn]: + print(" %#x %s" % (a, txt)) + + print("\n### VERDICT INPUT: functions touching ANY quantity slot") + q = set() + for off in (0x144, 0x148, 0x14c, 0x150, 0x154): + for a, (fn, txt) in allhits[off].items(): + q.add(fn) + print(" ", sorted(q) if q else "NONE") + + print("\n### STORE ROOT DESER 0x1801234e0 AND ITS CALLERS") + dump("store root deser", 0x1801234e0, OUT + "d3_store_root.txt", echo=True) + for a, n in callers(0x1801234e0): + print(" CALLER %#x %s" % (a, n)) + dump("caller of store root", a, OUT + "d3_storeroot_caller_%x.txt" % a, echo=True) + + print("\n### FutCreatePackServerResponse deser 0x180162880 (itemList / numberItems)") + dump("createpack deser", 0x180162880, OUT + "d3_createpack_deser.txt", echo=True) + for a, n in callers(0x180162880): + print(" CALLER %#x %s" % (a, n)) +except Exception: + traceback.print_exc() + sys.stdout.flush() diff --git a/fifa17-recon/tools/ghidra_queries/q_pack_content_3.py b/fifa17-recon/tools/ghidra_queries/q_pack_content_3.py new file mode 100644 index 0000000..9747dc7 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_pack_content_3.py @@ -0,0 +1,148 @@ +"""D3 run 3: TIGHT reader scan + who consumes the store response object. + +Run 2's disp32 byte scan was correct but too permissive: it accepted any operand +whose scalar equalled the offset, so `SUB RSP,0x150` counted. This run requires the +offset to appear as a MEMORY-OPERAND DISPLACEMENT (the instruction text must contain +"+ 0xNNN]") which is the only form a struct field access can take. + +ESTABLISHED SO FAR (run 1 + run 2, both derivations agreeing): + pack record size 0x158, ctor 0x1801342d0 zeroes +0x144/+0x14c(qwords)/+0x154(dword) + itemQuantity +0x144, goldQuantity +0x148, silverQuantity +0x14c, + bronzeQuantity +0x150, rareQuantity +0x154, state +0xb0, start +0xb4, + useDefaultImage +0xcc, unopened +0xcd + store root deser 0x1801234e0 puts the pack vector at responseObject+0x28, + timestamp at responseObject+0x5c. + +POSITIVE CONTROL (already passing in run 2, re-asserted here): the copy-assign +0x1801340e0 must show up reading AND writing +0x144, and the ctor 0x1801342d0 must +show up writing +0x144. If they do not, the scan is broken. + +HYPOTHESES UNDER TEST + H1 No function other than the record's own ctor/copy/dtor touches +0x144..+0x154. + H2 Nothing counts an item list against those numbers (no function reads a quantity + slot and also walks an item vector). + H3 `start` +0xb4 and `unopened` +0xcd are likewise unread inside CardsDLL. +""" +import traceback, sys, os + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres/" +os.makedirs(OUT, exist_ok=True) + +QTY = {0x144: "itemQuantity", 0x148: "goldQuantity", 0x14c: "silverQuantity", + 0x150: "bronzeQuantity", 0x154: "rareQuantity"} +OTHER = {0x0b4: "start", 0x0cd: "unopened", 0x0b0: "state"} +LIFECYCLE = {0x1801342d0: "record ctor", 0x1801340e0: "record copy-assign", + 0x180132180: "vector grow", 0x1801232a0: "record dtor", + 0x18013af30: "pack element deser"} + + +def dump(tag, va, path, echo=True): + src = dec(va) + hdr = "%s %#x fname=%s len(src)=%d (FULL, NOT TRUNCATED)" % ( + tag, va, fname(va), len(src)) + if echo: + print("=" * 78) + print(hdr) + print("=" * 78) + print(src) + with open(path, "w") as fh: + fh.write("// " + hdr + "\n" + src) + return src + + +def scan_mem(val): + """{func_entry: [(addr, text)]} for MEMORY accesses at displacement val""" + pat = bytes([val & 0xFF, (val >> 8) & 0xFF, (val >> 16) & 0xFF, (val >> 24) & 0xFF]) + tag = "+ %#x]" % val + out = {} + seen = set() + for h in find_all(pat, blocks=(".text",)): + ins = None + for back in range(0, 12): + i2 = listing.getInstructionContaining(addr(h - back)) + if i2 is not None: + ins = i2 + break + if ins is None: + continue + a = int(ins.getAddress().getOffset()) + if a in seen: + continue + seen.add(a) + txt = str(ins) + if tag not in txt: + continue + f = fm.getFunctionContaining(ins.getAddress()) + key = int(f.getEntryPoint().getOffset()) if f else 0 + out.setdefault(key, []).append((a, txt)) + return out + + +try: + print("### TIGHT MEMORY-DISPLACEMENT SCAN, .text, quantity slots") + per_off = {} + fn_offs = {} + for off in sorted(QTY) + sorted(OTHER): + m = scan_mem(off) + per_off[off] = m + nm = QTY.get(off) or OTHER.get(off) + tot = sum(len(v) for v in m.values()) + print("\n --- +%#05x %-16s : %d instruction(s) in %d function(s)" + % (off, nm, tot, len(m))) + for k in sorted(m): + fn_offs.setdefault(k, set()).add(off) + print(" %#x %-20s" % (k, fname(k) if k else "?")) + for a, t in m[k]: + print(" %#x %s" % (a, t)) + + print("\n### CONTROL: lifecycle functions must appear for the quantity slots") + for va, tag in LIFECYCLE.items(): + got = sorted(fn_offs.get(va, [])) + print(" %#x %-22s offsets seen: %s %s" + % (va, tag, [hex(x) for x in got], + "PASS" if got else "absent")) + + print("\n### FUNCTIONS TOUCHING >=2 DISTINCT QUANTITY SLOTS (candidate consumers)") + cands = [] + for k, offs in sorted(fn_offs.items()): + q = sorted(o for o in offs if o in QTY) + if len(q) >= 2: + cands.append((k, q)) + print(" %#x %-22s %s %s" + % (k, fname(k), [hex(x) for x in q], + "(lifecycle)" if k in LIFECYCLE else "<== NON-LIFECYCLE")) + + print("\n### FUNCTIONS TOUCHING EXACTLY ONE QUANTITY SLOT") + for k, offs in sorted(fn_offs.items()): + q = sorted(o for o in offs if o in QTY) + if len(q) == 1: + print(" %#x %-22s %s %s" % (k, fname(k), [hex(x) for x in q], + "(lifecycle)" if k in LIFECYCLE else "")) + + print("\n### DECOMPILE EVERY NON-LIFECYCLE FUNCTION THAT TOUCHES ANY QUANTITY SLOT") + for k, offs in sorted(fn_offs.items()): + if k in LIFECYCLE or k == 0: + continue + if not any(o in QTY for o in offs): + continue + dump("QTY TOUCHER offs=%s" % [hex(x) for x in sorted(offs)], k, + OUT + "d3_qty_%x.txt" % k, echo=True) + + print("\n### WHO CONSUMES THE STORE RESPONSE OBJECT (vector at +0x28)") + fac = 0x180123480 + dump("store response factory", fac, OUT + "d3_store_factory.txt", echo=True) + print(" callers of factory:") + for a, n in callers(fac): + print(" %#x %s" % (a, n)) + print(" callers of deser 0x1801234e0:") + for a, n in callers(0x1801234e0): + print(" %#x %s" % (a, n)) + + print("\n### CREATEPACK: numberItems and itemList") + dump("createpack deser", 0x180162880, OUT + "d3_createpack_deser.txt", echo=False) + for a, n in callers(0x180162880): + print(" CALLER %#x %s" % (a, n)) + dump("createpack deser caller", a, OUT + "d3_cp_caller_%x.txt" % a, echo=True) +except Exception: + traceback.print_exc() + sys.stdout.flush() diff --git a/fifa17-recon/tools/ghidra_queries/q_pack_content_4.py b/fifa17-recon/tools/ghidra_queries/q_pack_content_4.py new file mode 100644 index 0000000..f7dd1ff --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_pack_content_4.py @@ -0,0 +1,136 @@ +"""D3 run 4: close the consumer set for the pack record. + +WHAT RUN 3 SETTLED + Of everything in .text that touches +0x144..+0x154 as a memory displacement, + only four functions belong to the 0x158-stride pack record: + 0x1801342d0 ctor (zeroes them) 0x18013af30 deser (writes them) + 0x180133210 uninitialised_copy (0x158) 0x1801340e0 copy-assign + The rest were offset collisions on unrelated structs, proven by their stride or + their size: 0x180133af0 iterates with stride 0x168, 0x180134b50 copies out to + +0x163, 0x180173e00's object extends to +0x2f8 and sums 0x148+0x14c+0x150 as a + win/draw/loss total. + +WHAT THIS RUN DOES + 1. The 0x158 STRIDE CENSUS. Any loop over the pack vector must advance a pointer + by 0x158 or multiply an index by it. Enumerate every instruction that uses + 0x158 in pointer arithmetic (ADD/LEA/IMUL on a register), not as a stack frame + size. Control: 0x180133210 and 0x18013af30 must both appear. + 2. Locate the FutStoreGetPackTypesServerResponse vtable by searching .rdata for + the deserializer pointer 0x1801234e0, dump it, and take xrefs to the vtable so + the owner class and any accessor are visible. (The factory and the deser have + zero direct callers, so they are dispatched through this vtable.) + 3. Complete caller closure over the record's lifecycle functions: anything that + can own a pack record must construct, copy or destroy one. + 4. CreatePack side: numberItems store offset, and every reader of it, to answer + whether the reveal is sized from a declared count or from the actual list. +""" +import traceback, sys, os + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres/" +os.makedirs(OUT, exist_ok=True) + + +def dump(tag, va, path, echo=True): + src = dec(va) + hdr = "%s %#x fname=%s len(src)=%d (FULL, NOT TRUNCATED)" % ( + tag, va, fname(va), len(src)) + if echo: + print("=" * 78) + print(hdr) + print("=" * 78) + print(src) + with open(path, "w") as fh: + fh.write("// " + hdr + "\n" + src) + return src + + +def insn_at(h): + for back in range(0, 14): + i2 = listing.getInstructionContaining(addr(h - back)) + if i2 is not None: + return i2 + return None + + +try: + print("### 1. 0x158 STRIDE CENSUS (pointer arithmetic only, not frame sizes)") + pat = bytes([0x58, 0x01, 0x00, 0x00]) + seen = set() + keep = [] + for h in find_all(pat, blocks=(".text",)): + ins = insn_at(h) + if ins is None: + continue + a = int(ins.getAddress().getOffset()) + if a in seen: + continue + seen.add(a) + t = str(ins) + if "0x158" not in t: + continue + mn = t.split()[0] + if mn in ("SUB", "ADD") and t.split()[1].startswith("RSP"): + continue # stack frame + if mn in ("ADD", "LEA", "IMUL", "MOV", "CMP", "SHL"): + keep.append((a, fname(a), t)) + byfn = {} + for a, fn, t in keep: + byfn.setdefault(fn, []).append((a, t)) + print(" %d instruction(s) in %d function(s)" % (len(keep), len(byfn))) + for fn in sorted(byfn): + print(" %s" % fn) + for a, t in byfn[fn]: + print(" %#x %s" % (a, t)) + print(" CONTROL: 0x180133210 present=%s 0x18013af30 present=%s" + % ("FUN_180133210" in byfn, "FUN_18013af30" in byfn)) + + print("\n### 2. STORE RESPONSE VTABLE") + dp = (0x1801234e0).to_bytes(8, "little") + for h in find_all(dp, blocks=(".rdata", ".data")): + print(" deser pointer 0x1801234e0 found in .rdata/.data at %#x" % h) + for base in (h - 8, h - 0x10, h): + print(" candidate vtable base %#x:" % base) + for off, tgt, nm in vtable(base, 14): + print(" +%#04x %#018x %s" % (off, tgt, nm)) + break + for frm, typ, fn, ent in xrefs_to(h - 8): + print(" xref to (vtbl base %#x): %#x %s %s" % (h - 8, frm, typ, fn)) + for frm, typ, fn, ent in xrefs_to(h): + print(" xref to (slot itself %#x): %#x %s %s" % (h, frm, typ, fn)) + + print("\n### 3. CALLER CLOSURE OVER PACK-RECORD LIFECYCLE") + LIFE = {0x1801342d0: "record ctor", 0x1801340e0: "copy-assign", + 0x180133210: "uninit_copy(0x158)", 0x180132180: "vector grow", + 0x1801232a0: "record dtor", 0x18013af30: "element deser"} + lvl1 = {} + for va, tag in LIFE.items(): + cs = callers(va) + print(" %#x %-20s callers: %s" % (va, tag, [(hex(a), n) for a, n in cs])) + for a, n in cs: + lvl1.setdefault(a, set()).add(tag) + print("\n level-2 (callers of those callers):") + for a in sorted(lvl1): + if a in LIFE: + continue + print(" %#x %-20s via %s ; its callers: %s" + % (a, fname(a), sorted(lvl1[a]), [(hex(x), n) for x, n in callers(a)])) + print("\n full decompiles of every non-lifecycle caller:") + for a in sorted(lvl1): + if a in LIFE: + continue + dump("LIFECYCLE CALLER", a, OUT + "d3_life_caller_%x.txt" % a, echo=True) + + print("\n### 4. CREATEPACK numberItems") + src = dump("createpack deser", 0x180162880, OUT + "d3_createpack_deser.txt", echo=True) + for va, tag in ((0x180162880, "createpack deser"),): + pass + dpc = (0x180162880).to_bytes(8, "little") + for h in find_all(dpc, blocks=(".rdata", ".data")): + print(" createpack deser pointer at %#x (vtable slot)" % h) + for off, tgt, nm in vtable(h - 8, 12): + print(" +%#04x %#018x %s" % (off, tgt, nm)) + for frm, typ, fn, ent in xrefs_to(h - 8): + print(" xref to vtbl base: %#x %s %s" % (frm, typ, fn)) +except Exception: + traceback.print_exc() + sys.stdout.flush() diff --git a/fifa17-recon/tools/ghidra_queries/q_pack_content_5.py b/fifa17-recon/tools/ghidra_queries/q_pack_content_5.py new file mode 100644 index 0000000..a4a6def --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_pack_content_5.py @@ -0,0 +1,91 @@ +"""D3 run 5: the response objects' own virtuals, and who sizes the pack reveal. + +SETTLED SO FAR + Pack record (0x158 bytes) lifecycle inside CardsDLL is a CLOSED graph: + 0x1801234e0 root deser -> 0x18013af30 element deser -> ctor 0x1801342d0, + push_back 0x180132180 (-> uninit_copy 0x180133210, copy-assign 0x1801340e0), + stack copy destroyed by 0x1801232a0; vector freed by 0x180123200, whose only + caller is the response object's scalar_deleting_destructor 0x1801233e0. + Nothing else in .text constructs, copies or destroys one. + FutStoreGetPackTypesServerResponse vtable = 0x18021dd68 (referenced only by its + ctor 0x180123030). Pack vector at obj+0x28/0x30/0x38, timestamp obj+0x5c. + FutCreatePackServerResponse vtable = 0x180228260, deser 0x180162880: + numberItems(0x1dd) -> obj+0x28 (raw 8-byte store), itemList(0x16e) -> vector + obj+0x30/0x38/0x40 with 0x18-byte elements, purchasedPackId(0x264) -> obj+0x70. + +THIS RUN + A. Decompile every class-specific virtual of both response objects. The two + classes share slots +0x10..+0x38 and +0x48..+0x68 (generic base) but differ at + +0x00 and +0x40, so +0x40 is where per-class behaviour lives. + B. Find the RPC/command strings STOREPACKTYPES and CREATEPACK and their xrefs, to + reach the code that consumes each response. + C. Ask directly whether the reveal is sized from numberItems (obj+0x28) or from + the itemList vector length: enumerate readers of the CreatePack object. + CONTROL for B: the string "CREATEPACK" is written into the pack record by its own + ctor at rec+0xd8, so at least that xref must come back; if the string search + returns nothing at all the search is broken. +""" +import traceback, sys, os + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres/" +os.makedirs(OUT, exist_ok=True) + + +def dump(tag, va, path, echo=True): + src = dec(va) + hdr = "%s %#x fname=%s len(src)=%d (FULL, NOT TRUNCATED)" % ( + tag, va, fname(va), len(src)) + if echo: + print("=" * 78) + print(hdr) + print("=" * 78) + print(src) + with open(path, "w") as fh: + fh.write("// " + hdr + "\n" + src) + return src + + +try: + print("### A. CLASS-SPECIFIC VIRTUALS") + for va, tag in ((0x180123030, "FutStoreGetPackTypes ctor"), + (0x1801233e0, "FutStoreGetPackTypes scalar_deleting_dtor"), + (0x1801233a0, "FutStoreGetPackTypes vtbl+0x40"), + (0x180123100, "0x158-stride helper near store class"), + (0x180122420, "shared vtbl+0x20"), + (0x180162420, "FutCreatePack ctor"), + (0x1801624e0, "FutCreatePack scalar_deleting_dtor"), + (0x1801624a0, "FutCreatePack vtbl+0x40"), + (0x18014c990, "0x158 ADD (unclassified)")): + try: + dump(tag, va, OUT + "d3_v_%x.txt" % va, echo=True) + print(" callers: %s" % [(hex(a), n) for a, n in callers(va)]) + except Exception as e: + print(" !! %s: %s" % (tag, e)) + + print("\n### B. COMMAND STRINGS") + for s in (b"STOREPACKTYPES\x00", b"CREATEPACK\x00", b"V2STORE\x00", + b"STOREPACKQUANTITIES\x00", b"PURCHASEDITEMS\x00"): + hits = find_all(s) + print(" %-24s hits=%s" % (s.decode(errors="replace").strip("\x00"), + [hex(x) for x in hits])) + for h in hits: + for frm, typ, fn, ent in xrefs_to(h): + print(" xref %#x %s in %s" % (frm, typ, fn)) + + print("\n### C. WHO READS THE RESPONSE OBJECTS") + for vt, nm in ((0x18021dd68, "FutStoreGetPackTypes vtable"), + (0x180228260, "FutCreatePack vtable")): + print(" xrefs to %s %#x:" % (nm, vt)) + for frm, typ, fn, ent in xrefs_to(vt): + print(" %#x %s %s" % (frm, typ, fn)) + + print("\n### C2. every .text reference to the two vtable ADDRESSES as immediates") + for vt in (0x18021dd68, 0x180228260): + pat = vt.to_bytes(8, "little") + for h in find_all(pat, blocks=(".text", ".rdata", ".data")): + f = fm.getFunctionContaining(addr(h)) + print(" vtbl %#x embedded at %#x in %s" + % (vt, h, f.getName() if f else "(data)")) +except Exception: + traceback.print_exc() + sys.stdout.flush() diff --git a/fifa17-recon/tools/ghidra_queries/q_pack_content_6.py b/fifa17-recon/tools/ghidra_queries/q_pack_content_6.py new file mode 100644 index 0000000..f8a1bba --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_pack_content_6.py @@ -0,0 +1,109 @@ +"""D3 run 6: how does a store response leave CardsDLL, and can the packed exe see +the pack record at all? + +SETTLED: FutStoreGetPackTypesServerResponse is a 0x60-byte object; ctor 0x180123030 +sets vtable 0x18021dd68 and an empty FUT Vector at +0x28/+0x30/+0x38 (allocator ++0x40, "FUT Vector" tag +0x50, timestamp +0x5c). Its vtable has NO accessor: slot 0 +and slot +0x40 are deleting destructors, +0x08 is the deserializer, the rest are the +shared base-class slots also present on FutCreatePackServerResponse. So nothing in +the class hands a pack record out. + +THIS RUN + 1. The RPC descriptor row: find the data references to the factory 0x180123480 and + to the command strings, and print the surrounding qwords, so the table that + binds "STOREPACKTYPES" -> factory -> deserializer is visible. + 2. The shared response virtuals (+0x20 0x180122420, +0x10/+0x18 0x18016cac0, + +0x28 0x18016ca90, +0x38 0x18016c950, +0x48 0x18016bfc0, +0x58 0x18016ca60): + is any of them a data accessor rather than plumbing? + 3. CardsDLL EXPORT TABLE. If the packed exe reads pack quantities it must reach + them through an export or through a pointer an export returned. Enumerate every + export; that bounds the exe's reach. + 4. Re-confirm the 100-element cap in 0x18013af30 from disassembly. +CONTROL: the export enumeration must at minimum return the DLL's known entry points; +an empty export list means the query is broken, not that the DLL exports nothing. +""" +import traceback, sys, os + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres/" +os.makedirs(OUT, exist_ok=True) + + +def dump(tag, va, path, echo=True): + src = dec(va) + hdr = "%s %#x fname=%s len(src)=%d (FULL, NOT TRUNCATED)" % ( + tag, va, fname(va), len(src)) + if echo: + print("=" * 78) + print(hdr) + print("=" * 78) + print(src) + with open(path, "w") as fh: + fh.write("// " + hdr + "\n" + src) + return src + + +try: + print("### 1. DESCRIPTOR ROW FOR THE STORE RPC") + for target, nm in ((0x180123480, "store factory"), (0x1801234e0, "store deser"), + (0x18021f318, "\"STOREPACKTYPES\" string"), + (0x18021de20, "RS4 name literal")): + pat = target.to_bytes(8, "little") + hits = find_all(pat, blocks=(".rdata", ".data")) + print(" %s %#x embedded at: %s" % (nm, target, [hex(x) for x in hits])) + for h in hits: + lo = h - 0x40 + print(" context qwords around %#x:" % h) + for i in range(16): + a = lo + i * 8 + try: + q = qword(a) + except Exception: + continue + extra = "" + if 0x1801e5000 <= q < 0x1802e0000: + try: + s = rd_str(q, 60) + if s.isprintable() and len(s) > 2: + extra = " \"%s\"" % s + except Exception: + pass + if 0x180001000 <= q < 0x1801e5000: + extra = " fn=%s" % fname(q) + print(" %#x: %#018x%s%s" % (a, q, extra, " <== HIT" if a == h else "")) + + print("\n### 2. SHARED RESPONSE VIRTUALS") + for va in (0x180122420, 0x18016cac0, 0x18016ca90, 0x18016ca40, 0x18016c950, + 0x18016bfc0, 0x18016cb80, 0x18016ca60, 0x18016c110, 0x18016cb20): + try: + s = dump("shared virtual", va, OUT + "d3_sv_%x.txt" % va, echo=True) + except Exception as e: + print(" !! %#x %s" % (va, e)) + + print("\n### 3. EXPORT TABLE") + st = prog.getSymbolTable() + it = st.getExternalEntryPointIterator() + n = 0 + while it.hasNext(): + a = it.next() + syms = st.getSymbols(a) + nms = [str(s.getName()) for s in syms] + print(" %#x %s" % (int(a.getOffset()), nms)) + n += 1 + print(" total exported entry points: %d %s" + % (n, "PASS" if n else "FAIL (query broken)")) + + print("\n### 4. THE 100-PACK CAP") + f = func(0x18013af30) + it2 = listing.getInstructions(f.getBody(), True) + buf = [] + while it2.hasNext(): + i = it2.next() + buf.append("%#x %s" % (int(i.getAddress().getOffset()), str(i))) + for k, ln in enumerate(buf): + if "0x64" in ln or "0x158" in ln: + print(" ...") + for j in range(max(0, k - 6), min(len(buf), k + 7)): + print(" %s" % buf[j]) +except Exception: + traceback.print_exc() + sys.stdout.flush() diff --git a/fifa17-recon/tools/ghidra_queries/q_pack_content_7.py b/fifa17-recon/tools/ghidra_queries/q_pack_content_7.py new file mode 100644 index 0000000..f6cea66 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_pack_content_7.py @@ -0,0 +1,101 @@ +"""D3 run 7: the RPC descriptor row's handler, and the end of the pack-record trail. + +FOUND IN RUN 6: a descriptor table in .data at ~0x1802cb800 with 0x30-byte rows + [display-name ptr, 0x1b, COMMAND-token ptr, 0, 0, function ptr] + 0x1802cb860 "PurchaseItems" "PURCHASEITEMS" -> 0x180124240 + 0x1802cb890 "StorePackTypes" "STOREPACKTYPES" -> 0x180124810 + 0x1802cb8c0 "StorePackQuantities" "STOREPACKQUANTITIES" -> ? +Also a factory table at 0x18021ddf8 holding 0x180123480. +CardsDLL exports only PlugInitialize_ / PlugDeinitialize_ / entry, so everything the +packed exe can see comes through interfaces those hand out. + +THIS RUN + 1. Walk the descriptor table rows around 0x1802cb800 +/- 0x300 and print each row. + 2. Decompile the StorePackTypes handler 0x180124810 and the CreatePack handler, + then follow their callees/callers, looking for anything that touches the + response object's vector at +0x28. + 3. Same for the CreatePack response: who reads numberItems at obj+0x28 or the + itemList vector at obj+0x30/0x38, i.e. what sizes the reveal. +CONTROL: 0x180124810 must decompile to something that mentions the store response + factory 0x180123480 or the vtable 0x18021dd68 or the path string "store"; if it + looks unrelated the table row reading is wrong. +""" +import traceback, sys, os + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres/" +os.makedirs(OUT, exist_ok=True) + + +def dump(tag, va, path, echo=True): + src = dec(va) + hdr = "%s %#x fname=%s len(src)=%d (FULL, NOT TRUNCATED)" % ( + tag, va, fname(va), len(src)) + if echo: + print("=" * 78) + print(hdr) + print("=" * 78) + print(src) + with open(path, "w") as fh: + fh.write("// " + hdr + "\n" + src) + return src + + +def sstr(q): + if 0x1801e5000 <= q < 0x1802e0000: + try: + s = rd_str(q, 64) + if s and all(32 <= ord(c) < 127 for c in s): + return s + except Exception: + pass + return None + + +try: + print("### 1. DESCRIPTOR TABLE WALK") + base = 0x1802cb500 + for row in range(0, 0x600, 0x30): + a = base + row + try: + qs = [qword(a + i * 8) for i in range(6)] + except Exception: + continue + n0, n2 = sstr(qs[0]), sstr(qs[2]) + if not (n0 and n2): + continue + fn = qs[5] + print(" %#x %-24s %-24s flags=%#x fn=%#x %s" + % (a, n0, n2, qs[1], fn, fname(fn) if fn else "")) + + print("\n### 2. HANDLERS") + seen = set() + for va, tag in ((0x180124810, "StorePackTypes handler"), + (0x180124240, "PurchaseItems handler")): + dump(tag, va, OUT + "d3_h_%x.txt" % va, echo=True) + print(" callees:") + for a, n in callees(va): + print(" %#x %s" % (a, n)) + print(" callers:") + for a, n in callers(va): + print(" %#x %s" % (a, n)) + seen.add(va) + + print("\n### 3. WHO ELSE MENTIONS THE STORE FACTORY / VTABLE / FACTORY TABLE SLOT") + for tgt in (0x18021ddf8, 0x18021dd68, 0x180123480, 0x1801234e0, 0x180123030): + print(" xrefs to %#x:" % tgt) + for frm, typ, fn, ent in xrefs_to(tgt): + print(" %#x %s %s" % (frm, typ, fn)) + + print("\n### 4. CREATEPACK RESPONSE CONSUMERS") + dump("FutCreatePack ctor", 0x180162420, OUT + "d3_cp_ctor.txt", echo=True) + print(" callers of ctor: %s" % [(hex(a), n) for a, n in callers(0x180162420)]) + dump("FutCreatePack factory 0x180162770", 0x180162770, OUT + "d3_cp_factory.txt", + echo=True) + print(" callers of factory: %s" % [(hex(a), n) for a, n in callers(0x180162770)]) + for tgt in (0x180162770, 0x180162880, 0x180228260): + pat = tgt.to_bytes(8, "little") + for h in find_all(pat, blocks=(".rdata", ".data")): + print(" %#x embedded at %#x" % (tgt, h)) +except Exception: + traceback.print_exc() + sys.stdout.flush() diff --git a/fifa17-recon/tools/ghidra_queries/q_pack_content_8.py b/fifa17-recon/tools/ghidra_queries/q_pack_content_8.py new file mode 100644 index 0000000..92b7b39 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_pack_content_8.py @@ -0,0 +1,78 @@ +"""D3 run 8: what actually sizes the pack reveal. + +FOUND IN RUN 7: the CreatePack deserializer 0x180162880 push_backs EVERY parsed item +TWICE, once into the response object's own vector (obj+0x30/0x38/0x40) and once into +a singleton's vector reached as + mgr = FUN_18011a830() -> vtbl[0x160](mgr) (call it PACKMGR) + PACKMGR+0x30 / +0x38 / +0x40 item vector, 0x18-byte elements + PACKMGR+0x28 byte set to 1 after the whole body is parsed ("contents ready") + vtbl[0x10](PACKMGR) called BEFORE parsing (presumably clear) +numberItems (atom 0x1dd) is written to the RESPONSE at obj+0x28 and is never used to +size either vector: both grow one element per item actually present in itemList. + +THIS RUN + 1. Resolve FUN_18011a830 and the vtbl+0x160 accessor so PACKMGR's class is named. + 2. Decompile vtbl+0x10 (the pre-parse call) to confirm it is a clear. + 3. Find readers of PACKMGR's vector and of the +0x28 ready flag: that is the reveal. + 4. Disassemble the unanalysed RPC handler thunks 0x180124810 (StorePackTypes), + 0x180124800 (StorePackQuantities), 0x180124250 (PurchasePack) -- Ghidra created + no functions there, so read the bytes directly. +CONTROL: FUN_18011a830 must resolve to a singleton getter (a DAT_ load or a + create-on-first-use), and slot 0x160 must be a plain accessor. If either + decompiles to something unrelated the chain is misread. +""" +import traceback, sys, os + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres/" +os.makedirs(OUT, exist_ok=True) + + +def dump(tag, va, path, echo=True): + src = dec(va) + hdr = "%s %#x fname=%s len(src)=%d (FULL, NOT TRUNCATED)" % ( + tag, va, fname(va), len(src)) + if echo: + print("=" * 78) + print(hdr) + print("=" * 78) + print(src) + with open(path, "w") as fh: + fh.write("// " + hdr + "\n" + src) + return src + + +try: + print("### 1. SINGLETON CHAIN") + dump("FUN_18011a830", 0x18011a830, OUT + "d3_mgr_getter.txt", echo=True) + print(" callers of 0x18011a830: %d" % len(callers(0x18011a830))) + + print("\n### 4. RPC HANDLER THUNK BYTES") + for va, nm in ((0x180124810, "STOREPACKTYPES"), (0x180124800, "STOREPACKQUANTITIES"), + (0x180124250, "PURCHASEPACK"), (0x180124260, "PURCHASEDITEMS"), + (0x180124240, "PURCHASEITEMS")): + b = read_bytes(va, 32) + print(" %#x %-22s %s" % (va, nm, b.hex())) + f = fm.getFunctionContaining(addr(va)) + print(" containing function: %s" % (f.getName() if f else "NONE")) + ins = listing.getInstructionContaining(addr(va)) + print(" instruction: %s" % (str(ins) if ins else "NONE (undisassembled)")) + # decode a rel32 jmp/call if present + if b[0] == 0xE9: + t = va + 5 + int.from_bytes(b[1:5], "little", signed=True) + print(" JMP rel32 -> %#x %s" % (t, fname(t))) + if b[0] == 0x48 and b[1] == 0xFF and b[2] == 0x25: + t = va + 7 + int.from_bytes(b[3:7], "little", signed=True) + print(" JMP [rip+..] -> slot %#x = %#x" % (t, qword(t))) + + print("\n### 5. READERS OF THE RESPONSE-SIDE numberItems obj+0x28") + # obj+0x28 is disp8-encodable so a byte scan is useless; instead enumerate + # everything that can hold a FutCreatePackServerResponse: only its ctor names the + # vtable, and the factory has no callers, so the object is dispatched generically. + for tgt in (0x180228260, 0x1802282f0, 0x180228268): + print(" xrefs to %#x: %s" % (tgt, [(hex(f), t, n) for f, t, n, e in xrefs_to(tgt)])) + + print("\n### 6. duplicateItemIdList sub-parser 0x180138e10") + dump("dupe id list parser", 0x180138e10, OUT + "d3_dupe_parser.txt", echo=True) +except Exception: + traceback.print_exc() + sys.stdout.flush() diff --git a/fifa17-recon/tools/ghidra_queries/q_pack_content_9.py b/fifa17-recon/tools/ghidra_queries/q_pack_content_9.py new file mode 100644 index 0000000..731fa3f --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_pack_content_9.py @@ -0,0 +1,133 @@ +"""D3 run 9: CORRECTION RUN. There IS a reader, and I nearly missed it. + +WHAT WENT WRONG IN RUNS 3-8. I triaged the disp32 scan by ADDRESS BAND, treating +everything below ~0x180100000 as "engine noise", and on that basis dismissed +FUN_18002c3c0. It is in fact a pack-record -> view-model adapter that reads all five +packContentInfo slots, plus `start` (+0xb4) and `unopened` (+0xcd). Address band is +not evidence. This run replaces the band heuristic with a FIELD FINGERPRINT. + +FINGERPRINT. The pack record's distinctive, disp32-encodable field offsets are + 0xb0 state, 0xb4 start, 0xbc quantity, 0xc0, 0xc4, 0xc8 saleType, 0xcc + useDefaultImage, 0xcd unopened, 0xce isPremium, 0xcf dealType-free, + 0xd0 dealType-promo, 0x138 visible, 0x13c bonus, 0x140, 0x144 itemQuantity, + 0x148 gold, 0x14c silver, 0x150 bronze, 0x154 rare. +Any unrelated struct may collide on one or two of these. Colliding on five or more, +especially on the tight run 0xcd/0xce/0xcf/0xd0, is not chance. + +CONTROLS. The known-good members must score at the top: 0x1801340e0 (copy-assign), + 0x180133210 (uninitialised_copy), 0x18002c3c0 (the adapter just found). + 0x180133af0 (stride 0x168) and 0x180134b50 (extends to +0x163) must NOT, since + their strides prove they are other classes. + +THEN follow the view model FUN_18002c3c0 builds: its callers, FUN_18002cc90 which it +tail-calls, and every reader of the view model's copies of the quantities at +vm+0xc0/0xc4/0xc8/0xcc/0xd0, to see whether any of them counts an item list. +""" +import traceback, sys, os + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres/" +os.makedirs(OUT, exist_ok=True) + +FP = [0x0b0, 0x0b4, 0x0bc, 0x0c0, 0x0c4, 0x0c8, 0x0cc, 0x0cd, 0x0ce, 0x0cf, 0x0d0, + 0x138, 0x13c, 0x140, 0x144, 0x148, 0x14c, 0x150, 0x154] +TIGHT = [0x0cd, 0x0ce, 0x0cf, 0x0d0, 0x13c, 0x144, 0x14c, 0x154] +VM = [0x0c0, 0x0c4, 0x0c8, 0x0cc, 0x0d0, 0x084, 0x0b0, 0x0b5, 0x0b6, 0x0b7, 0x0b8] + + +def dump(tag, va, path, echo=True): + src = dec(va) + hdr = "%s %#x fname=%s len(src)=%d (FULL, NOT TRUNCATED)" % ( + tag, va, fname(va), len(src)) + if echo: + print("=" * 78) + print(hdr) + print("=" * 78) + print(src) + with open(path, "w") as fh: + fh.write("// " + hdr + "\n" + src) + return src + + +def scan_mem(val): + pat = bytes([val & 0xFF, (val >> 8) & 0xFF, (val >> 16) & 0xFF, (val >> 24) & 0xFF]) + tag = "+ %#x]" % val + out = {} + seen = set() + for h in find_all(pat, blocks=(".text",)): + ins = None + for back in range(0, 12): + i2 = listing.getInstructionContaining(addr(h - back)) + if i2 is not None: + ins = i2 + break + if ins is None: + continue + a = int(ins.getAddress().getOffset()) + if a in seen: + continue + seen.add(a) + t = str(ins) + if tag not in t: + continue + f = fm.getFunctionContaining(ins.getAddress()) + if f is None or f.getName().startswith("Unwind@"): + continue + out.setdefault(int(f.getEntryPoint().getOffset()), []).append((a, t)) + return out + + +try: + print("### 1. PACK-RECORD FIELD FINGERPRINT OVER ALL OF .text") + hits = {} + for off in FP: + for k in scan_mem(off): + hits.setdefault(k, set()).add(off) + ranked = sorted(hits.items(), key=lambda kv: -len(kv[1])) + print(" functions scoring >=5 fingerprint offsets:") + strong = [] + for k, offs in ranked: + if len(offs) < 5: + break + t = sorted(o for o in offs if o in TIGHT) + print(" %#x %-22s score=%2d tight=%d %s" + % (k, fname(k), len(offs), len(t), [hex(x) for x in sorted(offs)])) + strong.append(k) + print("\n CONTROLS: 0x1801340e0 in=%s 0x180133210 in=%s 0x18002c3c0 in=%s" + " | must NOT be strong: 0x180133af0 in=%s 0x180134b50 in=%s" + % (0x1801340e0 in strong, 0x180133210 in strong, 0x18002c3c0 in strong, + 0x180133af0 in strong, 0x180134b50 in strong)) + + print("\n full decompile of every strong function not already understood:") + KNOWN = {0x1801340e0, 0x180133210, 0x1801342d0, 0x18013af30, 0x18002c3c0} + for k in strong: + if k in KNOWN: + continue + dump("STRONG FINGERPRINT", k, OUT + "d3_fp_%x.txt" % k, echo=True) + + print("\n### 2. THE ADAPTER AND ITS VIEW MODEL") + print(" callers of adapter 0x18002c3c0: %s" + % [(hex(a), n) for a, n in callers(0x18002c3c0)]) + for a, n in callers(0x18002c3c0): + dump("ADAPTER CALLER", a, OUT + "d3_ad_caller_%x.txt" % a, echo=True) + dump("FUN_18002cc90 (tail call from adapter)", 0x18002cc90, + OUT + "d3_vm_18002cc90.txt", echo=True) + print(" callers of 0x18002cc90: %s" + % [(hex(a), n) for a, n in callers(0x18002cc90)]) + + print("\n### 3. VIEW-MODEL QUANTITY READERS (vm+0xc0..0xd0)") + vmhits = {} + for off in (0x0c0, 0x0c4, 0x0c8, 0x0cc, 0x0d0): + for k, v in scan_mem(off).items(): + vmhits.setdefault(k, {})[off] = v + cands = [(k, o) for k, o in vmhits.items() if len(o) >= 4] + print(" functions reading >=4 of vm+0xc0..0xd0: %d" % len(cands)) + for k, o in sorted(cands): + print(" %#x %-22s %s" % (k, fname(k), [hex(x) for x in sorted(o)])) + print("\n decompiles:") + for k, o in sorted(cands): + if k in KNOWN: + continue + dump("VM QTY READER", k, OUT + "d3_vm_%x.txt" % k, echo=True) +except Exception: + traceback.print_exc() + sys.stdout.flush() diff --git a/fifa17-recon/tools/ghidra_queries/q_pack_disp_1.py b/fifa17-recon/tools/ghidra_queries/q_pack_disp_1.py new file mode 100644 index 0000000..c581b92 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_pack_disp_1.py @@ -0,0 +1,125 @@ +"""DIMENSION 2 batch 1: what happens to cards after the reveal. + +HYPOTHESES + H1 duplicateItemIdList (atom 0xec) in createPackResponse (deser 0x180162880) + lands in a store offset that some UI/flow code reads. Find the offset, then + find every reader. + H2 Quick Sell == "Discard" in this codebase (strings CardsDiscardCard / + CardsDiscardCardList / CardsDiscardCardByRes, RS4:FutDiscardCardServerResponse). + FutDiscardCard deser 0x180127300 parses items/totalCredits/id. Question is + whether the coin credit is taken from totalCredits (server) or recomputed from + the item's discardValue (client). + H3 Send-to-transfer-list from the reveal reuses FutMoveCard (PUT ut/%s/item) with + a pile change rather than a new endpoint. + H4 CardsDiscardCardList is the bulk variant and is ONE request carrying a list. + +CONTROLS (must resolve, else class_deser is misbehaving this run): + FutSquadSave -> 0x180171a60, FutSquadList -> 0x180172140, + FutCreateMatch -> 0x180120380 +""" +import traceback, os + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres" + + +def dump(name, text): + p = os.path.join(OUT, name) + with open(p, "w") as f: + f.write(text) + print("[wrote %s %d chars]" % (p, len(text))) + + +try: + print("=" * 78) + print("SECTION 0 -- class_deser CONTROLS") + for c in ("FutSquadSave", "FutSquadList", "FutCreateMatch"): + print(" ", c, [hex(x[0]) for x in class_deser(c)]) + + print("=" * 78) + print("SECTION 0b -- class_deser TARGETS") + for c in ("FutDiscardCardServerResponse", "FutDiscardCardByResServerResponse", + "FutMoveCardServerResponse", "FutMoveCardByResServerResponse", + "FutSwapCardServerResponse", "FutCreatePackServerResponse", + "FutISStartServerResponse"): + print(" ", c, [hex(x[0]) for x in class_deser(c)]) + + print("=" * 78) + print("SECTION 1 -- createPackResponse deserializer 0x180162880 FULL") + s = dec(0x180162880) + print("len(src) =", len(s)) + print(s) + dump("d2_createpack_deser.txt", s) + + print("=" * 78) + print("SECTION 2 -- nested int-list parser 0x180138e10 FULL " + "(duplicateItemIdList element parser per ENDPOINT_MAP)") + s = dec(0x180138E10) + print("len(src) =", len(s)) + print(s) + dump("d2_138e10_intlist.txt", s) + + print("=" * 78) + print("SECTION 3 -- FutDiscardCard deser 0x180127300 FULL") + s = dec(0x180127300) + print("len(src) =", len(s)) + print(s) + dump("d2_discardcard_deser.txt", s) + + print("=" * 78) + print("SECTION 3b -- FutDiscardCardByRes deser 0x1801279c0 FULL") + s = dec(0x1801279C0) + print("len(src) =", len(s)) + print(s) + dump("d2_discardcardbyres_deser.txt", s) + + print("=" * 78) + print("SECTION 4 -- string xrefs for the action names") + names = { + "CardsDiscardCard": 0x1801EF6C5, + "CardsDiscardCardList": 0x1801EF6DD, + "CardsDiscardCardByRes": 0x1801EF6F5, + "DiscardCard": 0x18021EEA8, + "DISCARDCARD": 0x18021EEB8, + "DiscardCardByRes": 0x18021EEC8, + "DiscardACard": 0x18021EEF8, + "DISCARDACARD": 0x18021EF08, + "MoveCard": 0x18021EF18, + "MOVECARD": 0x18021EF28, + "SwapCard": 0x18021EF58, + "CardsSwapCards": 0x1801EF6B5, + "GetCardDuplicate": 0x1801F3ADF, + "TO_TRADEPILE": 0x1802391A8, + "Tradepile": 0x18021CF08, + "tradepile_lc": 0x18022FB20, + "tradePile_cc": 0x18022FB30, + "discardValue_key": 0x180230BE8, + "duplicateItemId": 0x180230D08, + "duplicateItemIdList": 0x180230D18, + "duplicateItemLoans": 0x180230D30, + "fcc_discardcoins": 0x1802231F4, + "swap_key": 0x18022F7EC, + "swapPlayerDefIds": 0x18022F7F8, + "AddCardBackToTradePile": 0x1801F3C15, + "RemoveFromTradePile": 0x1801EFADA, + "RemoveAllSoldFromTradePile": 0x1801EFAF9, + } + for n, va in sorted(names.items()): + got = rd_str(va, 60) + # the -4 rule may apply to some; report the literal we actually see + xs = xrefs_to(va) + print("%-28s %#x str=%r xrefs=%d" % (n, va, got, len(xs))) + for frm, typ, fn, ent in xs[:12]: + print(" from %#x %s in %s (%#x)" % (frm, typ, fn, ent)) + + print("=" * 78) + print("SECTION 5 -- where is atom 0xd7 discardValue handled? " + "search .text for the immediate 0xd7 near the item deser") + print("item element deser 0x18013fe00:") + s = dec(0x18013FE00) + print("len(src) =", len(s)) + dump("d2_item_elem_deser.txt", s) + print(s[:6000]) + print("... [full copy written to d2_item_elem_deser.txt]") + +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_pack_disp_10.py b/fifa17-recon/tools/ghidra_queries/q_pack_disp_10.py new file mode 100644 index 0000000..26f373d --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_pack_disp_10.py @@ -0,0 +1,66 @@ +"""DIMENSION 2 batch 10: the discard request URL, and every caller of the FUT +client-model singleton (to locate the reader of the duplicate field and of +totalCredits). + +HYPOTHESES + H1 FUN_180127530 is the DiscardCard ServerCall's request builder; the literal + "/%llu" at 0x180220638 is appended to ut/delete/%s/item, so quick sell is + DELETE ut/delete/game/fifa17/item/. + H2 Every consumer of the pack-reveal collection and of the card's duplicate + field goes through FUN_18011a830(). Enumerating its callers bounds the set + of readers; the ones that call slot 0x160 are the reveal-screen consumers. + +CONTROL: FutSquadListServerResponse -> 0x180172140. +""" +import traceback, os, re + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres" + +try: + print("CONTROL FutSquadListServerResponse ->", + [hex(a) for a, v, e in class_deser("FutSquadListServerResponse")]) + + print("=" * 78) + print("SECTION 1 -- discard request builders") + for lbl, va in (("FUN_180127530 (DiscardCard req)", 0x180127530), + ("FUN_180127290", 0x180127290), + ("FUN_1801277c0 (ByRes req)", 0x1801277C0), + ("FUN_180163750", 0x180163750), + ("FUN_1801631e0 (shared)", 0x1801631E0)): + s = dec(va) + print("---- %s len=%d ----" % (lbl, len(s))) + print(s) + + print("=" * 78) + print("SECTION 2 -- xrefs to the '/%llu' literal 0x180220638") + for r in xrefs_to(0x180220638): + print(" %#x %s %s %#x" % (r[0], r[1], r[2], r[3])) + + print("=" * 78) + print("SECTION 3 -- all callers of the model singleton FUN_18011a830, with the " + "vtable slot each one invokes") + calls = {} + for r in xrefs_to(0x18011A830): + ent = r[3] + if not ent: + continue + calls.setdefault(ent, 0) + calls[ent] += 1 + print(" %d distinct callers" % len(calls)) + slotpat = re.compile(r"\*plVar\d+ \+ (0x[0-9a-f]+)\)|\+ (0x[0-9a-f]+)\)\)\(plVar") + for ent in sorted(calls): + s = dec(ent) + slots = sorted(set(re.findall(r"\(\*\*\(code \*\*\)\(\*\w+ \+ (0x[0-9a-f]+)\)\)", s))) + print(" %#x %-24s calls=%d slots=%s" + % (ent, fname(ent), calls[ent], slots)) + + print("=" * 78) + print("SECTION 4 -- functions that invoke model slot 0x160 or 0xa30 or 0xa08") + for ent in sorted(calls): + s = dec(ent) + for slot in ("0x160", "0xa30", "0xa08", "0xa38", "0x168"): + if "+ %s)" % slot in s: + print(" %#x %s uses slot %s" % (ent, fname(ent), slot)) + +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_pack_disp_11.py b/fifa17-recon/tools/ghidra_queries/q_pack_disp_11.py new file mode 100644 index 0000000..bf374c5 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_pack_disp_11.py @@ -0,0 +1,35 @@ +"""DIMENSION 2 batch 11: the request URL builders and the reveal-screen readers. + +HYPOTHESES + H1 FUN_180127570 builds the DiscardCard URL and references "/%llu", so quick + sell targets a per-item URL. FUN_180126f40 / 0x180127cc0 / 0x1801281c0 / + 0x18012a550 are the sibling builders for ByRes / Move / Apply. + H2 One of the slot-0x160 consumers outside the deserializers reads the card's + +0x10 field (the duplicateItemId written by the createPack post-pass) and/or + the DiscardCard response's totalCredits at +0x28. + +CONTROL: FutCreateMatchServerResponse -> 0x180120380. +""" +import traceback, os + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres" + +try: + print("CONTROL FutCreateMatchServerResponse ->", + [hex(a) for a, v, e in class_deser("FutCreateMatchServerResponse")]) + + builders = [0x180126F40, 0x180127570, 0x180127CC0, 0x1801281C0, 0x18012A550, + 0x180124CA0] + consumers = [0x180051AD0, 0x180065EB0, 0x18007C5F0, 0x18009BC40, 0x1800AF4A0, + 0x1800E0500, 0x1800FFAA0, 0x18018AE40, 0x18018B940] + blob = [] + for va in builders + consumers: + s = dec(va) + hdr = "==== %#x %s len=%d ====" % (va, fname(va), len(s)) + print(hdr) + print(s) + blob.append(hdr + "\n" + s) + with open(os.path.join(OUT, "d2_builders_consumers.txt"), "w") as f: + f.write("\n".join(blob)) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_pack_disp_12.py b/fifa17-recon/tools/ghidra_queries/q_pack_disp_12.py new file mode 100644 index 0000000..2194496 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_pack_disp_12.py @@ -0,0 +1,57 @@ +"""DIMENSION 2 batch 12: the pack-reveal screen controller. + +HYPOTHESES + H1 FUN_18009bc40 is 'act on the revealed card at index N'. Its siblings in the + same screen class implement send-to-club / send-to-transfer-list / quick + sell, each building a {id,pile} vector and handing it to model slot 0xc0 + (pile 7=club, 5=trade) or to the discard path. + H2 A 'store all' bulk variant, if it exists, builds a MULTI-element vector for + the same slot 0xc0 call, i.e. one request with a list. + +CONTROL: FutSquadSaveServerResponse -> 0x180171a60. +""" +import traceback, os, re + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres" + +try: + print("CONTROL FutSquadSaveServerResponse ->", + [hex(a) for a, v, e in class_deser("FutSquadSaveServerResponse")]) + + print("=" * 78) + print("SECTION 1 -- neighbours of FUN_18009bc40 in the same screen class") + blob = [] + for va in (0x18009BC40, 0x18009BEC0, 0x18009AD90, 0x18009B160, 0x18009B800, + 0x18009B900, 0x18009BA00): + try: + s = dec(va) + except Exception as e: + s = "// err %s" % e + hdr = "==== %#x %s len=%d ====" % (va, fname(va), len(s)) + print(hdr) + print(s) + blob.append(hdr + "\n" + s) + + print("=" * 78) + print("SECTION 2 -- every function that calls model slot 0xc0 " + "(the {id,pile} move submitter)") + hits = [] + for r in xrefs_to(0x18011A830): + ent = r[3] + if not ent: + continue + hits.append(ent) + seen = set() + for ent in sorted(set(hits)): + s = dec(ent) + if "+ 0xc0))" in s or "+ 0xc0)\n" in s or "0xc0))(plVar" in s: + print("---- %#x %s len=%d ----" % (ent, fname(ent), len(s))) + print(s if len(s) < 7000 else s[:7000] + "\n...TRUNCATED len=%d" % len(s)) + blob.append("==== slot0xc0 %#x ====\n%s" % (ent, s)) + seen.add(ent) + print(" total slot-0xc0 callers:", len(seen)) + + with open(os.path.join(OUT, "d2_reveal_screen.txt"), "w") as f: + f.write("\n".join(blob)) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_pack_disp_13.py b/fifa17-recon/tools/ghidra_queries/q_pack_disp_13.py new file mode 100644 index 0000000..016fd1b --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_pack_disp_13.py @@ -0,0 +1,51 @@ +"""DIMENSION 2 batch 13 (last): find the reader of FutDiscardCardServerResponse +totalCredits (obj+0x28), and confirm discardValue is a display field. + +HYPOTHESIS + The DiscardCard class block 0x180126e00-0x180127a00 contains a small accessor + that returns *(int*)(this+0x28); its callers are the coin consumers. If no such + accessor exists, the response's totalCredits is read directly by a completion + callback that this pass has not reached, and that is an honest gap. + +CONTROL: FutSquadListServerResponse -> 0x180172140. +""" +import traceback, os + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres" + +try: + print("CONTROL FutSquadListServerResponse ->", + [hex(a) for a, v, e in class_deser("FutSquadListServerResponse")]) + + print("=" * 78) + print("SECTION 1 -- every function in 0x180126c00-0x180127a00") + it = fm.getFunctions(addr(0x180126C00), True) + while it.hasNext(): + f = it.next() + ep = int(f.getEntryPoint().getOffset()) + if ep > 0x180127A00: + break + s = dec(ep) + print("---- %#x %s len=%d ----" % (ep, f.getName(), len(s))) + print(s if len(s) < 2500 else s[:2500] + "\n...TRUNC len=%d" % len(s)) + if "0x28)" in s: + print(" *** references +0x28 ***") + + print("=" * 78) + print("SECTION 2 -- GetCardDetails Scaleform getter (does it expose discardValue?)") + hits = find_all(b"GetCardDetails\x00") + print(" lit hits:", [hex(h) for h in hits]) + for h in hits: + for r in xrefs_to(h): + print(" ref %#x in %s %#x" % (r[0], r[2], r[3])) + + print("=" * 78) + print("SECTION 3 -- xrefs to the fcc_discardcoins literal and its owner") + for lit in (b"fcc_discardcoins\x00", b"discardValue\x00"): + hs = find_all(lit) + print(" %s -> %s" % (lit, [hex(x) for x in hs])) + for h in hs: + for r in xrefs_to(h): + print(" ref %#x %s %s %#x" % (r[0], r[1], r[2], r[3])) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_pack_disp_2.py b/fifa17-recon/tools/ghidra_queries/q_pack_disp_2.py new file mode 100644 index 0000000..59ed039 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_pack_disp_2.py @@ -0,0 +1,108 @@ +"""DIMENSION 2 batch 2. + +HYPOTHESES + H1 The request-descriptor table around 0x1802cb230 maps action name -> + uppercase name -> url template / method / factory. Decoding one row decodes + all of them, and gives DiscardCard / DiscardACard / DiscardCardByRes / + MoveCard / SwapCard their routes and their request+response classes. + H2 FutDiscardCardServerResponse stores totalCredits at obj+0x28 and the last + discarded id at obj+0x30. Whoever reads +0x28 decides whether the coin + credit is server-authored or client-computed. + H3 FUN_18011a830() is the FUT client-model singleton; vtable slot 0xa30 removes + an item by id (called once per discarded id) and slot 0x160 hands out the + pack-reveal item collection that createPack post-processes. + H4 TO_TRADEPILE (FUN_1801be6a0) and 'Tradepile' (FUN_18010c3b0) are the + send-to-transfer-list paths. + +CONTROLS: class_deser with the FULL literal names, which is what the -4 rule +needs. FutSquadSaveServerResponse -> 0x180171a60, FutSquadListServerResponse -> +0x180172140, FutCreateMatchServerResponse -> 0x180120380. +""" +import traceback, os + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres" + + +def dump(name, text): + with open(os.path.join(OUT, name), "w") as f: + f.write(text) + print("[wrote %s %d chars]" % (name, len(text))) + + +def show(label, va): + s = dec(va) + print("-" * 74) + print("%s %#x fname=%s len(src)=%d" % (label, va, fname(va), len(s))) + print(s) + return s + + +try: + print("=" * 78) + print("SECTION 0 -- CONTROLS with the full RS4 literal name") + for c in ("FutSquadSaveServerResponse", "FutSquadListServerResponse", + "FutCreateMatchServerResponse"): + print(" ", c, [(hex(a), hex(v), hex(e)) for a, v, e in class_deser(c)]) + + print("=" * 78) + print("SECTION 0b -- targets, with vtable + factory") + for c in ("FutDiscardCardServerResponse", "FutDiscardCardByResServerResponse", + "FutMoveCardByResServerResponse", "FutCreatePackServerResponse", + "FutViewCardsServerResponse"): + print(" ", c, [(hex(a), hex(v), hex(e)) for a, v, e in class_deser(c)]) + + print("=" * 78) + print("SECTION 1 -- the action-descriptor table around 0x1802cb230") + base = 0x1802CB000 + for i in range(0, 0x600, 8): + va = base + i + try: + q = qword(va) + except Exception: + continue + note = "" + if 0x1801E5000 <= q <= 0x180290000: + try: + t = rd_str(q, 48) + if t and all(0x20 <= ord(ch) < 0x7F for ch in t): + note = "STR %r" % t + except Exception: + pass + if not note and 0x180001000 <= q < 0x1801E5000: + f = fm.getFunctionAt(addr(q)) + note = "FUNC %s" % (f.getName() if f else "(mid)") + print(" %#x : %#018x %s" % (va, q, note)) + + print("=" * 78) + print("SECTION 2 -- readers of the FutDiscardCard response fields") + print("xrefs to deser 0x180127300:") + for r in xrefs_to(0x180127300): + print(" ", [hex(r[0]), r[1], r[2], hex(r[3])]) + + print("=" * 78) + print("SECTION 3 -- TO_TRADEPILE / Tradepile owners") + show("FUN_1801be6a0 (TO_TRADEPILE)", 0x1801BE6A0) + show("FUN_18010c3b0 (Tradepile)", 0x18010C3B0) + + print("=" * 78) + print("SECTION 4 -- the FUT model singleton") + show("FUN_18011a830 (singleton getter)", 0x18011A830) + + print("=" * 78) + print("SECTION 5 -- string search for the Scaleform action names, exact literal") + for lit in (b"CardsDiscardCard\x00", b"CardsDiscardCardList\x00", + b"CardsDiscardCardByRes\x00", b"GetCardDuplicate\x00", + b"CardsSwapCards\x00", b"AddCardBackToTradePile\x00", + b"RemoveFromTradePile\x00", b"RemoveAllSoldFromTradePile\x00", + b"TradePileFull\x00", b"GetTradePileResults\x00", + b"CardsMoveCard\x00", b"CardsSendToClub\x00"): + hits = find_all(lit) + print(" %-30s hits=%s" % (lit.decode().strip("\x00"), [hex(h) for h in hits])) + for h in hits: + for r in xrefs_to(h): + print(" ref %#x %s %s %#x" % (r[0], r[1], r[2], r[3])) + for r in xrefs_to(h - 4): + print(" ref-4 %#x %s %s %#x" % (r[0], r[1], r[2], r[3])) + +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_pack_disp_3.py b/fifa17-recon/tools/ghidra_queries/q_pack_disp_3.py new file mode 100644 index 0000000..eccc36b --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_pack_disp_3.py @@ -0,0 +1,112 @@ +"""DIMENSION 2 batch 3. + +HYPOTHESES + H1 FUN_180028b50 is the Scaleform command registrar (CardsDiscardCard, + CardsDiscardCardList, CardsDiscardCardByRes, CardsSwapCards, CardsMoveCard, + RemoveFromTradePile, RemoveAllSoldFromTradePile). Each registration row + carries the C++ handler, which is the UI entry point for quick sell / move. + H2 FUN_1800394c0 is the Scaleform getter registrar (GetCardDuplicate, + GetTradePileResults, AddCardBackToTradePile). GetCardDuplicate's handler + reads the card field that createPack's duplicateItemIdList post-pass wrote. + H3 The int at row+8 of the action table 0x1802cb000 indexes the ut/%s/... URL + template array. Print that array so DiscardCard=0xf etc. can be resolved + rather than guessed from .rdata ordering. + H4 The request factories 0x180123cc0/cd0/ce0 (DiscardACard/DiscardCard/ + DiscardCardByRes), 0x1801241f0 (MoveCard), 0x180124830 (SwapCard) build the + request objects; their serializers give the request body. + +CONTROL: FutSquadSaveServerResponse -> 0x180171a60 (re-checked in this batch). +""" +import traceback, os + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres" + + +def dump(name, text): + with open(os.path.join(OUT, name), "w") as f: + f.write(text) + print("[wrote %s %d chars]" % (name, len(text))) + + +def show(label, va, save=None): + s = dec(va) + print("-" * 74) + print("%s %#x len(src)=%d" % (label, va, len(s))) + print(s) + if save: + dump(save, s) + return s + + +try: + print("CONTROL FutSquadSaveServerResponse ->", + [hex(a) for a, v, e in class_deser("FutSquadSaveServerResponse")]) + + print("=" * 78) + print("SECTION 1 -- URL template pointer array (find the array that holds " + "0x18021e490 'ut/%s/item')") + tgt = 0x18021E490 + for r in xrefs_to(tgt): + print(" xref to ut/%%s/item %#x %s %s %#x" % (r[0], r[1], r[2], r[3])) + # scan .data/.rdata for a qword equal to the auctionhouse template, then walk + for probe in (0x18021E308,): + import struct + hits = find_all(struct.pack(" %#x %r" % (i, va, q, s)) + + print("=" * 78) + print("SECTION 2 -- Scaleform command registrar FUN_180028b50") + s = dec(0x180028B50) + print("len(src) =", len(s)) + dump("d2_scaleform_cmd_registrar.txt", s) + for ln in s.splitlines(): + if any(k in ln for k in ("Discard", "SwapCard", "MoveCard", "TradePile", + "Duplicate", "QuickSell", "Sell")): + print(" ", ln.strip()) + + print("=" * 78) + print("SECTION 3 -- Scaleform getter registrar FUN_1800394c0") + s = dec(0x1800394C0) + print("len(src) =", len(s)) + dump("d2_scaleform_get_registrar.txt", s) + for ln in s.splitlines(): + if any(k in ln for k in ("Duplicate", "TradePile", "Discard", "Sell")): + print(" ", ln.strip()) + + print("=" * 78) + print("SECTION 4 -- request factories") + for lbl, va in (("DiscardACard", 0x180123CC0), ("DiscardCard", 0x180123CD0), + ("DiscardCardByRes", 0x180123CE0), ("MoveCard", 0x1801241F0), + ("MoveCardByRes", 0x180124200), ("SwapCard", 0x180124830), + ("ViewCards", 0x180124900)): + show("factory " + lbl, va) + + print("=" * 78) + print("SECTION 5 -- FutDiscardCard response vtable 0x180220488") + for off, t, n in vtable(0x180220488, 12): + print(" +%#04x -> %#x %s" % (off, t, n)) + print(" ctor/xrefs to vtable:") + for r in xrefs_to(0x180220488): + print(" ", hex(r[0]), r[1], r[2], hex(r[3])) + print(" xrefs to factory 0x180127160 / 0x180127630:") + for f in (0x180127160, 0x180127630): + for r in xrefs_to(f): + print(" ", hex(f), "<-", hex(r[0]), r[1], r[2], hex(r[3])) + +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_pack_disp_4.py b/fifa17-recon/tools/ghidra_queries/q_pack_disp_4.py new file mode 100644 index 0000000..3757fb6 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_pack_disp_4.py @@ -0,0 +1,68 @@ +"""DIMENSION 2 batch 4: the UI handlers behind quick sell / move / duplicate. + +HYPOTHESES + H1 FUN_18002a040 CardsDiscardCard issues one DiscardCard request for one id; + FUN_18002a0f0 CardsDiscardCardList issues ONE request with a list (Q4). + H2 FUN_180039fb0 GetCardDuplicate reads the card field that createPack's + duplicateItemIdList post-pass wrote (obj+0x10), proving what the list drives. + H3 CardsSellCard FUN_18002aed0 is list-on-market (ISStart), and + "send to transfer list" from the reveal is a MoveCard pile change, not a + dedicated endpoint. + H4 The coin credit after a quick sell comes from totalCredits in the response + (obj+0x28) rather than being summed client-side from discardValue. + RefreshUserCredit FUN_18002b870 and the response's virtual at vtable+0x20 + (0x180122420) are where to look. + +CONTROL: FutSquadListServerResponse -> 0x180172140. +""" +import traceback, os + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres" + + +def dump(name, text): + with open(os.path.join(OUT, name), "w") as f: + f.write(text) + + +def show(label, va, save=None): + s = dec(va) + print("-" * 74) + print("%s %#x len(src)=%d" % (label, va, len(s))) + print(s) + if save: + dump(save, s) + return s + + +try: + print("CONTROL FutSquadListServerResponse ->", + [hex(a) for a, v, e in class_deser("FutSquadListServerResponse")]) + + targets = [ + ("CardsDiscardCard", 0x18002A040), + ("CardsDiscardCardByRes", 0x18002A0C0), + ("CardsDiscardCardList", 0x18002A0F0), + ("CardsMoveCard", 0x18002AB40), + ("CardsMoveCardByRes", 0x18002ABE0), + ("CardsMoveMultipleCards", 0x18002AC30), + ("CardsSellCard", 0x18002AED0), + ("CardsSwapCards", 0x18002B070), + ("RemoveFromTradePile", 0x18002B900), + ("RemoveAllSoldFromTradePile", 0x18002B8E0), + ("RefreshUserCredit", 0x18002B870), + ("GetCardDuplicate", 0x180039FB0), + ("GetTradePileResults", 0x18003B100), + ("AddCardBackToTradePile", 0x180039C70), + ("CardsGetLastMoveCardId", 0x18002A200), + ("respvt+0x20 FUN_180122420", 0x180122420), + ("respvt+0x40 FUN_180126f00", 0x180126F00), + ] + all_src = [] + for lbl, va in targets: + s = show(lbl, va) + all_src.append("==== %s %#x ====\n%s\n" % (lbl, va, s)) + dump("d2_ui_handlers.txt", "\n".join(all_src)) + +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_pack_disp_5.py b/fifa17-recon/tools/ghidra_queries/q_pack_disp_5.py new file mode 100644 index 0000000..c516511 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_pack_disp_5.py @@ -0,0 +1,58 @@ +"""DIMENSION 2 batch 5: resolve the two service objects the UI layer calls into. + +HYPOTHESES + H1 DAT_1802de4d0 is the FUT request service. Slots seen from the Scaleform + handlers: +0x20 DiscardCard(id) - +0x28 DiscardCardList(ids[],n) - + +0x30 DiscardCardByRes - +0x38 MoveCard(id,?,pile) - + +0x40 MoveMultipleCards(ids[],n,pile) - +0x48 MoveCardByRes(res,pile) - + +0x70 RefreshUserCredit - +0x78 SellCard(id,a,b,c) - + +0xb0 GetLastMoveCardId - +0x190 RemoveFromTradePile(tradeId) - + +0x198 RemoveAllSoldFromTradePile. + H2 DAT_1802def18 is the card/UI data provider. +0x18 GetCardDuplicate, + +0xc8 GetTradePileResults, +0xd8 AddCardBackToTradePile. + Find each object's vtable by locating the store to the global, then dump slots. + +CONTROL: FutCreateMatchServerResponse -> 0x180120380. +""" +import traceback, os, struct + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres" + + +def dump(name, text): + with open(os.path.join(OUT, name), "w") as f: + f.write(text) + + +try: + print("CONTROL FutCreateMatchServerResponse ->", + [hex(a) for a, v, e in class_deser("FutCreateMatchServerResponse")]) + + for g in (0x1802DE4D0, 0x1802DEF18): + print("=" * 78) + print("global %#x xrefs:" % g) + seen = set() + for r in xrefs_to(g): + print(" %#x %s %s %#x" % (r[0], r[1], r[2], r[3])) + if r[3]: + seen.add(r[3]) + print(" containing functions:", [hex(x) for x in sorted(seen)]) + for fn in sorted(seen): + s = dec(fn) + if len(s) < 3000: + print("---- %#x len=%d ----" % (fn, len(s))) + print(s) + + print("=" * 78) + print("SECTION 2 -- candidate vtables: any .rdata table whose slot +0x198 and " + "+0x190 are functions and which is referenced by a ctor storing to " + "0x1802de4d0. Fallback: scan .rdata for PTR tables near known impls.") + # The Scaleform layer calls through the object; find the ctor by looking for + # functions that write the global. Print raw instruction text around each ref. + for g in (0x1802DE4D0, 0x1802DEF18): + for r in xrefs_to(g): + ins = listing.getInstructionAt(addr(r[0])) + print(" %#x %s" % (r[0], ins)) + +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_pack_disp_6.py b/fifa17-recon/tools/ghidra_queries/q_pack_disp_6.py new file mode 100644 index 0000000..2d391a2 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_pack_disp_6.py @@ -0,0 +1,74 @@ +"""DIMENSION 2 batch 6: find the concrete FUT request service and the request +builders behind DiscardCard / DiscardCardList / MoveCard / SellCard. + +HYPOTHESES + H1 FUN_1800295d0 is a setter; its caller passes the concrete service object, so + the caller reveals the vtable. + H2 0x180123cc0..0x180124910 is a block of tiny per-action factory thunks that + Ghidra never disassembled. Disassembling them yields, for each action, the + request class it constructs. + H3 The action table's base is below 0x1802cb000 and some function indexes it + with the action enum; that function is the request dispatcher. + +CONTROL: FutSquadSaveServerResponse -> 0x180171a60. +""" +import traceback, os + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres" + +try: + print("CONTROL FutSquadSaveServerResponse ->", + [hex(a) for a, v, e in class_deser("FutSquadSaveServerResponse")]) + + print("=" * 78) + print("SECTION 1 -- callers of the service setter FUN_1800295d0") + for r in xrefs_to(0x1800295D0): + print(" %#x %s %s %#x" % (r[0], r[1], r[2], r[3])) + for e in sorted({r[3] for r in xrefs_to(0x1800295D0) if r[3]}): + s = dec(e) + print("---- caller %#x len=%d ----" % (e, len(s))) + print(s if len(s) < 8000 else s[:8000] + "\n...TRUNCATED, len=%d" % len(s)) + + print("=" * 78) + print("SECTION 2 -- disassembly of the factory thunk block 0x180123900-0x180124950") + a = 0x180123900 + end = 0x180124950 + while a < end: + ins = listing.getInstructionAt(addr(a)) + if ins is None: + b = read_bytes(a, 16) + print(" %#x DATA %s" % (a, b.hex())) + a += 16 + continue + print(" %#x %s" % (a, ins)) + a += ins.getLength() + + print("=" * 78) + print("SECTION 3 -- action table extent and who indexes it") + # walk backwards from 0x1802cb000 in 0x30 steps while row[0] looks like a string + base = 0x1802CB000 + while True: + prev = base - 0x30 + try: + q = qword(prev) + except Exception: + break + if not (0x1801E5000 <= q <= 0x180290000): + break + try: + s = rd_str(q, 40) + except Exception: + break + if not s or not all(0x20 <= ord(c) < 0x7F for c in s): + break + base = prev + print(" table base ~ %#x" % base) + for r in xrefs_to(base): + print(" xref to base %#x %s %s %#x" % (r[0], r[1], r[2], r[3])) + for e in sorted({r[3] for r in xrefs_to(base) if r[3]}): + s = dec(e) + print("---- indexer %#x len=%d ----" % (e, len(s))) + print(s if len(s) < 9000 else s[:9000] + "\n...TRUNCATED, len=%d" % len(s)) + +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_pack_disp_7.py b/fifa17-recon/tools/ghidra_queries/q_pack_disp_7.py new file mode 100644 index 0000000..0b41586 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_pack_disp_7.py @@ -0,0 +1,73 @@ +"""DIMENSION 2 batch 7: the client model singleton, the itemState/pile enum, the +RS4 census, and the readers of the discard response's totalCredits. + +HYPOTHESES + H1 DAT_1802e6398 (returned by FUN_18011a830) is the FUT client model. Slot + 0xa30 removes an item by id (discard), 0xa08 inserts a parsed item, 0x160 + returns the pack-reveal collection. Finding its vtable makes all three + readable, including any credits mutator. + H2 FUN_180166660 is the itemState string->enum used for atom 0x172, so it + enumerates the piles ("free"/"pile"/"club"/"trade"/...). That names the + value a send-to-transfer-list MoveCard has to carry. + H3 A full RS4 census tells us whether a dedicated send-to-tradepile response + class exists at all, or whether the reveal reuses MoveCard/ISStart. + +CONTROL: FutSquadListServerResponse -> 0x180172140. +""" +import traceback, os + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres" + + +def dump(name, text): + with open(os.path.join(OUT, name), "w") as f: + f.write(text) + + +try: + print("CONTROL FutSquadListServerResponse ->", + [hex(a) for a, v, e in class_deser("FutSquadListServerResponse")]) + + print("=" * 78) + print("SECTION 1 -- RS4 census") + hits = find_all(b"RS4:") + names = [] + for h in hits: + s = rd_str(h, 80) + names.append((h, s)) + names.sort(key=lambda x: x[1]) + print("count =", len(names)) + for h, s in names: + print(" %#x %s" % (h, s)) + dump("d2_rs4_census.txt", "\n".join("%#x %s" % (h, s) for h, s in names)) + + print("=" * 78) + print("SECTION 2 -- client model singleton DAT_1802e6398") + for r in xrefs_to(0x1802E6398): + print(" %#x %s %s %#x" % (r[0], r[1], r[2], r[3])) + for e in sorted({r[3] for r in xrefs_to(0x1802E6398) if r[3] and r[1] == "WRITE"}): + s = dec(e) + print("---- writer %#x len=%d ----" % (e, len(s))) + print(s if len(s) < 6000 else s[:6000] + "\n...TRUNCATED len=%d" % len(s)) + + print("=" * 78) + print("SECTION 3 -- itemState enum decoder FUN_180166660 (atom 0x172)") + print(dec(0x180166660)) + print("SECTION 3b -- tradeState decoder 0x180166bd0, bidState 0x180166380") + print(dec(0x180166BD0)) + + print("=" * 78) + print("SECTION 4 -- callers of the FutDiscardCard factories") + for f in (0x180127160, 0x180127630, 0x180127890): + print(" factory %#x callers:" % f) + for r in xrefs_to(f): + print(" %#x %s %s %#x" % (r[0], r[1], r[2], r[3])) + + print("=" * 78) + print("SECTION 5 -- the fcc_discardcoins third key string") + print(" 0x18022315c ->", repr(rd_str(0x18022315C, 40))) + print(" 0x180223150 ->", repr(rd_str(0x180223150, 40))) + print(" raw:", read_bytes(0x180223150, 32).hex()) + +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_pack_disp_8.py b/fifa17-recon/tools/ghidra_queries/q_pack_disp_8.py new file mode 100644 index 0000000..362b303 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_pack_disp_8.py @@ -0,0 +1,84 @@ +"""DIMENSION 2 batch 8: piles, the response registry, and the credits path. + +HYPOTHESES + H1 0x180229cc0 is a {string,enum} table naming every itemState / pile value. + 'trade'/'tradepile' in it would be the value a send-to-transfer-list + MoveCard must carry. + H2 The rows around 0x1802705f0 / 0x1802fb370 are a response registry that pairs + each response class with its deserializer AND its handler; the handler for + FutDiscardCard is what reads totalCredits. + H3 FutUpdateCreditsServerResponse / FutUserCreditsServerResponse are the coin + balance carriers; if the client refetches credits after a discard, the + emulator must keep the balance consistent, not just echo totalCredits. + +CONTROL: FutCreateMatchServerResponse -> 0x180120380. +""" +import traceback, os + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres" + +try: + print("CONTROL FutCreateMatchServerResponse ->", + [hex(a) for a, v, e in class_deser("FutCreateMatchServerResponse")]) + + print("=" * 78) + print("SECTION 1 -- itemState enum table 0x180229cc0") + for i in range(24): + p = qword(0x180229CC0 + i * 0x10) + if p == 0: + print(" [%d] NULL terminator" % i) + break + v = dword(0x180229CC8 + i * 0x10) + print(" [%2d] %r -> %d" % (i, rd_str(p, 40), v)) + print("SECTION 1b -- tradeState enum table 0x180229e40") + for i in range(16): + p = qword(0x180229E40 + i * 0x10) + if p == 0: + print(" [%d] NULL" % i) + break + print(" [%2d] %r -> %d" % (i, rd_str(p, 40), dword(0x180229E48 + i * 0x10))) + print("SECTION 1c -- bidState decoder 0x180166380") + print(dec(0x180166380)) + + print("=" * 78) + print("SECTION 2 -- registry rows around the discard entries") + for base, n in ((0x180270580, 0x60), (0x1802FB330, 0x40), (0x180220470, 0x40)): + print("--- dump %#x ---" % base) + for i in range(n): + va = base + i * 8 + try: + q = qword(va) + except Exception: + break + note = "" + if 0x1801E5000 <= q <= 0x180290000: + s = rd_str(q, 60) + if s and all(0x20 <= ord(c) < 0x7F for c in s): + note = "STR %r" % s + if not note and 0x180001000 <= q < 0x1801E5000: + f = fm.getFunctionAt(addr(q)) + note = "FUNC %s" % (f.getName() if f else "(mid)") + print(" %#x : %#018x %s" % (va, q, note)) + + print("=" * 78) + print("SECTION 3 -- credits response classes") + for c in ("FutUpdateCreditsServerResponse", "FutUserCreditsServerResponse", + "FutMoveCardServerResponse", "FutGetPurchasedItemsServerResponse"): + r = class_deser(c) + print(" ", c, [(hex(a), hex(v), hex(e)) for a, v, e in r]) + for a, v, e in set(r): + s = dec(a) + print("---- deser %#x len=%d ----" % (a, len(s))) + print(s) + break + + print("=" * 78) + print("SECTION 4 -- MoveCard deser 0x180128600 FULL") + s = dec(0x180128600) + print("len(src) =", len(s)) + print(s) + with open(os.path.join(OUT, "d2_movecard_deser.txt"), "w") as f: + f.write(s) + +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_pack_disp_9.py b/fifa17-recon/tools/ghidra_queries/q_pack_disp_9.py new file mode 100644 index 0000000..fb7476a --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_pack_disp_9.py @@ -0,0 +1,68 @@ +"""DIMENSION 2 batch 9: the pile enum, the discard request class, and the +duplicate-field reader. + +HYPOTHESES + H1 FUN_180142650 is the pile string->enum for atom 0x226 in the MoveCard + verdict record. Its table names every destination a move can target, which + is exactly the value 'send to transfer list' has to carry. + H2 The .rdata block 0x180220470-0x180220780 holds both the FutDiscardCard + request vtable and the response vtable; a slot on the request side is the + completion handler that reads totalCredits. + H3 DAT_1802def18's concrete class is installed by a caller of FUN_180039b40 / + FUN_180039ba0; its vtable slot +0x18 is GetCardDuplicate, the only reader of + the card field that duplicateItemIdList writes. + +CONTROL: FutSquadSaveServerResponse -> 0x180171a60. +""" +import traceback, os + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres" + +try: + print("CONTROL FutSquadSaveServerResponse ->", + [hex(a) for a, v, e in class_deser("FutSquadSaveServerResponse")]) + + print("=" * 78) + print("SECTION 1 -- pile enum FUN_180142650") + s = dec(0x180142650) + print(s) + # try to find the table it walks + for ln in s.splitlines(): + if "PTR_" in ln or "DAT_" in ln: + print(" >>", ln.strip()) + + print("=" * 78) + print("SECTION 2 -- .rdata 0x180220470-0x180220790") + for va in range(0x180220470, 0x180220790, 8): + q = qword(va) + note = "" + if 0x1801E5000 <= q <= 0x180290000: + t = rd_str(q, 60) + if t and all(0x20 <= ord(c) < 0x7F for c in t): + note = "STR %r" % t + if not note and 0x180001000 <= q < 0x1801E5000: + f = fm.getFunctionAt(addr(q)) + note = "FUNC %s" % (f.getName() if f else "(mid)") + print(" %#x : %#018x %s" % (va, q, note)) + + print("=" * 78) + print("SECTION 3 -- discard request/response class functions") + for lbl, va in (("0x180127160", 0x180127160), ("0x180127630", 0x180127630), + ("0x180126f00 dtor", 0x180126F00)): + print("---- %s ----" % lbl) + print(dec(va)) + + print("=" * 78) + print("SECTION 4 -- who installs DAT_1802def18") + for setter in (0x180039B40, 0x180039BA0): + print(" setter %#x:" % setter) + print(dec(setter)) + for r in xrefs_to(setter): + print(" caller %#x %s %s %#x" % (r[0], r[1], r[2], r[3])) + for e in sorted({r[3] for r in xrefs_to(setter) if r[3]}): + s = dec(e) + print(" ---- caller %#x len=%d ----" % (e, len(s))) + print(s if len(s) < 5000 else s[:5000] + "\n...TRUNCATED len=%d" % len(s)) + +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_pack_inv_1.py b/fifa17-recon/tools/ghidra_queries/q_pack_inv_1.py new file mode 100644 index 0000000..55f5d0b --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_pack_inv_1.py @@ -0,0 +1,112 @@ +# -*- coding: utf-8 -*- +""" +q_pack_inv_1 -- DIMENSION 1 (pack inventory) pass 1. + +HYPOTHESIS + (a) FutCreateUserServerResponse deser 0x18014cc60 dispatches atom 0x2e5 + (starterPack) and 0x5d (bonusPacks) to dedicated sub-deserializers whose + addresses appear in its decompile. + (b) userInfo deser 0x18013ec10 dispatches atom 0x35e (unopenedPacks) to a + sub-deserializer. + (c) The image contains response classes beyond those in ENDPOINT_MAP.md; a + census of b"RS4:Fut" enumerates them all. + (d) Route/format fragments "ut/%s/", "purchased", "unassigned", "purchasegroup" + appear as literals whose xrefs name the builder functions. + +CONTROLS + * class_deser("FutSquadSave") must return 0x180171a60, + class_deser("FutSquadList") -> 0x180172140, + class_deser("FutCreateMatch") -> 0x180120380. If these fail the batch is void. + * b"RS4:FutSquadSave" must be found by the census scan (known to exist at + static 0x18022c618 per the ground-truth controls). + * Every decompile prints len(src) FIRST so no absence is concluded from a + truncated body. +""" +import traceback + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres" + +try: + print("########## CONTROL BLOCK ##########") + for nm, want in (("FutSquadSave", 0x180171a60), + ("FutSquadList", 0x180172140), + ("FutCreateMatch", 0x180120380)): + r = class_deser(nm) + got = sorted(set(x[0] for x in r)) + print("CONTROL class_deser(%-16s) -> %s expect %#x %s" + % (nm, [hex(g) for g in got], want, + "PASS" if want in got else "FAIL")) + + print("\n########## Q3a RS4:Fut CENSUS ##########") + hits = find_all(b"RS4:Fut", blocks=(".rdata", ".data", ".text")) + names = {} + for h in hits: + s = rd_str(h, 120) + nm = s[4:] + names.setdefault(nm, []).append(h) + print("raw hits: %d distinct names: %d" % (len(hits), len(names))) + ctl = "FutSquadSave" in names + print("CONTROL census contains FutSquadSave: %s" % ctl) + for nm in sorted(names): + print(" %-60s %s" % (nm, [hex(a) for a in names[nm]])) + + # also catch RS4: names that do not start with Fut, for completeness + print("\n########## Q3a-bis ALL RS4: CLASS NAMES ##########") + hits2 = find_all(b"RS4:", blocks=(".rdata", ".data", ".text")) + n2 = {} + for h in hits2: + s = rd_str(h, 120)[4:] + if not s: + continue + n2.setdefault(s, []).append(h) + print("raw hits: %d distinct: %d" % (len(hits2), len(n2))) + for nm in sorted(n2): + if not nm.startswith("Fut"): + print(" %-60s %s" % (nm, [hex(a) for a in n2[nm]])) + + print("\n########## Q3b ROUTE FRAGMENTS ##########") + for frag in (b"ut/%s/", b"purchased", b"unassigned", b"purchasegroup", + b"unopened", b"gift", b"entitlement", b"reward"): + hs = find_all(frag, blocks=(".rdata", ".data", ".text")) + print("\n--- %r : %d hits" % (frag, len(hs))) + for h in hs[:80]: + try: + s = rd_str(h - 0 if True else h, 140) + except Exception: + s = "?" + # back up to string start (previous NUL) for context + start = h + for k in range(1, 90): + try: + if (mem.getByte(addr(h - k)) & 0xFF) == 0: + start = h - k + 1 + break + except Exception: + break + full = rd_str(start, 200) + xr = xrefs_to(start) + print(" @%#x start=%#x %r" % (h, start, full)) + for (fr, ty, fn, en) in xr[:8]: + print(" ref %#x %-10s %s @%#x" % (fr, ty, fn, en)) + + print("\n########## Q1 FutCreateUserServerResponse deser 0x18014cc60 ##########") + src = dec(0x18014cc60) + print("len(src) = %d (FULL BODY FOLLOWS, untruncated)" % len(src)) + print(src) + open(OUT + "/d1_createuser_18014cc60.txt", "w").write(src) + + print("\n########## Q2 userInfo deser 0x18013ec10 ##########") + src2 = dec(0x18013ec10) + print("len(src) = %d (FULL BODY FOLLOWS, untruncated)" % len(src2)) + print(src2) + open(OUT + "/d1_userinfo_18013ec10.txt", "w").write(src2) + + print("\n########## Q4 pack element deser 0x18013af30 ##########") + src3 = dec(0x18013af30) + print("len(src) = %d (FULL BODY FOLLOWS, untruncated)" % len(src3)) + print(src3) + open(OUT + "/d1_packelem_18013af30.txt", "w").write(src3) + + print("\nDONE q_pack_inv_1") +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_pack_inv_10.py b/fifa17-recon/tools/ghidra_queries/q_pack_inv_10.py new file mode 100644 index 0000000..05dff9a --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_pack_inv_10.py @@ -0,0 +1,53 @@ +# -*- coding: utf-8 -*- +""" +q_pack_inv_10 -- DIMENSION 1 pass 10: the READERS of the unopenedPacks total. + +CHAIN ESTABLISHED SO FAR + userInfo.unopenedPacks{preOrderPacks,recoveredPacks} + -> FUN_18013ec10 sums them and calls model->vtbl[0x4e0] @0x18013f223 + -> FUN_18011e120 stores the sum at model+0x20950 and broadcasts event 0x273d + +A byte scan of .text (modrm mod=10, disp32 == 0x20950) finds exactly THREE accesses: + 0x18011e131 the setter itself + 0x18011c202 inside 0x18011c1f0, which is vtable slot +0x4e8 -> the GETTER + 0x18010e06d the only other reader +and the event id 0x273d appears at 0x18011e159 (the broadcast) plus 0x1800b3946, +0x18007e861, 0x180199e08. + +HYPOTHESIS: those four addresses are the complete client-side consumer set. + +CONTROLS + * class_deser("FutSquadSaveServerResponse") -> 0x180171a60. + * 0x18011c1f0 must be vtable(0x18021c2a0) slot +0x4e8 (it is, per the live read), + and its body must READ +0x20950 -- if it writes, the getter/setter call is + inverted and the reader analysis is void. +""" +import traceback + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres" + +try: + got = sorted(set(x[0] for x in class_deser("FutSquadSaveServerResponse"))) + print("CONTROL FutSquadSaveServerResponse -> %s %s" + % ([hex(g) for g in got], "PASS" if 0x180171A60 in got else "FAIL")) + print("CONTROL vtable+0x4e8 = %#x (expect 0x18011c1f0)" % qword(0x18021C2A0 + 0x4E8)) + + for va, tag in ((0x18011C1F0, "getter_4e8"), (0x18010E06D, "reader_18010e06d"), + (0x1800B3946, "evt_1800b3946"), (0x18007E861, "evt_18007e861"), + (0x180199E08, "evt_180199e08")): + f = func(va) + print("\n########## %s addr %#x in %s @%#x ##########" + % (tag, va, f.getName() if f else "?", + int(f.getEntryPoint().getOffset()) if f else 0)) + s = dec(va) + print("len=%d" % len(s)) + print(s) + open(OUT + "/d1_%s.txt" % tag, "w").write(s) + + print("\n########## callers of the getter 0x18011c1f0 ##########") + for (fr, ty, fn, en) in xrefs_to(0x18011C1F0): + print(" %#x %-10s %s @%#x" % (fr, ty, fn, en)) + + print("\nDONE q_pack_inv_10") +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_pack_inv_2.py b/fifa17-recon/tools/ghidra_queries/q_pack_inv_2.py new file mode 100644 index 0000000..311732f --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_pack_inv_2.py @@ -0,0 +1,125 @@ +# -*- coding: utf-8 -*- +""" +q_pack_inv_2 -- DIMENSION 1 (pack inventory) pass 2. + +HYPOTHESES + (a) FUN_18014cc60 really is the FutCreateUserServerResponse deserializer, so its + atom->type mapping (bonusPacks=BOOL, login=OBJECT->userInfo deser, + starterPack=ARRAY-of-ITEM) supersedes ENDPOINT_MAP.md's typing. + (b) userInfo.unopenedPacks pushes (preOrderPacks+recoveredPacks) into a global + model through singleton FUN_18011a830 -> vtbl[0x4e0]. That setter's member is + readable by a UI surface; find the setter, the member offset, and the readers. + (c) atom 0x20d "packList" is a real key somewhere. If any deserializer dispatches + on it, that is the pack-inventory response we have never modelled. + (d) ut/%s/purchased (FutGetPurchasedItemsServerResponse) is the only route that + lists already-owned-but-unrevealed things. + +CONTROLS + * class_deser on FULL class names must return the three known-good deserializers: + FutSquadSaveServerResponse -> 0x180171a60 + FutSquadListServerResponse -> 0x180172140 + FutCreateMatchServerResponse -> 0x180120380 + (pass 1 called class_deser("FutSquadSave") and got nothing -- the literal is the + FULL name, there is no bare "FutSquadSave\\0" in the image. Not a harness bug.) + * The atom-immediate scanner is controlled with atom 0x2cd (squad), which MUST be + found inside FUN_18014cc60, and 0x35e (unopenedPacks) inside FUN_18013ec10. + * Every decompile prints len(src) first and is dumped whole. +""" +import struct +import traceback + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres" + +ATOM_NAMES = { + 0x5d: "bonusPacks", 0xbc: "count", 0x1a5: "login", 0x20c: "packContentInfo", + 0x20d: "packList", 0x24b: "preOrderPacks", 0x262: "purchased", + 0x27b: "recoveredPacks", 0x2cd: "squad", 0x2e5: "starterPack", + 0x35d: "unopened", 0x35e: "unopenedPacks", 0x36d: "userData", +} + +try: + print("########## CONTROL BLOCK ##########") + want = {"FutSquadSaveServerResponse": 0x180171a60, + "FutSquadListServerResponse": 0x180172140, + "FutCreateMatchServerResponse": 0x180120380} + for nm, w in want.items(): + got = sorted(set(x[0] for x in class_deser(nm))) + print("CONTROL class_deser(%-32s) -> %s expect %#x %s" + % (nm, [hex(g) for g in got], w, "PASS" if w in got else "FAIL")) + + print("\n--- class_deser on the classes this dimension needs ---") + for nm in ("FutCreateUserServerResponse", "FutGetPurchasedItemsServerResponse", + "FutStoreGetPackTypesServerResponse", "FutGetUserInfoServerResponse", + "FutStoreVoucherRefreshResponse", "FutGetUserActionServerResponse", + "FutUpdateUserActionServerResponse"): + got = sorted(set(x[0] for x in class_deser(nm))) + print(" %-42s -> %s" % (nm, [hex(g) for g in got])) + + print("\n########## ATOM IMMEDIATE SCAN (.text) ##########") + print("(a hit is a 32-bit LE immediate equal to the atom; noisy by nature, so") + print(" only functions that ALSO call the FNV hasher 0x180180d00 are flagged HOT)") + hashers = set() + for (fr, ty, fn, en) in xrefs_to(0x180180d00): + if en: + hashers.add(en) + print("functions calling FNV 0x180180d00: %d" % len(hashers)) + for atom in sorted(ATOM_NAMES): + pat = struct.pack(" "ut/%s/..." literal); + dumping it enumerates every HTTP path CardsDLL can build, which answers Q3 + exhaustively rather than by keyword luck. + (b) 0x1802cb800.. is the matching COMMAND-NAME table (PurchasedItems, + PurchasePack, StorePackTypes, StorePackQuantities were all found there). + (c) The unopenedPacks total is pushed through model->vtbl[0x4e0]; the vtable can + be reached from the singleton storage DAT_1802e6398, and the reader is + another slot on the same vtable. + (d) Functions flagged HOT for atoms 0x35e/0x24b/0x20c in pass 2 are further + deserializers that touch pack inventory. + +CONTROLS + * The route-table dump MUST contain "ut/%s/squad" and "ut/%s/item", two routes we + already serve. If it does not, the table base is wrong. + * class_deser("FutSquadSaveServerResponse") is re-run and must still be + 0x180171a60 (guards against a stale/corrupt project). + * Each decompile prints len() first. +""" +import struct +import traceback + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres" + +try: + got = sorted(set(x[0] for x in class_deser("FutSquadSaveServerResponse"))) + print("CONTROL FutSquadSaveServerResponse -> %s %s" + % ([hex(g) for g in got], "PASS" if 0x180171a60 in got else "FAIL")) + + print("\n########## ROUTE TABLE DUMP .rdata 0x18021df00..0x18021e300 ##########") + for va in range(0x18021df00, 0x18021e300, 8): + try: + q = qword(va) + except Exception: + continue + s = "" + if 0x180001000 <= q <= 0x1802efc08: + try: + s = rd_str(q, 120) + except Exception: + s = "" + if s and s.isprintable() and len(s) > 1: + print(" %#x -> %#x %r" % (va, q, s)) + print("CONTROL route table contains ut/%s/squad and ut/%s/item: see above") + + print("\n########## COMMAND-NAME TABLE .rdata/.data 0x1802cb600..0x1802cbb00 ##########") + for va in range(0x1802cb600, 0x1802cbb00, 8): + try: + q = qword(va) + except Exception: + continue + s = "" + if 0x180001000 <= q <= 0x1802efc08: + try: + s = rd_str(q, 120) + except Exception: + s = "" + if s and s.isprintable() and len(s) > 1: + print(" %#x -> %#x %r" % (va, q, s)) + + print("\n########## SINGLETON STORAGE DAT_1802e6398 ##########") + for (fr, ty, fn, en) in xrefs_to(0x1802e6398): + print(" %#x %-10s %s @%#x" % (fr, ty, fn, en)) + + print("\n########## call [reg+0x4e0] SITES ##########") + for modrm in (0x90, 0x91, 0x92, 0x93, 0x96, 0x97): + pat = bytes([0xFF, modrm]) + struct.pack("vtbl[0x4e0] is the unopenedPacks-total setter; the vtable is reachable + from the singleton writer FUN_18011d780. + +CONTROLS + * The atlas MUST report atom 0x2cd (squad) for FUN_18014cc60 and atom 0x35e + (unopenedPacks) for FUN_18013ec10 -- both hand-verified in pass 1. If either is + missing the CMP/SUB walk is broken. + * The atom NAME TABLE lookup is controlled with index 0x2e5 -> "starterPack". + * Every decompile prints len() first. +""" +import traceback + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres" +ATOM_TABLE = 0x1802D2760 + + +def atom_name(i): + try: + p = qword(ATOM_TABLE + i * 8) + except Exception: + return "?" + if not (0x180001000 <= p <= 0x1802EFC08): + return "?" + try: + return rd_str(p, 60) + except Exception: + return "?" + + +try: + print("CONTROL atom_name(0x2e5) = %r (expect 'starterPack')" % atom_name(0x2E5)) + + hashers = set() + for tgt in (0x180180D00, 0x180141EE0): + for (fr, ty, fn, en) in xrefs_to(tgt): + if en: + hashers.add(en) + print("deserializer candidates (call FNV 0x180180d00 or wrapper 0x180141ee0): %d" + % len(hashers)) + + atlas = {} + for e in sorted(hashers): + f = func(e) + if f is None: + continue + atoms = set() + for ad in f.getBody().getAddresses(True): + ins = listing.getInstructionAt(ad) + if ins is None: + continue + m = str(ins.getMnemonicString()).upper() + if m not in ("CMP", "SUB", "MOV", "LEA"): + continue + for i in range(ins.getNumOperands()): + objs = ins.getOpObjects(i) + for o in objs: + try: + v = int(o.getValue()) + except Exception: + continue + if 1 <= v <= 0x38C and m in ("CMP", "SUB"): + atoms.add(v) + atlas[e] = atoms + + print("\n########## DESERIALIZER ATLAS ##########") + for e in sorted(atlas): + ats = sorted(atlas[e]) + print("\n%#x (%d compare-immediates in atom range)" % (e, len(ats))) + print(" " + ", ".join("%#x=%s" % (a, atom_name(a)) for a in ats)) + + print("\n########## CONTROLS ON THE ATLAS ##########") + print("FUN_18014cc60 has 0x2cd(squad): %s" + % (0x2CD in atlas.get(0x18014CC60, set()))) + print("FUN_18013ec10 has 0x35e(unopenedPacks): %s" + % (0x35E in atlas.get(0x18013EC10, set()))) + + print("\n########## WHICH FUNCTIONS TOUCH THE PACK-INVENTORY ATOMS ##########") + for a in (0x20D, 0x35D, 0x35E, 0x2E5, 0x5D, 0x24B, 0x27B, 0x260, 0x262, 0x264, + 0x20C, 0x16E, 0xEC, 0x1DD): + owners = [e for e in atlas if a in atlas[e]] + print(" atom %#x %-20s -> %s" + % (a, atom_name(a), [hex(x) for x in sorted(owners)] or "NONE")) + + print("\n########## PENDING-PACK UI ##########") + for lit in (b"GOTO_STORE_MYPACK\x00", b"FUT_GH_UNCLAIMED_PACK_0\x00", + b"mypacks\x00", b"CentralUnclaimedPack\x00"): + for h in find_all(lit, blocks=(".rdata", ".data")): + print("\n literal %r @ %#x" % (lit[:-1], h)) + for (fr, ty, fn, en) in xrefs_to(h): + print(" %#x %-10s %s @%#x" % (fr, ty, fn, en)) + + print("\n--- callers of the hub-tile builder FUN_1800b2680 ---") + for (fr, ty, fn, en) in xrefs_to(0x1800B2680): + print(" %#x %-10s %s @%#x" % (fr, ty, fn, en)) + + DECS = [ + (0x18011D780, "singleton_writer"), + (0x18013BD40, "purchaseditems_body"), + (0x1800150D0, "mypacks_ui_1800150d0"), + (0x180014580, "mypacks_ui_180014580"), + (0x1800147F0, "mypacks_ui_1800147f0"), + (0x180014DF0, "mypacks_ui_180014df0"), + ] + for va, tag in DECS: + print("\n########## %s %#x ##########" % (tag, va)) + s = dec(va) + print("len=%d" % len(s)) + print(s) + open(OUT + "/d1_%s.txt" % tag, "w").write(s) + + print("\n########## misc strings ##########") + for va in (0x18021DE58,): + print(" %#x = %r" % (va, rd_str(va, 80))) + + print("\nDONE q_pack_inv_4") +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_pack_inv_5.py b/fifa17-recon/tools/ghidra_queries/q_pack_inv_5.py new file mode 100644 index 0000000..a37fe2b --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_pack_inv_5.py @@ -0,0 +1,137 @@ +# -*- coding: utf-8 -*- +""" +q_pack_inv_5 -- DIMENSION 1 pass 5: DECOMPILE-BASED deserializer atlas. + +WHY THIS PASS EXISTS + Pass 4's instruction-level CMP/SUB scan FAILED its own control: FUN_18014cc60 + provably dispatches on atom 0x2cd (squad) yet the scan did not see it. Reason: + the dispatch is a RUNNING-SUM ladder ("sub eax,0x5d / jz / sub eax,0x148 / jz" + where 0x5d+0x148 = 0x1a5), so the raw immediates are DIFFERENCES, not atoms. + Ghidra's decompiler already folds the ladder back into `== 0x2cd`, so this pass + reads the atoms out of the decompiled C instead of the instruction stream. + +HYPOTHESIS + Decompiling every function that calls the FNV hasher 0x180180d00 or the wrapper + FUN_180141ee0 and regexing `== 0xNNN` / `!= 0xNNN` / `case 0xNNN` gives the + complete key-set of every JSON parser in CardsDLL. + +CONTROLS (the pass is void if any fails) + * FUN_18014cc60 must report 0x2cd(squad) AND 0x2e5(starterPack). + * FUN_18013ec10 must report 0x35e(unopenedPacks) AND 0x24b(preOrderPacks). + * FUN_18013af30 must report 0x20c(packContentInfo) AND 0x35d(unopened). + * atom_name(0x2e5) must be 'starterPack'. +""" +import re +import traceback + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres" +ATOM_TABLE = 0x1802D2760 +_nc = {} + + +def atom_name(i): + if i in _nc: + return _nc[i] + v = "?" + try: + p = qword(ATOM_TABLE + i * 8) + if 0x180001000 <= p <= 0x1802EFC08: + v = rd_str(p, 60) + except Exception: + pass + _nc[i] = v + return v + + +EQ = re.compile(r"(?:==|!=)\s*(0x[0-9a-fA-F]+|\d+)") +CASE = re.compile(r"case\s+(0x[0-9a-fA-F]+|\d+)\s*:") +NOISE = {6, 10, 0xB, 0xD, 0x38C, 0, 1, 2, 3, 4, 5, 7, 8, 9} + +try: + print("CONTROL atom_name(0x2e5) = %r" % atom_name(0x2E5)) + hashers = set() + for tgt in (0x180180D00, 0x180141EE0): + for (fr, ty, fn, en) in xrefs_to(tgt): + if en: + hashers.add(en) + print("parser candidates: %d" % len(hashers)) + + atlas = {} + bodies = {} + for e in sorted(hashers): + try: + src = dec(e, 240) + except Exception: + src = "" + bodies[e] = src + ats = set() + for m in EQ.finditer(src): + v = int(m.group(1), 0) + if v not in NOISE and 1 <= v <= 0x38C: + ats.add(v) + for m in CASE.finditer(src): + v = int(m.group(1), 0) + if v not in NOISE and 1 <= v <= 0x38C: + ats.add(v) + atlas[e] = ats + + print("\n########## CONTROLS ##########") + ck = [(0x18014CC60, 0x2CD), (0x18014CC60, 0x2E5), (0x18013EC10, 0x35E), + (0x18013EC10, 0x24B), (0x18013AF30, 0x20C), (0x18013AF30, 0x35D)] + ok = True + for fn, at in ck: + hit = at in atlas.get(fn, set()) + ok = ok and hit + print(" %#x has %#x(%-16s): %s" % (fn, at, atom_name(at), + "PASS" if hit else "FAIL")) + print("ATLAS CONTROL OVERALL: %s" % ("PASS" if ok else "FAIL")) + + print("\n########## ATLAS ##########") + lines = [] + for e in sorted(atlas): + ats = sorted(atlas[e]) + lines.append("\n%#x len(src)=%d keys=%d" % (e, len(bodies[e]), len(ats))) + lines.append(" " + ", ".join("%#x=%s" % (a, atom_name(a)) for a in ats)) + print("\n".join(lines)) + open(OUT + "/d1_atlas.txt", "w").write("\n".join(lines)) + + print("\n########## PACK-INVENTORY ATOM OWNERSHIP ##########") + for a in (0x20D, 0x35D, 0x35E, 0x2E5, 0x5D, 0x24B, 0x27B, 0x260, 0x262, + 0x264, 0x20C, 0x16E, 0x16B, 0xEC, 0x1DD, 0xBC, 0x2E3, 0x2EB, 0x37D): + owners = [e for e in atlas if a in atlas[e]] + print(" atom %#x %-22s -> %s" + % (a, atom_name(a), [hex(x) for x in sorted(owners)] or "NONE")) + + print("\n########## EXTRA DECOMPILES ##########") + for va, tag in ((0x180122C50, "f180122c50"), (0x18017FC20, "f18017fc20"), + (0x180161B00, "f180161b00"), (0x18013C3A0, "f18013c3a0"), + (0x180144E80, "f180144e80"), (0x180138E10, "dupidlist"), + (0x1801234E0, "storepacktypes_root")): + s = bodies.get(va) or dec(va) + print("\n--- %s %#x len=%d" % (tag, va, len(s))) + print(s) + open(OUT + "/d1_%s.txt" % tag, "w").write(s) + + print("\n########## hub-tile table around 0x1802097f8 ##########") + for va in range(0x1802096C0, 0x180209900, 8): + try: + q = qword(va) + except Exception: + continue + tag = "" + f = fm.getFunctionAt(addr(q)) if 0x180001000 <= q <= 0x1801E4F62 else None + if f: + tag = "FUNC " + f.getName() + elif 0x180001000 <= q <= 0x1802EFC08: + try: + s = rd_str(q, 60) + if s.isprintable() and len(s) > 1: + tag = repr(s) + except Exception: + pass + if tag: + print(" %#x -> %#x %s" % (va, q, tag)) + + print("\nDONE q_pack_inv_5") +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_pack_inv_6.py b/fifa17-recon/tools/ghidra_queries/q_pack_inv_6.py new file mode 100644 index 0000000..baa5c4a --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_pack_inv_6.py @@ -0,0 +1,94 @@ +# -*- coding: utf-8 -*- +""" +q_pack_inv_6 -- DIMENSION 1 pass 6: CLASS -> DESERIALIZER map for the whole image. + +HYPOTHESIS + Pass 5 found a top-level parser FUN_18017fc20 whose ONLY root key is + packList(0x20d). That is the pack-inventory response we have never modelled. + Running class_deser over every RS4:Fut* literal in the image names it, and at the + same time produces the complete class->deser table (useful far beyond this task). + +CONTROLS + * FutSquadSaveServerResponse -> 0x180171a60, FutSquadListServerResponse -> + 0x180172140, FutCreateMatchServerResponse -> 0x180120380 must all resolve. + * class_deser is KNOWN to false-negative, so an unresolved class is reported as + UNRESOLVED, never as "has no deserializer". +""" +import re +import traceback + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres" +ATOM_TABLE = 0x1802D2760 + + +def atom_name(i): + try: + p = qword(ATOM_TABLE + i * 8) + if 0x180001000 <= p <= 0x1802EFC08: + return rd_str(p, 60) + except Exception: + pass + return "?" + + +try: + names = set() + for h in find_all(b"RS4:", blocks=(".rdata", ".data")): + s = rd_str(h, 120)[4:] + if s.startswith("Fut"): + names.add(s) + elif s.startswith(":Fut"): + names.add(s[1:]) + print("class-name literals found: %d" % len(names)) + + fwd = {} + for nm in sorted(names): + got = sorted(set(x[0] for x in class_deser(nm))) + fwd[nm] = got + print(" %-46s -> %s" % (nm, [hex(g) for g in got] or "UNRESOLVED")) + + print("\n########## CONTROLS ##########") + for nm, w in (("FutSquadSaveServerResponse", 0x180171A60), + ("FutSquadListServerResponse", 0x180172140), + ("FutCreateMatchServerResponse", 0x180120380)): + print(" %-32s %s" % (nm, "PASS" if w in fwd.get(nm, []) else "FAIL")) + + print("\n########## REVERSE LOOKUP for the parsers this dimension found ##########") + for tgt in (0x18017FC20, 0x180122C50, 0x180161B00, 0x180124EE0, 0x18014CC60, + 0x1801234E0, 0x180162880, 0x1801758C0, 0x180174630, 0x180146970): + owners = [nm for nm, v in fwd.items() if tgt in v] + print(" %#x -> %s" % (tgt, owners or "UNRESOLVED")) + + print("\n########## packList ELEMENT deser FUN_18017f830 ##########") + s = dec(0x18017F830) + print("len=%d" % len(s)) + print(s) + open(OUT + "/d1_packlist_elem_18017f830.txt", "w").write(s) + ats = set() + for m in re.finditer(r"(?:==|!=)\s*(0x[0-9a-fA-F]+|\d+)", s): + v = int(m.group(1), 0) + if 1 <= v <= 0x38C and v not in (6, 10, 0xB, 0xD, 0x38C): + ats.add(v) + for m in re.finditer(r"case\s+(0x[0-9a-fA-F]+|\d+)\s*:", s): + v = int(m.group(1), 0) + if 1 <= v <= 0x38C and v not in (6, 10, 0xB, 0xD, 0x38C): + ats.add(v) + print("\npackList element keys: " + + ", ".join("%#x=%s" % (a, atom_name(a)) for a in sorted(ats))) + + print("\n########## who calls FUN_18017fc20 (the packList parser) ##########") + for (fr, ty, fn, en) in xrefs_to(0x18017FC20): + print(" %#x %-10s %s @%#x" % (fr, ty, fn, en)) + + print("\n########## model container getter vtbl[0x940] users ##########") + import struct as _s + for modrm in (0x90, 0x91, 0x92, 0x93, 0x96, 0x97): + pat = bytes([0xFF, modrm]) + _s.pack(" %s %s" + % ([hex(g) for g in got], "PASS" if 0x180171A60 in got else "FAIL")) + + for disp, tag, ctl in ((0xCC, "useDefaultImage (CONTROL)", 0x1800150D0), + (0xCD, "unopened", None), + (0x34, "displayGroup.priority (CONTROL)", 0x1800150D0)): + r = disp32_readers(disp) + print("\n### disp32 %#x %s : %d functions" % (disp, tag, len(r))) + if ctl is not None: + print(" CONTROL %#x present: %s" % (ctl, "PASS" if ctl in r else "FAIL")) + for e in sorted(r): + f = fm.getFunctionContaining(addr(e)) + print(" %#x %-30s sites=%s" + % (e, f.getName(), [hex(x) for x in r[e][:6]])) + + print("\n########## MODEL SINGLETON CTOR / VTABLE ##########") + ctors = set() + for (fr, ty, fn, en) in xrefs_to(0x18011D780): + print(" caller of FUN_18011d780: %#x %s @%#x" % (fr, fn, en)) + if en: + ctors.add(en) + for e in sorted(ctors): + s = dec(e) + print("\n--- ctor candidate %#x len=%d" % (e, len(s))) + print(s[:4000]) + open(OUT + "/d1_modelctor_%x.txt" % e, "w").write(s) + + print("\n########## VTABLE SCAN in .rdata for tables >= 0x950 bytes ##########") + LO, HI = 0x180001000, 0x1801E4F62 + blk = None + for b in mem.getBlocks(): + if b.getName() == ".rdata": + blk = b + start = int(blk.getStart().getOffset()) + end = int(blk.getEnd().getOffset()) + data = read_bytes(start, end - start + 1) + n = len(data) // 8 + qs = struct.unpack_from("<%dQ" % n, data, 0) + i = 0 + found = [] + while i < n: + if LO <= qs[i] <= HI: + j = i + while j < n and LO <= qs[j] <= HI: + j += 1 + if (j - i) * 8 >= 0x950: + found.append((start + i * 8, (j - i) * 8)) + i = j + else: + i += 1 + print("candidate vtables >= 0x950 bytes: %d" % len(found)) + for va, sz in found: + f4e0 = qword(va + 0x4E0) + f940 = qword(va + 0x940) if sz > 0x940 else 0 + n4e0 = fm.getFunctionAt(addr(f4e0)) + n940 = fm.getFunctionAt(addr(f940)) if f940 else None + print(" vtable %#x size %#x [+0x4e0]=%#x %s [+0x940]=%#x %s" + % (va, sz, f4e0, n4e0.getName() if n4e0 else "?", + f940, n940.getName() if n940 else "?")) + if n4e0 is not None: + s = dec(f4e0) + print(" --- [+0x4e0] len=%d\n%s" % (len(s), s)) + open(OUT + "/d1_vt%x_slot4e0_%x.txt" % (va, f4e0), "w").write(s) + + print("\n########## pack-element consumers ##########") + for va, tag in ((0x180014380, "find_group"), (0x18002C3C0, "tile_from_packelem"), + (0x180012950, "group_ctor")): + s = dec(va) + print("\n--- %s %#x len=%d" % (tag, va, len(s))) + print(s) + open(OUT + "/d1_%s.txt" % tag, "w").write(s) + + print("\nDONE q_pack_inv_7") +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_pack_inv_8.py b/fifa17-recon/tools/ghidra_queries/q_pack_inv_8.py new file mode 100644 index 0000000..9c6a5b3 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_pack_inv_8.py @@ -0,0 +1,95 @@ +# -*- coding: utf-8 -*- +""" +q_pack_inv_8 -- DIMENSION 1 pass 8: the model vtable slot 0x4e0, and the STORE +request builder that would have to return a My Packs group. + +HYPOTHESES + (a) Pass 7's vtable scan was too strict (it demanded every qword be inside .text, + so any vtable containing a NULL or a non-.text thunk was rejected; it found + only 2 candidates, neither plausible). Relaxing to "pointer into .text OR + zero" should surface the FUT model vtable, whose slot +0x4e0 is the + unopenedPacks-total setter reached from FUN_18013ec10 @0x18013f223. + (b) FUN_180123430 appends "/purchasegroup" + "/all" + "?ppInfo=true"; its caller + is the STORE request builder and shows the exact URL and HTTP verb. + +CONTROLS + * The relaxed vtable scan is controlled by requiring that the reported vtable's + slot +0x160 and +0x940 also resolve to real functions (both are used on the + same singleton by FUN_18013bd40 and FUN_18017fc20 respectively). A table that + satisfies all three slots is the right object. + * class_deser("FutSquadSaveServerResponse") must still be 0x180171a60. +""" +import struct +import traceback + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres" +LO, HI = 0x180001000, 0x1801E4F62 + +try: + got = sorted(set(x[0] for x in class_deser("FutSquadSaveServerResponse"))) + print("CONTROL FutSquadSaveServerResponse -> %s %s" + % ([hex(g) for g in got], "PASS" if 0x180171A60 in got else "FAIL")) + + blk = [b for b in mem.getBlocks() if b.getName() == ".rdata"][0] + start = int(blk.getStart().getOffset()) + end = int(blk.getEnd().getOffset()) + data = read_bytes(start, end - start + 1) + n = len(data) // 8 + qs = struct.unpack_from("<%dQ" % n, data, 0) + + def okslot(v): + return v == 0 or (LO <= v <= HI) + + cands = [] + i = 0 + while i < n: + if LO <= qs[i] <= HI: + j = i + while j < n and okslot(qs[j]): + j += 1 + if (j - i) * 8 >= 0x950: + cands.append((start + i * 8, (j - i) * 8)) + i = j + else: + i += 1 + print("relaxed vtable candidates >= 0x950 bytes: %d" % len(cands)) + for va, sz in cands: + slots = {} + good = True + for off in (0x8, 0x160, 0x1F8, 0x480, 0x4E0, 0x940): + if off >= sz: + good = False + break + t = qword(va + off) + f = fm.getFunctionAt(addr(t)) + slots[off] = (t, f.getName() if f else None) + if f is None: + good = False + print("\n vtable %#x size %#x allslots=%s" % (va, sz, good)) + for off in sorted(slots): + print(" +%#05x -> %#x %s" % (off, slots[off][0], slots[off][1])) + if good: + for off in (0x4E0, 0x940, 0x160): + t = slots[off][0] + s = dec(t) + print("\n ==== slot +%#x %#x len=%d\n%s" % (off, t, len(s), s)) + open(OUT + "/d1_vtslot_%x_%x.txt" % (off, t), "w").write(s) + + print("\n########## STORE REQUEST BUILDER ##########") + for (fr, ty, fn, en) in xrefs_to(0x180123430): + print(" ref to FUN_180123430: %#x %s @%#x" % (fr, fn, en)) + if en: + s = dec(en) + print(" --- caller %#x len=%d\n%s" % (en, len(s), s)) + open(OUT + "/d1_storebuilder_%x.txt" % en, "w").write(s) + + print("\n########## starting_pack_opened ##########") + for va, tag in ((0x180083990, "startingpack_990"), (0x180083B70, "startingpack_b70")): + s = dec(va) + print("\n--- %s %#x len=%d" % (tag, va, len(s))) + print(s) + open(OUT + "/d1_%s.txt" % tag, "w").write(s) + + print("\nDONE q_pack_inv_8") +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_pack_inv_9.py b/fifa17-recon/tools/ghidra_queries/q_pack_inv_9.py new file mode 100644 index 0000000..0ffd205 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_pack_inv_9.py @@ -0,0 +1,54 @@ +# -*- coding: utf-8 -*- +""" +q_pack_inv_9 -- DIMENSION 1 pass 9: the unopenedPacks-total setter and its readers. + +The FUT model singleton's vtable was resolved OUT OF THE LIVE PROCESS (read-only, +pid resolved by exact /proc/*/comm, slide proven against the FNV hasher prologue): + DAT_1802e6398 -> object -> vtable = static 0x18021c2a0 + slot +0x4e0 = 0x18011e120 <- called by userInfo deser FUN_18013ec10 @0x18013f223 + with (preOrderPacks + recoveredPacks) + slot +0x210 = 0x18011e100, +0x4e8 = 0x18011c1f0 (likely the matching getter) +Static .rdata pointer-run scanning had FAILED to find this table in passes 7 and 8; +the live read settled it. See d1_live_vtable.txt. + +HYPOTHESIS + 0x18011e120 writes the total into one member of the model; the readers of that + member offset are the consumers we are looking for. + +CONTROLS + * vtable(0x18021c2a0) slot +0x4e0 must equal 0x18011e120 in the STATIC image too. + If the static image disagrees with the live read, the slide or the object type + is wrong and nothing below can be trusted. + * class_deser("FutSquadSaveServerResponse") must still be 0x180171a60. +""" +import struct +import traceback + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres" +VT = 0x18021C2A0 + +try: + got = sorted(set(x[0] for x in class_deser("FutSquadSaveServerResponse"))) + print("CONTROL FutSquadSaveServerResponse -> %s %s" + % ([hex(g) for g in got], "PASS" if 0x180171A60 in got else "FAIL")) + s4e0 = qword(VT + 0x4E0) + print("CONTROL static vtable %#x slot +0x4e0 = %#x (live said 0x18011e120) %s" + % (VT, s4e0, "PASS" if s4e0 == 0x18011E120 else "FAIL")) + + for off in (0x210, 0x4E0, 0x4E8, 0x250, 0x218, 0x220, 0x160, 0x940, 0x480, 0x1F8): + t = qword(VT + off) + f = fm.getFunctionAt(addr(t)) + print("\n===== vtable +%#05x -> %#x %s" % (off, t, f.getName() if f else "?")) + s = dec(t) + print("len=%d\n%s" % (len(s), s)) + open(OUT + "/d1_model_vt_%03x_%x.txt" % (off, t), "w").write(s) + + print("\n########## FULL MODEL VTABLE 0x18021c2a0 ##########") + for i in range(0, 0x9C0 // 8): + t = qword(VT + i * 8) + f = fm.getFunctionAt(addr(t)) + print(" +%#05x %#x %s" % (i * 8, t, f.getName() if f else "")) + + print("\nDONE q_pack_inv_9") +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_pack_reveal_1.py b/fifa17-recon/tools/ghidra_queries/q_pack_reveal_1.py new file mode 100644 index 0000000..adfe99b --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_pack_reveal_1.py @@ -0,0 +1,88 @@ +"""D4 Q1: trace packOpeningAnimationEnabled (atom 0x20e = 526) through the +settings switch FUN_18013c6d0 -> settings-struct field index -> the applier +FUN_18011dc50 -> gate byte offset -> IS_* key published by FUN_18006cc60. + +HYPOTHESIS: atom 0x20e has an arm in FUN_18013c6d0 that stores into some field +index N of the settings struct; FUN_18011dc50 copies index N to a gate byte; +FUN_18006cc60 publishes that byte under an IS_* string key. + +CONTROLS (must reproduce before trusting the new answer): + friendlySeasonsEnabled -> field [0x16] -> gate 0x1fd3a -> IS_FRIENDLY_SEASON_ENABLED + enableDraftMode -> field [0x17] -> gate 0x1fd3d -> IS_DRAFT_MODE_ENABLED + +Also dumps every string in the image matching PACK/ANIM/REVEAL/WALKOUT so Q2/Q3 +have a target list. +""" +import traceback, re + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres/" + + +def w(name, text): + p = OUT + name + with open(p, "w") as f: + f.write(text) + print("WROTE %s (%d chars)" % (p, len(text))) + + +try: + # ---------- 1. the settings deserializer ---------- + src = dec(0x18013C6D0, 300) + print("=== FUN_18013c6d0 len=%d ===" % len(src)) + w("d4_settings_deser.txt", src) + + for needle in ("526", "0x20e", "0x20E"): + for m in re.finditer(re.escape(needle), src): + a, b = max(0, m.start() - 400), min(len(src), m.end() + 400) + print("--- hit %r at %d ---" % (needle, m.start())) + print(src[a:b]) + print("--- end hit ---") + + # ---------- 2. the applier ---------- + ap = dec(0x18011DC50, 300) + print("=== FUN_18011dc50 len=%d ===" % len(ap)) + w("d4_applier.txt", ap) + + # ---------- 3. the IS_* publisher ---------- + pub = dec(0x18006CC60, 300) + print("=== FUN_18006cc60 len=%d ===" % len(pub)) + w("d4_publisher.txt", pub) + + # ---------- 4. strings of interest ---------- + pats = [b"PACK", b"Pack", b"pack", b"WALKOUT", b"Walkout", b"walkout", + b"REVEAL", b"Reveal", b"reveal", b"ANIMATION", b"Animation", + b"animation", b"Anim"] + blocks = [b for b in mem.getBlocks() if b.isInitialized()] + seen = {} + for p in pats: + try: + hits = find_all(p, blocks) + except Exception as e: + print("find_all failed for %r: %s" % (p, e)) + continue + for h in hits: + va = int(h.getOffset()) if hasattr(h, "getOffset") else int(h) + # walk back to string start + start = va + for k in range(1, 96): + try: + bb = read_bytes(va - k, 1) + except Exception: + break + c = bb[0] & 0xFF + if c < 0x20 or c > 0x7E: + start = va - k + 1 + break + else: + start = va - 95 + s = rd_str(start) + if s and 3 < len(s) < 160: + seen.setdefault(s, start) + lines = ["%#x %s" % (v, k) for k, v in sorted(seen.items(), key=lambda kv: kv[1])] + print("=== %d distinct strings ===" % len(lines)) + w("d4_strings.txt", "\n".join(lines)) + for l in lines[:400]: + print(l) + +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_pack_reveal_2.py b/fifa17-recon/tools/ghidra_queries/q_pack_reveal_2.py new file mode 100644 index 0000000..d197dba --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_pack_reveal_2.py @@ -0,0 +1,79 @@ +"""D4 Q1b/Q2: find the READERS of the settings gate byte at FutDataManagerImpl+0x1fd45 +(the byte written from settings field [0x1d], which is the packOpeningAnimationEnabled +arm found by q_pack_reveal_1). + +HYPOTHESIS: some accessor reads [reg + 0x1fd45] and is called from the pack-reveal +path. If no IS_* key exists (the publisher FUN_18006cc60 does not mention it), the +byte must be read by a direct getter instead. + +CONTROLS: the same byte-displacement scan for 0x1fd3a (friendlySeasonsEnabled) and +0x1fd3d (enableDraftMode) must find the accessors that FUN_18006cc60 calls through +vtable slots +0x2b0 and +0x2c8 respectively. If the scan cannot reproduce those, the +scan technique is wrong and the 0x1fd45 result means nothing. + +Also dumps PACK/REVEAL/WALKOUT/ANIM strings using find_all's REAL signature +(block NAMES, not block objects -- q_pack_reveal_1 passed objects and silently got 0). +""" +import traceback, struct + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres/" + + +def w(name, text): + with open(OUT + name, "w") as f: + f.write(text) + print("WROTE %s%s (%d chars)" % (OUT, name, len(text))) + + +def disp_scan(off): + """functions containing the little-endian dword `off` inside .text.""" + pat = struct.pack(" accessor -> byte offset + print("=== strings ===") + pats = [b"PACK", b"Pack", b"WALKOUT", b"Walkout", b"walkout", b"REVEAL", + b"Reveal", b"ANIMATION", b"Animation", b"nimation"] + seen = {} + for p in pats: + for h in find_all(p, (".text", ".rdata", ".data")): + start = h + for k in range(1, 128): + try: + c = mem.getByte(addr(h - k)) & 0xFF + except Exception: + break + if c < 0x20 or c > 0x7E: + start = h - k + 1 + break + s = rd_str(start) + if s and 3 < len(s) < 200: + seen.setdefault(s, start) + lines = ["%#x %s" % (v, k) for k, v in sorted(seen.items(), key=lambda kv: kv[1])] + print("%d distinct strings" % len(lines)) + w("d4_strings.txt", "\n".join(lines)) + for l in lines: + print(l) + +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_pack_reveal_3.py b/fifa17-recon/tools/ghidra_queries/q_pack_reveal_3.py new file mode 100644 index 0000000..536bd2a --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_pack_reveal_3.py @@ -0,0 +1,107 @@ +"""D4 Q1c/Q2/Q3: (a) map FutDataManagerImpl vtable slots to the settings gate bytes so +we can tell whether byte 0x1fd45 (packOpeningAnimationEnabled) has an accessor at all, +and (b) open the reveal path via the USE_ANIMATION_STYLE / gmLoadFUTPackOpenSublevel / +CREATE_PACK_STATUS strings. + +HYPOTHESIS: FutDataManagerImpl publishes one bool accessor per settings gate byte in a +contiguous vtable band; the publisher FUN_18006cc60 uses slots 0x270..0x2f0. If a slot +returns [this+0x1fd45] then packOpeningAnimationEnabled is readable, and its call sites +tell us what it gates. + +CONTROLS: slot +0x2b0 MUST decompile to a read of 0x1fd3a (IS_FRIENDLY_SEASON_ENABLED) +and slot +0x2c8 MUST read 0x1fd3d (IS_DRAFT_MODE_ENABLED). Those two are already proven +by FUN_18006cc60's string arguments. If the vtable I pick does not reproduce them, I +have the wrong vtable and every other slot reading is worthless. +""" +import traceback + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres/" +BUF = [] + + +def p(*a): + s = " ".join(str(x) for x in a) + print(s) + BUF.append(s) + + +try: + # ---- ctor, to find the vtable ---- + ct = dec(0x18010CDC0, 300) + p("=== ctor FUN_18010cdc0 len=%d ; first 1200 chars ===" % len(ct)) + p(ct[:1200]) + with open(OUT + "d4_fdm_ctor.txt", "w") as f: + f.write(ct) + + # candidate vtables: any .rdata address referenced by the ctor whose first + # two qwords are functions + cands = [] + f0 = func(0x18010CDC0) + for ad in f0.getBody().getAddresses(True): + ins = listing.getInstructionAt(ad) + if ins is None: + continue + for r in ins.getReferencesFrom(): + t = int(r.getToAddress().getOffset()) + if 0x1801E5000 <= t <= 0x1802891FF: + try: + v0, v1 = qword(t), qword(t + 8) + except Exception: + continue + if fm.getFunctionAt(addr(v0)) and fm.getFunctionAt(addr(v1)): + if t not in cands: + cands.append(t) + p("=== vtable candidates from ctor: %s ===" % [hex(c) for c in cands]) + + for vt in cands: + try: + s2b0 = qword(vt + 0x2B0) + s2c8 = qword(vt + 0x2C8) + except Exception: + continue + if not (fm.getFunctionAt(addr(s2b0)) and fm.getFunctionAt(addr(s2c8))): + continue + d2b0 = dec(s2b0, 120) + d2c8 = dec(s2c8, 120) + ok = ("1fd3a" in d2b0.lower()) and ("1fd3d" in d2c8.lower()) + p("--- vtable %#x : slot2b0=%#x slot2c8=%#x CONTROL_OK=%s ---" % (vt, s2b0, s2c8, ok)) + p(" slot 0x2b0 body: %s" % d2b0.replace("\n", " ")[:300]) + p(" slot 0x2c8 body: %s" % d2c8.replace("\n", " ")[:300]) + if not ok: + continue + p("=== CONTROL PASSED for vtable %#x ; dumping slots 0x250..0x320 ===" % vt) + for off in range(0x250, 0x328, 8): + try: + t = qword(vt + off) + except Exception: + break + fn = fm.getFunctionAt(addr(t)) + if fn is None: + p(" +%#05x %#x (not a function)" % (off, t)) + continue + body = dec(t, 120).replace("\n", " ") + # squeeze + body = " ".join(body.split()) + p(" +%#05x %#x %s :: %s" % (off, t, fn.getName(), body[:260])) + + # ---- reveal-path strings ---- + for sname, sva in (("USE_ANIMATION_STYLE", 0x1801FD580), + ("gmLoadFUTPackOpenSublevel", 0x1801EE860), + ("gmUnloadFUTPackOpenAnimation", 0x180208828), + ("CREATE_PACK_STATUS", 0x180205F28), + ("PACK_CREATE_UNOPENED_PACK", 0x1801EC1B8), + ("NUM_RARES_IN_PACK", 0x1801EBF90)): + xs = xrefs_to(sva) + p("=== xrefs to %s (%#x): %d ===" % (sname, sva, len(xs))) + for frm, typ, fn, ent in xs: + p(" from %#x %s in %s @ %#x" % (frm, typ, fn, ent)) + +except Exception: + traceback.print_exc() +finally: + try: + with open(OUT + "d4_vtable_and_xrefs.txt", "w") as f: + f.write("\n".join(BUF)) + print("WROTE d4_vtable_and_xrefs.txt") + except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_pack_reveal_4.py b/fifa17-recon/tools/ghidra_queries/q_pack_reveal_4.py new file mode 100644 index 0000000..bd54191 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_pack_reveal_4.py @@ -0,0 +1,47 @@ +"""D4 Q1d: dump the FutDataManagerImpl primary vtable (PTR_LAB_18021c2a0, assigned last +in ctor FUN_18010cdc0) slots 0x240..0x330 and decompile each, to map vtable slot -> +settings gate byte. + +CONTROL: slot +0x2b0 must read byte 0x1fd3a (IS_FRIENDLY_SEASON_ENABLED per +FUN_18006cc60) and slot +0x2c8 must read 0x1fd3d (IS_DRAFT_MODE_ENABLED). If those two +do not come out right, the vtable is wrong and nothing else here counts. + +q_pack_reveal_3 failed to auto-detect this vtable because its first entries are +PTR_LAB_ thunks that Ghidra did not turn into functions, so the "first two qwords are +functions" filter rejected it. Addresses are hardcoded here on purpose. +""" +import traceback + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres/" +BUF = [] + + +def p(*a): + s = " ".join(str(x) for x in a) + print(s) + BUF.append(s) + + +try: + for vt in (0x18021C2A0,): + p("=== vtable %#x ===" % vt) + for off in range(0x240, 0x340, 8): + try: + t = qword(vt + off) + except Exception: + p(" +%#05x " % off) + continue + fn = fm.getFunctionAt(addr(t)) + nm = fn.getName() if fn else "?" + body = "" + if 0x180001000 <= t < 0x1801E5000: + body = " ".join(dec(t, 120).split()) + p(" +%#05x %#x %-22s :: %s" % (off, t, nm, body[:300])) + + # who calls the accessor at whatever slot reads 0x1fd45? find it first, then xref. +except Exception: + traceback.print_exc() +finally: + with open(OUT + "d4_fdm_vtable.txt", "w") as f: + f.write("\n".join(BUF)) + print("WROTE d4_fdm_vtable.txt") diff --git a/fifa17-recon/tools/ghidra_queries/q_pack_reveal_5.py b/fifa17-recon/tools/ghidra_queries/q_pack_reveal_5.py new file mode 100644 index 0000000..c692743 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_pack_reveal_5.py @@ -0,0 +1,96 @@ +"""D4 Q1e: many FutDataManagerImpl accessors are 8-byte leaf stubs that Ghidra never +turned into functions, so dec() returned nothing for them in q_pack_reveal_4. Decode +their bytes directly instead: `0f b6 81 c3` = movzx eax,byte ptr [rcx+disp32]. + +Goal: the full slot -> gate-byte map for vtable 0x18021c2a0, and specifically which +slot (if any) returns byte 0x1fd45, the byte written from settings field [0x1d], which +is the packOpeningAnimationEnabled arm. + +CONTROL: slot +0x2b0 must decode to 0x1fd3a and slot +0x2c8 to 0x1fd3d, because +FUN_18006cc60 calls exactly those two slots to publish IS_FRIENDLY_SEASON_ENABLED and +IS_DRAFT_MODE_ENABLED, and FUN_18011dc50 writes those two bytes from fields [0x16] and +[0x17], the two documented worked examples. + +Then: xrefs to whichever stub returns 0x1fd45. +""" +import traceback, struct + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres/" +BUF = [] + + +def p(*a): + s = " ".join(str(x) for x in a) + print(s) + BUF.append(s) + + +def stub_offset(t): + """decode a leaf accessor stub -> (kind, byte offset) or (None, raw hex).""" + b = read_bytes(t, 24) + h = b.hex() + # movzx eax, byte ptr [rcx+disp32] ; ret + if b[0:3] == b"\x0f\xb6\x81" and b[7:8] == b"\xc3": + return ("movzx byte", struct.unpack(" %#x %-12s field_byte=%#x" % (off, t, kind, o)) + found[off] = (t, kind, o) + else: + fn = fm.getFunctionAt(addr(t)) + p(" +%#05x -> %#x NOT-A-STUB %s bytes=%s" % (off, t, fn.getName() if fn else "?", h[:32])) + + p("=== CONTROL CHECK ===") + for slot, want, name in ((0x2B0, 0x1FD3A, "IS_FRIENDLY_SEASON_ENABLED"), + (0x2C8, 0x1FD3D, "IS_DRAFT_MODE_ENABLED")): + got = found.get(slot, (0, "?", -1))[2] + p(" slot %#x expect %#x got %#x %s %s" % + (slot, want, got, "PASS" if got == want else "FAIL", name)) + + p("=== slots returning the settings gate bytes 0x1fd2c..0x1fd48 ===") + for off, (t, kind, o) in sorted(found.items()): + if 0x1FD00 <= o <= 0x1FD70: + p(" slot +%#05x stub %#x byte %#x" % (off, t, o)) + + p("=== who reads 0x1fd45 ? ===") + hits = [(off, t) for off, (t, k, o) in found.items() if o == 0x1FD45] + p(" stubs returning 0x1fd45: %s" % [(hex(a), hex(b)) for a, b in hits]) + for off, t in hits: + xs = xrefs_to(t) + p(" xrefs to stub %#x : %d" % (t, len(xs))) + for frm, typ, fn, ent in xs: + p(" from %#x %s in %s @ %#x" % (frm, typ, fn, ent)) + # also: xrefs to the vtable slot address itself (indirect call sites are in the + # packed exe, so expect few/none) + for off, t in hits: + xs = xrefs_to(VT + off) + p(" xrefs to vtable slot %#x : %d -> %s" % (VT + off, len(xs), xs[:10])) + +except Exception: + traceback.print_exc() +finally: + with open(OUT + "d4_fdm_stubs.txt", "w") as f: + f.write("\n".join(BUF)) + print("WROTE d4_fdm_stubs.txt") diff --git a/fifa17-recon/tools/ghidra_queries/q_pack_reveal_6.py b/fifa17-recon/tools/ghidra_queries/q_pack_reveal_6.py new file mode 100644 index 0000000..6d4ab9f --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_pack_reveal_6.py @@ -0,0 +1,79 @@ +"""D4 Q2/Q3/Q4: the reveal path itself. + +(a) Scan .text for indirect calls through FutDataManagerImpl vtable slot +0x2e0 + (the packOpeningAnimationEnabled accessor 0x18011c590), i.e. the byte encodings + ff 90 e0 02 00 00 / ff 92 .. / ff 93 .. etc. + CONTROL: the same scan for slot +0x2b0 (IS_FRIENDLY_SEASON_ENABLED) MUST land + inside FUN_18006cc60, which we already know calls it. If the control finds + nothing the scan is broken and the 0x2e0 result is meaningless. + +(b) Full decompiles of the functions that reference the reveal strings: + USE_ANIMATION_STYLE -> FUN_1800706e0 + gmLoadFUTPackOpenSublevel -> FUN_18001eb90 + gmUnloadFUTPackOpenAnimation -> FUN_1800aa440 + CREATE_PACK_STATUS -> FUN_1800a8010 + PACK_CREATE_UNOPENED_PACK -> FUN_1800a5650, FUN_180015720 + NUM_RARES_IN_PACK -> FUN_180015d80 + plus the FutCreatePackServerResponse deser 0x180162880 for Q4 (item ordering). + +HYPOTHESIS for Q3: the reveal tier is chosen client-side from item fields the server +already sends (rating / rareflag / cardsubtypeid / playerType), and USE_ANIMATION_STYLE +is the data-provider key it is written to. +""" +import traceback, struct + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres/" +BUF = [] + + +def p(*a): + s = " ".join(str(x) for x in a) + print(s) + BUF.append(s) + + +def indirect_call_sites(slot): + """call qword ptr [reg + slot32] -- ff /2 with disp32, modrm 90..97 (except 94).""" + out = [] + d = struct.pack(" atom -> struct offset map, so we can + name the fields FUN_1800aa440 reads (+0x18, +0x38, +0x3c, +0x4c, +0x58, + +0x94, +0xb4, +0x146, +0x148) + 0x1800aa330 the headline predicate (does this item qualify for the special path) + 0x1800a9fe0 called with (item[0xb], item.byte@0xb4) -> stored as a display field. + Prime suspect for the animation-style / tier number. + 0x1800aa060 per-top-3-item expander + 0x1800a96a0 the sort over the collected (a,b,c,index) tuples <- Q4 lives here + 0x1800a9b10 display-struct init + 0x1800a9a10 display-struct -> event payload + callers of 0x1800aa440 + +CONTROL: the item deserializer must reproduce a field we already know from +docs/CARD_SYSTEM.md / ENDPOINT_MAP.md, e.g. atom 0x271 rareflag and atom 0x6c +cardsubtypeid must both appear in its atom ladder. If they do not, I have the wrong +function and the offset map is worthless. +""" +import traceback + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres/" +BUF = [] + + +def p(*a): + s = " ".join(str(x) for x in a) + print(s) + BUF.append(s) + + +try: + for va, nm in ((0x18013FE00, "item_deser"), + (0x1800AA330, "headline_predicate"), + (0x1800A9FE0, "style_from_item"), + (0x1800AA060, "top3_expander"), + (0x1800A96A0, "sort"), + (0x1800A9B10, "disp_init"), + (0x1800A9A10, "disp_to_event")): + src = dec(va, 300) + p("\n\n########## %s %#x len=%d ##########" % (nm, va, len(src))) + p(src) + with open(OUT + "d4_%s.txt" % nm, "w") as f: + f.write(src) + + p("\n=== callers of FUN_1800aa440 (pack-open summary) ===") + for c in callers(0x1800AA440): + p(" %s" % (c,)) + p("=== callers of FUN_1800a9fe0 ===") + for c in callers(0x1800A9FE0): + p(" %s" % (c,)) + p("=== callers of FUN_1800aa330 ===") + for c in callers(0x1800AA330): + p(" %s" % (c,)) + +except Exception: + traceback.print_exc() +finally: + with open(OUT + "d4_reveal_path2.txt", "w") as f: + f.write("\n".join(BUF)) + print("WROTE d4_reveal_path2.txt") diff --git a/fifa17-recon/tools/ghidra_queries/q_pack_reveal_8.py b/fifa17-recon/tools/ghidra_queries/q_pack_reveal_8.py new file mode 100644 index 0000000..37cc7dc --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_pack_reveal_8.py @@ -0,0 +1,66 @@ +"""D4 final: (1) who owns FUN_1800aa440 (the pack-open summary handler) and what the +0x33 / 0x34 state constants it pushes are; (2) the other seven indirect call sites +through vtable slot +0x2e0, to tell real packOpeningAnimationEnabled reads from +same-offset reads on unrelated objects; (3) FUN_1800d8330, the cardsubtypeid -> +cardtype map that decides item+0x4c (the ==1 player filter in the summary walk); +(4) the init constant at 0x1801f66a0 that seeds item+0x50/+0x54. + +CONTROL for (2): FUN_1800aa440 is already PROVEN to call the FutDataManagerImpl +accessor at +0x2e0, because its object plVar6 comes from FUN_180009c80 (the same +getter FUN_18006cc60 uses for the IS_* publisher). Any classification rule I apply to +the other seven must classify FUN_1800aa440 as a true positive. +""" +import traceback + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres/" +BUF = [] + + +def p(*a): + s = " ".join(str(x) for x in a) + print(s) + BUF.append(s) + + +try: + p("=== xrefs to FUN_1800aa440 ===") + for x in xrefs_to(0x1800AA440): + p(" %s" % (x,)) + p("=== xrefs to FUN_18001eb90 (gmLoadFUTPackOpenSublevel owner) ===") + for x in xrefs_to(0x18001EB90): + p(" %s" % (x,)) + + p("\n=== init constant at 0x1801f66a0 ===") + p(" qword %#x bytes %s" % (qword(0x1801F66A0), read_bytes(0x1801F66A0, 16).hex())) + + p("\n=== FUN_1800d8330 cardsubtypeid -> cardtype ===") + s = dec(0x1800D8330, 200) + p(s) + with open(OUT + "d4_cardsubtype_to_cardtype.txt", "w") as f: + f.write(s) + + for va, nm in ((0x180051CD0, "site_180051cd0"), + (0x18006AC20, "site_18006ac20"), + (0x180088CB0, "site_180088cb0"), + (0x18008B6E0, "site_18008b6e0"), + (0x1800D0600, "site_1800d0600"), + (0x18011A5C0, "site_18011a5c0"), + (0x18011CFA0, "site_18011cfa0")): + src = dec(va, 300) + with open(OUT + "d4_%s.txt" % nm, "w") as f: + f.write(src) + # only report the lines around a +0x2e0 dispatch and whether FUN_180009c80 appears + p("\n--- %s len=%d uses_FUN_180009c80=%s ---" + % (nm, len(src), "FUN_180009c80" in src)) + lines = src.split("\n") + for i, ln in enumerate(lines): + if "0x2e0" in ln: + p("\n".join(lines[max(0, i - 6):i + 4])) + p(" ...") + +except Exception: + traceback.print_exc() +finally: + with open(OUT + "d4_final.txt", "w") as f: + f.write("\n".join(BUF)) + print("WROTE d4_final.txt") diff --git a/fifa17-recon/tools/ghidra_queries/q_pack_v2_1.py b/fifa17-recon/tools/ghidra_queries/q_pack_v2_1.py new file mode 100644 index 0000000..8bc9c89 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_pack_v2_1.py @@ -0,0 +1,124 @@ +"""VERIFY-1. Adversarial re-derivation of the pack element deserializer. + +HYPOTHESES UNDER ATTACK (from D3/D5 reports): + H1 0x18013af30 parses packContentInfo (atom 0x20c) INLINE with EXACTLY five + children: 0x170,0x149,0x2c6,0x63,0x273 -> rec+0x144..+0x154. + H2 atoms 0x2e3 (start) and 0x35d (unopened) are TOP-LEVEL, not inside 0x20c. + H3 atoms 0x15c,0x26b,0x298,0x20f,0x176 have REAL arms (not SKIP). + H4 record stride 0x158, and a CMP against 0x64 caps the store at 100 packs. + H5 firstPartyStoreId (0x127) in the PACK element uses the STR getter 0x1801c7aa0 + + atoi, NOT the INT getter. + +METHOD DELIBERATELY DIFFERENT FROM THE ORIGINALS: I dump the FULL RAW DISASSEMBLY +of the function (every instruction, address + mnemonic + operands) and analyse the +ladder from bytes/asm, not from the decompiler's frame locals. I also +cross-check with the decompile but the asm is primary. + +CONTROL: the function must contain a call to the known SKIP primitive 0x180135ff0 +and to the known INT/BOOL/STR primitives; and the FNV hasher 0x180180d00 must +disassemble to the known prologue. +""" +import traceback, sys + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres/" + +try: + def dump_asm(entry, path, label): + f = func(entry) + if f is None: + print("NO FUNCTION at %#x" % entry); return None + body = f.getBody() + lines = [] + it = listing.getInstructions(body, True) + n = 0 + while it.hasNext(): + ins = it.next() + a = int(ins.getAddress().getOffset()) + lines.append("%010x %-8s %s" % (a, ins.getMnemonicString(), + str(ins).split(None, 1)[1] if ' ' in str(ins) else '')) + n += 1 + open(path, "w").write("\n".join(lines) + "\n") + print("[%s] %s entry=%#x instructions=%d bodysize=%#x -> %s" + % (label, f.getName(), int(f.getEntryPoint().getOffset()), n, + int(body.getNumAddresses()), path)) + return lines + + print("=" * 78) + print("CONTROL: FNV hasher prologue at 0x180180d00") + print("bytes:", read_bytes(0x180180d00, 16).hex()) + f = func(0x180180d00) + print("ghidra fn:", f.getName() if f else None) + + print() + print("=" * 78) + print("PACK ELEMENT DESERIALIZER 0x18013af30") + src = dec(0x18013af30) + open(OUT + "v1_packelem_dec.txt", "w").write(src) + print("decompile len(src) =", len(src), " lines =", src.count("\n")) + lines = dump_asm(0x18013af30, OUT + "v1_packelem.asm", "packelem") + + # ---- ladder reconstruction from ASM ---- + # Find every CMP/SUB against an immediate in the atom range, in address order, + # together with the following conditional jump target. + print() + print("--- ATOM LADDER (SUB/CMP against immediates, address order) ---") + f = func(0x18013af30) + it = listing.getInstructions(f.getBody(), True) + seq = [] + while it.hasNext(): + ins = it.next() + m = ins.getMnemonicString() + if m in ("SUB", "CMP", "ADD"): + try: + sc = ins.getScalar(1) + except Exception: + sc = None + if sc is not None: + v = int(sc.getUnsignedValue()) + if 1 <= v <= 0x400: + seq.append((int(ins.getAddress().getOffset()), m, str(ins), v)) + run = 0 + for a, m, s, v in seq: + if m == "SUB": + run += v + elif m == "CMP": + run += v + print("%010x %-40s imm=%#x running=%#x" % (a, s, v, run)) + + print() + print("--- CALLS to known primitives, with address ---") + PRIM = {0x1801c79d0: "INT", 0x1801c7620: "BOOL", 0x1801c7aa0: "STR", + 0x180135ff0: "SKIP", 0x1801c7f10: "NEXTTOK", 0x1801c8270: "BEGINOBJ", + 0x18013fe00: "ITEMDESER", 0x1800d7af0: "clampI32", 0x1800d7b30: "clampNonNegI32", + 0x1800d7b10: "toU16", 0x180138bd0: "CURRENCYELEM", 0x180139070: "FINALPRICE", + 0x18013aae0: "ORIGPRICE"} + it = listing.getInstructions(f.getBody(), True) + while it.hasNext(): + ins = it.next() + if ins.getMnemonicString() == "CALL": + for r in ins.getFlows(): + t = int(r.getOffset()) + if t in PRIM: + print("%010x CALL %-14s (%#x)" % (int(ins.getAddress().getOffset()), PRIM[t], t)) + + print() + print("--- STRIDE / CAP evidence: instructions between 0x18013bad0 and 0x18013bb60 ---") + a = 0x18013ad0 and 0x18013bad0 + while a < 0x18013bb60: + ins = listing.getInstructionAt(addr(a)) + if ins is None: + a += 1; continue + print("%010x %s" % (a, str(ins))) + a += ins.getLength() + + print() + print("=" * 78) + print("PACK RECORD CTOR 0x1801342d0") + src2 = dec(0x1801342d0) + open(OUT + "v1_packctor_dec.txt", "w").write(src2) + print("len =", len(src2)) + print(src2) + dump_asm(0x1801342d0, OUT + "v1_packctor.asm", "packctor") + +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_pack_v2_2.py b/fifa17-recon/tools/ghidra_queries/q_pack_v2_2.py new file mode 100644 index 0000000..2a80a92 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_pack_v2_2.py @@ -0,0 +1,155 @@ +"""VERIFY-2. Attack the ABSENCE claims by asm-level immediate enumeration. + +HYPOTHESES UNDER ATTACK: + H6 extPrice finalPrice (0x180139070) / originalPrice (0x18013aae0) read ONLY + atom 0x11a (externalPriceId). They do NOT read 0x1b (amount) or 0xc4 (currency). + H7 currency element deser 0x180138bd0 reads ONLY 0x1d0/0x134/0x124, stride 0x30. + H8 FutCreatePackServerResponse deser 0x180162880 has arms ONLY for + 0x16e,0x1dd,0x264,0xec -- no reason/errorCode/state. + H9 FUN_18002c3c0 has exactly ONE caller (0x1800150d0) and does pure copies of + +0x144..+0x154. + H10 the 0x20f..0x298 jump table in 0x18013af30 really does bind 0x26b quantity, + 0x298 saleType, 0x20f packType to real arms. + +METHOD: instead of reading the decompiler, I enumerate EVERY scalar immediate that +appears in a CMP/SUB/LEA/MOV inside each function's real instruction listing. If an +atom id is nowhere in that set, it cannot be dispatched on. This is an exhaustive +upper bound over the function body and is a different method from reading C output. + +CONTROL: 0x18013af30 must yield 0x20c, 0x2e3, 0x35d in its immediate set (known +present) and must yield the 5 packContentInfo ids. If the technique misses those, +it is broken and every absence below is void. +""" +import traceback +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres/" + +try: + def all_scalars(entry): + f = func(entry) + if f is None: return None, None + out = {} + it = listing.getInstructions(f.getBody(), True) + n = 0 + while it.hasNext(): + ins = it.next(); n += 1 + for i in range(ins.getNumOperands()): + try: sc = ins.getScalar(i) + except Exception: sc = None + if sc is None: continue + v = int(sc.getUnsignedValue()) + out.setdefault(v, []).append((int(ins.getAddress().getOffset()), str(ins))) + return out, n + + def dump(entry, tag): + src = dec(entry) + p = OUT + "v2_%s_%x.txt" % (tag, entry) + open(p, "w").write(src) + print("[%s] %#x len(src)=%d lines=%d -> %s" % (tag, entry, len(src), src.count("\n"), p)) + return src + + def asm(entry, tag): + f = func(entry) + lines = [] + it = listing.getInstructions(f.getBody(), True) + while it.hasNext(): + i = it.next() + lines.append("%010x %s" % (int(i.getAddress().getOffset()), str(i))) + p = OUT + "v2_%s_%x.asm" % (tag, entry) + open(p, "w").write("\n".join(lines) + "\n") + return p + + ATOMS = {0x1b: "amount", 0xc4: "currency", 0x11a: "externalPriceId", + 0x124: "finalFunds", 0x134: "funds", 0x1d0: "name", + 0x16e: "itemList", 0x1dd: "numberItems", 0x264: "purchasedPackId", + 0xec: "duplicateItemIdList", 0x2eb: "state", 0x28b: "reason", + 0x20b: "packId", 0x127: "firstPartyStoreId", 0x26b: "quantity", + 0x298: "saleType", 0x20f: "packType", 0x176: "isPremium", + 0x15c: "id", 0x20c: "packContentInfo", 0x2e3: "start", 0x35d: "unopened", + 0x63: "bronzeQuantity", 0x149: "goldQuantity", 0x170: "itemQuantity", + 0x273: "rareQuantity", 0x2c6: "silverQuantity", 0x240: "points", + 0x265: "purchaseLimit", 0x261: "purchaseCount", 0x37d: "visible", + 0x36a: "useDefaultImage", 0x102: "end", 0xcc: "dealType", + 0x2cb: "sortPriority", 0x33a: "transactionId", 0x367: "useAuth", + 0x368: "useCount", 0x375: "useTime", 0x369: "useCredits", + 0x36b: "usePreOrder", 0x258: "productId", 0x14e: "groupName", + 0x266: "purchasePackType", 0x260: "purchase"} + + for entry, tag in [(0x18013af30, "CONTROL_packelem"), + (0x180139070, "finalPrice"), + (0x18013aae0, "originalPrice"), + (0x180138bd0, "currencyElem"), + (0x180162880, "createpack_deser"), + (0x180162530, "createpack_reqser"), + (0x1801269f0, "purchaseitems_deser")]: + sc, n = all_scalars(entry) + if sc is None: + print("!! no function at %#x" % entry); continue + present = sorted(a for a in ATOMS if a in sc) + print() + print("=" * 74) + print("%s %#x instructions=%d distinct scalars=%d" % (tag, entry, n, len(sc))) + print(" ATOM IDS PRESENT AS IMMEDIATES:") + for a in present: + sites = sc[a][:3] + print(" %-6s %-22s %s" % (hex(a), ATOMS[a], + "; ".join("%010x %s" % s for s in sites))) + missing = sorted(a for a in ATOMS if a not in sc) + print(" ABSENT: " + ", ".join("%s(%s)" % (hex(a), ATOMS[a]) for a in missing)) + + print() + print("=" * 74) + print("H10: jump table behind LEA EAX,[R15-0x20f]; CMP EAX,0x89") + # find R13 base + f = func(0x18013af30) + it = listing.getInstructions(f.getBody(), True) + while it.hasNext(): + i = it.next() + s = str(i) + if "R13" in s and i.getMnemonicString() in ("LEA", "MOV") and s.split(',')[0].endswith("R13"): + print(" R13 set:", "%010x %s" % (int(i.getAddress().getOffset()), s)) + idx = read_bytes(0x18013bcb4, 0x8a) + print(" index table @0x18013bcb4 (%d bytes):" % len(idx), idx.hex()) + offs = [dword(0x18013bc98 + 4 * k) for k in range(max(idx) + 1)] + print(" offset table @0x18013bc98:", ["%08x" % o for o in offs]) + print(" atom -> target:") + for k in range(0x8a): + atom = 0x20f + k + t = (offs[idx[k]] + 0x180000000) & 0xFFFFFFFFFFFF + nm = ATOMS.get(atom, "") + if nm or t != (offs[idx[0x8a - 1]] + 0x180000000): + pass + # group atoms by target + from collections import defaultdict + g = defaultdict(list) + for k in range(0x8a): + g[offs[idx[k]] + 0x180000000].append(0x20f + k) + for t in sorted(g): + ats = g[t] + named = [("%s=%s" % (hex(a), ATOMS[a])) for a in ats if a in ATOMS] + print(" target %010x n=%-3d %s" % (t, len(ats), ", ".join(named) if named else "")) + + print() + print("=" * 74) + print("H9: xrefs to FUN_18002c3c0") + for r in xrefs_to(0x18002c3c0): + print(" from %010x %-14s in %s (%010x)" % (r[0], r[1], r[2], r[3])) + s = dump(0x18002c3c0, "adapter") + import re + print(" --- lines mentioning 0x14[4-9c]/0x15[04]/0xb4/0xcd ---") + for ln in s.split("\n"): + if any(k in ln for k in ("0x144", "0x148", "0x14c", "0x150", "0x154", "+ 0xb4", "+ 0xcd")): + print(" ", ln.strip()) + + print() + print("=" * 74) + print("STRING LITERALS referenced by the pack element deser") + for a in (0x1801e98c8, 0x180223228, 0x1801fd44c, 0x180223238, 0x180221c04, + 0x1801efea0, 0x1801ec008): + print(" %010x = %r" % (a, rd_str(a, 40))) + + for e, t in [(0x180139070, "finalPrice"), (0x18013aae0, "originalPrice"), + (0x180138bd0, "currencyElem"), (0x180162880, "createpack_deser")]: + dump(e, t); asm(e, t) + +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_pack_v2_3.py b/fifa17-recon/tools/ghidra_queries/q_pack_v2_3.py new file mode 100644 index 0000000..385ebe9 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_pack_v2_3.py @@ -0,0 +1,132 @@ +"""VERIFY-3. Attack the two biggest absence claims and the actionable tables. + + H11 (D3 #4) NOTHING in CardsDLL compares the delivered itemList against the + declared packContentInfo quantities. Original method: disp32 BYTE SCAN. + MY METHOD: whole-.text instruction-operand scalar census via Ghidra's own + decoded operands, which sees disp8, disp32, SIB and LEA forms alike and does + not care how the displacement was encoded. Strictly wider than a byte scan. + H12 (D3 #5) numberItems at FutCreatePackServerResponse+0x28 is read by NOTHING. + MY METHOD: enumerate every xref to the response vtable 0x180228260, the + factory 0x180162770, the class literal, and the owning ServerCall vtable + 0x180228270, then look at the completion consumer. + H13 FUN_18002c3c0 has DATA xrefs at 0x1802f1620 and 0x180244880 that the D3 + report did not mention. Are they vtable slots (=> a second, indirect caller)? + H14 the HTTP status table FUN_1801844c0 maps only 200 to success. + H15 the 9-entry transaction state table at 0x1802d02c0. + H16 the RPC descriptor table at 0x1802cb500, 0x30-byte rows. + +CONTROL for the census: FUN_18002c3c0 (the known adapter) MUST appear with all +five offsets. If it does not, the census is broken and every absence is void. +""" +import traceback +from collections import defaultdict +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres/" + +try: + TARGETS = {0x144, 0x148, 0x14c, 0x150, 0x154} + print("=" * 74) + print("H11 CENSUS: every function whose decoded operands carry a displacement/") + print(" scalar in {0x144,0x148,0x14c,0x150,0x154}") + hits = defaultdict(set) + sites = defaultdict(list) + nfun = 0 + fi = fm.getFunctions(True) + while fi.hasNext(): + f = fi.next() + nfun += 1 + ent = int(f.getEntryPoint().getOffset()) + it = listing.getInstructions(f.getBody(), True) + while it.hasNext(): + ins = it.next() + for i in range(ins.getNumOperands()): + try: sc = ins.getScalar(i) + except Exception: sc = None + if sc is None: continue + v = int(sc.getUnsignedValue()) + if v in TARGETS: + hits[ent].add(v) + if len(sites[ent]) < 8: + sites[ent].append("%010x %s" % (int(ins.getAddress().getOffset()), str(ins))) + print(" functions scanned:", nfun) + ranked = sorted(hits.items(), key=lambda kv: (-len(kv[1]), kv[0])) + print(" functions carrying ALL FIVE offsets:") + allfive = [e for e, s in ranked if len(s) == 5] + for e in allfive: + print(" %010x %s" % (e, fname(e) if callable(globals().get("fname")) else "")) + for s in sites[e]: + print(" ", s) + print(" CONTROL 0x18002c3c0 present with 5 offsets:", 0x18002c3c0 in allfive, + " (offsets seen: %s)" % sorted(hex(x) for x in hits.get(0x18002c3c0, set()))) + print(" functions with 4 offsets:", ["%010x" % e for e, s in ranked if len(s) == 4]) + print(" functions with 3 offsets:", ["%010x" % e for e, s in ranked if len(s) == 3]) + print(" total functions with >=1 of the five: %d" % len(hits)) + with open(OUT + "v3_census.txt", "w") as fh: + for e, s in ranked: + fh.write("%010x n=%d %s\n" % (e, len(s), sorted(hex(x) for x in s))) + for t in sites[e]: + fh.write(" %s\n" % t) + + print() + print("=" * 74) + print("H13 DATA xrefs to FUN_18002c3c0") + for a in (0x1802f1620, 0x180244880): + blk = mem.getBlock(addr(a)) + print(" %010x in block %s" % (a, blk.getName() if blk else "?")) + for k in range(-4, 6): + q = qword(a + k * 8) + f = fm.getFunctionAt(addr(q)) if 0x180000000 <= q < 0x181000000 else None + print(" [%+3d] %016x %s" % (k * 8, q, f.getName() if f else "")) + print(" xrefs to that slot address:", xrefs_to(a)) + + print() + print("=" * 74) + print("H12 who touches FutCreatePackServerResponse") + for lit in find_all(b"RS4:FutCreatePackServerResponse\x00"): + print(" literal at %010x xrefs:" % lit, xrefs_to(lit)) + for v in (0x180228260, 0x180228270): + print(" vtable %010x xrefs: %s" % (v, xrefs_to(v))) + for slot, t, n in vtable(v, 20): + if t == 0: break + print(" +%03x %016x %s" % (slot, t, n)) + print(" xrefs to factory 0x180162770:", xrefs_to(0x180162770)) + print(" xrefs to ctor 0x180162420:", xrefs_to(0x180162420)) + print(" callers of deser 0x180162880:", xrefs_to(0x180162880)) + + print() + print("=" * 74) + print("H14 HTTP status table FUN_1801844c0") + s = dec(0x1801844c0) + open(OUT + "v3_http_1801844c0.txt", "w").write(s) + print(" len(src)=%d lines=%d" % (len(s), s.count("\n"))) + print(s) + + print() + print("=" * 74) + print("H15 transaction state table 0x1802d02c0") + for k in range(12): + a = 0x1802d02c0 + k * 16 + v = dword(a); p = qword(a + 8) + nm = rd_str(p, 40) if 0x180000000 <= p < 0x181000000 else "<%016x>" % p + print(" [%2d] %010x value=%-6d name=%r" % (k, a, v if v < 0x80000000 else v - (1 << 32), nm)) + + print() + print("=" * 74) + print("H16 RPC descriptor table 0x1802cb500 (0x30 rows)") + bad = 0 + for k in range(40): + r = 0x1802cb500 + k * 0x30 + try: + p0 = qword(r); f1 = qword(r + 8); p2 = qword(r + 0x10) + z3 = qword(r + 0x18); z4 = qword(r + 0x20); fn = qword(r + 0x28) + except Exception: + print(" row %d unreadable" % k); break + n0 = rd_str(p0, 48) if 0x180000000 <= p0 < 0x181000000 else "" + n2 = rd_str(p2, 48) if 0x180000000 <= p2 < 0x181000000 else "" + ok = n2.isupper() and n2.isalpha() if n2 else False + if not ok: bad += 1 + print(" %010x %-28r flags=%-6x %-28r z=%d,%d fn=%010x %s" + % (r, n0, f1, n2, z3, z4, fn, "" if ok else " <-- third qword not an UPPER token")) + print(" rows whose third qword is NOT an uppercase token: %d/40" % bad) + +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_pack_v2_4.py b/fifa17-recon/tools/ghidra_queries/q_pack_v2_4.py new file mode 100644 index 0000000..10218f5 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_pack_v2_4.py @@ -0,0 +1,117 @@ +"""VERIFY-4. Re-run the failed census with a WORKING method, plus the remaining +actionable D5 claims. + +WHY A RERUN: q_pack_v2_3's census used Instruction.getScalar(), which returns null +for the displacement inside a memory operand. Its own control (FUN_18002c3c0, known +to read +0x144..+0x154) scored ZERO, so the technique was void and no absence could +be concluded from it. Here I match against the printed operand text instead, which +shows the displacement whatever its encoding (disp8, disp32, SIB, LEA). + + H11 nothing besides the known lifecycle+adapter set touches +0x144..+0x154. + H12 FutCreatePackServerResponse+0x28 (numberItems) is read by nothing. + H17 FUN_18002cc90 early-returns when tile+0x6c == -1. + H18 PurchaseItems req serializer 0x180126440 + URL builder 0x180126720. + H19 FUN_1801267b0 turns 409 + "User already has a transaction" into 0x70. + H20 purchaseitems response deser field map. + H21 the pack element's firstPartyStoreId call target qword[0x1801e51d0] is atoi. + +CONTROL: the census must list FUN_18002c3c0 with all five offsets, and must list +the pack-record copy-assign 0x1801340e0 and uninit-copy 0x180133210. If those three +are missing the census is broken again. +""" +import traceback, re +from collections import defaultdict +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres/" + +try: + PAT = re.compile(r"0x(144|148|14c|150|154)\b") + hits = defaultdict(set); sites = defaultdict(list) + ninst = 0; nfun = 0 + fi = fm.getFunctions(True) + while fi.hasNext(): + f = fi.next(); nfun += 1 + ent = int(f.getEntryPoint().getOffset()) + it = listing.getInstructions(f.getBody(), True) + while it.hasNext(): + ins = it.next(); ninst += 1 + s = str(ins) + m = PAT.findall(s) + if m: + for v in m: + hits[ent].add(int(v, 16)) + if len(sites[ent]) < 10: + sites[ent].append("%010x %s" % (int(ins.getAddress().getOffset()), s)) + print("=" * 74) + print("H11 CENSUS (operand-text method). functions=%d instructions=%d" % (nfun, ninst)) + ranked = sorted(hits.items(), key=lambda kv: (-len(kv[1]), kv[0])) + ctl = {0x18002c3c0, 0x1801340e0, 0x180133210} + print(" CONTROLS: " + ", ".join("%010x=%d offsets" % (c, len(hits.get(c, ()))) for c in sorted(ctl))) + print(" functions with >=4 of the five offsets:") + for e, s in ranked: + if len(s) < 4: break + print(" %010x n=%d %s" % (e, len(s), sorted(hex(x) for x in s))) + for t in sites[e]: + print(" ", t) + print(" functions with exactly 3: %s" % ["%010x" % e for e, s in ranked if len(s) == 3]) + print(" functions with exactly 2: %d, with exactly 1: %d" + % (sum(1 for _, s in ranked if len(s) == 2), sum(1 for _, s in ranked if len(s) == 1))) + with open(OUT + "v4_census.txt", "w") as fh: + for e, s in ranked: + fh.write("%010x n=%d %s\n" % (e, len(s), sorted(hex(x) for x in s))) + for t in sites[e]: + fh.write(" %s\n" % t) + print(" full census -> " + OUT + "v4_census.txt") + + print() + print("=" * 74) + print("H12 hunt the FutCreatePackServerResponse consumer") + print(" callers of ServerCall ctor 0x1801623d0:", xrefs_to(0x1801623d0)) + print(" callers of pool builder 0x18010cdc0:", xrefs_to(0x18010cdc0)[:10]) + for s in (b"OnPurchasePackResponse", b"OnCreatePackResponse", b"PurchasePack", + b"OnPackPurchase", b"CREATEPACK\x00"): + h = find_all(s) + print(" string %r at %s" % (s, ["%010x" % a for a in h])) + for a in h: + for r in xrefs_to(a): + print(" xref %010x %s in %s" % (r[0], r[1], r[2])) + + print() + print("=" * 74) + print("H17/H18/H19/H20 decompiles") + for e, tag in [(0x18002cc90, "price_formatter"), + (0x180126440, "purchaseitems_reqser"), + (0x180126720, "purchaseitems_url"), + (0x1801267b0, "purchaseitems_httperr"), + (0x180126900, "purchaseitems_state1body")]: + s = dec(e) + open(OUT + "v4_%s_%x.txt" % (tag, e), "w").write(s) + print() + print("---- %s %#x len=%d lines=%d ----" % (tag, e, len(s), s.count("\n"))) + print(s if len(s) < 4200 else s[:4200] + "\n...TRUNCATED, full text in file...") + + print() + print("=" * 74) + print("H20 purchaseitems_deser 0x1801269f0 ladder (raw asm, dispatch region)") + f = func(0x1801269f0) + it = listing.getInstructions(f.getBody(), True) + lines = [] + while it.hasNext(): + i = it.next() + lines.append("%010x %s" % (int(i.getAddress().getOffset()), str(i))) + open(OUT + "v4_purchaseitems_deser.asm", "w").write("\n".join(lines) + "\n") + for l in lines: + a = int(l[:10], 16) + if 0x180126ab0 <= a <= 0x180126e60: + print(" " + l) + + print() + print("=" * 74) + print("H21 import at 0x1801e51d0") + t = qword(0x1801e51d0) + print(" qword[0x1801e51d0] = %016x" % t) + d = listing.getDataAt(addr(0x1801e51d0)) + print(" ghidra data/label:", d.getLabel() if d else None, + [str(s) for s in prog.getSymbolTable().getSymbols(addr(0x1801e51d0))]) + +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_pack_v2_5.py b/fifa17-recon/tools/ghidra_queries/q_pack_v2_5.py new file mode 100644 index 0000000..17fd79b --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_pack_v2_5.py @@ -0,0 +1,83 @@ +"""VERIFY-5. Last batch: the vtable-shape evidence D3 gave, the CreatePack mode +derivation, and the MEDIUM-graded first-party-store claims. + + H22 D3 says "the class vtable at 0x180228260 has no accessor (slot 0 and slot + +0x40 are deleting destructors, ... the rest are the shared base slots also + present on the store response)". D5 says that vtable is only TWO slots and the + ServerCall vtable starts at 0x180228270. Both cannot be right. Settle it from + the two constructors. + H23 CreatePack req serializer mode derivation (mode 0/1/2/4). + H24 0x180220400 is "PURCHASEERROR". + H25 descriptor 0x1801f0458 + inline literal "CARDPACK" at 0x1801f0490. + H26 FUN_18003ed70 registers the five store script bindings. + H27 which vtable slot index carries the HTTP-status handler. + +CONTROL: FUN_1801623d0 must reference 0x180228270 and FUN_180162420 must reference +0x180228260, as the xref dump in VERIFY-3 already showed. +""" +import traceback +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres/" + +try: + print("=" * 74) + print("H22 the two constructors, verbatim") + for e, tag in [(0x180162420, "resp_ctor"), (0x1801623d0, "call_ctor"), + (0x1801624a0, "f1801624a0"), (0x180162490, "f180162490")]: + s = dec(e) + print() + print("---- %s %#x len=%d ----" % (tag, e, len(s))) + print(s) + + print() + print("--- raw qwords 0x180228240..0x1802282e0 with symbols ---") + st = prog.getSymbolTable() + for a in range(0x180228240, 0x1802282e8, 8): + q = qword(a) + f = fm.getFunctionAt(addr(q)) if 0x180000000 <= q < 0x181000000 else None + syms = [str(s) for s in st.getSymbols(addr(a))] + print(" %010x %016x %-40s %s" % (a, q, f.getName() if f else "", syms)) + + print() + print("=" * 74) + print("H27 which slot of 0x180228270 / 0x1802202f8 holds the status handler") + for base, nm in [(0x180228270, "CreatePack call vtbl"), (0x1802202f8, "PurchaseItems call vtbl")]: + for i in range(24): + q = qword(base + i * 8) + f = fm.getFunctionAt(addr(q)) if 0x180000000 <= q < 0x181000000 else None + mark = "" + if q in (0x18016c060, 0x1801267b0): mark = " <== HTTP STATUS HANDLER" + print(" %s slot %2d (+%03x) %016x %s%s" % (nm, i, i * 8, q, f.getName() if f else "", mark)) + print() + + print("=" * 74) + print("H23 CreatePack request serializer, verbatim") + s = dec(0x180162530) + open(OUT + "v5_createpack_reqser.txt", "w").write(s) + print("len=%d lines=%d" % (len(s), s.count("\n"))) + print(s) + + print("=" * 74) + print("H24 literals") + for a in (0x180220400, 0x1802203f8, 0x1801eae8c, 0x1801f0490, 0x1801efeb0): + print(" %010x = %r rawbytes=%s" % (a, rd_str(a, 40), read_bytes(a, 16).hex())) + + print() + print("=" * 74) + print("H25 descriptor 0x1801f0458") + for k in range(-2, 12): + a = 0x1801f0458 + k * 8 + q = qword(a) + f = fm.getFunctionAt(addr(q)) if 0x180000000 <= q < 0x181000000 else None + print(" %010x [%+3d] %016x %-30s ascii=%r" % (a, k * 8, q, f.getName() if f else "", + read_bytes(a, 8))) + + print() + print("=" * 74) + print("H26 FUN_18003ed70") + s = dec(0x18003ed70) + open(OUT + "v5_scriptreg.txt", "w").write(s) + print("len=%d" % len(s)) + print(s[:5000]) + +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_pack_v3_1.py b/fifa17-recon/tools/ghidra_queries/q_pack_v3_1.py new file mode 100644 index 0000000..452cfa0 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_pack_v3_1.py @@ -0,0 +1,153 @@ +# -*- coding: utf-8 -*- +"""ADVERSARIAL VERIFY 1: the packOpeningAnimationEnabled gate chain (D4 claim 1 + 2). + +HYPOTHESES UNDER ATTACK + H1 FUN_18013c6d0 case 0x20e writes param_2[0x1d] + H2 FUN_18011dc50 line ~40 writes +0x1fd45 = param_2[0x1d] == 1 + H3 vtable 0x18021c2a0 slot +0x2e0 -> 0x18011c590 -> movzx eax,[rcx+0x1fd45] + H4 FUN_18006cc60 never uses slot 0x2e0 (ABSENCE -- attacked with a different method: + I enumerate EVERY vtable-slot displacement the publisher calls, from the DISASSEMBLY, + not from the decompile text.) + H5 exactly one reader of slot +0x2e0 in CardsDLL (ABSENCE) + +CONTROLS + * class_deser("FutSquadSave") must be 0x180171a60 and class_deser("FutSquadList") + 0x180172140. If those come back empty the whole harness is suspect. + * vtable slots +0x2b0 and +0x2c8 must decode to 0x1fd3a and 0x1fd3d, which is what the + known IS_FRIENDLY_SEASON_ENABLED / IS_DRAFT_MODE_ENABLED publisher demands. + * the byte scan for `call [reg+0x2e0]` is run alongside the SAME scan for +0x2b0, which + has a known-present site inside FUN_18006cc60. +""" +import traceback, struct, re + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres" + +def dump(name, s): + p = "%s/v_%s.txt" % (OUT, name) + with open(p, "w") as f: + f.write(s) + print("[wrote %s %d chars]" % (p, len(s))) + +try: + print("=" * 78) + print("CONTROL: class_deser") + for n, exp in (("FutSquadSave", 0x180171a60), ("FutSquadList", 0x180172140), + ("FutCreateMatch", 0x180120380)): + try: + r = class_deser(n) + except Exception as e: + r = "EXC %s" % e + print(" class_deser(%-16s) = %s expected %#x" % (n, r, exp)) + + print("=" * 78) + print("H1: FUN_18013c6d0 settings deserializer -- FULL decompile length + case 0x20e") + s = dec(0x18013c6d0) + print("len(src) = %d chars, %d lines <-- FULL, not truncated" % (len(s), s.count("\n") + 1)) + dump("q1_settings_deser", s) + for i, ln in enumerate(s.split("\n")): + if "0x20e" in ln or "[0x1d]" in ln or "0x1d]" in ln: + print(" L%-4d %s" % (i + 1, ln.strip())) + + print("=" * 78) + print("H2: FUN_18011dc50 applier -- FULL decompile") + s2 = dec(0x18011dc50) + print("len(src) = %d chars, %d lines" % (len(s2), s2.count("\n") + 1)) + dump("q1_applier", s2) + print(s2) + + print("=" * 78) + print("H3: vtable 0x18021c2a0 slots decoded from raw bytes") + VT = 0x18021c2a0 + for slot in range(0x260, 0x310, 8): + try: + p = qword(VT + slot) + except Exception as e: + print(" +%#05x qword failed %s" % (slot, e)); continue + if not p: + continue + try: + b = read_bytes(p, 12) + except Exception: + b = b"" + bb = bytes(bytearray([(x & 0xff) for x in b])) + disp = None + kind = "" + if len(bb) >= 7 and bb[0] == 0x0f and bb[1] == 0xb6 and bb[2] == 0x81: + disp = struct.unpack_from("= 6 and bb[0] == 0x8b and bb[1] == 0x81: + disp = struct.unpack_from(" %#x %s %s %s" % (slot, p, bb.hex(), kind, fname(p) or "")) + + print("=" * 78) + print("H4: FUN_18006cc60 publisher -- FULL decompile, then DISASSEMBLY slot list") + s3 = dec(0x18006cc60) + print("len(src) = %d chars, %d lines" % (len(s3), s3.count("\n") + 1)) + dump("q1_publisher", s3) + print(s3) + f = func(0x18006cc60) + print("--- disassembly-derived indirect-call displacements in %s ---" % f.getName()) + it = listing.getInstructions(f.getBody(), True) + slots = [] + while it.hasNext(): + ins = it.next() + t = str(ins) + if t.startswith("CALL") and "[" in t and "+" in t: + m = re.search(r"\+\s*(0x[0-9a-fA-F]+)\]", t) + if m: + slots.append((int(m.group(1), 16), int(ins.getAddress().getOffset()))) + # also LEA/MOV of a string arg is noise; skip + print(" indirect-call displacements used:", sorted(set(x[0] for x in slots))) + for d, a in slots: + print(" %#x at %#x" % (d, a)) + print(" 0x2e0 present? ", 0x2e0 in set(x[0] for x in slots)) + print(" 0x2b0 present? ", 0x2b0 in set(x[0] for x in slots), " <-- CONTROL, must be True") + + print("=" * 78) + print("H5: xrefs to the stub 0x18011c590") + try: + for r in xrefs_to(0x18011c590): + print(" ", r) + except Exception as e: + print(" xrefs_to raised", e) + print("callers(0x18011c590):") + try: + print(" ", callers(0x18011c590)) + except Exception as e: + print(" ", e) + + print("=" * 78) + print("H5b: whole-.text disassembly scan for CALL [reg+0x2e0] and CALL [reg+0x2b0]") + blk = None + for b in mem.getBlocks(): + if b.getName() == ".text": + blk = b + print(" .text %s - %s" % (blk.getStart(), blk.getEnd())) + from ghidra.program.model.address import AddressSet + aset = AddressSet(blk.getStart(), blk.getEnd()) + it = listing.getInstructions(aset, True) + found = {0x2e0: [], 0x2b0: [], 0x2c8: []} + n = 0 + while it.hasNext(): + ins = it.next() + n += 1 + t = str(ins) + if t[0] != "C" or not t.startswith("CALL"): + continue + if "[" not in t: + continue + m = re.search(r"\+\s*(0x[0-9a-fA-F]+)\]", t) + if not m: + continue + d = int(m.group(1), 16) + if d in found: + found[d].append((int(ins.getAddress().getOffset()), t)) + print(" instructions walked: %d" % n) + for d in (0x2e0, 0x2b0, 0x2c8): + print(" --- displacement %#x : %d call sites ---" % (d, len(found[d]))) + for a, t in found[d]: + fn = fname(a) + print(" %#x in %-24s %s" % (a, fn, t)) + +except Exception: + traceback.print_exc() +print("QUERY DONE") diff --git a/fifa17-recon/tools/ghidra_queries/q_pack_v3_2.py b/fifa17-recon/tools/ghidra_queries/q_pack_v3_2.py new file mode 100644 index 0000000..bb06d6e --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_pack_v3_2.py @@ -0,0 +1,123 @@ +# -*- coding: utf-8 -*- +"""ADVERSARIAL VERIFY 2: who WRITES the gate byte, and who else READS slot +0x2e0. + +WHY. Live memory (pid 4048, read-only) says FutDataManagerImpl+0x1fd45 is currently 01, +while utas_server.py is running with FUT_SETTINGS unset, i.e. it serves {"configs": []}. +So either the settings struct default-initialises those fields to 1 and the applier runs +anyway, or something other than FUN_18011dc50 writes the byte. Both possibilities +contradict the reviewed report's premise that "the byte defaults to zero". + +H1 FUN_18011dc50 is the ONLY writer of +0x1fd45 / +0x1fd3a in CardsDLL .text. + (disassembly scan for any memory operand with displacement 0x1fd3a..0x1fd48) +H2 the settings struct handed to the applier is default-constructed with 1s. + (callers of FUN_18013c6d0, and the allocation site) +H3 of the 8 CALL [reg+0x2e0] sites, only 0x1800aaa43 is a FutDataManagerImpl accessor. + ATTACK: decompile all 8 and look at what object they call it on. +CONTROL: the same write-scan for 0x1fd3a must find FUN_18011dc50 too. +""" +import traceback, re, struct + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres" + +def dump(n, s): + p = "%s/v_%s.txt" % (OUT, n) + open(p, "w").write(s) + print("[wrote %s %d chars]" % (p, len(s))) + +try: + from ghidra.program.model.address import AddressSet + blk = [b for b in mem.getBlocks() if b.getName() == ".text"][0] + aset = AddressSet(blk.getStart(), blk.getEnd()) + + print("=" * 78) + print("H1: every instruction in .text whose operand displacement is 0x1fd28..0x1fd50") + it = listing.getInstructions(aset, True) + hits = {} + n = 0 + rx = re.compile(r"0x1fd([0-9a-f]{2})") + while it.hasNext(): + ins = it.next() + n += 1 + t = str(ins) + m = rx.search(t) + if not m: + continue + d = int("1fd" + m.group(1), 16) + if not (0x1fd28 <= d <= 0x1fd50): + continue + a = int(ins.getAddress().getOffset()) + hits.setdefault(d, []).append((a, fname(a), t)) + print(" instructions walked: %d" % n) + for d in sorted(hits): + print(" --- disp %#x : %d sites ---" % (d, len(hits[d]))) + for a, f, t in hits[d]: + print(" %#x %-24s %s" % (a, f, t)) + + print("=" * 78) + print("H2: callers of the applier FUN_18011dc50 and of the settings deser FUN_18013c6d0") + for tgt in (0x18011dc50, 0x18013c6d0): + print(" callers(%#x):" % tgt) + try: + cs = callers(tgt) + except Exception as e: + cs = "EXC %s" % e + print(" ", cs) + try: + for r in xrefs_to(tgt): + print(" xref", r, fname(r[0]) if isinstance(r, tuple) else "") + except Exception as e: + print(" xrefs_to EXC", e) + + print("=" * 78) + print("H2b: FULL decompile of every caller of the applier") + seen = set() + try: + cs = callers(0x18011dc50) + except Exception: + cs = [] + for c in cs: + va = c if isinstance(c, int) else int(c) + if va in seen: + continue + seen.add(va) + s = dec(va) + print("----- caller %#x (%s) len=%d -----" % (va, fname(va), len(s))) + print(s) + dump("q2_applier_caller_%x" % va, s) + + print("=" * 78) + print("H3: decompile every CALL [reg+0x2e0] site's containing function, show the line") + SITES = [(0x1800522ac, 0x180051cd0), (0x18006b3dd, 0x18006ac20), + (0x18008918b, 0x180088cb0), (0x18008bcc0, 0x18008b6e0), + (0x1800aaa43, 0x1800aa440), (0x1800d06a4, 0x1800d0600), + (0x18011a61d, 0x18011a5c0), (0x18011cfdc, 0x18011cfa0)] + for site, fn in SITES: + s = dec(fn) + print("--- site %#x in %s : decompile %d chars ---" % (site, fname(fn), len(s))) + dump("q2_site_%x" % fn, s) + for i, ln in enumerate(s.split("\n")): + if "0x2e0" in ln: + print(" L%-4d %s" % (i + 1, ln.strip())) + # what object? print 12 instructions before the call + a = addr(site) + ins = listing.getInstructionAt(a) + back = [] + for _ in range(14): + ins = ins.getPrevious() if ins else None + if ins is None: + break + back.append(" %#x %s" % (int(ins.getAddress().getOffset()), ins)) + for l in reversed(back): + print(l) + print(" %#x %s <== the call" % (site, listing.getInstructionAt(a))) + + print("=" * 78) + print("H3b: is 0x18011cfa0 / 0x18011a5c0 operating on the same vtable? print them fully") + for fn in (0x18011cfa0, 0x18011a5c0, 0x1800d0600): + s = dec(fn) + print("===== %#x %s (%d chars) =====" % (fn, fname(fn), len(s))) + print(s) + +except Exception: + traceback.print_exc() +print("QUERY DONE") diff --git a/fifa17-recon/tools/ghidra_queries/q_pack_v3_3.py b/fifa17-recon/tools/ghidra_queries/q_pack_v3_3.py new file mode 100644 index 0000000..bc680f8 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_pack_v3_3.py @@ -0,0 +1,136 @@ +# -*- coding: utf-8 -*- +"""ADVERSARIAL VERIFY 3: the reveal brain, the tier table, the headline predicate, +the sort, the NUM_*_IN_PACK provider, and the playerType ABSENCE claim. + +H1 FUN_1800aa440 ranks on item+0x38 with fallback item+0x3c, gates on cardtype==1, + fires 0x33 / 0x34, and issues NO network request. + ATTACK on the "no network request" ABSENCE: instead of grepping the decompile text, + I enumerate EVERY call target in the function FROM THE DISASSEMBLY and print its name, + then check them against the known URL-builder / request machinery. +H2 FUN_1800a9fe0(rareflag, rating) -> 1/2/3 with the quoted thresholds. +H3 FUN_1800aa330 = (loans < 1) && (rating > 0x57 || playerid in fcc_GrandStandPlayers) +H4 FUN_1800a96a0 is a stable descending sort keyed on tuple[0] +H5 FUN_180015d80 reads NUM_*_IN_PACK from packdef +0xc0..+0xd0 +H6 ABSENCE: atom 0x23d playerType has no arm in 0x18013fe00. + ATTACK with a DIFFERENT METHOD than grepping the decompile: I reconstruct the + atom ladder from the DISASSEMBLY of 0x18013fe00 by walking every SUB/CMP/DEC + immediate on the dispatch register and accumulating the running sum, then report + the full set of atom ids the ladder can reach. CONTROL: 0x23f playStyle, 0xd7 + discardValue, 0x6c cardsubtypeid, 0x274 rating and 0x271 rareflag must all appear. +""" +import traceback, re + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres" + +def dump(n, s): + p = "%s/v_%s.txt" % (OUT, n) + open(p, "w").write(s) + print("[wrote %s %d chars]" % (p, len(s))) + +try: + print("=" * 78) + print("H1: FUN_1800aa440 FULL") + s = dec(0x1800aa440) + print("len(src) = %d chars, %d lines" % (len(s), s.count("\n") + 1)) + dump("q3_reveal", s) + print(s) + + print("--- disassembly: EVERY call target inside FUN_1800aa440 ---") + f = func(0x1800aa440) + it = listing.getInstructions(f.getBody(), True) + direct, indirect = [], [] + while it.hasNext(): + ins = it.next() + t = str(ins) + if not t.startswith("CALL"): + continue + a = int(ins.getAddress().getOffset()) + fl = ins.getFlows() + if fl and len(fl) > 0: + tgt = int(fl[0].getOffset()) + direct.append((a, tgt, fname(tgt))) + else: + indirect.append((a, t)) + print(" direct calls: %d" % len(direct)) + for a, tgt, nm in direct: + print(" %#x -> %#x %s" % (a, tgt, nm)) + print(" indirect calls: %d" % len(indirect)) + for a, t in indirect: + print(" %#x %s" % (a, t)) + print(" URL builder 0x180129200 called?", any(t == 0x180129200 for _, t, _ in direct)) + + print("=" * 78) + print("H2: FUN_1800a9fe0 FULL") + s2 = dec(0x1800a9fe0) + print("len=%d" % len(s2)); dump("q3_tier", s2); print(s2) + + print("=" * 78) + print("H3: FUN_1800aa330 FULL") + s3 = dec(0x1800aa330) + print("len=%d" % len(s3)); dump("q3_pred", s3); print(s3) + + print("=" * 78) + print("H4: FUN_1800a96a0 FULL") + s4 = dec(0x1800a96a0) + print("len=%d" % len(s4)); dump("q3_sort", s4); print(s4) + + print("=" * 78) + print("H5: FUN_180015d80 FULL") + s5 = dec(0x180015d80) + print("len=%d" % len(s5)); dump("q3_numpack", s5); print(s5) + + print("=" * 78) + print("H6: atom ladder of 0x18013fe00 reconstructed FROM DISASSEMBLY") + s6 = dec(0x18013fe00) + print("item deser decompile len=%d chars, %d lines" % (len(s6), s6.count("\n") + 1)) + dump("q3_item_deser", s6) + # decompile-side case list, for cross-check + cases = sorted(set(int(x, 16) for x in re.findall(r"case 0x([0-9a-f]+):", s6))) + print(" decompile 'case 0x..' arms: %d -> %s" % (len(cases), [hex(c) for c in cases])) + print(" decompile contains '0x23d'? ", "0x23d" in s6) + print(" decompile contains '0x23f'? ", "0x23f" in s6) + + f6 = func(0x18013fe00) + print(" function body: %s - %s" % (f6.getBody().getMinAddress(), f6.getBody().getMaxAddress())) + it = listing.getInstructions(f6.getBody(), True) + running = 0 + ladder = [] + seq = [] + while it.hasNext(): + ins = it.next() + mn = ins.getMnemonicString() + t = str(ins) + a = int(ins.getAddress().getOffset()) + if mn in ("SUB", "CMP", "DEC", "ADD"): + m = re.search(r",\s*(0x[0-9a-fA-F]+)$", t) + imm = None + if m: + imm = int(m.group(1), 16) + elif mn == "DEC": + imm = 1 + if imm is None: + continue + if mn == "SUB" or mn == "DEC": + running += imm + ladder.append((a, running, t)) + elif mn == "CMP": + ladder.append((a, running + imm, t + " [CMP => atom %#x]" % (running + imm))) + elif mn == "ADD": + running -= imm + ladder.append((a, running, t)) + seq.append((a, mn, imm, running)) + reach = sorted(set(v for _, v, _ in ladder)) + print(" ladder entries: %d ; distinct running-sum values: %d" % (len(ladder), len(reach))) + print(" reachable atom-ish values (hex): %s" % [hex(v) for v in reach]) + for probe, nm in ((0x23d, "playerType"), (0x23f, "playStyle"), (0xd7, "discardValue"), + (0x6c, "cardsubtypeid"), (0x274, "rating"), (0x271, "rareflag"), + (0x172, "itemState"), (0x19b, "loans"), (0x287, "resourceId")): + print(" atom %#-6x %-14s in ladder? %s in decompile cases? %s" + % (probe, nm, probe in reach, probe in cases)) + print(" --- raw ladder (first 400) ---") + for a, v, t in ladder[:400]: + print(" %#x sum=%#-6x %s" % (a, v, t)) + +except Exception: + traceback.print_exc() +print("QUERY DONE") diff --git a/fifa17-recon/tools/ghidra_queries/q_pack_v3_4.py b/fifa17-recon/tools/ghidra_queries/q_pack_v3_4.py new file mode 100644 index 0000000..d32cbbe --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_pack_v3_4.py @@ -0,0 +1,146 @@ +# -*- coding: utf-8 -*- +"""ADVERSARIAL VERIFY 4. + +A. ABSENCE ATTACK on "atom 0x23d playerType has no arm in 0x18013fe00". + The reviewed agent grepped the DECOMPILE TEXT. I attack it two other ways: + A1 decode the actual SWITCH JUMP TABLE from the disassembly (the ground truth + the decompiler's `case` labels are only a rendering of), and + A2 scan the WHOLE of .text for any instruction carrying the immediate 0x23d, + with 0x23f (playStyle, known present) and 0x20e (packOpeningAnimationEnabled, + known present in the settings deser) as positive controls. + +B. NUM_*_IN_PACK: is param_4+0xc0..0xd0 really filled from the pack DEFINITION JSON? + Full decompile of the pack element deser 0x18013af30, every write to +0xc0..+0xd0. + +C. FutCreatePackServerResponse 0x180162880 -- itemList appended in wire order. + +D. ABSENCE: USE_ANIMATION_STYLE 0x1801fd580 has exactly one xref. + +E. BONUS, the D6 open lead: which path reaches CARDS_CB_ERR_PACK_NOT_IN_DIME? +""" +import traceback, re, struct + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres" + +def dump(n, s): + p = "%s/v_%s.txt" % (OUT, n) + open(p, "w").write(s) + print("[wrote %s %d chars]" % (p, len(s))) + +try: + from ghidra.program.model.address import AddressSet + + print("=" * 78) + print("A1: switch dispatch inside 0x18013fe00 -- find indirect JMPs and their tables") + f6 = func(0x18013fe00) + print(" body %s - %s" % (f6.getBody().getMinAddress(), f6.getBody().getMaxAddress())) + it = listing.getInstructions(f6.getBody(), True) + jmps = [] + while it.hasNext(): + ins = it.next() + if ins.getMnemonicString() == "JMP" and "[" in str(ins): + jmps.append((int(ins.getAddress().getOffset()), str(ins))) + print(" indirect JMPs: %d" % len(jmps)) + for a, t in jmps: + print(" %#x %s" % (a, t)) + # Ghidra's switch recovery: look at the flow refs out of this instruction + try: + rs = refs.getReferencesFrom(addr(a)) + tgts = sorted(set(int(r.getToAddress().getOffset()) for r in rs + if r.getReferenceType().isFlow())) + print(" %d computed flow targets" % len(tgts)) + except Exception as e: + print(" refs failed", e) + # and the switch's case labels from the listing + # Ghidra stores case values as labels "caseD_xx" or in the jump table; use + # the decompiler's own high-level switch instead, but validate arm COUNT + s6 = dec(0x18013fe00) + cases = sorted(set(int(x, 16) for x in re.findall(r"case 0x([0-9a-f]+):", s6))) + print(" decompile arms: %d" % len(cases)) + + print("=" * 78) + print("A2: whole-.text immediate scan for 0x23d / 0x23f / 0x20e / 0xd7") + blk = [b for b in mem.getBlocks() if b.getName() == ".text"][0] + aset = AddressSet(blk.getStart(), blk.getEnd()) + it = listing.getInstructions(aset, True) + want = {0x23d: [], 0x23f: [], 0x20e: [], 0x2c5: []} + n = 0 + while it.hasNext(): + ins = it.next() + n += 1 + try: + nops = ins.getNumOperands() + except Exception: + continue + for oi in range(nops): + objs = ins.getOpObjects(oi) + for o in objs: + try: + v = int(o.getValue()) + except Exception: + continue + if v in want: + a = int(ins.getAddress().getOffset()) + want[v].append((a, fname(a), str(ins))) + print(" instructions walked: %d" % n) + for v in sorted(want): + lst = want[v] + print(" --- immediate %#x : %d sites ---" % (v, len(lst))) + for a, fn, t in lst[:40]: + print(" %#x %-26s %s" % (a, fn, t)) + print(" 0x23d anywhere in .text? ", len(want[0x23d]) > 0) + print(" 0x23f (control) sites in 0x18013fe00? ", + [hex(a) for a, fn, t in want[0x23f] if fn == "FUN_18013fe00"]) + print(" 0x20e (control) sites in 0x18013c6d0? ", + [hex(a) for a, fn, t in want[0x20e] if fn == "FUN_18013c6d0"]) + + print("=" * 78) + print("B: pack element deser 0x18013af30 -- writes to +0xc0..+0xd0") + sb = dec(0x18013af30) + print(" len=%d chars, %d lines" % (len(sb), sb.count("\n") + 1)) + dump("q4_pack_elem_deser", sb) + for i, ln in enumerate(sb.split("\n")): + if re.search(r"0x(c0|c4|c8|cc|d0)\b", ln) or "0xc0" in ln: + print(" L%-4d %s" % (i + 1, ln.strip())) + print(" --- its case arms ---") + cb = sorted(set(int(x, 16) for x in re.findall(r"case 0x([0-9a-f]+):", sb))) + print(" ", [hex(c) for c in cb]) + + print("=" * 78) + print("C: FutCreatePackServerResponse deser 0x180162880") + sc = dec(0x180162880) + print(" len=%d chars, %d lines" % (len(sc), sc.count("\n") + 1)) + dump("q4_createpack_deser", sc) + print(sc) + + print("=" * 78) + print("D: xrefs to USE_ANIMATION_STYLE 0x1801fd580") + print(" string there:", rd_str(0x1801fd580)) + try: + for r in xrefs_to(0x1801fd580): + a = r[0] if isinstance(r, tuple) else int(r) + print(" ", r, fname(a)) + except Exception as e: + print(" EXC", e) + hits = find_all(b"USE_ANIMATION_STYLE", None) + print(" find_all('USE_ANIMATION_STYLE'):", [hex(int(h)) for h in hits]) + + print("=" * 78) + print("E: CARDS_CB_ERR_PACK_NOT_IN_DIME") + hits = find_all(b"CARDS_CB_ERR_PACK_NOT_IN_DIME", None) + print(" string sites:", [hex(int(h)) for h in hits]) + for h in hits: + try: + for r in xrefs_to(int(h)): + a = r[0] if isinstance(r, tuple) else int(r) + print(" xref %s in %s" % (r, fname(a))) + se = dec(a) + print(" ---- containing function %s, %d chars ----" % (fname(a), len(se))) + dump("q4_dime_%s" % fname(a), se) + print(se[:9000]) + except Exception as e: + print(" EXC", e) + +except Exception: + traceback.print_exc() +print("QUERY DONE") diff --git a/fifa17-recon/tools/ghidra_queries/q_pack_v3_5.py b/fifa17-recon/tools/ghidra_queries/q_pack_v3_5.py new file mode 100644 index 0000000..6552d2c --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_pack_v3_5.py @@ -0,0 +1,137 @@ +# -*- coding: utf-8 -*- +"""ADVERSARIAL VERIFY 5. + +A. THE LADDER. My immediate-scan in q_pack_v3_4 FAILED ITS OWN CONTROL: neither 0x23f + (a case Ghidra shows in 0x18013fe00) nor 0x20e (a case in 0x18013c6d0) exists as a + raw immediate. That proves the dispatch is a running SUB/DEC ladder and that any + "grep for the constant" method is invalid here. So: dump the FULL disassembly of + 0x18013fe00 and reconstruct the ladder from the SUB/JZ chain, validating the + reconstruction against the 52 arms Ghidra's decompiler reports. Only if the + reconstruction reproduces those 52 do I get to say anything about 0x23d. + +B. Who WRITES pack-definition +0xc0..+0xd0? D4 claim 13 grades the NUM_*_IN_PACK + fields authority=SERVER but its evidence only shows the READ. Find every function + that writes ALL of 0xc0/0xc4/0xc8/0xcc/0xd0 as dwords, and the callers of + FUN_180015d80 so param_4 can be identified. + +C. CARDS_CB_ERR_PACK_NOT_IN_DIME xref (the D6 open lead). q4 crashed here because I + passed blocks=None to find_all; fixed. +""" +import traceback, re + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres" + +def dump(n, s): + p = "%s/v_%s.txt" % (OUT, n) + open(p, "w").write(s) + print("[wrote %s %d chars]" % (p, len(s))) + +try: + from ghidra.program.model.address import AddressSet + + print("=" * 78) + print("A: full disassembly of 0x18013fe00, and ladder reconstruction") + f6 = func(0x18013fe00) + it = listing.getInstructions(f6.getBody(), True) + lines = [] + insns = [] + while it.hasNext(): + ins = it.next() + a = int(ins.getAddress().getOffset()) + lines.append("%#x %s" % (a, ins)) + insns.append((a, ins.getMnemonicString(), str(ins))) + dump("q5_item_deser_disasm", "\n".join(lines)) + print(" %d instructions" % len(insns)) + + # ladder: SUB reg,imm (or DEC reg) whose NEXT instruction is a conditional jump + acc = {} + atoms = [] + for i, (a, mn, t) in enumerate(insns): + nxt = insns[i + 1][1] if i + 1 < len(insns) else "" + m = re.match(r"^(SUB|CMP|DEC|MOV)\s+([A-Z0-9]+),?\s*(.*)$", t) + if not m: + continue + op, reg, rest = m.group(1), m.group(2), m.group(3) + imm = None + mi = re.match(r"^(0x[0-9a-fA-F]+|\d+)$", rest.strip()) + if mi: + imm = int(mi.group(1), 0) + if op == "MOV": + # a fresh load of the dispatch register resets the running sum + acc[reg] = 0 + continue + if op == "DEC": + imm = 1 + rest = "1" + if imm is None: + continue + cond = nxt.startswith("J") and nxt not in ("JMP",) + if op == "SUB": + acc[reg] = acc.get(reg, 0) + imm + if cond: + atoms.append((a, acc[reg], reg, t, nxt)) + elif op == "CMP": + if cond: + atoms.append((a, acc.get(reg, 0) + imm, reg, t, nxt)) + vals = sorted(set(v for _, v, _, _, _ in atoms)) + s6 = dec(0x18013fe00) + cases = sorted(set(int(x, 16) for x in re.findall(r"case 0x([0-9a-f]+):", s6))) + print(" reconstructed ladder values (%d): %s" % (len(vals), [hex(v) for v in vals])) + print(" decompiler case arms (%d): %s" % (len(cases), [hex(c) for c in cases])) + inter = sorted(set(vals) & set(cases)) + print(" RECONSTRUCTION CONTROL: %d/%d decompiler arms reproduced" % (len(inter), len(cases))) + print(" arms the ladder found that the decompiler did not: %s" + % [hex(v) for v in sorted(set(vals) - set(cases))][:60]) + print(" 0x23d in reconstructed ladder? ", 0x23d in vals) + print(" 0x23f in reconstructed ladder? ", 0x23f in vals) + print(" --- ladder trace ---") + for a, v, reg, t, nxt in atoms: + print(" %#x atom=%#-6x %-28s next=%s" % (a, v, t, nxt)) + + print("=" * 78) + print("B: functions writing dwords at +0xc0/+0xc4/+0xc8/+0xcc/+0xd0") + blk = [b for b in mem.getBlocks() if b.getName() == ".text"][0] + it = listing.getInstructions(AddressSet(blk.getStart(), blk.getEnd()), True) + per = {} + rx = re.compile(r"MOV\s+dword ptr \[([A-Z0-9]+) \+ (0x(?:c0|c4|c8|cc|d0))\]") + while it.hasNext(): + ins = it.next() + t = str(ins) + m = rx.match(t) + if not m: + continue + a = int(ins.getAddress().getOffset()) + per.setdefault(fname(a), set()).add(m.group(2)) + full = [(k, sorted(v)) for k, v in per.items() if len(v) >= 4] + print(" functions writing >=4 of the five: %d" % len(full)) + for k, v in full: + print(" %-26s %s" % (k, v)) + print(" callers of FUN_180015d80:") + try: + for c in callers(0x180015d80): + print(" ", c) + except Exception as e: + print(" EXC", e) + print(" xrefs_to(0x180015d80):") + try: + for r in xrefs_to(0x180015d80): + print(" ", r) + except Exception as e: + print(" EXC", e) + + print("=" * 78) + print("C: CARDS_CB_ERR_PACK_NOT_IN_DIME") + hits = find_all(b"CARDS_CB_ERR_PACK_NOT_IN_DIME") + print(" string sites:", [hex(int(h)) for h in hits]) + for h in hits: + for r in xrefs_to(int(h)): + a = r[0] if isinstance(r, tuple) else int(r) + print(" xref %s in %s" % (r, fname(a))) + se = dec(a) + dump("q5_dime_%s" % (fname(a) or "unk"), se) + print(" ---- %s %d chars ----" % (fname(a), len(se))) + print(se) + +except Exception: + traceback.print_exc() +print("QUERY DONE") diff --git a/fifa17-recon/tools/ghidra_queries/q_verify_1.py b/fifa17-recon/tools/ghidra_queries/q_verify_1.py new file mode 100644 index 0000000..62e254b --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_verify_1.py @@ -0,0 +1,64 @@ +"""VERIFY PASS 1. + +Hypothesis under attack: the D1/D2 agents' HIGH claims about deserializer shapes. +Method: print FULL decompiles with len(src) for every function whose contents an +absence claim depends on, so truncation can be ruled out by the reader. + +Controls (must all resolve, else the batch is suspect): + FutSquadSaveServerResponse -> 0x180171a60 + FutSquadListServerResponse -> 0x180172140 + FutCreateMatchServerResponse -> 0x180120380 +""" +import traceback + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres/v1_raw.txt" + +try: + lines = [] + def P(*a): + s = " ".join(str(x) for x in a) + print(s) + lines.append(s) + + P("=" * 70) + P("CONTROL BATCH: class_deser") + for name in ["FutSquadSaveServerResponse", "FutSquadListServerResponse", + "FutCreateMatchServerResponse", "FutDiscardCardServerResponse", + "FutMoveCardServerResponse", "FutUserCreditsServerResponse", + "FutSBCSubmitChallengeServerResponse", + "FutGetPurchasedItemsServerResponse"]: + try: + P(" %-42s -> %s" % (name, [hex(x[0]) for x in class_deser(name)])) + except Exception as e: + P(" %-42s -> ERR %s" % (name, e)) + + TARGETS = [ + (0x18014cc60, "D1-1 FutCreateUserServerResponse deser"), + (0x18013ec10, "D1-2/D1-7 userInfo record deser"), + (0x180138e10, "D2-1 duplicateItemIdList element parser"), + (0x180127300, "D2-5 FutDiscardCardServerResponse deser"), + (0x180128600, "D2-9 FutMoveCardServerResponse deser"), + (0x180162880, "D2-2 createPackResponse deser"), + (0x180126f40, "D2-10 bulk discard body builder"), + (0x180127cc0, "D2-3 MoveCard request body builder"), + (0x180142650, "D2-8 pile string->enum decoder"), + (0x18013bd40, "D1-11 FutGetPurchasedItems body deser"), + (0x180124ee0, "D1-11 FutGetPurchasedItems root deser"), + (0x180127570, "D2-4 discard URL suffix builder"), + ] + for va, tag in TARGETS: + f = func(va) + src = dec(va) + P("") + P("=" * 70) + P("### %s @ %#x fname=%s entry=%s len(src)=%d" % + (tag, va, fname(va) if f else "?", + hex(int(f.getEntryPoint().getOffset())) if f else "NONE", len(src))) + P("=" * 70) + P(src) + + with open(OUT, "w") as fh: + fh.write("\n".join(lines)) + print("WROTE", OUT, len(lines), "lines") +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_verify_2.py b/fifa17-recon/tools/ghidra_queries/q_verify_2.py new file mode 100644 index 0000000..9c9f71f --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_verify_2.py @@ -0,0 +1,97 @@ +"""VERIFY PASS 2. + +Attacks: + D1-4 displayGroup(0xd9) is an OBJECT {priority,value}, not an ARRAY + D1-6 unopened(0x35d) is TOP-LEVEL in the pack element, not inside packContentInfo + D1-13 seven "SKIP" keys are actually parsed + D1-5 FUN_1800150d0 walks the same 0x158 array and matches "mypacks" + D1-9 atom 0x20d packList is not a wire key -- ATTACKED WITH A DIFFERENT METHOD: + a raw byte scan of .text for the 4-byte immediate 0d 02 00 00, reporting + the containing function and the two preceding opcode bytes. Their method + was a decompile-based atlas of hasher callers; if the byte scan finds a + dispatch site in a function their atlas missed, the claim falls. + CONTROL for the scan: the same scan for 0x35e (unopenedPacks) MUST hit + FUN_18013ec10, and for 0x2e5 (starterPack) MUST hit FUN_18014cc60. + D1-15 model vtable 0x18021c2a0 slots +""" +import traceback + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres/v2_raw.txt" + +try: + lines = [] + def P(*a): + s = " ".join(str(x) for x in a) + print(s) + lines.append(s) + + # ---------- immediate byte scan ---------- + def imm_scan(val, label, expect=None): + pat = bytes([val & 0xFF, (val >> 8) & 0xFF, (val >> 16) & 0xFF, (val >> 24) & 0xFF]) + hits = find_all(pat, blocks=(".text",)) + seen = {} + for h in hits: + f = fm.getFunctionContaining(addr(h)) + if f is None: + continue + pre = read_bytes(h - 3, 3).hex() + e = int(f.getEntryPoint().getOffset()) + seen.setdefault(e, []).append((h, pre)) + P("") + P("--- imm_scan %s (0x%x) : %d raw hits in .text, %d containing functions" + % (label, val, len(hits), len(seen))) + for e in sorted(seen): + P(" %-14s %s sites=%s" % (fname(e), hex(e), + ",".join("%x[pre=%s]" % (h, p) for h, p in seen[e][:6]))) + if expect is not None: + P(" CONTROL expect %s present: %s" % (hex(expect), expect in seen)) + return seen + + imm_scan(0x35e, "unopenedPacks", expect=0x18013ec10) + imm_scan(0x2e5, "starterPack", expect=0x18014cc60) + imm_scan(0x20d, "packList") + imm_scan(0x35d, "unopened") + imm_scan(0x20c, "packContentInfo") + + # ---------- string xrefs, independent of the atlas ---------- + P("") + P("--- literal xrefs") + for lit in [b"packs/dreamsquad/dreamsquadpacklist.json\x00", b"mypacks\x00", + b"RELOAD_CENTRAL_PANEL\x00", b"GOTO_STORE_MYPACK\x00", + b"fcc_discardcoins\x00", b"/purchasegroup\x00", b"?ppInfo=true\x00"]: + for a in find_all(lit): + P(" %-42s @ %#x xrefs=%s" % (lit[:-1].decode(), a, + [(hex(x[0]), x[2]) for x in xrefs_to(a)])) + + # ---------- model vtable ---------- + P("") + P("--- model vtable 0x18021c2a0 selected slots") + for off in (0x160, 0x1f8, 0x480, 0x4e0, 0x4e8, 0x940, 0xa30, 0xa48, 0xc0, 0x120): + try: + t = qword(0x18021c2a0 + off) + P(" +0x%03x -> %#x %s" % (off, t, fname(t) if fm.getFunctionAt(addr(t)) else "")) + except Exception as e: + P(" +0x%03x ERR %s" % (off, e)) + + # ---------- decompiles ---------- + for va, tag in [(0x18013af30, "D1-4/6/13 store pack element deser"), + (0x1800150d0, "D1-5 My Packs screen builder"), + (0x18002c3c0, "D1-6 pack tile view-model copy"), + (0x180123430, "D1-12 purchasegroup URL builder"), + (0x18011e120, "D1-7 model vt+0x4e0 setter"), + (0x18017fc20, "D1-9 packList file parser")]: + f = func(va) + src = dec(va) + P("") + P("=" * 70) + P("### %s @ %#x fname=%s entry=%s len(src)=%d" % + (tag, va, fname(va) if f else "?", + hex(int(f.getEntryPoint().getOffset())) if f else "NONE", len(src))) + P("=" * 70) + P(src) + + with open(OUT, "w") as fh: + fh.write("\n".join(lines)) + print("WROTE", OUT) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_verify_3.py b/fifa17-recon/tools/ghidra_queries/q_verify_3.py new file mode 100644 index 0000000..fe2f239 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_verify_3.py @@ -0,0 +1,73 @@ +"""VERIFY PASS 3. + +Attacks: + D1-10 FutUserCredits(0x180122c50) / FutSBCSubmitChallenge(0x180161b00) key sets + D1-9 packList parser 0x18017fc20 and its element parser 0x18017f830 + D1-8 tile 0x1c CentralUnclaimedPack in FUN_1800b2680 + D2-2 reveal-screen reader FUN_18009bc40 (does it gate on card+0x10?) + D2-6 discardValue client fallback inside FUN_18013fe00 (print the guard region) + D2-7 bounded-negative: who reads FutDiscardCardServerResponse+0x28 + Also: xrefs to the two ByRes deserializers, and whether chemistry(0x81) appears + in FutMoveCardByRes 0x180128e30 (docs put chemistry on MoveCard). +""" +import traceback + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/packres/v3_raw.txt" + +try: + lines = [] + def P(*a): + s = " ".join(str(x) for x in a) + print(s) + lines.append(s) + + for va, tag in [(0x180122c50, "D1-10 FutUserCreditsServerResponse deser"), + (0x180161b00, "D1-10 FutSBCSubmitChallengeServerResponse deser"), + (0x18017fc20, "D1-9 packList root parser"), + (0x18017f830, "D1-9 packList element parser"), + (0x18009bc40, "D2-2 pack-reveal controller"), + (0x180128e30, "D2-9 FutMoveCardByRes deser (chemistry?)"), + (0x1801279c0, "D2-5 FutDiscardCardByRes deser")]: + f = func(va) + src = dec(va) + P("") + P("=" * 70) + P("### %s @ %#x fname=%s entry=%s len(src)=%d" % + (tag, va, fname(va) if f else "?", + hex(int(f.getEntryPoint().getOffset())) if f else "NONE", len(src))) + P("=" * 70) + P(src) + + # discardValue fallback: print the item deser around the fcc_discardcoins site + P("") + P("=" * 70) + src = dec(0x18013fe00) + P("### D2-6 item element deser 0x18013fe00 len(src)=%d" % len(src)) + P("=" * 70) + L = src.split("\n") + idx = [i for i, l in enumerate(L) if "fcc_discardcoins" in l or "discardValue" in l + or '"price"' in l or '"rare"' in l or '"level"' in l or '"cardtype"' in l] + P("marker lines: %s" % idx) + lo = max(0, min(idx) - 45) if idx else 0 + hi = min(len(L), max(idx) + 30) if idx else 0 + for i in range(lo, hi): + P("%5d %s" % (i, L[i])) + P("--- all lines mentioning 0xd7 (discardValue atom) ---") + for i, l in enumerate(L): + if "0xd7" in l: + P("%5d %s" % (i, l)) + + # D2-7 bounded negative, done a different way: every xref to the response vtable + P("") + P("--- D2-7 vtable 0x180220488 slots + xrefs ---") + for i in range(8): + t = qword(0x180220488 + i * 8) + P(" +0x%02x -> %#x %s" % (i * 8, t, fname(t) if fm.getFunctionAt(addr(t)) else "")) + P(" xrefs to 0x180220488: %s" % [(hex(x[0]), x[2]) for x in xrefs_to(0x180220488)]) + P(" dec(0x180122420) = %s" % dec(0x180122420).replace("\n", " ")[:300]) + + with open(OUT, "w") as fh: + fh.write("\n".join(lines)) + print("WROTE", OUT) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/utas_server.py b/fifa17-recon/tools/utas_server.py index 2daff6a..5042476 100755 --- a/fifa17-recon/tools/utas_server.py +++ b/fifa17-recon/tools/utas_server.py @@ -18,8 +18,13 @@ from fut_seed import CLUB, SQUAD, USER_LIST, squad_summary # forged starter sq from fut_store import STORE, PACK_CATALOG, pack_by_id, PACK_POOL, _item # profile + packs from fut_account import ACCOUNT, validate_club # identity + club, single source -ADDR = ("127.0.0.1", 8099) -LOG = "/tmp/utas_server.log" +# FUT_PORT exists so a second, THROWAWAY instance can be started without touching the +# one the live client is talking to. Research agents kept bouncing the live server +# because the only way to exercise a route was to restart the only server there was; +# with this plus FUT_PROFILE (a copy of the save) and FUT_TEST_BASE, a test run is +# fully isolated. The default stays 8099: that is the port the hook redirects to. +ADDR = ("127.0.0.1", int(os.environ.get("FUT_PORT", "8099"))) +LOG = os.environ.get("FUT_LOG", "/tmp/utas_server.log") SID = "OPENFUT-SID-0000000000000001" # IDENTITY NOTE: there are no PERSONA_ID / PERSONA_NAME literals in this file any # more. They lived here, in fut_store.py, fut_seed.py, blaze_responder_v3b.py and