Skip to content

Unrecoverable deadlock when upgrading UUPS proxy from v4 (sequential storage) to v5 (ERC-7201 namespaced storage) #6362

@KortexNovaK

Description

@KortexNovaK

UUPS Proxy Permanently Bricked by OZ v4→v5 Storage Collision — Seeking Recovery Guidance

Summary

We have a UUPS proxy on BNB Smart Chain (BSC) that is permanently deadlocked after upgrading from an OpenZeppelin v4 implementation to an OpenZeppelin v5 implementation. The ERC-7201 namespaced storage layout in v5 is incompatible with the sequential storage layout from v4, resulting in a contract that cannot be initialized, upgraded, or administered through any on-chain transaction.

We have exhaustively analyzed every possible recovery vector and believe the contract is irrecoverable without chain-level intervention. We are reaching out to ask whether the OpenZeppelin team is aware of any recovery path we may have missed.

All token data (1.44 billion tokens, all holder balances, all configuration) is fully intact in the proxy's storage. The contract logic is functional. The problem is purely a storage layout mismatch.


Contract Details

Item Value
Proxy 0x02634E559651Ac67B8a3c73B15C8CF79b31242Ec (BSC)
Current implementation (OZ v5) 0x47de9eeAa7E9eAF07972038eA89D85181c3D20D3
Previous implementation (OZ v4) 0x3C936A5d0F55aFD352DDf1343018D68943Bc852a
Owner 0x4A90d19E6E88bb7c1ab6409D40760A27Ee210362
Proxy pattern ERC1967Proxy + UUPSUpgradeable
v4 framework @openzeppelin/contracts-upgradeable v4.x (sequential storage + __gap)
v5 framework @openzeppelin/contracts-upgradeable v5.x (ERC-7201 namespaced storage)
UPGRADE_INTERFACE_VERSION "5.0.0" (confirmed via eth_call)

Root Cause

The proxy was originally deployed with an OZ v4 implementation using sequential storage with __gap arrays. An upgrade was performed to a new implementation compiled with OZ v5, which uses ERC-7201 namespaced storage.

The result is a complete storage misalignment:

Variable OZ v4 slot (data location) OZ v5 slot (code reads) Consequence
_initialized Slot 0 = 0x01 0xf0c57e16...00 = 0x00 v5 thinks not initialized
_owner Slot 154 = 0x4A90d19E... 0x9016d09d...00 = 0x00 v5 sees owner as address(0)
_name Slot 101 = "Outter Finance" Slot 0 = 0x01 Corrupt string → Panic(0x22)
_totalSupply Slot 280 = 1.44B v5 namespaced slot = 0x00 v5 sees zero supply

The Deadlock (Two Interlocking Failures)

Failure 1: initialize() → Panic(0x22)

The v5 initialize() attempts to write _name = "Outter Finance" to storage. In v5's ERC20Upgradeable, the _name field maps to slot 0 of the ERC-7201 namespace. However, slot 0 on the proxy contains 0x01 from v4's Initializable._initialized flag.

Solidity's string storage encoding interprets 0x01 as a "long string" marker (bit 0 = 1) with length (0x01 - 1) / 2 = 0. This violates the invariant that long strings must have length ≥ 32, triggering an unrecoverable Panic(0x22) (bad storage encoding).

This panic occurs before any state is written — the owner is never set.

Note: The v5 _initialized slot (0xf0c57e16...00) is empty, so the initializer modifier itself would pass. The Panic happens inside the function body, not in the modifier.

Failure 2: upgradeToAndCall()OwnableUnauthorizedAccount

The v5 OwnableUpgradeable reads _owner from the namespaced slot 0x9016d09d72d40fdae2fd8ceac6b6234c7706214fd39c1cd1e609a0528c199300. This slot was never written by v4, so it contains address(0).

Every function with onlyOwner — including upgradeToAndCall(), transferOwnership(), and all admin functions — reverts with OwnableUnauthorizedAccount(caller) for any caller.

Result: Deadlock

initialize() → Panic(0x22) → cannot set v5 owner
                                    ↓
                         owner = address(0)
                                    ↓
upgradeToAndCall() → OwnableUnauthorizedAccount → cannot change implementation
                                    ↓
                         implementation stays v5
                                    ↓
                         back to initialize() → Panic(0x22) → ∞

Exhaustive Analysis of All Recovery Vectors

We have systematically tested every possible path to recover this contract:

1. All 124 function selectors in the v5 bytecode

We extracted every PUSH4 from the implementation bytecode and tested each one. Complete access control map:

Category Count Details
View functions (succeed) 34 All return zero/empty (reading from wrong slots)
onlyOwner blocked 21 All revert with OwnableUnauthorizedAccount
Panic(0x22) 2 initialize(), name()
Panic(0x12) 1 getCirculatingSupply() (division by zero from empty supply)
Other reverts 66 require(false), missing args, etc.

Every function that writes state requires onlyOwner. The only permissionless write function is approve(), which writes to the allowance mapping — not to the owner slot.

2. address(0) simulation

Since owner() == address(0), calling any onlyOwner function with from: address(0) via eth_call succeeds:

upgradeToAndCall(v4_impl, 0x) from address(0)  → SUCCESS
transferOwnership(real_owner) from address(0)   → SUCCESS

This proves the upgrade mechanism is functional. The barrier is that no entity can sign a transaction from address(0).

3. initialize() with slot 0 cleared (state override simulation)

Clearing slot 0 removes the Panic(0x22), but initialize() then fails with "Pancake: PAIR_EXISTS" — the function tries to create a PancakeSwap pair that already exists. Even clearing the factory's mapping, a third internal require(false) blocks execution.

initialize() is not a viable path even if the Panic is resolved.

4. Reinitializer search

  • Only one initializer function exists: initialize() (selector 0x8129fc1c)
  • No reinitializer(2) or any other reinitializer variant found in bytecode
  • The InvalidInitialization error appears only once in bytecode
  • NotInitializing error does not appear at all

5. Meta-transactions / trusted forwarder (ERC-2771)

  • isTrustedForwarder(address): NOT in bytecode
  • trustedForwarder(): NOT in bytecode
  • No ERC-2771 support

6. AccessControl / role-based access

  • hasRole(), grantRole(), DEFAULT_ADMIN_ROLE(): NOT in bytecode
  • The contract uses only OwnableUpgradeable for access control

7. Arbitrary delegatecall / execute

  • No execute(address,bytes), multicall(bytes[]), or functionDelegateCall() in bytecode
  • The only delegatecall-capable function is upgradeToAndCall(), which requires onlyOwner

8. Ownable2Step

  • acceptOwnership() and pendingOwner(): NOT in bytecode
  • Standard single-step OwnableUpgradeable, not Ownable2StepUpgradeable

What We Need

Since no on-chain transaction from any address can break the deadlock, the only recovery paths are:

Option A (preferred): BSC chain-level state correction — set the EIP-1967 implementation slot back to the working v4 implementation:

  • Slot: 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc
  • Value: 0x0000000000000000000000003c936a5d0f55afd352ddf1343018d68943bc852a

Option B: Set the v5 OwnableUpgradeable _owner slot to the legitimate owner:

  • Slot: 0x9016d09d72d40fdae2fd8ceac6b6234c7706214fd39c1cd1e609a0528c199300
  • Value: 0x0000000000000000000000004a90d19e6e88bb7c1ab6409d40760a27ee210362

Questions for OpenZeppelin

  1. Are you aware of any recovery path we may have missed? We've analyzed every function selector, checked for reinitializers, meta-transactions, AccessControl, Ownable2Step, arbitrary delegatecall, and trusted forwarders. Is there any mechanism in OZ v5's upgradeable contracts that could allow state modification without passing onlyOwner?

  2. Is the Panic(0x22) on slot 0 a known issue? When v4's _initialized flag (0x01) occupies the same slot as v5's first ERC-7201 string variable, the Solidity string encoding invariant is violated. Is this documented anywhere as a hazard of v4→v5 migration?

  3. Has this specific v4→v5 storage collision been reported before? We'd like to know if other projects have encountered this exact deadlock and what resolution was reached.

  4. Would OpenZeppelin consider issuing guidance or tooling to help projects that have accidentally performed this incompatible upgrade? The @openzeppelin/upgrades plugin warns against storage layout changes, but the v4→v5 migration is a special case because the entire storage strategy changed fundamentally.

  5. Would OpenZeppelin be willing to support our request to the BNB Chain team for a chain-level state correction? A statement from the framework authors confirming that this is an unrecoverable storage collision would significantly strengthen our case.


Impact

  • 1,441,103,829 OUT tokens frozen (all holder balances inaccessible)
  • ~106M OUT locked in PancakeSwap LP
  • All transfer(), balanceOf(), and admin functions non-functional
  • The contract cannot be upgraded through any standard mechanism

Reproducibility

All analysis scripts are available and all results can be independently verified:

// Confirm v5 owner is address(0)
await provider.call({ to: PROXY, data: "0x8da5cb5b" }); // owner() → 0x000...000

// Confirm v5 _initialized is empty
await provider.getStorage(PROXY, "0xf0c57e16840df040f15088dc2f81fe391c3923bec73e23a9662efc9c229c6a00"); // → 0x00

// Confirm v4 owner is intact
await provider.getStorage(PROXY, 154); // → 0x...4a90d19e6e88bb7c1ab6409d40760a27ee210362

// Confirm upgrade works from address(0)
await provider.call({
    to: PROXY,
    data: "0x4f1ef286" + /* v4 impl padded */ + /* empty bytes */,
    from: ethers.ZeroAddress
}); // → SUCCESS (0x)

Contact

Eder Jhonatan
ederjhonatann22@gmail.com
Telegram: @Puttion | Discord: PuTTioN

Owner address: 0x4A90d19E6E88bb7c1ab6409D40760A27Ee210362
We can provide signed ownership proofs, full storage dumps, and any additional technical evidence.


This issue affects a live token on BNB Smart Chain with real user funds at stake. We appreciate any guidance the OpenZeppelin team can provide.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions