Platform: Code4rena
Start Date: 19/01/2024
Pot Size: $36,500 USDC
Total HM: 9
Participants: 113
Period: 3 days
Judge: 0xsomeone
Id: 322
League: ETH
Rank: 5/113
Findings: 6
Award: $1,726.63
🌟 Selected for report: 1
🚀 Solo Findings: 0
🌟 Selected for report: NPCsCorp
Also found by: 0x11singh99, 0xAadi, 0xBugSlayer, 0xE1, 0xPluto, 0xSimeon, 0xSmartContract, 0xabhay, 0xdice91, 0xprinc, Aamir, Aymen0909, CDSecurity, DadeKuma, DarkTower, EV_om, Eeyore, GeekyLumberjack, GhK3Ndf, Giorgio, Greed, Inference, JanuaryPersimmon2024, Kaysoft, Krace, Matue, MrPotatoMagic, NentoR, Nikki, PUSH0, Soliditors, Tendency, Tigerfrake, Timeless, Timenov, ZanyBonzy, ZdravkoHr, abiih, adeolu, al88nsk, azanux, bareli, boredpukar, cu5t0mpeo, d4r3d3v1l, darksnow, deth, dutra, ether_sky, haxatron, ke1caM, kodyvim, m4ttm, mgf15, mrudenko, nmirchev8, nobody2018, nuthan2x, peanuts, piyushshukla, ravikiranweb3, rouhsamad, seraviz, simplor, slylandro_star, stealth, th13vn, vnavascues, wangxx2026, zaevlad
0.1172 USDC - $0.12
There is a missing access control check in setRouter
in DcntEth.sol
.
contract DcntEth is OFTV2 { address public router; modifier onlyRouter() { require(msg.sender == router); _; } constructor( address _layerZeroEndpoint ) OFTV2("Decent Eth", "DcntEth", 18, _layerZeroEndpoint) {} /** * @param _router the decentEthRouter associated with this eth */ => function setRouter(address _router) public { router = _router; } function mint(address _to, uint256 _amount) public onlyRouter { _mint(_to, _amount); } function burn(address _from, uint256 _amount) public onlyRouter { _burn(_from, _amount); } function mintByOwner(address _to, uint256 _amount) public onlyOwner { _mint(_to, _amount); } function burnByOwner(address _from, uint256 _amount) public onlyOwner { _burn(_from, _amount); } }
Simply put, anyone can call setRouter
and set themselves as router, allowing them to bypass the onlyRouter
check and mint DcntEth tokens to anyone or burn DcntEth tokens from anyone.
The dcntEth can be redeeemed on the real router to drain its WETH reserves.
function redeemWeth( uint256 amount ) public onlyIfWeHaveEnoughReserves(amount) { dcntEth.transferFrom(msg.sender, address(this), amount); weth.transfer(msg.sender, amount); }
Manual Review
Add onlyOwner
modifier to setRouter
.
Access Control
#0 - c4-pre-sort
2024-01-23T21:02:23Z
raymondfam marked the issue as primary issue
#1 - c4-pre-sort
2024-01-23T21:02:27Z
raymondfam marked the issue as sufficient quality report
#2 - raymondfam
2024-01-23T21:04:08Z
Unrestricted access to sensitive a setter function is indeed a vulnerability.
#3 - c4-sponsor
2024-01-30T16:52:47Z
wkantaros (sponsor) confirmed
#4 - c4-judge
2024-02-03T13:07:56Z
alex-ppg marked the issue as satisfactory
#5 - c4-judge
2024-02-03T13:33:41Z
alex-ppg marked issue #721 as primary and marked this issue as a duplicate of 721
104.9182 USDC - $104.92
When the transaction on the bridge executor on the destination bridge fails, it will refund the tokens to the address specified by _from
address. This _from
address is the source chain address of the source chain's bridge adapter.
Therefore when the call from bridge executor fails (it can be due to slippage checks failing during swapping in UTB
in the destination chain), then the funds are sent to the _from
address in the destination chain, which is not controlled by any user.
The following shows that the executor will refund tokens / ETH to the from
address
DecentBridgeExecutor.sol#L24C1-L65C6
function _executeEth( address from, address target, uint256 amount, bytes memory callPayload ) private { weth.withdraw(amount); (bool success, ) = target.call{value: amount}(callPayload); if (!success) { => payable(from).transfer(amount); } } ... // omitted executeWeth as it is largely the same as _executeEth function execute( address from, address target, bool deliverEth, uint256 amount, bytes memory callPayload ) public onlyOwner { weth.transferFrom(msg.sender, address(this), amount); if (!gasCurrencyIsEth || !deliverEth) { => _executeWeth(from, target, amount, callPayload); } else { => _executeEth(from, target, amount, callPayload); } }
The from
address, however, is the msg.sender
which calls bridgeWithPayload
on the source chain
Observe that on the destination chain, the from
/_from
address is taken from the 2nd slot of the payload.
DecentEthRouter.sol#L237C1-L282C6
function onOFTReceived( uint16 _srcChainId, bytes calldata, uint64, bytes32, uint _amount, bytes memory _payload ) external override onlyLzApp { => (uint8 msgType, address _from, address _to, bool deliverEth) = abi .decode(_payload, (uint8, address, address, bool)); bytes memory callPayload = ""; if (msgType == MT_ETH_TRANSFER_WITH_PAYLOAD) { (, , , , callPayload) = abi.decode( _payload, (uint8, address, address, bool, bytes) ); } ... } else { weth.approve(address(executor), _amount); executor.execute(_from, _to, deliverEth, _amount, callPayload); } }
The source chain encodes the msg.sender
of the payload into the 2nd slot.
(Note: the function below is called by _bridgeWithPayload
which is called by bridgeWithPayload
)
DecentEthRouter.sol#L80C1-L111C1
function _getCallParams( uint8 msgType, address _toAddress, uint16 _dstChainId, uint64 _dstGasForCall, bool deliverEth, bytes memory additionalPayload ) ... if (msgType == MT_ETH_TRANSFER) { => payload = abi.encode(msgType, msg.sender, _toAddress, deliverEth); } else { payload = abi.encode( msgType, => msg.sender, _toAddress, deliverEth, additionalPayload ); } }
However, msg.sender
that calls bridgeWithPayload
is actually the source chain's bridge adapter:
DecentBridgeAdapter.sol#L81C1-L125C6
function bridge( uint256 amt2Bridge, SwapInstructions memory postBridge, uint256 dstChainId, address target, address paymentOperator, bytes memory payload, bytes calldata additionalArgs, address payable refund ) public payable onlyUtb returns (bytes memory bridgePayload) { ... router.bridgeWithPayload{value: msg.value}( lzIdLookup[dstChainId], destinationBridgeAdapter[dstChainId], swapParams.amountIn, false, dstGas, bridgePayload ); }
Hence, when the executor attempts to refund the cross-chain transaction on a failed call (which can be due to a failed swap in UTB), then the tokens will be sent to the _from
address which is not controlled by either the user or Decent (because contracts may not necessarily use the same address on different chains), leading to a lost transaction.
Manual Review
Pass a destination chain refund address into the payload sent cross-chain and replace the from
address used in the executor with it.
Other
#0 - c4-pre-sort
2024-01-24T00:41:27Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-01-24T00:44:19Z
raymondfam marked the issue as duplicate of #27
#2 - c4-judge
2024-02-02T17:28:42Z
alex-ppg marked the issue as satisfactory
1032.2629 USDC - $1,032.26
When the DecentEthRouter receives the dcntEth OFT token from a cross-chain transaction, if the WETH balance of the destination router is less than amount of dcntEth received (this could be due to the router receiving more cross-chain transactions than than sending cross-chain transactions which depletes its WETH reserves), then the dcntEth will get transferred to the address specified by _to
.
function onOFTReceived( uint16 _srcChainId, bytes calldata, uint64, bytes32, uint _amount, bytes memory _payload ) external override onlyLzApp { ... if (weth.balanceOf(address(this)) < _amount) { => dcntEth.transfer(_to, _amount); return; } if (msgType == MT_ETH_TRANSFER) { if (!gasCurrencyIsEth || !deliverEth) { weth.transfer(_to, _amount); } else { weth.withdraw(_amount); payable(_to).transfer(_amount); } } else { weth.approve(address(executor), _amount); executor.execute(_from, _to, deliverEth, _amount, callPayload); } }
This dcntEth is sent to the user so that they can either redeem the WETH / ETH from the router once the WETH balance is refilled or send it back to the source chain to redeem back the WETH.
The problem is that if the msgType != MT_ETH_TRANSFER, then the _to
address is not the user, it is instead the target meant to be called by the destination chain's bridge executor (if the source chain uses a decent bridge adapter, the target is always the destination chain's bridge adapter which does not have a way to withdraw the dcntEth)
The following snippet shows what occurs in the bridge executor (_executeEth
omitted as it does largely the same thing as _executeWeth
):
DecentBridgeExecutor.sol#L24-L82
function _executeWeth( address from, address target, uint256 amount, bytes memory callPayload ) private { uint256 balanceBefore = weth.balanceOf(address(this)); weth.approve(target, amount); (bool success, ) = target.call(callPayload); if (!success) { weth.transfer(from, amount); return; } uint256 remainingAfterCall = amount - (balanceBefore - weth.balanceOf(address(this))); // refund the sender with excess WETH weth.transfer(from, remainingAfterCall); } ... function execute( address from, address target, bool deliverEth, uint256 amount, bytes memory callPayload ) public onlyOwner { weth.transferFrom(msg.sender, address(this), amount); if (!gasCurrencyIsEth || !deliverEth) { _executeWeth(from, target, amount, callPayload); } else { _executeEth(from, target, amount, callPayload); } }
Therefore, once the dcntEth is transferred to the execution target (which is almost always the destination chain bridge adapter, see Appendix for the code walkthrough). The user cannot do anything to retrieve the dcntEth out of the execution target, so the cross-chain transaction is lost.
Manual Review
Pass a destination chain refund address into the payload sent cross-chain and replace the _to
address used in DecentEthRouter.sol#L267:
if (weth.balanceOf(address(this)) < _amount) { // REPLACE '_to' with the destination chain refund address => dcntEth.transfer(_to, _amount); return; }
To see why the target is always the destination bridge adapter if the source chain is a decent bridge adapter:
UTB.sol will first call the bridge
function in the adapter with the destination bridge adapter address as the 2nd argument.
DecentBridgeAdapter.sol#L117C1-L124C11
function bridge( ... router.bridgeWithPayload{value: msg.value}( lzIdLookup[dstChainId], destinationBridgeAdapter[dstChainId], swapParams.amountIn, false, dstGas, bridgePayload ); }
which calls the below function in the router, where the _toAddress
is the 2nd argument and therefore is the destination bridge adapter address
DecentEthRouter.sol#L196C1-L204C23
/// @inheritdoc IDecentEthRouter function bridgeWithPayload( uint16 _dstChainId, address _toAddress, uint _amount, bool deliverEth, uint64 _dstGasForCall, bytes memory additionalPayload ) public payable { return _bridgeWithPayload( MT_ETH_TRANSFER_WITH_PAYLOAD, _dstChainId, _toAddress, _amount, _dstGasForCall, additionalPayload, deliverEth ); ...
which calls _bridgeWithPayload
which calls _getCallParams
to encode the payload to send to the destination chain:
DecentEthRouter.sol#L148C1-L168C15
function _bridgeWithPayload( uint8 msgType, uint16 _dstChainId, address _toAddress, uint _amount, uint64 _dstGasForCall, bytes memory additionalPayload, bool deliverEth ) internal { ( bytes32 destinationBridge, bytes memory adapterParams, bytes memory payload ) = _getCallParams( msgType, _toAddress, _dstChainId, _dstGasForCall, deliverEth, additionalPayload ); ...
The _toAddress
parameter is always the 3rd parameter in the payload sent.
function _getCallParams ... if (msgType == MT_ETH_TRANSFER) { payload = abi.encode(msgType, msg.sender, _toAddress, deliverEth); } else { payload = abi.encode( msgType, msg.sender, _toAddress, deliverEth, additionalPayload ); } ...
Which matches _to
variable in onOFTReceived
/// @inheritdoc IOFTReceiverV2 function onOFTReceived( uint16 _srcChainId, bytes calldata, uint64, bytes32, uint _amount, bytes memory _payload ) external override onlyLzApp { (uint8 msgType, address _from, address _to, bool deliverEth) = abi .decode(_payload, (uint8, address, address, bool)); bytes memory callPayload = ""; if (msgType == MT_ETH_TRANSFER_WITH_PAYLOAD) { (, , , , callPayload) = abi.decode( _payload, (uint8, address, address, bool, bytes) ); } ...
Other
#0 - c4-pre-sort
2024-01-23T23:36:04Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-01-23T23:36:09Z
raymondfam marked the issue as primary issue
#2 - raymondfam
2024-01-23T23:37:02Z
Lengthy elaboration. Could have had a coded POC.
#3 - c4-sponsor
2024-01-30T16:14:20Z
wkantaros marked the issue as disagree with severity
#4 - c4-sponsor
2024-01-30T16:19:33Z
wkantaros (sponsor) confirmed
#5 - alex-ppg
2024-02-02T15:13:20Z
This and all duplicate submissions detail an interesting way in which cross-chain relays will fail to properly invoke the target recipient of the relayed call, effectively leading to loss of funds as the assets will be transferred to an entity that potentially is not equipped to handle the token.
Based on discussions in #505, this is a very likely scenario and thus a high-risk severity is appropriate as the vulnerability should manifest consistently in non-Ethereum chains.
I would normally select #410 as the best report, however, #410 contains a slightly incorrect alleviation in its example, and as such this report has been selected as the best.
#6 - c4-judge
2024-02-02T15:13:26Z
alex-ppg marked the issue as selected for report
#7 - c4-judge
2024-02-02T15:13:30Z
alex-ppg marked the issue as satisfactory
🌟 Selected for report: NPCsCorp
Also found by: Soliditors, haxatron, nmirchev8, peanuts
522.8302 USDC - $522.83
In https://github.com/decentxyz/decent-bridge/blob/7f90fd4489551b69c20d11eeecb17a3f564afb18/src/DecentEthRouter.sol#L197-L234, anyone can call bridge
and bridgeWithPayload
function bridgeWithPayload( uint16 _dstChainId, address _toAddress, uint _amount, bool deliverEth, uint64 _dstGasForCall, bytes memory additionalPayload ) public payable { return _bridgeWithPayload( MT_ETH_TRANSFER_WITH_PAYLOAD, _dstChainId, _toAddress, _amount, _dstGasForCall, additionalPayload, deliverEth ); } /// @inheritdoc IDecentEthRouter function bridge( uint16 _dstChainId, address _toAddress, uint _amount, uint64 _dstGasForCall, bool deliverEth // if false, delivers WETH ) public payable { _bridgeWithPayload( MT_ETH_TRANSFER, _dstChainId, _toAddress, _amount, _dstGasForCall, bytes(""), deliverEth ); }
These functions are meant to be called by the bridge adapter only which is called by UTB which takes a fee, but they can called by anyone as they have no modifiers set.
Therefore, a user can send a cross-chain transaction without paying fees to Decent by directly calling bridge
and bridgeWithPayload
. A secondary impact is that this will also deplete the ETH / WETH reserves of the router in the destination chain.
Manual Review.
Add access control checks to these functions (only bridge adapter should call these functions)
Access Control
#0 - c4-pre-sort
2024-01-23T23:10:20Z
raymondfam marked the issue as sufficient quality report
#1 - raymondfam
2024-01-23T23:13:15Z
Fee dodging via the router.
#2 - c4-pre-sort
2024-01-23T23:13:25Z
raymondfam marked the issue as duplicate of #15
#3 - c4-pre-sort
2024-01-26T02:47:43Z
raymondfam marked the issue as not a duplicate
#4 - c4-pre-sort
2024-01-26T02:47:46Z
raymondfam marked the issue as primary issue
#5 - raymondfam
2024-01-26T02:52:57Z
Bridge dodging that should have access restrictions.
#6 - c4-sponsor
2024-01-30T16:55:28Z
wkantaros (sponsor) confirmed
#7 - c4-judge
2024-02-02T16:22:18Z
alex-ppg marked issue #647 as primary and marked this issue as a duplicate of 647
#8 - c4-judge
2024-02-02T16:24:56Z
alex-ppg marked the issue as satisfactory
23.067 USDC - $23.07
https://github.com/code-423n4/2024-01-decent/blob/main/src/UTB.sol#L311
Whenever a user wants to call swapAndExecute
(which performs a swap and executes a transaction for a user in the same chain), they have to pay a fee via retrieveAndCollectFees
modifier.
function swapAndExecute( SwapAndExecuteInstructions calldata instructions, FeeStructure calldata fees, bytes calldata signature ) public payable retrieveAndCollectFees(fees, abi.encode(instructions, fees), signature) { _swapAndExecute( instructions.swapInstructions, instructions.target, instructions.paymentOperator, instructions.payload, instructions.refund ); }
However there is another function that does essentially the same thing without the retrieveAndCollectFees
modifier.
function receiveFromBridge( SwapInstructions memory postBridge, address target, address paymentOperator, bytes memory payload, address payable refund ) public { _swapAndExecute(postBridge, target, paymentOperator, payload, refund); }
Essentially, this means a user can bypass fees in swapAndExecute
by using the function receiveFromBridge
.
Note that though this function is not payable, _swapAndExecute
also accepts ERC-20 tokens, meaning that one can abuse receiveFromBridge
to swap ERC-20 tokens and execute transactions without a fee.
Manual Review
The receiveFromBridge
function needs to check if the msg.sender
is actually a bridge adapter by using a new storage mapping that stores a boolean whether a given address is a valid bridge adapter.
Access Control
#0 - c4-pre-sort
2024-01-23T21:07:19Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-01-23T21:07:23Z
raymondfam marked the issue as primary issue
#2 - raymondfam
2024-01-23T21:12:23Z
Dodging fee payment via this postBridge call is indeed a loophole.
#3 - c4-sponsor
2024-01-30T16:08:07Z
wkantaros (sponsor) confirmed
#4 - c4-judge
2024-02-03T12:45:20Z
alex-ppg marked issue #590 as primary and marked this issue as a duplicate of 590
#5 - c4-judge
2024-02-03T12:45:33Z
alex-ppg marked the issue as satisfactory
43.4302 USDC - $43.43
When setting up the LzCallParams
for the cross-chain transaction the refund address is set to the msg.sender
of the transaction.
DecentEthRouter.sol#L170C1-L174C12
ICommonOFT.LzCallParams memory callParams = ICommonOFT.LzCallParams({ => refundAddress: payable(msg.sender), zroPaymentAddress: address(0x0), adapterParams: adapterParams });
The refundAddress
in LzCallParams
is the address to pay if the fee paid for the cross-chain LayerZero transaction is overpaid (this includes gas fees for the transaction). See here.
However, the msg.sender
which calls bridgeWithPayload
is the source chain bridge adapter
DecentBridgeAdapter.sol#L81C2-L125C6
function bridge( ... ) public payable onlyUtb returns (bytes memory bridgePayload) { ... => router.bridgeWithPayload{value: msg.value}( lzIdLookup[dstChainId], destinationBridgeAdapter[dstChainId], swapParams.amountIn, false, dstGas, bridgePayload ); }
Therefore, if the gasValue
passed into dcntEth.sendAndCall
is greater than the gas cost of of the cross-chain transaction, the excess value is refunded and stuck in the source chain bridge adapter instead of the user who initiated a cross chain transaction.
Manual Review
Allow users to pass in a source chain refund address which will get refunded the excess gas fee.
Other
#0 - c4-pre-sort
2024-01-24T02:54:42Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-01-24T02:55:03Z
raymondfam marked the issue as duplicate of #27
#2 - c4-judge
2024-02-02T16:57:15Z
alex-ppg marked the issue as not a duplicate
#3 - c4-judge
2024-02-02T16:58:50Z
alex-ppg marked the issue as duplicate of #262
#4 - c4-judge
2024-02-02T17:03:35Z
alex-ppg marked the issue as satisfactory