When 2^128 becomes 2^32
A way to recover the map position of an in-game router (RouterB) given only:
RouterA), andPower) that RouterA reports for RouterB,
retrieved by iwconfig or wifi_networks().
No world seed is required.
With the world seed, players can
already generate a router's details, BSSID, ESSID, password, and so on, by replaying generation
forward. That's been done. Position was the exception, because each router seeds its own
RNG with Guid.NewGuid().GetHashCode(), and there's no path from the world seed to a
router's coordinates. Deriving position from this still is not possible.
What's different here is that position is now predictable from an observable, which it wasn't before. Given one known router and a single signal reading, the per-router seed can be recovered by brute force, so the position comes out even though it can't be derived from the world seed.
Each router in the game is placed with its own RNG instance:
new System.Random(Guid.NewGuid().GetHashCode())
I had long ignored the possibility of brute-forcing the Random seed because Guid.NewGuid()
is 128 bits, and 2^128 is a
few too many exponents for me to spend a weekend writing code to crack. As an aside, the name for that
number is 340 undecillion.
But revisiting the code recently, I realized we're not cracking against Guid.NewGuid()
directly — GetHashCode() returns an int, so the seed is only 32 bits — about
4.3
billion
candidates. The GUID is presumably there to keep position from being derivable off the
world seed, and it does that. But 32 bits is small enough to sweep exhaustively on a GPU.
For a given seed, generation is deterministic and short:
| RNG draw | Purpose | Maps to |
|---|---|---|
| sample 0 | cell index | int(s * cell_count) |
| sample 1 | x offset | [-20, 20] within the cell |
| sample 2 | y offset | [-20, 20] within the cell |
Cell anchors are static (from serialCeldas.json), so a seed gives a fixed world
position: anchor[cell] + (x_off, y_off).
This is the part that makes the search feasible. The game doesn't sample a free point in space — if it did, the placement would carry far more than 32 bits of effective entropy and a distance reading wouldn't narrow things down much. Instead it picks a cell, then a bounded offset (±20 per axis) from that cell's anchor, both from the same three RNG draws. Fix the seed and the position is reconstructed exactly, not approximated.
Power is derived from distance as:
Power = 100 - int(distance * 100f / 0.5f)
= 100 - int(distance * 200.0f) // float32 throughout
With k = 100 - Power, the reading constrains distance to a band:
k / 200.0 <= distance < (k + 1) / 200.0
That band is a thin ring centered on RouterA, so the search reduces to: which seeds put
RouterB on that ring?
The kernel runs two filters per seed:
int(distance * 200.0f) in float32 and keep seeds where it equals the observed
k.
The float32 arithmetic has to match the game exactly, or the correct seed gets rejected at the band edges.
[-2³¹, -1] and [0, 2³¹-1]) to
keep
thread indexing inside int64._dn_init / _dn_sample reimplement .NET's System.Random state
machine on-device, so
each thread advances the same RNG sequence the game produces._MBIG_INV (1 / 2,147,483,647) converts a raw System.Random sample to
[0, 1),
matching NextDouble().
Worth tracing why the geometry works against the actual generation code. GeneraPosRouter
builds a position as:
float xOff = (float)(random.NextDouble() * (sizeCelda.x) - sizeCelda.x / 2f);
float yOff = (float)(random.NextDouble() * (sizeCelda.y) - sizeCelda.y / 2f);
return celda.anchoredPosition + new Vector2(xOff, yOff);
That's an offset from a known center, not a coordinate. RouterB is always inside
anchor ± (20, 20). The reported Power then maps to a distance band around
RouterA.
So you're intersecting two things whose shape you know:
RouterB positions (the cell, ±20 per axis), andk/200 centered on RouterA (the distance band).
A small box intersected with a thin ring is a short arc, and only a few of the 4.3 billion seeds land a position on it. One reading prunes most of the space; a second reading from another known router gives a second ring, and the two rings intersect at roughly a point. Standard trilateration, except the beacons are router positions recovered the same way.
There's an assumption buried in "given RouterA's exact (x, y)": you're not really
supposed to know any router's exact position. The game doesn't hand it over. There is a
method it exposes that gets you the exact coordinate, though — so the anchor is obtainable,
just not advertised. I'm not going to call that method in a loop to enumerate everything:
it fires an RPC each time, and hammering the server to brute-force a map isn't a nice thing
to do to it. One read for the anchor, then math for the rest.
A single reading rarely returns exactly one seed — the ring usually intersects a few valid placements, so the tool reports all candidates and their average. A second neighbor's reading (a second ring) narrows it to at most a couple of points — the same trilateration that makes the single-anchor case work.
Limited, now. The point of it was mapping every network on the map, and the only use for that map was wifi-hopping between in-game networks, which has since been patched. So it's a demonstration of the technique rather than a working exploit, which is probably the better outcome anyway.
The whole thing rests on two observations: the per-router GUID seed is only 32 bits and so brute-forceable, and a single distance reading from a known anchor is a tight enough constraint to pin it down. The GUID isn't real entropy; the float32 arithmetic is the only thing you have to get exactly right.