The setup

The target was a yield vault behind a transparent proxy. Users deposit a token, the vault mints shares, and an implementation contract holds the accounting logic. Standard pattern — until the team shipped a v2 implementation that reordered a couple of state variables.

Why storage layout matters

In the proxy pattern, state lives in the proxy's storage while logic lives in the implementation. The implementation reads and writes storage by slot, computed from the order variables are declared. Reorder a variable in v2 and the new logic reads slot n expecting one value while the proxy still holds something completely different there.

// v1 layout                 // v2 layout (reordered!)
mapping(address => uint) bal;  address owner;
address owner;                 mapping(address => uint) bal;

After the upgrade, balances[attacker] in v2 resolved to the slot that v1 had used for owner — an address-shaped value the EVM happily reinterpreted as an enormous integer balance.

The exploit path

  1. Read the post-upgrade storage and confirm the collision off-chain.
  2. Call withdraw(); the contract believed the attacker held a balance equal to a packed address.
  3. The vault dutifully transferred far more than was ever deposited.
Upgrades aren't free. Every reordered variable is a potential collision, and the compiler will not warn you.

The fix

Append-only storage and a storage gap. New variables go at the end; never reorder, never remove. Tools like openzeppelin-upgrades can diff layouts in CI and fail the build on an incompatible change — cheap insurance against an eight-figure mistake.

Takeaway

Upgradeability is a security boundary, not a convenience. Treat every implementation swap like a migration that can corrupt state, because that is exactly what it is.