indexed.digital
Back to posts

Empty API key and full IDOR chain on a Filipino browser strategy game


Empty API key and full IDOR chain on a Filipino browser strategy game

war.add.ph runs a game called "Tribes of Malaya" -- a browser-based town-building strategy game where you manage population, resources, buildings, and fight other players. It's a small Filipino indie project, React frontend built with Vite, custom REST API on Apache behind Cloudflare.

The entire API authenticates using a single X-api-key header. Setting it to an empty string bypasses everything. Pair that with a spoofed X-User-Id header and you can act as any user on the platform: read their DMs, rename their towns, demolish their buildings, post messages as them, send friend requests on their behalf. The server never checks whether you actually own what you're modifying.

How the auth works (and doesn't)

The frontend stores three values in localStorage after login: api_key, user_id, and town_id. An Axios interceptor attaches them as headers on every request:

X-api-key: <from localStorage>
X-User-Id: <from localStorage>
X-Town-Id: <from localStorage>

The server checks whether X-api-key is present. If you omit it, you get 403 "Missing API Key". If you send a real-looking string like "admin" or "null", you get 403 "Invalid API Key". But if you send an empty string -- literally X-api-key: "" -- it returns 200. Every endpoint. No exceptions.

I found this by pulling apart the minified JS bundle (index-DiFOg7Tn.js, 478KB). The interceptor logic was in there, along with every API route the app uses.

The IDOR

Once past the auth check, the server trusts X-User-Id completely. There's no session token, no JWT, no server-side check that the user ID matches whoever originally authenticated. You just set the header to any integer and you become that user.

So every authenticated endpoint is an IDOR. I didn't find a single one that checks ownership.

What I pulled

151 user accounts

GET /api/users/search returns every registered user. Just usernames and IDs, but that's enough to enumerate the whole player base. One user had their email as their username: l.aw.xr.e.ncevela.s.co@gmail.com (uid 8280).

379 global chat messages

GET /api/messages/global dumps the full chat history. No pagination needed, it all comes back at once. Players discussing game mechanics, trading resources, complaining about bugs. The developer (alberto, uid 2) is active in chat asking players to report bugs on Facebook.

120 private DMs across 9 users

GET /api/messages/friend with a spoofed X-User-Id returns that user's private messages. I checked a subset of known users and pulled 120 DMs total. The heaviest inbox belonged to uid 6504 with 54 messages, followed by uid 8289 with 33.

97 friend records across 34 users

GET /api/friends with each user ID returns their friend list. 34 users had at least one friend connection, 97 records total.

14 towns with full game state

GET /api/my/town/1/attack/suggestions leaks every attackable town: name, owner username, owner ID, population count. 14 towns showed up. Fetching individual towns with GET /api/my/town/{id} returned full state including resource capacities, morale, troop capacity, and the owner's tribe.

Some town names:

  • "Bayan ni alberto" (pop 229, owner alberto)
  • "BAYAN NG CEBU" (pop 564, owner Mai)
  • "Hidden Village of Grandline" (pop 629, owner web2sign)
  • "AKO TO SI NATOY WALANG TULOG" (pop 416, owner Caberon)
  • "Narnia" (pop 807, owner Jameslee)

What I could do (and did test)

I tested each destructive endpoint with throwaway values. All of them went through.

  • Renamed town 54 by POSTing to /api/my/town/54/rename as uid 8167. Response: {"message":"Town renamed successfully","name":"IDOR_TEST_RENAME"}. I don't own that town.

  • Demolished a building via DELETE /api/my/town/54/buildings/906. Got back {"message":"Building demolition scheduled","demolish_timestamp":1771681125}. The building just starts tearing down.

  • Posted a global chat message as the game developer. POST /api/messages/global with X-User-Id: 2 and {"message":"IDOR_TEST_MSG"}. Shows up in chat as "alberto". Message ID 3654.

  • Sent a friend request as someone else. POST /api/friends with a spoofed user ID. {"message":"Friend request sent"}.

  • Tried deleting a friendship. DELETE /api/friends/{id} returned 404 (not found), not 403 (unauthorized). No auth check at all.

  • Attempted building upgrades via PATCH /api/my/town/{id}/buildings/{id}. Only rejected for insufficient resources -- not for wrong ownership. If the town has enough food/wood, it goes through.

  • Tried launching attacks with POST /api/my/town/{attacker}/attack/{target}. Only gated by a 15-population minimum. Ownership not checked.

The registration endpoint

POST /api/public/users/verify/send creates new account verification requests. No rate limiting, no CAPTCHA, accepts duplicate emails. You can flood it. The full registration flow goes: verify/send (email + name) -> verify (email + token + access_code) -> first-access (set username + password + birthdate). That last step only works once per account, so you can't use it to change existing passwords. But you can spam the first step endlessly.

Stack

  • Cloudflare CDN (172.67.131.169, 104.21.4.47)
  • Apache backend
  • React 18 SPA, built with Vite
  • Custom REST API (not a framework I recognize, possibly hand-rolled Node or PHP)
  • Auth: custom header-based, no JWT, no sessions
  • Game: "Tribes of Malaya", Filipino-themed town sim with PvP

The root cause

The server validates that X-api-key is present and non-null, but treats an empty string as valid. That's the auth bypass. After that, it uses X-User-Id from the request header as the authenticated identity without any server-side session binding. Every "my" endpoint (/my/town/, /friends, /messages/friend, etc.) just reads the header and assumes you are who you say you are.

Screenshot 2026-02-21 220424.png

There is no ownership check. When you POST to /my/town/54/rename, the server doesn't verify that user 8167 actually owns town 54. It just does it.

Data dump

351KB JSON file containing:

  • 151 user accounts (id + username)
  • 379 global chat messages with timestamps
  • 120 private DMs across 9 users
  • 97 friend records across 34 users
  • 14 towns with full game state (population, resources, capacities, owner data)
  • 1 email address exposed through username field