Summary
If a user tries to bridge his lock from a secondary chain to any chain that is not the primary one, he will lose his lock along with his funds
Vulnerability Details
The flow of a reSDL token bridge works as follows
User call transferRESDL. Here, the important parameter that the user will have to provide is the _destinationChainSelector, which will be the chain where the ccip will send the message along with the reSDL lock.
function transferRESDL(
uint64 _destinationChainSelector,
address _receiver,
uint256 _tokenId,
bool _payNative,
uint256 _maxLINKFee
) external payable returns (bytes32 messageId) {
if (msg.sender != sdlPool.ownerOf(_tokenId)) revert SenderNotAuthorized();
if (_receiver == address(0)) revert InvalidReceiver();
if (_payNative == false && msg.value != 0) revert InvalidMsgValue();
(address destination, ISDLPool.RESDLToken memory reSDLToken) = sdlPoolCCIPController.handleOutgoingRESDL(
_destinationChainSelector,
msg.sender,
_tokenId
);
bytes memory extraArgs = extraArgsByChain[_destinationChainSelector];
Client.EVM2AnyMessage memory evm2AnyMessage = _buildCCIPMessage(
_receiver,
_tokenId,
reSDLToken,
destination,
_payNative ? address(0) : address(linkToken),
extraArgs
);
uint256 fees = IRouterClient(sdlPoolCCIPController.getRouter()).getFee(_destinationChainSelector, evm2AnyMessage);
if (_payNative) {
if (fees > msg.value) revert InsufficientFee();
messageId = sdlPoolCCIPController.ccipSend{value: fees}(_destinationChainSelector, evm2AnyMessage);
if (fees < msg.value) {
(bool success, ) = msg.sender.call{value: msg.value - fees}("");
if (!success) revert TransferFailed();
}
} else {
if (fees > _maxLINKFee) revert FeeExceedsLimit();
linkToken.safeTransferFrom(msg.sender, address(sdlPoolCCIPController), fees);
messageId = sdlPoolCCIPController.ccipSend(_destinationChainSelector, evm2AnyMessage);
}
emit TokenTransferred(
messageId,
_destinationChainSelector,
msg.sender,
_receiver,
_tokenId,
_payNative ? address(0) : address(linkToken),
fees
);
}
As we can see, there is no check here for whitelisted chain selector or something like this.
Inside this function, handleOutgoingRESDL is called from the sdlPoolCCIPControllerSecondary contract. This functions executes the following:
function handleOutgoingRESDL(
uint64,
address _sender,
uint256 _tokenId
) external override onlyBridge returns (address, ISDLPool.RESDLToken memory) {
return (primaryChainDestination, ISDLPoolSecondary(sdlPool).handleOutgoingRESDL(_sender, _tokenId, address(this)));
}
Here, we can see that there is no check either for the chain selector. It is not even used in the function. This call always returns the primary chain destination address, hence the address of the sdlPoolCCIPControllerPrimary on the primary chain.
Finally, in the previous exposed transferRESDL function, we can see that the destination chain selector is used directly to send the message via CCIP and the destination address is the address returned by the sdlPoolCCIPControllerSecondary::handleOutgoingRESDL.
if (_payNative) {
if (fees > msg.value) revert InsufficientFee();
messageId = sdlPoolCCIPController.ccipSend{value: fees}(_destinationChainSelector, evm2AnyMessage);
if (fees < msg.value) {
(bool success, ) = msg.sender.call{value: msg.value - fees}("");
if (!success) revert TransferFailed();
}
} else {
if (fees > _maxLINKFee) revert FeeExceedsLimit();
linkToken.safeTransferFrom(msg.sender, address(sdlPoolCCIPController), fees);
messageId = sdlPoolCCIPController.ccipSend(_destinationChainSelector, evm2AnyMessage);
}
That means that if a user has a lock in a secondary chain and wants to bridge his lock to an other secondary chain, or even, to any other ccip chain that is not even registered in the protocol, he will lose the lock along with the funds attached to it.
That is because the transferRESDL function will send a ccip message to the chain that the user selected but the receiver will always be the address of sdlPoolCCIPController on the primary chain but, since that address will not be correct in the chain selected by the user, the message will never be delivered but the user will not be owning his lock anymore.
Proof of Concept
import { ethers } from 'hardhat'
import { assert, expect } from 'chai'
import { toEther, deploy, deployUpgradeable, getAccounts, fromEther, assertThrowsAsync } from '../../utils/helpers'
import {
ERC677,
CCIPOnRampMock,
CCIPOffRampMock,
CCIPTokenPoolMock,
WrappedNative,
RESDLTokenBridge,
SDLPoolSecondary,
SDLPoolCCIPControllerSecondary,
} from '../../../typechain-types'
import { time } from '@nomicfoundation/hardhat-network-helpers'
import { Signer } from 'ethers'
describe('PoC bridge from L2 to L2', () => {
let linkToken: ERC677
let sdlToken: ERC677
let token2: ERC677
let bridge: RESDLTokenBridge
let sdlPool: SDLPoolSecondary
let sdlPoolCCIPController: SDLPoolCCIPControllerSecondary
let onRamp: CCIPOnRampMock
let offRamp: CCIPOffRampMock
let tokenPool: CCIPTokenPoolMock
let tokenPool2: CCIPTokenPoolMock
let wrappedNative: WrappedNative
let accounts: string[]
let signers: Signer[]
before(async () => {
;({ signers, accounts } = await getAccounts())
})
beforeEach(async () => {
linkToken = (await deploy('ERC677', ['Chainlink', 'LINK', 1000000000])) as ERC677
sdlToken = (await deploy('ERC677', ['SDL', 'SDL', 1000000000])) as ERC677
token2 = (await deploy('ERC677', ['2', '2', 1000000000])) as ERC677
wrappedNative = (await deploy('WrappedNative')) as WrappedNative
const armProxy = await deploy('CCIPArmProxyMock')
const router = await deploy('Router', [wrappedNative.address, armProxy.address])
tokenPool = (await deploy('CCIPTokenPoolMock', [sdlToken.address])) as CCIPTokenPoolMock
tokenPool2 = (await deploy('CCIPTokenPoolMock', [token2.address])) as CCIPTokenPoolMock
onRamp = (await deploy('CCIPOnRampMock', [
[sdlToken.address, token2.address],
[tokenPool.address, tokenPool2.address],
linkToken.address,
])) as CCIPOnRampMock
offRamp = (await deploy('CCIPOffRampMock', [
router.address,
[sdlToken.address, token2.address],
[tokenPool.address, tokenPool2.address],
])) as CCIPOffRampMock
await router.applyRampUpdates([[11111, onRamp.address]], [], [[11111, offRamp.address]])
await router.applyRampUpdates([[22222, onRamp.address]], [], [[22222, offRamp.address]])
let boostController = await deploy('LinearBoostController', [4 * 365 * 86400, 4])
sdlPool = (await deployUpgradeable('SDLPoolSecondary', [
'reSDL',
'reSDL',
sdlToken.address,
boostController.address,
5
])) as SDLPoolSecondary
let primaryChainAddress = "0x0000000000000000000000000000000000000ace"
let primaryChainSelector = 11111
sdlPoolCCIPController = (await deploy('SDLPoolCCIPControllerSecondary', [
router.address,
linkToken.address,
sdlToken.address,
sdlPool.address,
primaryChainSelector,
primaryChainAddress,
toEther(10),
"0x"
])) as SDLPoolCCIPControllerSecondary
bridge = (await deploy('RESDLTokenBridge', [
linkToken.address,
sdlToken.address,
sdlPool.address,
sdlPoolCCIPController.address,
])) as RESDLTokenBridge
await sdlPoolCCIPController.setRESDLTokenBridge(bridge.address)
await sdlPool.setCCIPController(accounts[0])
await linkToken.approve(bridge.address, ethers.constants.MaxUint256)
await bridge.setExtraArgs(primaryChainSelector, '0x11')
await sdlToken.transfer(accounts[1], toEther(200))
await sdlToken
.connect(signers[1])
.transferAndCall(
sdlPool.address,
toEther(100),
ethers.utils.defaultAbiCoder.encode(['uint256', 'uint64'], [0, 0])
)
await sdlPool.handleOutgoingUpdate()
await sdlPool.handleIncomingUpdate(1)
await sdlPool.connect(signers[1]).executeQueuedOperations([])
await sdlPool.setCCIPController(sdlPoolCCIPController.address)
})
it('PoC lose of reSDL when bridging', async () => {
let user = accounts[1]
let primaryChainAddress = "0x0000000000000000000000000000000000000ace"
let primaryChainSelector = 11111
let secondaryChainSelectorToBridgeTheLock = 22222
assert.equal(await sdlPool.ownerOf(1), user)
await bridge.connect(signers[1]).transferRESDL(
secondaryChainSelectorToBridgeTheLock,
user,
1,
true,
toEther(10),
{ value: toEther(10)}
)
let lastRequestMsg = await onRamp.getLastRequestMessage()
let formatedDestinationAddress = lastRequestMsg[0].substring(0,2) + lastRequestMsg[0].substring(26,lastRequestMsg[0].length)
assert.equal(formatedDestinationAddress, primaryChainAddress)
})
Impact
High, user will lose his funds because are attached to the lock
Tools Used
Manual review
Recommendations
Check inside sdlPoolCCIPControllerSecondary that the chain selector provided by the user matches the primary chain selector. That way, users will be only capable of bridging locks to the primary chain.
function handleOutgoingRESDL(
uint64 _destinationChainSelector,
address _sender,
uint256 _tokenId
) external override onlyBridge returns (address, ISDLPool.RESDLToken memory) {
+ if(_destinationChainSelector != primaryChainSelector) revert();
return (primaryChainDestination, ISDLPoolSecondary(sdlPool).handleOutgoingRESDL(_sender, _tokenId, address(this)));
}