Skip to content

Latest commit

 

History

History
94 lines (78 loc) · 3.72 KB

File metadata and controls

94 lines (78 loc) · 3.72 KB

Noisy Quartz Bird

High

Storage Manipulation Attack using _version in initialization process

Summary

https://github.com/sherlock-audit/2025-01-perennial-v2-4-update/blob/main/perennial-v2/packages/core/contracts/Market.sol#L120-L128

/// @dev The initialized flag
    Uint256Storage private constant _version = Uint256Storage.wrap(keccak256("equilibria.root.Initializable.version"));

    /// @dev The initializing flag
    BoolStorage private constant _initializing = BoolStorage.wrap(keccak256("equilibria.root.Initializable.initializing"));

    /// @dev Can only be called once per version, `version` is 1-indexed
    modifier initializer(uint256 version) {
        if (version == 0) revert InitializableZeroVersionError();
        if (_version.read() >= version) revert InitializableAlreadyInitializedError(version);

        _version.store(version);
        _initializing.store(true);

        _;

        _initializing.store(false);
        emit Initialized(version);
    }

_version is critical to upgrade security. If it is reset, manipulated, or bypassed, an attacker can take over the contract via re-initialization or forced downgrades. If _version is stored in an upgradeable proxy’s storage, an attacker could overwrite it and re-trigger initialize(), bypassing security. This attack is a serious risk in upgradeable contracts. Many real-world exploits involve storage overwrites, making _version a critical weakness. https://solodit.cyfrin.io/issues/changing-_initializableslot-can-cause-_disableinitializers-to-actually-enable-initializers-spearbit-none-coinbase-solady-pdf

PoC

Since _version is used to track the initialization state, if an attacker finds a way to corrupt or reset _version, they can re-trigger initialize().

Attack Steps Find a Storage Collision or Overwrite _version

If _version is stored in an upgradeable proxy contract, an attacker could override this slot by deploying a malicious contract that writes over the same storage index. Example Attack Contract:

contract StorageCorruptor {
    function overwriteVersion(address target) public {
        bytes32 slot = keccak256("equilibria.market.version"); // Hypothetical storage slot
        assembly {
            sstore(slot, 0) // Force `_version` back to 0
        }
        IMarket(target).initialize(IMarket.MarketDefinition(...)); // Reinitialize!
    }
}

This forces _version = 0, bypassing the initializer(1) check. Call initialize() Again

After corrupting _version, the attacker calls initialize() again to replace the token and oracle with malicious versions. Outcome The attacker resets initialization. They replace the token with a malicious ERC-20 that allows infinite minting. They set oracle to a fake price feed, allowing market manipulation. They drain all user funds by exploiting liquidity rules.

Recommendation

How to Fix This?

  1. Store _version in an Immutable Contract If _version is only stored in a proxy contract, it becomes easier to overwrite. Moving _version into an immutable logic contract removes this attack vector. Double-Lock Initialization with a Boolean Flag
bool private _isInitialized;
modifier initializer(uint256 version) {
    require(!_isInitialized, "Already initialized"); 
    _isInitialized = true;
    _;
}

This prevents any form of re-initialization. Use Explicit Storage Slots for Upgradeable Contracts

Instead of keccak256("some string"), explicitly define storage slots to prevent overwrites. 2. Use a Hard Upgrade Lock

require(_version == expectedVersion, "Version mismatch!");

Blocks reverting to older versions.

  1. Verify _version Before Upgrading
require(proxyAdmin.getVersion() == latestVersion, "Cannot upgrade from old version");