Unsafe Low-Level Call
Unsafe Low-Level Call refers to a programming operation, often found in languages like Solidity for smart contracts on the Ethereum blockchain, where a contract interacts with another contract with minimal safeguards or without the typical security features provided by higher-level abstractions.
This type of low-level call can be risky and may expose a contract to vulnerabilities, such as reentrancy attacks, if not used carefully. Developers need to be cautious and implement additional security measures when performing unsafe low-level calls to protect the integrity and security of their smart contracts.
In Solidity, you can either use low-level calls such as: address.callf()
, address.callcode()
, address.delegatecall()
, and address.send()
; or you can use contract calls such as: ExternalContract.doSomething()
.
Low-level calls can be a good way to efficiently or arbitrarily make contract calls. However, it's important to be aware of the caveats it possesses.
Unchecked call return value
Low-level calls will never throw an exception, instead they will return false
if they encounter an exception, whereas contract calls will automatically throw. Thus if the return value of a low-level call is not checked, the execution may resume even if the function call throws an error. This can lead to unexpected behaviour and break the program logic. A failed call can even be caused intentionally by an attacker, who may be able to further exploit the application.
In the case that you use low-level calls, be sure to check the return value to handle possible failed calls, e.g.:
// Simple transfer of 1 ether
(bool success,) = to.call{value: 1 ether}("");
// Revert if unsuccessful
require(success);
Successful call to non-existent contract
As noted in the Solidity docs: "Due to the fact that the EVM considers a call to a non-existing contract to always succeed, Solidity uses the extcodesize
opcode to check that the contract that is about to be called actually exists (it contains code) and causes an exception if it does not. This check is skipped if the return data will be decoded after the call and thus the ABI decoder will catch the case of a non-existing contract.
Note that this check is not performed in case of low-level calls which operate on addresses rather than contract instances."
It's imperative that we do not simply assume that a contract to be called via a low-level call actually exists, since if it doesn't our logic will proceed even though our external call effectively failed. This can lead to loss of funds and/or an invalid contract state. Instead, we must verify that the contract being called exists, either immediately before being called with an extcodesize
check, or by verifying during contract deployment and using a c constant
/immutable
value if the contract can be fully trusted.
// Verify address is a contract
require(to.code.length > 0);
// Simple transfer of 1 ether
(bool success,) = to.call{value: 1 ether}("");
// Revert if unsuccessful
require(success);
Return bomb
There is a risk associated with low-level calls in which the length of return data is not limited. This can potentially lead to a situation where an untrusted callee could cause a Denial-of-Service (DoS) attack on a particular functionality.
To better understand this, let's take an example from the ExcessivelySafeCall library:
contract BadGuy {
function youveActivateMyTrapCard() external pure returns (bytes memory) {
assembly{
revert(0, 1_000_000)
}
}
}
contract Mark {
function oops(address badGuy) {
bool success;
bytes memory ret;
// Mark pays a lot of gas for this copy 😬😬😬
(success, ret) == badGuy.call(
SOME_GAS,
abi.encodeWithSelector(
BadGuy.youveActivateMyTrapCard.selector
)
);
// Mark may OOG here, preventing local state changes
importantCleanup();
}
}
In this example, Mark calls the BadGuy and copies an unknown return data, which can be problematic. Although the call has a gas limit set, the copy operation will be executed in Mark's context and will consume all the gas, causing the transaction to fail. This happens because the BadGuy can manipulate the length of the data.