Vulnerability Details
According to Chainlink CCIP docs, when building and sending a message between chains, the message has to be formatted to the following struct:
Client.sol#L12-L18
struct EVM2AnyMessage {
bytes receiver;
bytes data;
struct Client.EVMTokenAmount[] tokenAmounts;
address feeToken;
bytes extraArgs;
}
and regarding extraArgs
:
"Users fill in the EVMExtraArgsV1 struct then encode it to bytes using the _argsToBytes function"
Client.sol#L35-L37
function _argsToBytes(EVMExtraArgsV1 memory extraArgs) internal pure returns (bytes memory bts) {
return abi.encodeWithSelector(EVM_EXTRA_ARGS_V1_TAG, extraArgs);
}
Currently this is how extraArgs
are defined in the protocol:
RESDLTokenBridge.sol#L156-L164
function setExtraArgs(uint64 _chainSelector, bytes calldata _extraArgs) external onlyOwner {
❌ extraArgsByChain[_chainSelector] = _extraArgs;
emit SetExtraArgs(_chainSelector, _extraArgs);
}
Which won't work with CCIP because once the message is build using _buildCCIPMessage()
RESDLTokenBridge.sol#L194-L236
function _buildCCIPMessage(
address _receiver,
uint256 _tokenId,
ISDLPool.RESDLToken memory _reSDLToken,
address _destination,
address _feeTokenAddress,
bytes memory _extraArgs
) internal view returns (Client.EVM2AnyMessage memory) {
...
Client.EVM2AnyMessage memory evm2AnyMessage = Client.EVM2AnyMessage({
receiver: abi.encode(_destination),
data: abi.encode(
_receiver,
_tokenId,
_reSDLToken.amount,
_reSDLToken.boostAmount,
_reSDLToken.startTime,
_reSDLToken.duration,
_reSDLToken.expiry
),
tokenAmounts: tokenAmounts,
❌ extraArgs: _extraArgs,
feeToken: _feeTokenAddress
});
...
}
it's sent to Router::ccipSend()
which calls onRamp::forwardFromRouter()
Router.sol#L145
function ccipSend(
uint64 destinationChainSelector,
Client.EVM2AnyMessage memory message
) external payable whenHealthy returns (bytes32) {
...
❌ return IEVM2AnyOnRamp(onRamp).forwardFromRouter(message, feeTokenAmount, msg.sender);
}
which will try to decode extraArgs
using _fromBytes()
File: @chainlink/contracts-ccip/src/v0.8/ccip/onRamp/EVM2EVMOnRamp.sol
function forwardFromRouter(
Client.EVM2AnyMessage calldata message,
uint256 feeTokenAmount,
address originalSender
) external whenHealthy returns (bytes32) {
...
❌ Client.EVMExtraArgsV1 memory extraArgs = _fromBytes(message.extraArgs);
...
}
But the _fromBytes()
function will revert if extraArgs.length
isn't 0 and if (bytes4(extraArgs) != Client.EVM_EXTRA_ARGS_V1_TAG)
EVM2EVMOnRamp.sol#L360-L371
function _fromBytes(bytes calldata extraArgs) internal view returns (Client.EVMExtraArgsV1 memory) {
if (extraArgs.length == 0) {
return Client.EVMExtraArgsV1({gasLimit: i_defaultTxGasLimit, strict: false});
}
❌ if (bytes4(extraArgs) != Client.EVM_EXTRA_ARGS_V1_TAG) revert InvalidExtraArgsTag();
return abi.decode(extraArgs[4:], (Client.EVMExtraArgsV1));
}
The tests are passing because it's using a mock which doesn't have the _fromBytes()
function
CCIPOnRampMock.sol#L49-L57
function forwardFromRouter(
Client.EVM2AnyMessage calldata _message,
uint256 _feeTokenAmount,
address _originalSender
) external returns (bytes32) {
requestMessages.push(_message);
requestData.push(RequestData(_feeTokenAmount, _originalSender));
return keccak256(abi.encode(block.timestamp));
}
Impact
All messages sent by:
contracts/core/ccip/RESDLTokenBridge.sol
contracts/core/ccip/SDLPoolCCIPControllerPrimary.sol
contracts/core/ccip/SDLPoolCCIPControllerSecondary.sol
contracts/core/ccip/WrappedTokenBridge.sol
using their _buildCCIPMessage()
function will revert because extraArgs
aren't properly encoded.
PoC
Add this to the CCIPOnRampMock.sol
mock and run tests:
File: contracts/core/test/chainlink/CCIPOnRampMock.sol
function forwardFromRouter(
Client.EVM2AnyMessage calldata _message,
uint256 _feeTokenAmount,
address _originalSender
) external returns (bytes32) {
requestMessages.push(_message);
requestData.push(RequestData(_feeTokenAmount, _originalSender));
+ Client.EVMExtraArgsV1 memory extraArgs = _fromBytes(_message.extraArgs);
return keccak256(abi.encode(block.timestamp));
}
function setTokenPool(address _token, address _pool) external {
tokenPools[_token] = _pool;
}
+ error InvalidExtraArgsTag();
+ function _fromBytes(bytes calldata extraArgs) internal view returns (Client.EVMExtraArgsV1 memory) {
+ if (extraArgs.length == 0) {
+ return Client.EVMExtraArgsV1({gasLimit: 123, strict: false});
+ }
+ if (bytes4(extraArgs) != Client.EVM_EXTRA_ARGS_V1_TAG) revert InvalidExtraArgsTag();
+ return abi.decode(extraArgs[4:], (Client.EVMExtraArgsV1));
+ }
Hardhat tests will revert with Error: VM Exception while processing transaction: reverted with custom error 'InvalidExtraArgsTag()'
Recommendations
Encode extraArgs
properly before sending the message as shown in the ChainLink CCIP starter kit:
extraArgs: Client._argsToBytes(
Client.EVMExtraArgsV1({gasLimit: 200_000, strict: false})
),