#!/usr/bin/env python3 """OpenFUT — POW / EASFC server for FIFA 17 (clean-room). WHY THIS EXISTS --------------- The FUT hub's "EA FC servers are unreachable / PRESS Q TO RE-CONNECT" banner is NOT the FUT/UTAS layer, NOT Blaze and NOT Origin/LSX -- all three are healthy in our live logs while the banner is showing. It is the EASFC layer, implemented in `powdll_Win64_retail.dll` (1.1 MB, UNPACKED and string-rich -- unlike the Denuvo -packed FIFA17.exe, this one can actually be reversed). POW is a THIRD HTTP API, alongside Blaze and UTAS, that we have never served: api pas.gt.easfc.ea.com:8094 paths `pow/...` content content.lt.easfc.ea.com:8080 paths `pow/imgAssets/...`, artAssets, ... Neither hostname is in /etc/hosts nor in the iptables DNAT, so every POW call dies at DNS resolution and the client raises the reconnect prompt. REVERSED FROM powdll (PE base 0x180000000, Ghidra project /tmp/pow/powproj): * FUN_18005a460 -- POW config init. Reads, through the SAME client-config store that already feeds us ROSTERUPDATE_URL (cfg->vtbl[0x30] = getString with a default): "FIFA_POW_URL", "FIFA_POW_CONTENT_SERVER_URL", and "POW_IS_ON". It picks an http:// vs https:// prefix (PTR_s_http____18010aee0 / PTR_s_https____18010aee8). So POW can be redirected purely by serving those keys from blaze_responder_v3b.py -- no /etc/hosts and no root required. * FUN_18005cb40 -- the health-check / reconnect handler. Issues `pow/healthcheck/system/all` via the request builder FUN_18005e780, then sets the POW connection state at POWmgr[0x6ac]: 1 = connected/online 3 = disconnected (raises the prompt) It is also the site that fires the `POWService::PowReconnect` FE event. * FUN_18005c970 fires POWService::PowBlazeDisconnected, FUN_1800a8590 fires POWService::TriggerPleaseConnectMsg, FUN_1800ad090 references TXT_EASFC_RECONNECT_PROMPT (the banner string). STATUS: the REQUEST side is mapped (58 `pow/...` path templates extracted from the binary, see PATHS below). The RESPONSE schemas are NOT yet reversed -- powdll's parsers have not been walked. So this server's job right now is to be a faithful, loud LOGGER: bind the ports, answer every request in a way that cannot wedge the client, and write the exact method/path/headers/body of everything POW asks for to /tmp/pow_server.log. That capture is what turns the response schemas from guesswork into reversing targets, exactly as the UTAS log did for the squad work. MODES (POW_MODE): log (default) every request -> 200 {} (assets -> 404), everything logged. Nothing is asserted about our capabilities; safest first run. serve additionally answers the handful of paths whose shape we can infer (auth/healthcheck/counts) with minimal plausible bodies. Use this only AFTER a capture run, and expect to iterate. Ports: POW_ADDR (default 127.0.0.1:8094), POW_CONTENT_ADDR (default 127.0.0.1:8080). """ import datetime, json, os, re, sys, threading, http.server sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) try: from fut_account import ACCOUNT # username/persona, single source except Exception: # keep the logger usable standalone ACCOUNT = None LOG = os.environ.get("POW_LOG", "/tmp/pow_server.log") # Default flipped to `serve` once the schemas were recovered from powdll: `log` # answers every list with {}, which makes the catalogue pager spin forever (983 # requests in 84s, live-captured). POW_MODE=log is still available for a fresh # capture run. MODE = os.environ.get("POW_MODE", "serve") API_ADDR = os.environ.get("POW_ADDR", "127.0.0.1:8094") CONTENT_ADDR = os.environ.get("POW_CONTENT_ADDR", "127.0.0.1:8080") def _split(hostport, default_port): host, _, port = hostport.partition(":") return (host or "127.0.0.1", int(port or default_port)) def log(m): line = "[%s] %s" % (datetime.datetime.now().strftime("%H:%M:%S"), m) print(line, flush=True) try: with open(LOG, "a") as f: f.write(line + "\n") except Exception: pass # Every `pow/...` path template found in powdll_Win64_retail.dll. Kept verbatim so # the log can flag an incoming path that is NOT in this list (i.e. our extraction # missed something) rather than silently lumping it in with the known set. PATHS = [ "pow/auth", "pow/healthcheck/system/all", "pow/nucleus/entitlements", "pow/v2/activity", "pow/activity/count", "pow/bank/user/account", "pow/bank/currency/%s/cap/info", "pow/chal/user/prog", "pow/communication/all", "pow/communication/all/countUnread", "pow/communication/count", "pow/communication/type/%s", "pow/communication/attributes/type/%s", "pow/components/EASFCWidget", "pow/gamechange/gamechangetype/%s", "pow/inventory/item", "pow/inventory/item/list", "pow/lvl/user/tiergp/%s/tiertp/%s", "pow/lvl/weight/tiergp/%s/tiertp/%s", "pow/message", "pow/mm", "pow/mm/game/%s/message/list", "pow/news/count/unread", "pow/news/opt", "pow/news/user", "pow/pfyc/user", "pow/pfyc/user/club", "pow/pfyc/user/prefs/shareinfo", "pow/store/game/%s/catalog/list", "pow/store/game/%s/catalog/%d/item/list", "pow/store/gift/list", "pow/user/friends", "pow/users/info/tiergp/%s/tiertp/%s", ] _KNOWN = [re.compile("^/?" + re.escape(p).replace(r"\%s", "[^/]+").replace(r"\%d", r"\d+") .replace(r"\%lld", r"\d+") + "$") for p in PATHS] # Asset roots are PREFIXES in the binary ("pow/imgAssets/", plus %d-templated file # names), so match them by prefix rather than exact template or every art fetch # trips the unknown-path flag. _ASSET_PREFIXES = ("pow/imgAssets/", "pow/artAssets/", "pow/facebook/", "pow/cacheresponse/") def is_known_path(path): p = path.split("?", 1)[0] if p.lstrip("/").startswith(_ASSET_PREFIXES): return True return any(rx.match(p) for rx in _KNOWN) def _username(): if ACCOUNT is not None: return ACCOUNT.persona_name return os.environ.get("POW_USERNAME", "CAGE") def _persona_id(): if ACCOUNT is not None: return ACCOUNT.persona_id return int(os.environ.get("POW_PERSONA_ID", "33068179")) # ---- response schemas, recovered from powdll --------------------------------- # Every key below is a LITERAL STRING in powdll_Win64_retail.dll, i.e. a name the # client's parser actually compares against. Addresses are the literal's location. # # level parser FUN_180094700 groups exactly these seven: # level(0x1800c9862) exp(0x1800e1974) currLevelExpMin(0x1800e1978) # currLevelExpMax(0x1800e1988) isMaxLevel(0x1800e1998) dailyXpCap(0x1800e1bb0) # currency(0x1800c96e0) # paging: itemsTotal(0x1800e18c0) numItems(0x1800e17d8) totalCount(0x1800c9218) # bank (contiguous field-name table, i.e. a reflection-style schema): # currencies(0x1800c96b8) currency(0x1800c96e0) currencyName(0x1800c96f0) # funds(0x1800c98f0) fundsBalance(0x1800c98f8) fundsCap(0x1800c9908) # fundsCapInfo(0x1800c9918) fundsEarned(0x1800c9928) # accountBalance(0x1800c92e0) balance(0x1800ccb28) numCurrency(0x1800e1808) # pow_funds(0x1800c7f30) -- the currency NAME the client asks for by # `pow/bank/currency/pow_funds/cap/info` (live-captured). # # DELIBERATELY NOT INVENTED: personaId / personaName / userId / sessionId / # displayName / personaList do NOT exist as literals anywhere in powdll, so an # auth response carrying them would be parsed as nothing. An earlier draft of this # file asserted exactly those keys -- it was wrong and is corrected here. POW_CURRENCY = "pow_funds" # ---- ENVELOPE PROBE ---------------------------------------------------------- # The field NAMES are certain (literals in powdll). The top-level ENVELOPE is not: # serving the level record at the JSON root was live-tested and IGNORED -- the hub # still read "LVL: 0/0". The wrapper is not statically recoverable so far: the name # tables (FUN_180094700 etc.) are plain `return names[idx]` helpers with no schema # descriptor attached, and their only other xrefs are .pdata unwind entries. # # So probe empirically, but in ONE launch instead of one-per-candidate: emit the # record at the root AND under every plausible wrapper key at once. A reflection # parser ignores members it has no field for (the same SKIP behaviour CardsDLL's # deserializers use), so the extra copies are inert -- whichever wrapper the client # looks for, it finds. Wrapper candidates are the envelope-ish literals that exist # in powdll: data(0x1800c818c) result(0x1800ce3fc) items(0x1800c9e28) # content(0x1800c9378) status(0x1800dd2c0) success(0x1800daed8) message(0x1800cef28). # # Set POW_ENVELOPE=root to serve ONLY the bare record (no probe copies) once the # right wrapper is known. _ENVELOPE = os.environ.get("POW_ENVELOPE", "probe") def _wrap(record, list_key="items"): """Root record + probe copies under each candidate wrapper.""" if _ENVELOPE == "root": return dict(record) body = dict(record) for k in ("data", "result", "content"): body[k] = dict(record) body[list_key] = [dict(record)] body["numItems"] = 1 body["itemsTotal"] = 1 body["totalCount"] = 1 body["status"] = "OK" body["success"] = True return body def level_record(): """The seven fields powdll's level name table (FUN_180094700) enumerates.""" a = ACCOUNT return { "level": a.pow_level if a else 1, "exp": a.pow_exp if a else 0, "currLevelExpMin": 0, "currLevelExpMax": a.pow_exp_max if a else 1000, "isMaxLevel": False, "dailyXpCap": 0, "currency": POW_CURRENCY, } def level_body(): """pow/lvl/user/tiergp/%s/tiertp/%s -> the hub's "LVL: x/y" widget.""" return _wrap(level_record(), list_key="levels") def bank_body(): """pow/bank/user/account -> the EASFC credit counter next to the cart.""" a = ACCOUNT funds = a.pow_funds if a else 0 cap = a.pow_funds_cap if a else 100000 entry = { "currencyName": POW_CURRENCY, "currency": POW_CURRENCY, "funds": funds, "fundsBalance": funds, "fundsEarned": 0, "fundsCap": cap, "balance": funds, "accountBalance": funds, } body = _wrap(entry, list_key="currencies") body["numCurrency"] = 1 return body def _empty_page(): """Any paginated list. The count fields are what TERMINATE the pager. Not cosmetic: with a bare {} the catalogue pager never learns the result count and re-requests offset=0&count=49 forever -- 983 identical requests in 84s on the first live capture, still 432 with only itemsTotal/numItems set. So emit the FULL count vocabulary that powdll's list envelope reader FUN_180094560 enumerates (numItems 0x1800e17d8, numOwnedItems 0x1800e17e8, numLockedItems 0x1800e17f8, numCurrency 0x1800e1808) plus the totals the catalog-item reader FUN_1800945c0 knows (itemCount 0x1800e18b0, itemsTotal 0x1800e18c0, itemsOwned 0x1800e18d0), and an empty array under every plausible list key.""" body = { "numItems": 0, "numOwnedItems": 0, "numLockedItems": 0, "numCurrency": 0, "itemCount": 0, "itemsTotal": 0, "itemsOwned": 0, "totalCount": 0, "count": 0, "offset": 0, "status": "OK", "success": True, } for k in ("items", "list", "data", "result", "content", "catalogs", "currencies", "entries"): body[k] = [] return body def serve_body(path, method): """MODE=serve. Bodies built only from keys verified present in powdll (above). Returns None to fall through to {}.""" p = path.split("?", 1)[0].lstrip("/") if p == "pow/healthcheck/system/all": # FUN_18005cb40 issues this first, then sets POWmgr[0x6ac] 1=connected / # 3=disconnected. Live: the client went ONLINE with a bare {} here, so the # state is driven by transport success, not by this body. Keep it minimal. return {} if p.startswith("pow/lvl/"): # user + weight both parse here return level_body() if p == "pow/bank/user/account": return bank_body() if p.startswith("pow/bank/currency/") and p.endswith("/cap/info"): a = ACCOUNT return {"currencyName": POW_CURRENCY, "fundsCap": (a.pow_funds_cap if a else 100000), "fundsEarnedInPeriod": 0} if p.endswith("/count") or p.endswith("/countUnread"): return {"count": 0, "totalCount": 0} # Everything list-shaped gets a terminating page. Catalogue, inventory, gifts, # friends, activity, messages, news -- all were captured live and all page. if ("/list" in p or p in ("pow/v2/activity", "pow/user/friends", "pow/message", "pow/mm", "pow/communication/all", "pow/news/user", "pow/nucleus/entitlements", "pow/inventory/item")): return _empty_page() return None class _Handler(http.server.BaseHTTPRequestHandler): protocol_version = "HTTP/1.1" kind = "api" def _handle(self): n = int(self.headers.get("Content-Length", 0) or 0) body = self.rfile.read(n) if n else b"" tag = "" if is_known_path(self.path) else " !! PATH NOT IN THE EXTRACTED TEMPLATE SET" log("%s %s %s%s" % (self.kind.upper(), self.command, self.path, tag)) for k, v in self.headers.items(): log(" %s: %s" % (k, v)) if body: log(" body: %s" % body[:65536].decode("utf-8", "replace")) if self.kind == "content": # Art assets (.dds/.png). We have none; 404 is the honest answer and is # what a missing-asset CDN would return. Logged so we learn what art the # client wants before deciding to synthesise any. self.send_response(404) self.send_header("Content-Length", "0") self.end_headers() log(" -> 404 (no asset)") return payload = serve_body(self.path, self.command) if MODE == "serve" else None raw = json.dumps(payload if payload is not None else {}).encode() self.send_response(200) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(raw))) self.end_headers() if self.command != "HEAD": self.wfile.write(raw) log(" -> 200 %s" % raw[:400].decode()) do_GET = do_POST = do_PUT = do_DELETE = do_HEAD = do_PATCH = _handle def log_message(self, *a): pass class _ContentHandler(_Handler): kind = "content" def _serve(addr, handler, label): host, port = addr srv = http.server.ThreadingHTTPServer((host, port), handler) log("=== pow %s listening on http://%s:%d ===" % (label, host, port)) srv.serve_forever() if __name__ == "__main__": open(LOG, "a").close() api = _split(API_ADDR, 8094) content = _split(CONTENT_ADDR, 8080) log("=== pow_server MODE=%s api=%s:%d content=%s:%d user=%r ===" % (MODE, api[0], api[1], content[0], content[1], _username())) t = threading.Thread(target=_serve, args=(content, _ContentHandler, "content"), daemon=True) t.start() _serve(api, _Handler, "api")