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: 3/113
Findings: 5
Award: $2,570.95
🌟 Selected for report: 2
🚀 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.1524 USDC - $0.15
By allowing anybody to set the address of the Router contract to any address they want to set it allows malicious users to get access to the mint and burn functions of the DcntEth contract.
The DcntEth::setRouter() function
has not an access control to restrict who can call this function. This allows anybody to set the address of the router contract to any address they'd like to set it.
DcntEth.sol
//@audit-issue => No access control to restrict who can set the address of the router contract function setRouter(address _router) public { router = _router; }
The functions DcntEth::mint() function
& DcntEth::burn() function
can be called only by the router contract.
DcntEth.sol
//@audit-info => Only the router can call the mint() function mint(address _to, uint256 _amount) public onlyRouter { _mint(_to, _amount); } //@audit-info => Only the router can call the burn() function burn(address _from, uint256 _amount) public onlyRouter { _burn(_from, _amount); }
A malicious user can set the address of the router contract to an account of their own and:
DcntEth::mint() function
or the DcntEth::burn() function
, if the router address is set to be different than the address of the DecentEthRouter, all the calls made from the DecentEthRouter to the DcnEth contract will revert.DecentEthRouter.sol
/// @inheritdoc IDecentEthRouter function addLiquidityEth() public payable onlyEthChain userDepositing(msg.value) { weth.deposit{value: msg.value}(); //@audit-issue => If router in the dcnteth contract is not set to the address of the DecentEthRouter, this call will revert dcntEth.mint(address(this), msg.value); } /// @inheritdoc IDecentEthRouter function removeLiquidityEth( uint256 amount ) public onlyEthChain userIsWithdrawing(amount) { //@audit-issue => If router in the dcnteth contract is not set to the address of the DecentEthRouter, this call will revert dcntEth.burn(address(this), amount); weth.withdraw(amount); payable(msg.sender).transfer(amount); } /// @inheritdoc IDecentEthRouter function addLiquidityWeth( uint256 amount ) public payable userDepositing(amount) { weth.transferFrom(msg.sender, address(this), amount); //@audit-issue => If router in the dcnteth contract is not set to the address of the DecentEthRouter, this call will revert dcntEth.mint(address(this), amount); } /// @inheritdoc IDecentEthRouter function removeLiquidityWeth( uint256 amount ) public userIsWithdrawing(amount) { //@audit-issue => If router in the dcnteth contract is not set to the address of the DecentEthRouter, this call will revert dcntEth.burn(address(this), amount); weth.transfer(msg.sender, amount); }
Manual Audit
Make sure to add an Acess Control mechanism to limit who can set the address of the Router in the DcnEth contract.
Access Control
#0 - c4-pre-sort
2024-01-25T21:45:55Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-01-25T21:46:06Z
raymondfam marked the issue as duplicate of #14
#2 - c4-judge
2024-02-03T13:08:01Z
alex-ppg marked the issue as satisfactory
#3 - alex-ppg
2024-02-03T13:33:37Z
This and all relevant submissions correctly specify that the lack of access control in the DcntEth::setRouter
function can be exploited maliciously and effectively compromise the entire TVL of the Decent ETH token.
A high-risk severity is appropriate, and this submission was selected as the best due to detailing all possible impacts:
UTB
#4 - c4-judge
2024-02-03T13:33:44Z
alex-ppg marked the issue as selected for report
#5 - c4-sponsor
2024-02-13T21:41:56Z
wkantaros (sponsor) confirmed
🌟 Selected for report: iamandreiski
Also found by: EV_om, NPCsCorp, nuthan2x, windhustler
1742.7673 USDC - $1,742.77
The root cause of the vulnerability exists in the DecentEthRouter::_getCallParams()
internal function which is responsible for configuring the adapter parameters which will be sent to Layerzero
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, _toAddress, deliverEth, additionalPayload ); } }
The DecentEthRouter::_getCallParams()
function takes the value of the parameter _dstGasForCall
(which is user supplied) and adds it to the pre-set amount of gas for the relayer (100,000) and sets the result as the amount of gas to be sent.
The issue is that the attacker can supply zero as the value of _dstGasForCall
parameter, leading to only 100,000 gas being set which is not sufficient and will result in StoredPayload
and the message pathway getting blocked.
The user gets to supply the value of _dstGasForCall
parameter at the very beginning when he decides to call the function UTB::bridgeAndExecute()
in bridgeInstructions.additionalArgs
, and then it keeps getting passed until it reaches DecentEthRouter::bridgeWithPayload
which calls the internal function DecentEthRouter:_bridgeWithPayload()
which will call the DecentEthRouter::_getCallParams()
which will prepare the adapter parameters for the call to be sent to LayerZero
Additionally, the attacker can instruct the protocol to conduct an cross-chain arbitrary call to a malicious gas-griefing contract of his which will deplete all of the gas supplied leading the LZ call to fail and getting the message pathway to be be blocked.
The communication channel between a source chain and a destination chain can be blocked by exploiting the ability to specify arbitrary gas parameters for the destination chain function calls. An attacker consistently (and at a low cost), can block communication between the source chaind and a destination chain (which can only be unblocked through a manual intervention)
Manual Audit & LayerZero
This is most easily fixed by checking if the gas passed in the instructions.additionalArgs
in the UTB::bridgeAndExecute()
function is less than the minimum threshold.
Or fix it in the DecentEthRouter::_getCallParams()
function and check if the _dstGasForCall
meets the minimum amount of gas needed.
Found & reported by: sin1st3r__
Team: NPCsCorp
Invalid Validation
#0 - c4-pre-sort
2024-01-25T21:59:53Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-01-25T22:00:02Z
raymondfam marked the issue as duplicate of #212
#2 - raymondfam
2024-01-25T22:02:33Z
Same root cause as in #212 with additional attacking vector.
#3 - c4-judge
2024-02-02T12:32:10Z
alex-ppg marked the issue as satisfactory
104.9182 USDC - $104.92
https://github.com/decentxyz/decent-bridge/blob/7f90fd4489551b69c20d11eeecb17a3f564afb18/src/DecentEthRouter.sol#L101 https://github.com/decentxyz/decent-bridge/blob/7f90fd4489551b69c20d11eeecb17a3f564afb18/src/DecentEthRouter.sol#L105
Users will lost all their bridged funds in case that the bridge execution fails in the destination chain.
When a user is bridging tokens to perform an execution in the destination chain, the users initiates their transaction by:
UTB::bridgeAndExecute() function
, from there it will do the swap pre-bridge (in the src chain) & will modify the swapInstructions for the swap post-bridge (in the dest chain), then it will invoke the UTB::callBridge() function
UTB::callBridge() function
some approvals are granted to the DecentBridgeAdapter contract, and then the execution is forwarded to the DecentBridgeAdapter::bridge() function
DecentBridgeAdapter::bridge() function
, some data is encoded and decoded to prepare the calldata for the bridge, and then the DecentEthRouter::bridgeWithPayload() function
is called.DecentEthRouter::bridgeWithPayload() function
, notice how the msg.sender
in the DecentEthRouter contract will be the DecentBridgeAdapter contract, (from step 3 to step 4, the caller is the DecentBridgeAdapter contract). When the payload
that is sent for the execution in the destination chain is defined in the DecentEthRouter::_getCallParams() function
, which is called by the DecentEthRouter::_bridgeWithPayload() function
, the second parameter that is encoded as part of the payload
variable it is set to be the msg.sender
, keep in mind that the msg.sender
is the address of the DecentBridgeAdapter, not the user's address or an address controlled by him.payload
variable to the DecentEthRouter::onOFTReceived()
function in the destination chain.DecentEthRouter::onOFTReceived()
function is called, the received _payload
is decoded and the encoded values are saved in individual variables,*** the one we are interested is the second variable***, which is the msg.sender
that was encoded in the DecentEthRouter::_getCallParams()
function, then, if the crosschain message happens to be of an execute type, the execution will be forwarded to the DecentBridgeExecutor::execute() function
DecentBridgeExecutor::execute() function
, either the DecentBridgeExecutor::_executeWeth() function
or the DecentBridgeExecutor::_executeEth() function
are called, and the received from
parameter (which was just decoded from the received payload in the onOFTReceived()
) is forwarded to those functions.DecentBridgeAdapter::receiveFromBridge()
of the current chain).from
parameter, which is exactly the msg.sender
that was encoded in the DecentEthRouter::_getCallParams()
function. (See step 4)msg.sender
who called the DecentEthRouter::_getCallParams()
function in the source chain will cause that the users lost all his bridged funds, and instead, the DecentBridgeAdapter contract address of the source chain will be the one that received the refund of the bridged funds.Manual Audit
Instead of hardcoding the refundAddress
to be the msg.sender
, use the refundee
address that was already provided by the user. This address was passed in the calldata sent to the UTB::bridgeAndExecute() function
, specifically, the refundAddress specified by the user is encoded in the instructions.refund
variable.
DecentEthRouter::_getCallParams() function
. Read the value of the instructions.refund
variable that was sent at the beginning of the execution, and use that address to set the second variable of the payload
variable that is passed as a parameter for the execution in the destination chain.en/de-code
#0 - c4-pre-sort
2024-01-25T21:40:45Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-01-25T21:40:53Z
raymondfam marked the issue as duplicate of #27
#2 - c4-judge
2024-02-02T17:19:24Z
alex-ppg marked the issue as satisfactory
🌟 Selected for report: NPCsCorp
Also found by: Soliditors, haxatron, nmirchev8, peanuts
679.6793 USDC - $679.68
bridgeAndExecute
functionTo understand the vulnerability, we need to understand the execution flow of the bridgeAndExecute()
function, at least a small portion of it.
When the user wants to bridge tokens of him and execute an action on another chain, he will need to execute the UTB::bridgeAndExecute()
function.
Suppose the user exists in Polygon, he has USDC and he wants to mint an NFT in Optimism which costs 1000 DAI. What will happen is that the protocol will first, in polygon, swap the user's USDC with WETH, then bridge the WETH to Optimism, then swap the WETH with DAI and then execute the arbitrary call the user wants to execute, which will be to mint the NFT in exchange for the resulting 1000 DAI from the post-bridge swap operation.
When this function is called, the following will happen:
Step 1: When the user calls the UTB::bridgeAndExecute()
function, it will do three things: first, it will collect the fees by calling the UTBFeeCollector:collectFees()
function, secondly, it will conduct the pre-bridge swap operation (occurs in the source destination), it will swap the user's USDC to WETH. thirdly, it will modify the swapInstructions
which the user supplied to prepare for the post-bridge swap. Then after all of the 3 operations take place, it will invoke the UTB::callBridge()
function.
Step 2: In the UTB::callBridge()
function, some approvals are granted to the DecentBridgeAdapter
contract, and then the it will invoke the function DecentBridgeAdapter::bridge()
in the DecentBridgeAdapter
contract.
Step 3: In the DecentBridgeAdapter::bridge()
function, some data like the post-bridge swap payload and bridge payload (what to execute when the TX reaches destination) will be encoded, then it will reach out to the DecentEthRouter
contract and invoke the function DecentEthRouter::bridgeWithPayload
Step 4: When the execution reaches the DecentEthRouter::bridgeWithPayload
function, an internal function containing the actual logic, with the same name will also be called: DecentEthRouter::_bridgeWithPayload
Note: Notice that the `DecentEthRouter::bridgeWithPayload() function isn't protected by any modifiers, any body can call it directly
Step 5: When the execution gets inside the DecentEthRouter::_bridgeWithPayload
function, the function will prepare the LzCallParams
for the layerzero call and the actual bridging will happen when the dcntEth::sendAndCall
function is actually invoked.
Step 6: The bridging process kickstarts and the execution flow is continued in the destination chain.
Here is a graph of the execution flow
The vulnerability is that any user can conduct the pre-bridge swap operation using uniswap by himself, prepare the right calldata and call the function DecentEthRouter::bridgeWithPayload
directly (since anybody can call it), doing so will allow the user to bypass the fee collection process completely.
The fee collection as mentioned in the previously detailed execution flow, happens when the user first calls the bridgeAndExecute()
function. But as I mentioned, nothing really forces him to start execution from there. All that function does is collect the fees, conduct the pre-bridge swap operation and prepare the proper call data. The user can conduct the pre-bridge swap operation himself, prepare the proper calldata and talk directly to the DecentEthRouter::bridgeWithPayload
function, effecitvely bypassing fee collection.
Anybody can call the DecentEthRouter::bridgeWithPayload
directly, and the protocol has no mechanism of determining whether or not the user is using the protocol with or without paying fees.
Users can use the protocol without paying any fees.
Manual Audit
Tighten up the access control on the DecentEthRouter::bridgeWithPayload
function. Allow only the DecentBridgeAdapter to call the bridgeWithPayload() function.
Found & reported by: sin1st3r__
Team: NPCsCorp
Access Control
#0 - c4-pre-sort
2024-01-25T05:44:02Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-01-25T05:44:08Z
raymondfam marked the issue as duplicate of #15
#2 - c4-pre-sort
2024-01-26T02:50:55Z
raymondfam marked the issue as not a duplicate
#3 - c4-pre-sort
2024-01-26T02:51:06Z
raymondfam marked the issue as duplicate of #51
#4 - raymondfam
2024-01-26T02:54:56Z
This report details a clear flow on how bridge dodging can be used to avoid fees.
#5 - c4-pre-sort
2024-01-26T02:55:04Z
raymondfam marked the issue as high quality report
#6 - c4-judge
2024-02-02T16:22:20Z
alex-ppg marked the issue as selected for report
#7 - alex-ppg
2024-02-02T16:23:47Z
The Warden has demonstrated that it is possible to bypass Decent fees when performing bridging operations by directly interacting with the DecentEthRouter
. This is indeed a valid concern and has been confirmed by the Sponsor.
I believe a severity of medium is more appropriate given that uncaptured profit is solely affected.
#8 - c4-judge
2024-02-02T16:23:51Z
alex-ppg marked the issue as satisfactory
#9 - c4-judge
2024-02-02T16:23:56Z
alex-ppg changed the severity to 2 (Med Risk)
#10 - mcgrathcoutinho
2024-02-06T16:54:08Z
Hi @alex-ppg, thank you for the detailed response.
This issue is invalid since the decent bridge does not charge any bridge fees in the first place. If we look at the modifier retrieveAndCollectFees, it uses the fees.feeAmount
(which is the swap fee) while the fees.bridgeFee is not considered. This is expected since the decent bridge is not charging fees.
We can see this expected behaviour here. When the bridgedAmount() function is called on the adapter, the decentBridgeAdapter just returns the same amount without any bridge fees applied (see here), while the stargateBridgeAdapter applies the bridgeFee correctly here.
Please consider re-evaluating this issue.
Thank you.
#11 - stalinMacias
2024-02-07T00:56:19Z
This issue is invalid since the decent bridge does not charge any bridge fees in the first place.
That doesn't make any sense. In the bypassed retrieveAndCollectFees function natspec, it is clearly saying: "Transfers fees from the sender to UTB, and finally to the Fee Collector"
And it does in fact execute a transferFrom
TX to transfer the fees.feeAmount
count of fees.feeToken
to the UTB Fee collector (which is supposed to be holding fees).
Additionally, if we look at the docs, here is it's saying:
Do you understand from all this that there are no fees being collected?
#12 - mcgrathcoutinho
2024-02-07T01:12:30Z
Hi @stalinMacias, thank you for the comment.
The fees.feeAmount
is the swap fee and not the bridge fee. We can see this in the FeeStructure struct below:
struct FeeStructure { uint bridgeFee; address feeToken; uint feeAmount; }
The decent bridge not taking bridge fees is something that the sponsor had confirmed as well (although in my private thread). It would be good if the judge clarifies this separately with the sponsor since it would be against the code of conduct for me to introduce information from the private thread (though I'm willing to invite anyone to it for proof).
#13 - stalinMacias
2024-02-07T02:41:09Z
@mcgrathcoutinho Let's break this down.
UTB.bridgeAndExecute() function
are generated by an API/backend of the protocol, why do we know this? Because in the retrieveAndCollectFees() modifier
all the inputs are sent to the FeeCollector.collectFees() function
to be verified that they match with the signature that was signed with an account controlled by the protocol.FeeStructure.feeAmount
represents the amount of fees that the user will be charged for the usage of the protocol's services, again, such feeAmount was previously computed by their API that will generate a signature to validate that all the inputted parameters are correct and have not been altered.From your comment: "The fees.feeAmount is the swap fee and not the bridge fee. "
From your comment: "The decent bridge not taking bridge fees is something that the sponsor had confirmed as well "
Again, one thing is that the bridge itself charges fees for its usage, like the Stargate Bridge, anybody who uses that bridge will pay a fee that will be stored in that contract. And another very different thing is charging fees for a service provided by the Decent Protocol. As per the Decent's documentation, all of their services charges fees (except the direct executions), those fees are what is represented in the FeeStructure.feeAmount
.
At this point, I've already made a clear explanation about the FeeStructure. Now, reiterating the point of this report, by directly calling the DecentEthRouter::bridgeWithPayload() function, users will be able to use the protocol's service bridgeSwapAndExecute without paying fees to the protocol for the usage of their infrastructure.
@mcgrathcoutinho I hope this explanation clears any doubts you may had about the report.
#14 - alex-ppg
2024-02-09T10:18:26Z
Hey @mcgrathcoutinho and @stalinMacias, thank you for contributing to this thread.
Regardless of what the Sponsor shared in private discussions, all material in the documentation as well as the code of the project points to a fee being charged. The entire presence of the UTBFeeCollector
(as its name implies) is to collect fees from the UTB
contract. Otherwise, it would not exist in the repository and not be in the scope of the contest.
Based on the above, my original ruling on this issue stands.
#15 - c4-sponsor
2024-02-13T21:42:43Z
wkantaros (sponsor) confirmed
43.4302 USDC - $43.43
Users will lose any excess gas they pay for LZ calls because the refundee
address is incorrectly hardcoded to be the msg.sender
of the DecentEthRouter::bridgeWithPayload() function
rather than the refund
address the user specified
When a crosschain message is sent through LayerZero it is required to prepare a calldata that it'll be sent to the LayerZero contracts. One of the parameter for this calldata it is the refundAddress
, which indicates to the LayerZero contracts where to refund the excess gas that was paid for the transaction in case that the transaction's cost is cheaper than the amount of value passed.
This is the execution flow when a user wants to perform a bridge of funds from one chain to another
UTB::bridgeAndExecute()
function, here the fees for the operation are charged, a swap pre-bridge its executed && the swapParams for the swap post-bridge are prepared, then execution goes to the UTB::callBridge()
functionUTB::callBridge()
function, some approvals are granted to the DecentBridgeAdapter
contract, and then the execution is forwarded to the DecentBridgeAdapter::bridge()
functionDecentBridgeAdapter::bridge()
function, some data is encoded and decoded to prepare the calldata for the bridge, and then the DecentEthRouter::bridgeWithPayload() function
is called.DecentEthRouter::bridgeWithPayload()
function, the msg.sender
is be the DecentBridgeAdapter
contract's address, (from step 3 to step 4, the caller is the DecentBridgeAdapter
contract), when the LZCallParams are defined in the DecentEthRouter::_bridgeWithPayload()
function, the callParams.refundAddress
is set to msg.sender
.callParams.refundAddress
to be the msg.sender
, what is happening is that the DecentBridgeAdapater
contract is set as the refundAddress for the excess gas that was paid for the execution, instead of setting the refundAddress
to be an address controller by the user.
DecentBridgeAdapter
contract receives the refund of excess gas instead of an account controlled by the user who initiated the bridge operation.
DecentBridgeAdapter
contract will accumulate a balance, and the user losses the excess gas that was paid for the lz call, which means that instead of refunding the excess gas to an account controlled by the user, any excess gas in the destination chain will be refunded to the address of the DecentBridgeAdapater contract
.From the DecentEthRouter::_bridgeWithPayload()
function, the execution is sent to the DcntEth::sendAndCall() function
where it will end up calling the LayerZeroEndpoint::send()
function.
Once the execution reaches the LayerZeroEndpoint contract, at the end of the execution of the LayerZeroEndpoint::send() function
, the endpoint contract computes if there is any leftover gas that was paid but was not spent, if so, it refunds the excess gas to the _refundAddress
that was passed in the callParams.refundAddress
. The problem is that the callParams.refundAddress
was encoded as the msg.sender
who called the DecentEthRouter::bridgeWithPayload()
function [Step 4]. This will cause that the refunded excess gas is sent to the DecentBridgeAdapater contract instead of an account controlled by the user.
Manual Audit, LayerZero Documentation & LayerZeroEndpoint contract
Instead of hardcoding the refundAddress
to be the msg.sender
, use the refundee
address that was already provided by the user. This address was passed in the calldata sent to the UTB::bridgeAndExecute() function
, specifically, the refundAddress specified by the user is encoded in the instructions.refund
variable.
DecentEthRouter::bridgeWithPayload()
function. Read the value of the instructions.refund
variable that was sent at the beginning of the execution, and use that address to set the callParams.refundAddress
which specifies the address where the excess gas paid to LayerZero should be refunded.en/de-code
#0 - c4-pre-sort
2024-01-25T21:36:09Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-01-25T21:36:16Z
raymondfam marked the issue as duplicate of #27
#2 - c4-judge
2024-02-02T16:57:06Z
alex-ppg marked the issue as not a duplicate
#3 - c4-judge
2024-02-02T16:58:08Z
alex-ppg marked the issue as duplicate of #262
#4 - c4-judge
2024-02-02T17:00:32Z
alex-ppg marked the issue as satisfactory
#5 - c4-judge
2024-02-04T23:04:27Z
alex-ppg changed the severity to 2 (Med Risk)