HIGH severity: HYPR — The root cause was that the attacker managed to circumvent the 'finalizeERC20Withdrawal' function check by reinitializing the contract, due to the existence of the 'clearLegacySlot' modifier. The clearLegacySlot modifier here will reset the value of the storage slot 0. This action, in turn, resets the value of storage slot 0 of the Initializable contract (to verify this, we can inspect the inheritance chain of the L1StandardBridge contract). If we read the comments carefully, we'll know that the current version of this code is not ready for production. So, that's the reason why this function can be called multiple times.. https://x.com/BlockSecTeam/status/1735197818883588574 https://blog.verichains.io/p/hypers-op-stack-bridge-exploit-analysis

Vulnerable code:
// @notice Storage slot 0 holds a legacy value on upgraded networks. It is an empty placeholder slot on new networks. Manually set it to 0 so that 'Initializable' can use the first storage slot. This few lines of code helps to prevent a large diff in the source code to preserve the storage layout. This should be removed during the next contract upgrade.
modifier clearLegacySlot() {
    assembly {
        sstore(0, 0)
    }
    _;
}

// @notice Initializer
// The fix modifier should be removed during the next contract upgrade.
function initialize(CrossDomainMessenger _messenger) public clearLegacySlot reinitializer(2) {
    __StandardBridge_init({_messenger: _messenger});
} ... // @custom:legacy
/// @notice Finalizes a withdrawal of ERC20 tokens from L2.
/// @param _l1Token Address of the token on L1.
/// @param _l2Token Address of the corresponding token on L2.
/// @param _from Address of the withdrawer on L2.
/// @param _to Address of the recipient on L1.
/// @param _amount Amount of the ERC20 to withdraw.
/// @param _extraData Optional data forwarded from L2.
function finalizeERC20Withdrawal(
    address _l1Token,
    address _l2Token,
    address _from,
    address _to,
    uint256 _amount,
    bytes calldata _extraData
) external {
    finalizeBridgeERC20(_l1Token, _l2Token, _from, _to, _amount, _extraData);
} ... // @notice Finalizes a withdrawal of ERC20 tokens from L2.
function finalizeBridgeERC20(
    address _localToken,
    address _remoteToken,
    address _from,
    address _to,
    uint256 _amount,
    bytes calldata _extraData
) public onlyOtherBridge {
    if (_isOptimismMintableERC20(_localToken)) {
        require(
            _isCorrectTokenPair(_localToken, _remoteToken),
            "StandardBridge: wrong remote token for Optimism Mintable ERC20 local token"
        );
        OptimismMintableERC20(_localToken).mint(_to, _amount);
    } else {
        deposits[_localToken][_remoteToken] = deposits[_localToken][_remoteToken] - _amount;
        IERC20(_localToken).safeTransfer(_to, _amount);
    }
    // Emit the correct events. By default this will be ERC20BridgeFinalized, but child
    // contracts may override this function in order to emit legacy events as well.
    _emitERC20BridgeFinalized(_localToken, _remoteToken, _from, _to, _amount, _extraData);
} ... // @notice Ensures that the caller is a cross-chain message from the other bridge.
modifier onlyOtherBridge() {
    require(
        msg.sender == address(messenger) &&
        messenger.xDomainMessageSender() == address(OTHER_BRIDGE),
        "StandardBridge: function can only be called from the other bridge"
    );
    _;
}

Vulnerability type: Business Logic Flaw