stake.link

stake.link
DeFiHardhatBridge
27,500 USDC
View results
Submission Details
Severity: high
Invalid

User trying to bridge his lock from a secondary chain to any other chain will lose his lock

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); // here
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); // here
}

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
// The user has a lock in a secondary chain
assert.equal(await sdlPool.ownerOf(1), user)
// The user will call the transferRESDL function in the bridge contract with a chain selector
// of a chain he wants to tranfer his reSDL, but the sdlCCIPControllerSecondary will pass the
// address of the sdlPoolCCIPController in the primary chain. This will lead the CCIP to send
// the message to the address of the sdlPoolCCIPController but in a wrong chain because user
// wrote a different chain selector in the parameter
await bridge.connect(signers[1]).transferRESDL(
secondaryChainSelectorToBridgeTheLock,
user, // his own address in an other chain
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)));
}
Updates

Lead Judging Commences

0kage Lead Judge almost 2 years ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement
draiakoo Submitter
almost 2 years ago
draiakoo Submitter
almost 2 years ago
0kage Lead Judge
almost 2 years ago
draiakoo Submitter
almost 2 years ago
0kage Lead Judge almost 2 years ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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