Intro to Smart Contract Security Auditing: Comprehensive Guide to Contract Size Checks
In our first article, we discussed various methods to prevent reentrancy attacks. One such method involves checking the caller’s identity and disallowing contract invocations to thwart reentrancy. In this installment, we will explore how to identify the caller’s identity using Contract Size Checks.
As is commonly known, contracts are written in code. Once deployed, these contracts will certainly have corresponding bytecode at their address. Externally Owned Accounts (EOA) addresses, on the other hand, do not have bytecode. Thus, we can determine whether a caller is a contract simply by checking if there is bytecode at the caller’s address. To do this, we can use the extcodesize() function available in Solidity inline assembly. Let’s go through an example to better understand the actual effect of this function.
First, let’s use a code snippet to understand the purpose of extcodesize():
After deploying on Remix and calling the Size() function while passing in the Deployer’s EOA (Externally Owned Account) address (0x787…cabaB), we find that it returns 0.
Upon calling the Size() function again and passing in the contract’s own address (0xC58…905F2), it returns 348.
From the above discussion, it’s clear that when the account is an EOA (Externally Owned Account), extcodesize() will return 0. On the other hand, if the account is a contract address, extcodesize() will return the size of the deployed contract’s bytecode. At this point, it should be straightforward to see that you can determine whether an address is an EOA or a deployed contract by calling extcodesize() and checking if the return value is 0. Ah, just like that, we’ve identified a vulnerability.
We can see that isContract() checks whether the account bytecode is 0 and returns a Boolean value accordingly. The protected() function then modifies the state variable pwned based on the return value of isContract(). However, there is an overlooked aspect here: During contract deployment, the logic inside the constructor function is sent along with the contract deployment transaction to be packaged into a block by miners. Because the contract deployment is not yet complete at this point, the corresponding bytecode has not been stored at the contract address. As a result, calling extcodesize() to check the contract address’s bytecode within the constructor function would return 0. Let’s examine what would be returned under normal invocation:
Calling pwn() and passing in the contract address 0x058…4c899 results in a revert. This illustrates that after the contract has been deployed, invoking the protected() function will fail the require(!isContract(msg.sender)) check.
Next, let’s try placing the calling logic within the constructor function:
Upon deploying the “Hack” contract and passing in the contract address 0xbd7…9EFB3, it was found that the contract successfully bypassed the require(!isContract(msg.sender)) check and successfully modified the state of the pwned variable.
Using extcodesize() to determine whether an address is an EOA (Externally Owned Account) is not foolproof. In practical development, a more reliable way to ascertain the identity of the caller is by using tx.origin, as demonstrated below:
Since tx.origin can only be an EOA (Externally Owned Account) address, we simply need to check if the top-level caller’s msg.sender is the same as tx.origin. By doing so, we can ascertain the identity of the caller. Taking the previously mentioned vulnerable code as an example, we only need to make minor adjustments to fix the issue.
If you deploy the following “Hack” contract and attempt the same attack, it will now be reverted and return the message “no contract allowed.”
During the auditing process, if you encounter logic that restricts contract address interactions, it’s essential to assess its robustness in the context of the actual business logic. Evaluate whether the checks in place are stringent enough and whether there’s a possibility that they could be bypassed.