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: 9/113
Findings: 5
Award: $954.05
🌟 Selected for report: 0
🚀 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
In the DcntEth.sol contract, the function setRouter() is used to set the address for the decentEthRouter being used. The function does not have the onlyOwner modifier applied to it though. Due to this the following could occur:
In the below code, we can see that the function can be called by anyone to set the router.
File: DcntEth.sol 22: function setRouter(address _router) public { 23: router = _router; 24: }
Due to this, the functions below in the DecentEthRouter contract will not work since the mint() and burn() calls are not accessible to this router once the attacker sets it to his router.
File: DecentEthRouter.sol 304: /// @inheritdoc IDecentEthRouter 305: function addLiquidityEth() 306: public 307: payable 308: onlyEthChain 309: userDepositing(msg.value) 310: { 311: weth.deposit{value: msg.value}(); 312: dcntEth.mint(address(this), msg.value); 313: } 314: 315: /// @inheritdoc IDecentEthRouter 318: function removeLiquidityEth( 319: uint256 amount 320: ) public onlyEthChain userIsWithdrawing(amount) { 321: dcntEth.burn(address(this), amount); 322: weth.withdraw(amount); 323: payable(msg.sender).transfer(amount); 324: } 325: 326: /// @inheritdoc IDecentEthRouter 327: function addLiquidityWeth( 328: uint256 amount 329: ) public payable userDepositing(amount) { 330: weth.transferFrom(msg.sender, address(this), amount); 331: dcntEth.mint(address(this), amount); 332: } 333: 334: /// @inheritdoc IDecentEthRouter 336: function removeLiquidityWeth( 337: uint256 amount 338: ) public userIsWithdrawing(amount) { 339: dcntEth.burn(address(this), amount); 340: weth.transfer(msg.sender, amount); 341: }
Once the router has been set by the attacker, he gains access to the mint() and burn() functions below since he is now able to bypass the onlyRouter modifier. This can be used to mint or burn any number of dcntEth tokens to/from any address.
File: DcntEth.sol 26: function mint(address _to, uint256 _amount) public onlyRouter { 27: _mint(_to, _amount); 28: } 29: 30: function burn(address _from, uint256 _amount) public onlyRouter { 31: _burn(_from, _amount); 32: }
Manual Review
Apply the onlyOwner modifier to the function setRouter().
Access Control
#0 - c4-pre-sort
2024-01-24T02:59:31Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-01-24T02:59:39Z
raymondfam marked the issue as duplicate of #14
#2 - c4-judge
2024-02-03T13:28:11Z
alex-ppg marked the issue as satisfactory
104.9182 USDC - $104.92
In the function _executeWeth(), if the call to the target
(i.e. destination bridge adapter) fails (due payload call failure or some other reason) or the target partially spends the WETH tokens, the remaining WETH is transferred to the from
address. This from
address is the source chain's decentBridgeAdapter contract. The issue is that the WETH will be transferred to the equivalent of the from
address (src chain adapter) but on the destination chain, which may or may not be owned by anyone. This would mean loss of WETH for the user who was expecting the tokens.
from
address is set to be the msg.sender over here. This msg.sender is the decentBridgeAdapter contract on the source chain since it is the one that calls the bridgeWithPayload() function on the source router (see here). The execution path would like as follows:bridgeAndExecute (UTB) => callBridge (UTB) => bridge (Adapter) => bridgeWithPayload (Router)
from
address is then decoded on the destination chain in the function onOFTReceived().File: DecentEthRouter.sol 238: function onOFTReceived( 239: uint16 _srcChainId, 240: bytes calldata, 241: uint64, 242: bytes32, 243: uint _amount, 244: bytes memory _payload 245: ) external override onlyLzApp { 246: (uint8 msgType, address _from, address _to, bool deliverEth) = abi 247: .decode(_payload, (uint8, address, address, bool));
from
address, which is the source chain's adapter address being used by someone or no one on the destination chain. Due to this, the tokens the user expects will be lost forever.from
address incorrectly.File: DecentBridgeExecutor.sol 24: function _executeWeth( 25: address from, 26: address target, 27: uint256 amount, 28: bytes memory callPayload 29: ) private { 30: uint256 balanceBefore = weth.balanceOf(address(this)); 31: weth.approve(target, amount); 32: 33: (bool success, ) = target.call(callPayload); 34: 35: if (!success) { 36: weth.transfer(from, amount); 37: return; 38: } 39: 40: uint256 remainingAfterCall = amount - 41: (balanceBefore - weth.balanceOf(address(this))); 42: 43: // refund the sender with excess WETH 44: weth.transfer(from, remainingAfterCall); 45: }
Through this, we can see that the incorrect from
address can cause loss of tokens for the user.
Manual Review
Consider setting the from address in the payload encoding on the source chain to the user who initiates the bridgeAndExecute() transaction.
Error
#0 - c4-pre-sort
2024-01-25T19:32:26Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-01-25T19:32:38Z
raymondfam marked the issue as duplicate of #27
#2 - c4-judge
2024-02-02T17:21:26Z
alex-ppg marked the issue as satisfactory
794.0484 USDC - $794.05
In the onOFTReceived() function in the decentEthRouter contract, if the WETH balance of the contract is less than the amount needed to send, the dcntEth tokens are transferred instead.
The issue is that if the call originates from the bridgeAndExecute() function on the source chain, the address that should receive the tokens on the destination chain is encoded as the destination decentBridgeAdapter instead of the user. Due to this, the tokens would be permanently locked in the destination's adapter and the user would lose those tokens.
Over here, we can see that the _toAddress is encoded in the payload. This _toAddress is passed in as the destination bridge adapter from the decentBridgeAdapter on the source chain as seen here. See the code snippets below for proof:
See Line 120:
File: DecentBridgeAdapter.sol 118: router.bridgeWithPayload{value: msg.value}( 119: lzIdLookup[dstChainId], 120: destinationBridgeAdapter[dstChainId], 121: swapParams.amountIn, 122: false, 123: dstGas, 124: bridgePayload 125: );
See Line 106:
File: DecentEthRouter.sol 100: if (msgType == MT_ETH_TRANSFER) { 101: payload = abi.encode(msgType, msg.sender, _toAddress, deliverEth); 102: } else { 103: payload = abi.encode( 104: msgType, 105: msg.sender, 106: _toAddress, 107: deliverEth, 108: additionalPayload 109: ); 110: }
Due to this, when the payload is decoded on the destination chain as seen below in the onOFTReceived() function, the _to address is set to the destination's decentBridgeAdapter.
File: DecentEthRouter.sol 238: function onOFTReceived( 239: uint16 _srcChainId, 240: bytes calldata, 241: uint64, 242: bytes32, 243: uint _amount, 244: bytes memory _payload 245: ) external override onlyLzApp { 246: (uint8 msgType, address _from, address _to, bool deliverEth) = abi 247: .decode(_payload, (uint8, address, address, bool));
Now, if the WETH balance of the destination's decentEthRouter is less than the _amount of WETH being requested, the code would transfer dcntEth. But this transfer would be done to the destination's decentBridgeAdapter, where the tokens would be permanently locked. Following the incorrect transfer, we halt the execution by returning early.
File: DecentEthRouter.sol 267: if (weth.balanceOf(address(this)) < _amount) { 268: dcntEth.transfer(_to, _amount); 269: return; 270: }
Through this, we can see how the user loses his tokens and funds are accumulated in the destination chain's adapter.
Manual Review
Consider providing the _toAddress on the source chain as the user who initiated the bridgeAndExecute() transaction.
Other
#0 - c4-pre-sort
2024-01-25T04:23:12Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-01-25T04:23:18Z
raymondfam marked the issue as duplicate of #27
#2 - c4-judge
2024-02-02T17:14:21Z
alex-ppg marked the issue as not a duplicate
#3 - c4-judge
2024-02-02T17:18:15Z
alex-ppg marked the issue as duplicate of #59
#4 - c4-judge
2024-02-02T17:18:35Z
alex-ppg marked the issue as satisfactory
11.5335 USDC - $11.53
In the UTB.sol contract, the receiveFromBridge() function is used to call _swapAndExecute() when it receives a call from another chain. The issue with the function is that it is public without any modifier to restrict calls only from the decent bridge adapter or stargate bridge adapter. Due to this, an attacker or user could avoid paying the fees for same chain swap transactions by calling the function directly.
The bypassing of fees would mean major loss of protocol revenue for the team. Due to this, the issue has been marked as high-severity.
Note: Allowing users to bypass fees is not expected behaviour as confirmed by the sponsor
Here is the whole process:
swapAndExecute (UTB) => retrieveAndCollectFees (UTB) => collectFees (UTBFeeCollector) => _swapAndExecute (UTB) => performSwap (UTB) => swap (UniSwapper) => swapNoPath/swapExactIn/swapExactOut (UniSwapper) => call continues to withdraw tokens through executor for user
receiveFromBridge() (UTB) => _swapAndExecute (UTB) => performSwap (UTB) => swap (UniSwapper) => swapNoPath/swapExactIn/swapExactOut (UniSwapper) => call continues to withdraw tokens through executor for user
As we can see above, [A] goes through the UTBFeeCollector to collect the fees from the user while [B] completely skips over that part since there is a direct call to the internal function _swapAndExecute().
Manual Review
Restrict the calls to receiveFromBridge() from only the decent bridge adapter and stargate bridge adapter contracts. If more adapters will be added, consider maintaining a mapping or some kind of verification when a call is made from the adapter to the receiveFromBridge() function in UTB.sol.
Other
#0 - c4-pre-sort
2024-01-24T06:05:24Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-01-24T06:05:37Z
raymondfam marked the issue as duplicate of #15
#2 - alex-ppg
2024-02-03T12:39:03Z
I have penalized this submission as it goes against what I consider etiquette when dealing with C4 contests.
All submissions in a C4 contest are at the sole discretion of the judge and the Sponsor merely provides feedback and reviews for their own benefit. As can be observed throughout all C4 contests, the Sponsor and the Judge often disagree with rulings (i.e. even in this contest on #117).
Adding screenshots from direct messaging discussions with the Sponsor (I was not able to find the above conversation on the public Discord channel) is frowned upon as it provides no useful substance to the submission and instead attempts to rationalize the exhibit's validity based on opinionated facts that may be detached from reality.
In this particular instance, the discussion is completely out of context (what fees are bypassed? bridge or swap fees?) and the Sponsor's response is ambiguous (potentially yes
) further illustrating that this attachment provides no insight into the submission.
As a final note, we have to keep in mind that all discussions in this repository will be public once the contest ends. The Sponsor should reasonably expect that direct messaging discussions are private between the parties discussing and I do not believe that the Sponsor gave consent for this message to be attached to the submission, which is a breach of conduct in my eyes.
From a technical perspective, bypassing fees would not be a "major loss" for the protocol and this particular submission cannot be considered a high-risk vulnerability as it only affects a single feature of the protocol (i.e. bridge operations are unaffected).
The Proof-of-Concept chapter contains a lot of unnecessary information, as the vulnerability is present at the top-level UTB
call due to the absence of the modifier. The call chains within UniSwapper
do not aid in describing the vulnerability, and the code could have invoked any swapper implementation.
Based on the fact that the submission uses strong language to justify a high-risk grade (i.e. major loss of protocol revenue
) and contains the Sponsor's "feedback" on the issue to attempt to substantiate it further, I will penalize and award a 50% grade as I deem this a misguided effort. I initially wished to penalize at 25%, but the submission does specify what the issue is even though it has an incorrect severity and cluttered proof-of-concept chapter.
#3 - c4-judge
2024-02-03T12:39:07Z
alex-ppg marked the issue as partial-50
#4 - c4-judge
2024-02-03T13:03:51Z
alex-ppg changed the severity to 2 (Med Risk)
#5 - mcgrathcoutinho
2024-02-06T16:34:54Z
Hi @alex-ppg, thank you for providing the detailed response:
I was hoping you could change the partial-50 to a partial-75. Nonetheless, I am grateful for the feedback and will respect your judgement.
#6 - alex-ppg
2024-02-09T10:44:45Z
Hey @mcgrathcoutinho, thank you for taking note of my feedback shared above. I understand other Wardens appear to be doing the same and my statements apply to them as well, they were not directed solely to you. I hope to see more of your contributions in the future, but will sadly retain my judgment on this one.
43.4302 USDC - $43.43
Excess msg.value provided for gas is permanently locked in the DecentBridgeAdapter contract.
File: DecentEthRouter.sol 170: ICommonOFT.LzCallParams memory callParams = ICommonOFT.LzCallParams({ 172: refundAddress: payable(msg.sender), 173: zroPaymentAddress: address(0x0), 174: adapterParams: adapterParams 175: });
File: DecentEthRouter.sol 186: dcntEth.sendAndCall{value: gasValue}( 187: address(this), // from address that has dcntEth (so DecentRouter) 188: _dstChainId, 189: destinationBridge, // toAddress 190: _amount, // amount 191: payload, //payload (will have recipients address) 192: _dstGasForCall, // dstGasForCall 193: callParams // refundAddress, zroPaymentAddress, adapterParams 194: );
Manual Review
Use refund address as the user who initiated the bridgeAndExecute() transaction.
Error
#0 - c4-pre-sort
2024-01-25T04:00:38Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-01-25T04:00:44Z
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:11Z
alex-ppg marked the issue as duplicate of #262
#4 - c4-judge
2024-02-02T17:00:56Z
alex-ppg marked the issue as satisfactory
#5 - c4-judge
2024-02-04T23:04:25Z
alex-ppg changed the severity to 2 (Med Risk)