Project

One World
NFTDeFi
15,000 USDC
View results
Submission Details
Severity: low
Invalid

Low level call in function `callExternalContract()` succeeds even if the call is made to a non-existing contract

Summary

In Solidity, low-level calls (address.call) do not check for the existence of contract code at the target address, unlike high-level calls that verify contract existence using the extcodesize opcode. This behavior allows calls to non-existent contracts to appear successful, returning true even when no code exists at the target address.

Vulnerability Details

As highlighted 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.

For that matter, when the callExternalContract() in both MembershipFactory.sol and MembershipERC1155.sol function attempts to call a contract at contractAddress using contractAddress.call(data), the EVM does not verify whether the address contains contract code. As a result, a call to a non-existent contract will return success = true despite no actual contract execution occurring.

function callExternalContract(
address contractAddress,
bytes memory data
) external payable onlyRole(EXTERNAL_CALLER) returns (bytes memory) {
(bool success, bytes memory returndata) = contractAddress.call{value: msg.value}(data);
require(success, "External call failed");
return returndata;
}

In the above as it exists in MembershipFactory.sol:

  • The low-level call does not verify if contractAddress points to a valid contract.

  • If contractAddress lacks code, i.e is a non-existent contract, then this call will still return success = true, causing the assumption that the call was successful.

This particularly impacts scenarios where critical logic relies on the call's success flag to determine further actions.

PoC

// SPDX-License-Identifier: MIT
pragma solidity 0.8.22;
contract ExternalCaller {
/// @notice Performs an external call to another contract
/// @param contractAddress The address of the external contract
/// @param data The calldata to be sent
/// @return result The bytes result of the external call
function callExternalContract(
address contractAddress,
bytes memory data
) external payable returns (bytes memory) {
(bool success, bytes memory returndata) = contractAddress.call{
value: msg.value
}(data);
require(success, "External call failed");
return returndata;
}
}
// SPDX-License-Identifier: MIT
pragma solidity 0.8.22;
import "forge-std/Test.sol";
import "../src/ExternalCaller.sol";
contract ExternalCallerTest is Test {
ExternalCaller externalCaller;
function setUp() public {
// Deploy the ExternalCaller contract
externalCaller = new ExternalCaller();
}
function testCallNonExistentContract() public {
// Define an address with no contract deployed at it
address nonExistentContractAddress = address(
0x000000000000000000000000000000000000dEaD
);
// Define some arbitrary calldata (which won't actually be used)
bytes memory arbitraryData = abi.encodeWithSignature(
"nonExistentFunction()"
);
// Expect the call to succeed, even though no contract exists at the address
// The call should "succeed" in terms of returning true but won't perform any meaningful action.
bytes memory result = externalCaller.callExternalContract(
nonExistentContractAddress,
arbitraryData
);
// Check that result is empty, indicating no actual function call was executed
assertEq(
result.length,
0,
"Expected empty result for call to non-existent contract"
);
}
}

Impact

Leads to the assumption that the low level call was successful.

Tools Used

Manual Code Review

Recommendations

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 constant/immutable value if the contract can be fully trusted.

function callWithCheck(address contractAddress, bytes memory data) external returns (bytes memory) {
require(contractAddress.code.length > 0, "Target address has no contract code");
(bool success, bytes memory returndata) = contractAddress.call(data);
require(success, "External call failed");
return returndata;
}
Updates

Lead Judging Commences

0xbrivan2 Lead Judge 10 months ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.