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: 21/113
Findings: 5
Award: $321.35
🌟 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
I'm submitting this issue as a sensitive disclosure because both of these contract are already deployed on multiple chains. For example, on Mainnet:
Router: 0x4B3876a8E3Bb072787F51c39be83d7cB0b4e6388 DecentEth: 0x4B3876a8E3Bb072787F51c39be83d7cB0b4e6388
Right now it's not possible to steal funds as the router doesn't have any WETH
, but these contracts seem active.
Moreover, it's still possible to accumulate DecentEth
right now and redeem WETH
later on.
Anyone can change decentEthRouter
as DcntEth.setRouter
lacks any access control.
This can be used to access privileged functions such as DcntEth.mint
and DcntEth.burn
to manipulate the DcntEth
supply.
This will cause a loss of funds as WETH
can be redeemed by transferring DcntEth
in the original router.
This function lacks access control, so it's callable by anyone:
/** * @param _router the decentEthRouter associated with this eth */ function setRouter(address _router) public { router = _router; }
Attack steps:
DecentEthRouter
has some WETH
liquidityDcntEth.setRouter
and changes it to a contract they own, so that they can call DcntEth.mint
:function mint(address _to, uint256 _amount) public onlyRouter { _mint(_to, _amount); }
DecentEthRouter.redeemWeth
in the original router to drain its funds:function redeemWeth( uint256 amount ) public onlyIfWeHaveEnoughReserves(amount) { dcntEth.transferFrom(msg.sender, address(this), amount); weth.transfer(msg.sender, amount); }
Manual review
Add a onlyOwner
modifier to DcntEth.setRouter
.
#0 - thebrittfactor
2024-01-24T18:03:59Z
For transparency, the sensitive disclosure contents were not included in the original submission. After sponsor review and approval, the original content has been added.
#1 - c4-pre-sort
2024-01-24T19:53:09Z
raymondfam marked the issue as sufficient quality report
#2 - c4-pre-sort
2024-01-24T19:53:23Z
raymondfam marked the issue as duplicate of #14
#3 - c4-judge
2024-02-03T13:16:37Z
alex-ppg marked the issue as satisfactory
136.3937 USDC - $136.39
https://github.com/decentxyz/decent-bridge/blob/7f90fd4489551b69c20d11eeecb17a3f564afb18/src/DecentEthRouter.sol#L101-L105 https://github.com/decentxyz/decent-bridge/blob/7f90fd4489551b69c20d11eeecb17a3f564afb18/src/DecentBridgeExecutor.sol#L63
When the DecentBridgeExecutor._executeWeth/_executeEth
target call fails, a refund is issued to the from
address.
However, this address is wrongly set, so those refunds will be permanently lost.
UTB.bridgeAndExecute
(Link) calls DecentBridgeAdapter.bridge
(Link), which calls DecentEthRouter.bridgeWithPayload
(Link), where the payload is constructed (Link):
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 ); ...
Inside _getCallParams
the from
address is the msg.sender
, i.e. the DecentBridgeAdapter
address on the source chain (Link):
function _getCallParams( uint8 msgType, address _toAddress, uint16 _dstChainId, uint64 _dstGasForCall, bool deliverEth, bytes memory additionalPayload ) private view returns ( bytes32 destBridge, bytes memory adapterParams, bytes memory payload ) { uint256 GAS_FOR_RELAY = 100000; uint256 gasAmount = GAS_FOR_RELAY + _dstGasForCall; adapterParams = abi.encodePacked(PT_SEND_AND_CALL, gasAmount); destBridge = bytes32(abi.encode(destinationBridges[_dstChainId])); if (msgType == MT_ETH_TRANSFER) { @> payload = abi.encode(msgType, msg.sender, _toAddress, deliverEth); } else { payload = abi.encode( msgType, @> msg.sender, //@audit 'from' address _toAddress, deliverEth, additionalPayload ); } }
After the payload is constructed, DecentEthRouter
sends the message:
dcntEth.sendAndCall{value: gasValue}( address(this), // from address that has dcntEth (so DecentRouter) _dstChainId, destinationBridge, // toAddress _amount, // amount payload, //payload (will have recipients address) _dstGasForCall, // dstGasForCall callParams // refundAddress, zroPaymentAddress, adapterParams );
Finally, on the destination chain, DecentEthRouter
will receive the message (Link):
function onOFTReceived( uint16 _srcChainId, bytes calldata, uint64, bytes32, uint _amount, bytes memory _payload ) external override onlyLzApp { //@audit from is the decentBridgeAdapter address on the source chain (uint8 msgType, address _from, address _to, bool deliverEth) = abi .decode(_payload, (uint8, address, address, bool)); ... }
At the end of this function, the executor
is invoked with the same _from
address:
} else { weth.approve(address(executor), _amount); executor.execute(_from, _to, deliverEth, _amount, callPayload); }
Finally, this is the execute
function in DecentBridgeExecutor
(Link):
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); } }
Both _executeWeth
and _executeEth
have the same issue and funds will be lost when the target call fails, for example _executeEth
(Link):
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); //@audit wrong address as it uses the source address, not destination } }
Now, DecentBridgeAdapter
as a refund address seems wrong, as I disclosed in another issue, but let's suppose that it isn't, as it's possible to prove both scenarios.
As proof by contradiction, funds should be sent to DecentBridgeAdapter
, and this would be a non-issue if these contracts are deployed with CREATE2
, as they would have the same address. But they are not deployed like that.
There is hard evidence that they have different addresses, for example, these are the addresses for DcntEth
and DecentEthRouter
in two different chains, which are already deployed:
lib/decent-bridge/actual-deployments/latest/arbitrumAddresses.json
{ "arbitrum_DcntEth": "0x8450e1A0DebF76fd211A03c0c7F4DdbB1eEF8A85", "done": true, "arbitrum_DecentEthRouter": "0x17479B712A1FE1FFaeaf155379DE3ad1440fA99e" }
lib/decent-bridge/actual-deployments/latest/optimismAddresses.json
{ "DcntEth": "0x4DB4ea27eA4b713E766bC13296A90bb42C5438D0", "done": true, "DecentEthRouter": "0x57beDF28C3CB3F019f40F330A2a2B0e0116AA0c2" }
If we take a look at the deploy script for DecentBridgeAdapter
it also doesn't use CREATE2
, as there isn't a factory:
function deployDecentBridgeAdapter( address utb, address decentEthRouter, address decentBridgeExecutor ) internal returns ( DecentBridgeAdapter decentBridgeAdapter ) { string memory chain = vm.envString("CHAIN"); bool gasIsEth = gasEthLookup[chain]; address weth = wethLookup[chain]; address bridgeToken = gasIsEth ? address(0) : weth; @> decentBridgeAdapter = new DecentBridgeAdapter(gasIsEth, bridgeToken); decentBridgeAdapter.setUtb(utb); decentBridgeAdapter.setRouter(decentEthRouter); decentBridgeAdapter.setBridgeExecutor(decentBridgeExecutor); UTB(payable(utb)).registerBridge(address(decentBridgeAdapter)); }
So these funds will be sent to a random address in any case.
Manual review
The executor.execute
call in DecentEthRouter.onOFTReceived
should be changed to an appropriate address (e.g. the user refund address) instead of using _from
:
} else { weth.approve(address(executor), _amount); executor.execute(_from, _to, deliverEth, _amount, callPayload); }
ETH-Transfer
#0 - c4-pre-sort
2024-01-24T20:12:14Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-01-24T20:12:22Z
raymondfam marked the issue as duplicate of #27
#2 - c4-judge
2024-02-02T17:14:32Z
alex-ppg marked the issue as selected for report
#3 - c4-judge
2024-02-02T17:18:47Z
alex-ppg marked the issue as satisfactory
#4 - alex-ppg
2024-02-02T17:18:48Z
The Warden has detailed how the encoding of the cross-chain payload will use an incorrect _from
parameter under normal operating conditions, leading to failed transfers at the target chain refunding the wrong address.
This submission was selected as the best given that it precisely details that the _from
address is known to be incorrect at all times when the protocol is used normally.
A high-risk rating is appropriate as any failed call will lead to full fund loss for the cross-chain call.
#5 - alex-ppg
2024-02-02T17:20:47Z
A general penalty of 25% (partial-75
) has been applied to submissions that "guess" the _from
address may not be owned by the user due to L2 concerns / smart contract interactions.
Other penalties will be elaborated on a per-exhibit basis.
#6 - DadeKuma
2024-02-06T17:45:36Z
@alex-ppg
A general penalty of 75% has been applied to submissions that "guess" the _from address may not be owned by the user due to L2 concerns / smart contract interactions.
I'm not sure if this is a typo, but a penalty of 75% is the same as 25% of the reward (i.e. partial-25
), yet only partial-50
and partial-75
labels were applied.
#7 - alex-ppg
2024-02-09T11:35:44Z
Apologies for the confusion, I meant to state that a reward of 75% / a penalty of 25% has been applied. All cases that state partial-50
have a justification response as to why they were penalized further. I have edited my original comment for clarity.
#8 - c4-sponsor
2024-02-13T21:40:35Z
wkantaros (sponsor) confirmed
23.067 USDC - $23.07
https://github.com/code-423n4/2024-01-decent/blob/main/src/UTB.sol#L108-L124 https://github.com/code-423n4/2024-01-decent/blob/main/src/UTB.sol#L311-L319
When users perform a swap through UTB.swapAndExecute
, they have to pay some fees, which are collected through the retrieveAndCollectFees
modifier.
However, users can avoid paying those fees by spoofing a bridge receive call.
Normally, a user should call UTB.swapAndExecute
(Link) to perform a swap, and fees are paid and collected through retrieveAndCollectFees
:
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, a user can simply call UTB.receiveFromBridge
to perform a swap without paying any fees, as it lacks access control (Link):
function receiveFromBridge( SwapInstructions memory postBridge, address target, address paymentOperator, bytes memory payload, address payable refund ) public { _swapAndExecute(postBridge, target, paymentOperator, payload, refund); }
Manual review
Consider adding access control in the receiveFromBridge
function, which should be callable only by a bridge adapter.
Access Control
#0 - c4-pre-sort
2024-01-24T20:15:32Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-01-24T20:15:39Z
raymondfam marked the issue as duplicate of #15
#2 - c4-judge
2024-02-03T12:20:45Z
alex-ppg marked the issue as satisfactory
43.4302 USDC - $43.43
https://github.com/code-423n4/2024-01-decent/blob/main/src/UTB.sol#L259 https://github.com/code-423n4/2024-01-decent/blob/main/src/UTB.sol#L289 https://github.com/code-423n4/2024-01-decent/blob/main/src/bridge_adapters/DecentBridgeAdapter.sol#L117 https://github.com/decentxyz/decent-bridge/blob/7f90fd4489551b69c20d11eeecb17a3f564afb18/src/DecentEthRouter.sol#L148
The LZ refundAddress
is not set to a user controlled address, as all refunds will be sent to DecentBridgeAdapter
.
This contract is not meant to hold any funds, and there is no way to extract them, so they will be permanently locked.
This is the bridging flow:
UTB.bridgeAndExecute
(Link)UTB.bridgeAndExecute
calls BridgeAdapter.bridge
(Link)BridgeAdapter.bridge
calls DecentEthRouter.bridgeWithPayload
(Link)If we look at the _bridgeWithPayload
function (Link):
function _bridgeWithPayload( uint8 msgType, uint16 _dstChainId, address _toAddress, uint _amount, uint64 _dstGasForCall, bytes memory additionalPayload, bool deliverEth ) internal { ... ICommonOFT.LzCallParams memory callParams = ICommonOFT.LzCallParams({ @> refundAddress: payable(msg.sender), zroPaymentAddress: address(0x0), adapterParams: adapterParams });
The refund address will be set to the BridgeAdapter
address, as it was the last caller.
The adapter is not meant to hold any funds, so every time a refund occurs, those funds will be lost/locked inside the adapter.
Manual review
Consider adding an explicit refund address as a function parameter:
function _bridgeWithPayload( uint8 msgType, uint16 _dstChainId, address _toAddress, uint _amount, uint64 _dstGasForCall, bytes memory additionalPayload, bool deliverEth, + address payable refundAddress ) internal { ... ICommonOFT.LzCallParams memory callParams = ICommonOFT.LzCallParams({ - refundAddress: payable(msg.sender), + refundAddress: refundAddress, zroPaymentAddress: address(0x0), adapterParams: adapterParams });
ETH-Transfer
#0 - c4-pre-sort
2024-01-24T19:55:19Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-01-24T19:55:33Z
raymondfam marked the issue as duplicate of #27
#2 - c4-judge
2024-02-02T16:57:11Z
alex-ppg marked the issue as not a duplicate
#3 - c4-judge
2024-02-02T16:58:30Z
alex-ppg marked the issue as duplicate of #262
#4 - c4-judge
2024-02-02T17:02:36Z
alex-ppg marked the issue as satisfactory
#5 - c4-judge
2024-02-04T23:04:25Z
alex-ppg changed the severity to 2 (Med Risk)
🌟 Selected for report: Kaysoft
Also found by: 0xmystery, Aamir, DadeKuma, IceBear, Pechenite, SBSecurity, Shaheen, bronze_pickaxe, ether_sky, nobody2018, rjs, rouhsamad, slvDev, zxriptor
118.3363 USDC - $118.34
Risk | Title |
---|---|
[L-01] | Refund calls can fail and funds will be locked inside UTBExecutor |
[L-02] | Anyone can swap freely without paying fees when signer is not set |
[L-03] | Deadline not set when swapping |
[L-04] | Excess value is not refunded to the user when swapping native |
[L-05] | onlyIfWeHaveEnoughReserves fails if there are enough reserves |
[L-06] | Funds can be locked when a user adds weth liquidity with some msg.value |
[L-07] | Approvals are not canceled after a partial transfer |
UTBExecutor
When a target
call fails, a refund should be issued to the user's refund
address.
However, it's not guaranteed that the refund call will succeed: in this case, these funds will be permanently locked inside UTBExecutor
:
if (token == address(0)) { (success, ) = target.call{value: amount}(payload); if (!success) { @> (refund.call{value: amount}("")); } return; }
https://github.com/code-423n4/2024-01-decent/blob/main/src/UTBExecutor.sol#L51-L55
signer
is not setFees are collected each time a user performs a swap, which are collected through collectFees
:
function collectFees( FeeStructure calldata fees, bytes memory packedInfo, bytes memory signature ) public payable onlyUtb { bytes32 constructedHash = keccak256( abi.encodePacked(BANNER, keccak256(packedInfo)) ); (bytes32 r, bytes32 s, uint8 v) = splitSignature(signature); @> address recovered = ecrecover(constructedHash, v, r, s); @> require(recovered == signer, "Wrong signature"); //@audit L - no check for recovered == 0? if (fees.feeToken != address(0)) { IERC20(fees.feeToken).transferFrom( utb, address(this), fees.feeAmount ); } }
https://github.com/code-423n4/2024-01-decent/blob/main/src/UTBFeeCollector.sol#L53-L54
signer
is initially set to the zero address, so the expected behavior seems to be that every transaction should revert until the signer
address is set to a valid address.
However, users can pass a malformed signature
so that ecrecover
returns zero: in this case the require
will pass, and users will not pay any fees.
The deadline is not set when swapping, as it's commented out on ExactOutputParams
, and it's missing on ExactInputParams
.
IV3SwapRouter.ExactOutputParams memory params = IV3SwapRouter .ExactOutputParams({ path: swapParams.path, recipient: address(this), //@audit L - deadline not set //deadline: block.timestamp, amountOut: swapParams.amountOut, amountInMaximum: swapParams.amountIn });
https://github.com/code-423n4/2024-01-decent/blob/main/src/swappers/UniSwapper.sol#L153
IV3SwapRouter.ExactInputParams memory params = IV3SwapRouter .ExactInputParams({ path: swapParams.path, recipient: address(this), amountIn: swapParams.amountIn, amountOutMinimum: swapParams.amountOut });
https://github.com/code-423n4/2024-01-decent/blob/main/src/swappers/UniSwapper.sol#L129-L135
This will cause unfavorable swaps to the users, especially if the TX stays on the mempool for a long time.
It's recommended to add a deadline parameter, preferably as a swapParams
field, so the user can set it.
When the tokenIn
is native, the msg.value
needs to be greater or equal than swapParams.amountIn
:
if (swapParams.tokenIn == address(0)) { @> require(msg.value >= swapParams.amountIn, "not enough native"); //@audit L - msg.value excess not refunded wrapped.deposit{value: swapParams.amountIn}(); swapParams.tokenIn = address(wrapped); swapInstructions.swapPayload = swapper.updateSwapParams( swapParams, swapInstructions.swapPayload ); }
However, when msg.value
is greater, the excess is not refunded to the user.
Consider either changing the require
so that the msg.value
is strictly equal to the amountIn
, or as alternative, refund the excess amount.
onlyIfWeHaveEnoughReserves
fails if there are enough reservesDue to an off-by-one error, this modifier will fail even when the balance is sufficient.
The balance needs to be amount
+ 1 wei
to avoid a revert. This can cause some issues when the balance is zero, and a precise amount
is sent to a contract, if the caller doesn't remember to top it with 1 additional wei
:
modifier onlyIfWeHaveEnoughReserves(uint256 amount) { require(weth.balanceOf(address(this)) > amount, "not enough reserves"); //@audit L - fails with enough reserves _; }
Consider modifying the require
so that it doesn't fail when weth.balanceOf(address(this)) >= amount
instead.
weth
liquidity with some msg.value
A user can call by mistake addLiquidityWeth
with some msg.value
: these funds will not be used and they will be locked inside the contract
function addLiquidityWeth( uint256 amount ) public payable userDepositing(amount) { weth.transferFrom(msg.sender, address(this), amount); dcntEth.mint(address(this), amount); }
Consider removing payable
from the function signature.
It's not guaranteed that the target will consume the entire allowance after a call:
uint256 balanceBefore = weth.balanceOf(address(this)); weth.approve(target, amount); (bool success, ) = target.call(callPayload); //@audit L - approval not canceled after the call
Consider adding a weth.approve(target, 0);
after the actual call to avoid any leftover approvals.
#0 - raymondfam
2024-01-26T05:52:06Z
[L-01]: refund is typically an EOA according to the function NatSpec [L-04]: Will be refunded via the returning calls [L-05]: Inconsequential 1 wei diff refactoring [L-07]: Fund Accumulation: Other than the UTBFeeCollector, and DcntEth, the contracts are not intended to hold on to any funds or unnecessary approvals. Any native value or erc20 flowing through the protocol should either get delivered or refunded.
3 L
#1 - c4-pre-sort
2024-01-26T05:52:11Z
raymondfam marked the issue as sufficient quality report
#2 - alex-ppg
2024-02-04T22:47:43Z
The Warden's QA report has been graded A based on a score of 32
combined with a manual review per the relevant QA guideline document located here.
The Warden's submission's score was assessed based on the following accepted findings:
#3 - c4-judge
2024-02-04T22:47:47Z
alex-ppg marked the issue as grade-a