Summary
User may lose funds when creating Nexus account or executing user operations, due to that `msg.value` is ignored.
Vulnerability Details
When user calls deployWithFactory() on BiconomyMetaFactory
to create Nexus account, the factory
contract specified by user will be called but msg.value
is ignored:
function deployWithFactory(address factory, bytes calldata factoryData) external payable returns (address payable createdAccount) {
require(factoryWhitelist[address(factory)], FactoryNotWhitelisted());
@> (bool success, bytes memory returnData) = factory.call(factoryData);
require(success, CallToDeployWithFactoryFailed());
assembly {
createdAccount := mload(add(returnData, 0x20))
}
}
When executeUserOp() is called by EntryPoint, protocol extracts inner call data from user operation and calls to the target contract with the decoded data, but msg.value
is ignored:
function executeUserOp(PackedUserOperation calldata userOp, bytes32) external payable virtual onlyEntryPoint {
bytes calldata innerCall = userOp.callData[4:];
bytes memory innerCallRet = "";
if (innerCall.length > 0) {
(address target, bytes memory data) = abi.decode(innerCall, (address, bytes));
bool success;
@> (success, innerCallRet) = target.call(data);
require(success, InnerCallFailed());
}
emit Executed(userOp, innerCallRet);
}
When fallback() is revoked, is calltype
is CALLTYPE_SINGLE
, a fallback handler will be called, but again msg.value
is ignore:
if (calltype == CALLTYPE_SINGLE) {
assembly {
calldatacopy(0, 0, calldatasize())
mstore(calldatasize(), shl(96, caller()))
@> if iszero(call(gas(), handler, 0, 0, add(calldatasize(), 20), 0, 0)) {
returndatacopy(0, 0, returndatasize())
revert(0, returndatasize())
}
returndatacopy(0, 0, returndatasize())
return(0, returndatasize())
}
}
Impact
User will lose funds if they call with ETH;
Transaction may revert due to msg.value
is not forwarded.
Tools Used
Manual Review
Recommendations
Handle msg.value
properly.
BiconomyMetaFactory.sol
function deployWithFactory(address factory, bytes calldata factoryData) external payable returns (address payable createdAccount) {
require(factoryWhitelist[address(factory)], FactoryNotWhitelisted());
- (bool success, bytes memory returnData) = factory.call(factoryData);
+ (bool success, bytes memory returnData) = factory.call{value: msg.value}(factoryData);
// Check if the call was successful
require(success, CallToDeployWithFactoryFailed());
// Decode the returned address
assembly {
createdAccount := mload(add(returnData, 0x20))
}
}
Nexus.sol
function executeUserOp(PackedUserOperation calldata userOp, bytes32) external payable virtual onlyEntryPoint {
// Extract inner call data from user operation, skipping the first 4 bytes.
bytes calldata innerCall = userOp.callData[4:];
bytes memory innerCallRet = "";
// Check and execute the inner call if data exists.
if (innerCall.length > 0) {
// Decode target address and call data from inner call.
(address target, bytes memory data) = abi.decode(innerCall, (address, bytes));
bool success;
// Perform the call to the target contract with the decoded data.
- (success, innerCallRet) = target.call(data);
+ (success, innerCallRet) = target.call{value: msg.value}(data);
// Ensure the call was successful.
require(success, InnerCallFailed());
}
// Emit the Executed event with the user operation and inner call return data.
emit Executed(userOp, innerCallRet);
}
ModuleManager.sol
if (calltype == CALLTYPE_SINGLE) {
assembly {
calldatacopy(0, 0, calldatasize())
// The msg.sender address is shifted to the left by 12 bytes to remove the padding
// Then the address without padding is stored right after the calldata
mstore(calldatasize(), shl(96, caller()))
- if iszero(call(gas(), handler, 0, 0, add(calldatasize(), 20), 0, 0)) {
+ if iszero(call(gas(), handler, callvalue(), 0, add(calldatasize(), 20), 0, 0)) {
returndatacopy(0, 0, returndatasize())
revert(0, returndatasize())
}
returndatacopy(0, 0, returndatasize())
return(0, returndatasize())
}
}