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