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: 33/113
Findings: 2
Award: $153.96
🌟 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
https://github.com/decentxyz/decent-bridge/blob/7f90fd4489551b69c20d11eeecb17a3f564afb18/src/DcntEth.sol#L20-L22 https://github.com/decentxyz/decent-bridge/blob/7f90fd4489551b69c20d11eeecb17a3f564afb18/src/DcntEth.sol#L9 https://github.com/decentxyz/decent-bridge/blob/7f90fd4489551b69c20d11eeecb17a3f564afb18/src/DcntEth.sol#L24
Attacker can mint 10 billion tokens on all chains using the setRouter(...) function of DcntEth.sol contract because the function lacks access control
Exploit Scenario
Attacker calls the setRouter(...)
function on all chains with attacker's address
Attacker calls the mint
function of the DcntEth to mint 10 billion tokens to the attacker address
Attacker burns
all other users' tokens on all chains
In order to achieve further impact, the attacker drains all asset in DecentEthRouter.sol contract too through the steps below
registerDcntEth
functionsetRouter
is registered redeemEth(...)
and redeemWeth(...)
functions to drain all Eth and WETH on the DecentEthRouter.sol contract.The DcntEth.sol contract allows two roles to mint
and burn
tokens and the two roles are router
and owner
.
The issue is due to the fact that the setRouter
function lacks access control. This allows an Attacker to set an address that the attacker controls and use it to mint as much tokens as possible on all chains.
The setRouter
function lacks access control protection.
File: DcntEth.sol 20: function setRouter(address _router) public { 21: router = _router;//@audit no access control. 22: }
onlyRouter
role can mint
type(uint).max amount of tokens
File: DcntEth.sol function mint(address _to, uint256 _amount) public onlyRouter { _mint(_to, _amount); }
onlyRouter
role can also burn
any amount of any user tokens
File: DcntEth.sol function burn(address _from, uint256 _amount) public onlyRouter { _burn(_from, _amount); }
Here is the onlyRouter modifier
modifier onlyRouter() { require(msg.sender == router); _; }
Manual Review
Add onlyOwner
modifier to the setRouter(...)
function.
File: DcntEth.sol -- function setRouter(address _router) public { ++ function setRouter(address _router) public onlyOwner { router = _router;//@audit no access control. }
Access Control
#0 - c4-pre-sort
2024-01-24T01:26:20Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-01-24T01:26:28Z
raymondfam marked the issue as duplicate of #14
#2 - c4-judge
2024-02-03T13:28:46Z
alex-ppg marked the issue as satisfactory
🌟 Selected for report: Kaysoft
Also found by: 0xmystery, Aamir, DadeKuma, IceBear, Pechenite, SBSecurity, Shaheen, bronze_pickaxe, ether_sky, nobody2018, rjs, rouhsamad, slvDev, zxriptor
153.8372 USDC - $153.84
bool success
returned value of address.call not validatedThere is 1 instance of this
The boolean return value of target.call(payload)
on line 71
of UTBExecutor.sol#execute(...) function was assigned but not validated.
File: UTBExecutor.sol 41: function execute( ...//Some lines omitted for clarity 49: ) public onlyOwner { ...//Some lines omitted for clarity 64: if (extraNative > 0) { 65: (success, ) = target.call{value: extraNative}(payload); 66: if (!success) { 67: (refund.call{value: extraNative}("")); 68: } 69: } else { 70:@> (success, ) = target.call(payload);//@audit no success check. 71: } 72: 73: uint remainingBalance = IERC20(token).balanceOf(address(this)) - 74: initBalance; ...//Some lines omitted for clarity
Impact:
In situations where target.call(payload)
fail, the transaction continues execution. This could lead to critical issues depending on the target
address and the payload
. It important to revert early when one of the intended actions fails.
Recommendation:
Validate all return values of an address.call
using:
(success, ) = target.call(payload); ++ if(!success) revert("address.call failed");
There are 2 instances of this
To prevent potential issues, it is important to revert early as soon as an address.call
fails in a function. The execute(...)
function of UTBExecutor.sol
does not revert the transaction when an address.call
fails.
File: UTBExecutor.sol 41: function execute( ...//Some lines omitted for clarity 49: ) public onlyOwner { ...//Some lines omitted for clarity 51: if (token == address(0)) { 52: (success, ) = target.call{value: amount}(payload); 53: if (!success) { 54: (refund.call{value: amount}(""));//@audit revert early 55: } 56: return; 57: } 58: 59: uint initBalance = IERC20(token).balanceOf(address(this)); 60: 61: IERC20(token).transferFrom(msg.sender, address(this), amount); 62: IERC20(token).approve(paymentOperator, amount); 63: 64: if (extraNative > 0) { 65: (success, ) = target.call{value: extraNative}(payload); 66: if (!success) { 67: (refund.call{value: extraNative}(""));//@audit revert early. 68: } 69: } else { 70: (success, ) = target.call(payload);//@audit no success check 71: } ...
It is important to revert the transaction early when there is a failed address.call
Impact:
If for example the transaction in line 65 above fails, the transaction only sends back the extraNative
ETH and continues execution. There is no way to tell what succeeds and what does not until the sender manually compares there balance. This opens up to assumptions which can lead to potential issues.
Recommendation:
Implement a revert whenever the boolean return value from an address.call
reverts in a function
(success, ) = target.call{value: extraNative}(payload); if (!success) revert("call failed");
address.call
There are 3 instances of this
Whenever the returned bytes data
is not required, using the .call()
function with non TRUSTED addresses opens the transaction to unnecessary gas griefing by return huge bytes data
.
Note that this
(success, ) = target.call{value: amount}(payload);
is the same thing as this
(success, bytes data ) = target.call{value: amount}(payload);
So in both cases, the bytes data
is returned and copied to memory. Malicious target address
can return huge bytes data
to cause gas grief to the sender.
Impact:
Malicious target address
can gas grief the sender making the sender waste more gas than necessary.
Recommendation: Short term: When returned data is not required, use a low level call.
bool status; assembly { status := call(gas(), receiver, amount, 0, 0, 0, 0) } require(status, "call failed");
Long Term: Consider using https://github.com/nomad-xyz/ExcessivelySafeCall
There are 3 instances of this
The target
address in the execute(...)
function was not checked for address existence before making a .call(...)
to it.
According to Solidity docs:
The low-level functions call, delegatecall and staticcall return true as their >first return value if the account called is non-existent, as part of the design of >the EVM. Account existence must be checked prior to calling if needed.
Impact: if target
address does not exist, the boolean return value from .call(...)
will return true when infact the asset was not transferred.
Recommendation:
Implement contract existence check before address.call()
.
function doesContractExist(address contractAddress) external view returns (bool) { // Check if the contract's code size is greater than zero uint256 codeSize; assembly { codeSize := extcodesize(contractAddress) } return codeSize > 0; }
.call()
with the same payload
is made to target
address 3 times in the same function.There is 1 instance of this
The call() function is made to the target
address 3 different times with the same payload in the UTBExecutor.sol#execute(...)
function.
Making 3 contract calls to the same address with the same payload is not efficient and can be refactored in to just 1 contract call since the payload is the same.
File: UTBExecutor.sol function execute( address target, address paymentOperator, bytes memory payload, address token, uint amount, address payable refund, uint extraNative ) public onlyOwner { bool success; if (token == address(0)) { @> (success, ) = target.call{value: amount}(payload);//@audit 3 different calls with same payload. if (!success) { (refund.call{value: amount}("")); } return; } uint initBalance = IERC20(token).balanceOf(address(this)); IERC20(token).transferFrom(msg.sender, address(this), amount); IERC20(token).approve(paymentOperator, amount); if (extraNative > 0) { @> (success, ) = target.call{value: extraNative}(payload); if (!success) { (refund.call{value: extraNative}("")); } } else { @> (success, ) = target.call(payload); } uint remainingBalance = IERC20(token).balanceOf(address(this)) - initBalance; if (remainingBalance == 0) { return; } IERC20(token).transfer(refund, remainingBalance); }
Impact: It wastes gas and reduces code readability and simplicity.
Recommendation:
Refactor the target.call(payload)
so that the target
contract call is done just once instead of 3 times.
if (token == address(0)) { (success, ) = target.call{value: amount + extraNative}(payload); require(success, "call failed"); }
The above can replace the 3 target.call(payload)
call since its the same target
address and payload
.
deadline
and address zero check for signaturesThere is one instance of this
The collectFees(...)
function of the UTBFeeCollector.sol contract verifies signature without a deadline
.
Secondly there is no check to ensure the recovered
address is not zero address. This is because ecrecover can return address zero for invalid signature.
Lastly there is no use of nonce for the signatures.
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"); if (fees.feeToken != address(0)) { IERC20(fees.feeToken).transferFrom( utb, address(this), fees.feeAmount ); } }
Impact:
signer
state variable is set since it will initially be zero.Recommendation:
Implement deadline
to signatures and also add address zero check on the recovered address.
require(blocktimestamp >= deadline, "signature expired"); require(recovered != address(0), "signature expired");
See EIP712: https://eips.ethereum.org/EIPS/eip-712
ecrecover
is susceptible to signature malleabilityThere is 1 instance of this
The collectFees(...)
function uses built in ecrecover(...)
to recover the address of the signer. The ecrecover(...)
function is succeptible to signature malleability
File: UTBFeeCollector.sol 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);//@audit no deadline for signature address recovered = ecrecover(constructedHash, v, r, s); require(recovered == signer, "Wrong signature");//@audit check that sig is not zero addresss. if (fees.feeToken != address(0)) { IERC20(fees.feeToken).transferFrom( utb, address(this), fees.feeAmount ); } }
Impact: Signature malleability leading to potential signature replay attack since there is even no nonce implemented.
Recommendation:
Consider using openzeppelin's ECDSA.ecrecover(...)
function instead of the built in ecrecover
.
deadline
protection for swapThere are 2 instances of these
The swapExactIn
and swapExactOut
functions did not add deadline
protection on swap.
This can allow a miner delay your transaction from being mined until the swap transaction incurs maximum slippage that would allow the miner profit from the swap transaction through sandwich attack.
According to the Uniswap docs:
deadline: the unix time after which a swap will fail, to protect against long->pending transactions and wild swings in prices
During high price swings, a miner can delay the transaction as possible until it incurs maximum slippage since there is no unix timestamp supplied at which the swap transaction must revert.
File: Uniswapper.sol function swapExactIn( SwapParams memory swapParams, // SwapParams is a struct address receiver ) public payable routerIsSet returns (uint256 amountOut) { swapParams = _receiveAndWrapIfNeeded(swapParams); IV3SwapRouter.ExactInputParams memory params = IV3SwapRouter .ExactInputParams({ path: swapParams.path, recipient: address(this), amountIn: swapParams.amountIn, amountOutMinimum: swapParams.amountOut });//@audit no deadline specified. IERC20(swapParams.tokenIn).approve(uniswap_router, swapParams.amountIn); amountOut = IV3SwapRouter(uniswap_router).exactInput(params);
Impact: Loss of asset through a combination of swap transaction delay and sandwich attack to profit from some slippage due to market swings.
Recommendation:
Allow users to pass a deadline
for example 20 minutes in the future at which a swap transaction must fail if it has not been executed.
This is handled by most swap frontends like Uniswap frontend. A user just gets to choose how many minutes in the future should be set as the deadline
for the swap.
The user can select for example 20 minutes on the frontend and the frontend handles the calculation of the timestamp literal to be supplied to the swap function.
For example deadline is unix timestamp
+ 20 minutes
.
unix timestamp
= number of seconds from 1970 till the current moment
= block.timestamp
.
20 minutes
= 20 * 60.
deadline = 2578383 + 1800 = 2579583 seconds.
_sendToRecipient(...)
is unnecessary if the receiver
is passed as the receipient
of the swap parameter.There are 2 instances of this
The swapExactIn(...)
and the swapExactOut(...)
did a two way movement of output asset by first sending the output asset of the swap to address(this)
before sending it to the receiver.
It is much more efficient to pass the receiver
parameter as the recipient
address in the swap param in order to directly send asset to the receiver
.
With this there will be no need for the _sendToRecipient(...)
function since swap output will be sent directly.
function swapExactIn( SwapParams memory swapParams, // SwapParams is a struct address receiver ) public payable routerIsSet returns (uint256 amountOut) { swapParams = _receiveAndWrapIfNeeded(swapParams); IV3SwapRouter.ExactInputParams memory params = IV3SwapRouter .ExactInputParams({ path: swapParams.path, @> recipient: address(this),//@audit pass receiver here amountIn: swapParams.amountIn, amountOutMinimum: swapParams.amountOut }); IERC20(swapParams.tokenIn).approve(uniswap_router, swapParams.amountIn); amountOut = IV3SwapRouter(uniswap_router).exactInput(params); @> _sendToRecipient(receiver, swapParams.tokenOut, amountOut); }
Impact: Waste of gas and adds more unnecessary codes to the codebase which could open more attack surface for vulnerabilities.
Recommendation:
Consider passing the receiver
parameter to the ExactInputParams' receipient
like in this diff below
function swapExactIn(...) { ... IV3SwapRouter.ExactInputParams memory params = IV3SwapRouter .ExactInputParams({ path: swapParams.path, -- recipient: address(this), ++ recipient: receiver, amountIn: swapParams.amountIn, amountOutMinimum: swapParams.amountOut }); IERC20(swapParams.tokenIn).approve(uniswap_router, swapParams.amountIn); amountOut = IV3SwapRouter(uniswap_router).exactInput(params); -- _sendToRecipient(receiver, swapParams.tokenOut, amountOut); }
swapParams
can be used to steal tokens because it accepts both path
and tokenOut.both are used in code and user can use different tokens depending on how profitable.
#0 - raymondfam
2024-01-26T07:40:22Z
[L-1] - [L-5]: Generically reported by bot(s) and may be counted as 1 finding [L-6] & [L-7]: May be counted as 1 finding [L-10]: Inadequate elaboration with no instances given
4 L
#1 - c4-pre-sort
2024-01-26T07:40:34Z
raymondfam marked the issue as sufficient quality report
#2 - alex-ppg
2024-02-04T22:49:34Z
The Warden's QA report has been graded A based on a score of 36
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 - alex-ppg
2024-02-04T22:54:41Z
This report is tied with #276 and was selected as the best due to its more curated look, highlighting instances per finding instead of containing only code.
#4 - c4-judge
2024-02-04T22:54:48Z
alex-ppg marked the issue as selected for report
#5 - c4-judge
2024-02-04T22:54:56Z
alex-ppg marked the issue as grade-a
#6 - alex-ppg
2024-02-05T18:13:21Z
Just to clarify my earlier judgment, I would like to point out that none of the remaining exhibits were considered valid except for L-4 which can be considered acceptable as a QA (NC) finding in the final C4 report.
#7 - c4-sponsor
2024-02-13T21:40:50Z
wkantaros (sponsor) confirmed