According to this protocol's token whitelist, this protocol intends to support fee-on-transfer tokens, such as STA. As shown by STA's transfer and cut functions below, _balances[to] would be increased by a value that is less than the token amount that is intended to be transferred.
function cut(uint256 value) public view returns (uint256) {
uint256 roundValue = value.ceil(basePercent);
uint256 cutValue = roundValue.mul(basePercent).div(10000);
return cutValue;
}
For such token, when calling the following _transferOutAndCallV5 function, executing aggregationPayload.fromAsset.call(abi.encodeWithSignature("transfer(address,uint256)", aggregationPayload.target, aggregationPayload.fromAmount) would transfer a token amount that is less than aggregationPayload.fromAmount to the aggregator. Yet, when calling the aggregator's swapOutV5 function below, aggregationPayload.fromAmount is used as the amountIn input for calling the UniswapV2Router02.swapExactTokensForETH function below. Because the aggregator's balance of such token is less than aggregationPayload.fromAmount, executing TransferHelper.safeTransferFrom(path[0], msg.sender, UniswapV2Library.pairFor(factory, path[0], path[1]), amounts[0]) that attempts to transfer aggregationPayload.fromAmount from the aggregator in the UniswapV2Router02.swapExactTokensForETH function reverts. Hence, when the corresponding ERC20 token is a fee-on-transfer token like STA, calling the _transferOutAndCallV5 function always reverts, and such functionality is always DOS'ed for all vaults.
function swapOutV5(
address fromAsset,
uint256 fromAmount,
address toAsset,
address recipient,
uint256 amountOutMin,
bytes memory payload,
string memory originAddress
) public payable nonReentrant {
address[] memory path = new address[](2);
...
if (fromAsset == address(0)) {
...
} else {
// received an ERC20, swap to ETH or do as you please
path[0] = fromAsset;
path[1] = WETH;
safeApprove(fromAsset, address(swapRouter), 0); // best practice
safeApprove(fromAsset, address(swapRouter), fromAmount);
if (payload.length == 0) {
// no payload, process without wasting gas
// UniV2 transfers from 'msg.sender', so we need a low-level `.call` to change the msg.sender from the target's perspective
(bool aggSuccess, ) = address(swapRouter).call(
abi.encodeWithSignature(
"swapExactTokensForETH(uint256,uint256,address[],address,uint256)",
fromAmount,
amountOutMin,
path,
recipient,
type(uint).max // Assuming using the maximum uint256 value as the deadline
)
);
require(aggSuccess, "swapExactTokensForETH failed");
} else {
// do something with your payload like parse to an aggregator specific structure
// UniV2 transfers from 'msg.sender', so we need a low-level `.call` to change the msg.sender from the target's perspective
(bool success, ) = address(swapRouter).call(
abi.encodeWithSignature(
"swapExactTokensForETH(uint256,uint256,address[],address,uint256)",
fromAmount,
amountOutMin,
path,
recipient,
type(uint).max // Assuming using the maximum uint256 value as the deadline
)
);
require(success, "swapExactTokensForETH failed");
}
}
}
The following steps can occur for the described scenario.
Currently, 1% of the STA token amount to be transferred would be cut and burned.
A user calls the depositWithExpiry function with 100 STA as the amount input for Vault A.
After such depositWithExpiry function call, due to the 1% cut, _vaultAllowance for STA and Vault A is increased to 99 STA, and the router holds 99 STA.
Vault A calls the transferOutAndCallV5 function, which further calls the _transferOutAndCallV5 function that eventually calls the UniswapV2Router02.swapExactTokensForETH function, with 99 STA as aggregationPayload.fromAmount.
Because of the 1% cut, 98 STA is transferred to the aggregator.
Yet, when UniswapV2Router02.swapExactTokensForETH function is eventually called, aggregationPayload.fromAmount that is 99 STA is used as the amountIn input.
Because the UniswapV2Router02.swapExactTokensForETH function attempts to transfer 99 STA from the aggregator but the aggregator only holds 98 STA, calling such function reverts.
Thus, calling the _transferOutAndCallV5 function for STA is DOS'ed for Vault A.
_vaultAllowance[msg.sender][
aggregationPayload.fromAsset
] -= aggregationPayload.fromAmount; // Reduce allowance
uint256 _startBal = iERC20(aggregationPayload.fromAsset).balanceOf(aggregationPayload.target);
// send ERC20 to aggregator contract so it can do its thing
(bool transferSuccess, bytes memory data) = aggregationPayload
.fromAsset
.call(
abi.encodeWithSignature(
"transfer(address,uint256)",
aggregationPayload.target,
aggregationPayload.fromAmount
)
);
require(
transferSuccess && (data.length == 0 || abi.decode(data, (bool))),
"Failed to transfer token before dex agg call"
);
// add test case if aggregator fails, it should not revert the whole transaction (transferOutAndCallV5 call succeeds)
// call swapOutV5 with erc20. if the aggregator fails, the transaction should not revert
(bool _dexAggSuccess, ) = aggregationPayload.target.call{value: 0}(
abi.encodeWithSignature(
"swapOutV5(address,uint256,address,address,uint256,bytes,string)",
aggregationPayload.fromAsset,
iERC20(aggregationPayload.fromAsset).balanceOf(aggregationPayload.target) - _startBal,
aggregationPayload.toAsset,
aggregationPayload.recipient,
aggregationPayload.amountOutMin,
aggregationPayload.payload,
aggregationPayload.originAddress
)
);
Lines of code
https://github.com/code-423n4/2024-06-thorchain/blob/5b91b5c6683a222b0ce046533515e301c9699d74/chain/ethereum/contracts/THORChain_Router.sol#L304-L389 https://github.com/code-423n4/2024-06-thorchain/blob/0e02d3c221d7e546a73f25183e29500eaa63f4cf/chain/ethereum/contracts/THORChain_Aggregator.sol#L144-L216
Vulnerability details
Impact
According to this protocol's token whitelist, this protocol intends to support fee-on-transfer tokens, such as STA. As shown by STA's
transfer
andcut
functions below,_balances[to]
would be increased by a value that is less than the token amount that is intended to be transferred.https://etherscan.io/address/0xa7de087329bfcda5639247f96140f9dabe3deed1#code#L115
https://etherscan.io/address/0xa7de087329bfcda5639247f96140f9dabe3deed1#code#L109
For such token, when calling the following
_transferOutAndCallV5
function, executingaggregationPayload.fromAsset.call(abi.encodeWithSignature("transfer(address,uint256)", aggregationPayload.target, aggregationPayload.fromAmount)
would transfer a token amount that is less thanaggregationPayload.fromAmount
to the aggregator. Yet, when calling the aggregator'sswapOutV5
function below,aggregationPayload.fromAmount
is used as theamountIn
input for calling theUniswapV2Router02.swapExactTokensForETH
function below. Because the aggregator's balance of such token is less thanaggregationPayload.fromAmount
, executingTransferHelper.safeTransferFrom(path[0], msg.sender, UniswapV2Library.pairFor(factory, path[0], path[1]), amounts[0])
that attempts to transferaggregationPayload.fromAmount
from the aggregator in theUniswapV2Router02.swapExactTokensForETH
function reverts. Hence, when the corresponding ERC20 token is a fee-on-transfer token like STA, calling the_transferOutAndCallV5
function always reverts, and such functionality is always DOS'ed for all vaults.https://github.com/code-423n4/2024-06-thorchain/blob/5b91b5c6683a222b0ce046533515e301c9699d74/chain/ethereum/contracts/THORChain_Router.sol#L304-L389
https://github.com/code-423n4/2024-06-thorchain/blob/0e02d3c221d7e546a73f25183e29500eaa63f4cf/chain/ethereum/contracts/THORChain_Aggregator.sol#L144-L216
https://github.com/Uniswap/v2-periphery/blob/0335e8f7e1bd1e8d8329fd300aea2ef2f36dd19f/contracts/UniswapV2Router02.sol#L284-L300
Proof of Concept
The following steps can occur for the described scenario.
depositWithExpiry
function with 100 STA as theamount
input for Vault A.depositWithExpiry
function call, due to the 1% cut,_vaultAllowance
for STA and Vault A is increased to 99 STA, and the router holds 99 STA.transferOutAndCallV5
function, which further calls the_transferOutAndCallV5
function that eventually calls theUniswapV2Router02.swapExactTokensForETH
function, with 99 STA asaggregationPayload.fromAmount
.UniswapV2Router02.swapExactTokensForETH
function is eventually called,aggregationPayload.fromAmount
that is 99 STA is used as theamountIn
input.UniswapV2Router02.swapExactTokensForETH
function attempts to transfer 99 STA from the aggregator but the aggregator only holds 98 STA, calling such function reverts._transferOutAndCallV5
function for STA is DOS'ed for Vault A.Tools Used
Manual Review
Recommended Mitigation Steps
https://github.com/code-423n4/2024-06-thorchain/blob/5b91b5c6683a222b0ce046533515e301c9699d74/chain/ethereum/contracts/THORChain_Router.sol#L342-L375 can be updated to the following code.
Assessed type
DoS