Reenactment of the stolen 2 million DAI (~ $ 2 million) of Akropolis

Tram Ho

overview

Akropolis’s Delphi service is a Defi platform that allows users to deposit ERC20 into a Pool, such as DAI, in exchange for Pool’s bonus token – dDAI, after a while will use dDAI to withdraw the initially deposited DAI + interest . Delphi allows users to choose which Protocol they want to use to deposit into the Pool, each Protocol will have a different validate and calculation way.

I will not introduce much about the Akropolis system, you can learn more here .

The problem that occurs with Delphi is determined to be caused by 2 basic reasons:

  1. The validate token input mechanism of the CurveFiY protocol is not tight, so hackers can easily make a call to a malicious contract.
  2. Does not resist reentrance for the deposit () and withdraw () functions.

Combining the two holes above, the hacker attacked according to the deposit mechanism to 1 DAI but was minted to 2 dDAI, then used 2 dDAI to draw 2 DAI, which is twice as much as the actual amount of DAI deposited. Doing so many times, the hacker stole 2 million DAI, or $ 2 million, of Akropolis.

Details of the transaction can be found here

This is the link repo including the smart contract of Delphi and the steps to reproduce the attack that I have prepared, people can clone to follow along with the article.

Compare 2 protocols Compound and CurveFi

As I said above, Delphi allows users to deposit DAI into the Pool according to different protocols, look at the function deposit(address _protocol, address[] memory _tokens, uint256[] memory _dnAmounts) function function deposit(address _protocol, address[] memory _tokens, uint256[] memory _dnAmounts) in the contracts/modules/savings/SavingsModule.sol :

The user will select the Protocol they want to use through the addresss _protocol parameter, then inside the deposit() function will call depositToProtocol(_protocol, _tokens, _dnAmounts) :

In the depositToProtocol() function there are two important steps to be taken:

  1. IERC(tkn).safeTransferFrom(_msgSender(), _protocol, _dnAmounts[i]) , this function allows a low-call to the transferFrom() function to the smart contract of the token to be deposited into
  2. IDefiProtocol(_protocol).handleDeposit(tkn, _dnAmounts[i]) , which calls the handleDeposit() function of the protocol the user has chosen.

We will see the source code of the two protocols Compound and CurveFiY:

Thus, when the hacker chooses the protocol as CompoundProtocol and makes a deposit of a token that is not DAI, he will be entangled in require(token == address(baseToken), "CompoundProtocol: token not supported") where baseToken is DAI, therefore the transaction will be reverted and no attacks will occur.

When the hacker chooses the protocol is CurveFiYProtocol and deposits a FakeDai which is not one of the 4 tokens of this protocol, DAI, USDC, BUSD, USDT (you can learn more about CurveFI to understand this paragraph), it is still easy The pass is passed because the require(amounts[i] >= amount, "CurveFiYProtocol: requested amount is not deposited") is put in if(_registeredTokens[i] == token) but the token is FakeDai is not one of 4 DAI, USDC, BUSD, USDT should never run into this if .

Note here that CurveFiYProtocol has a flaw in the use case of Delphi Akropolis does not mean that it also has a flaw in the use case of CurveFiY, each platform has many different modules so this logic can be tight in CurveFi context because it is also supported by many other modules, it’s just that Akropolis reuses it without considering it closely with its modules or not that causes this vulnerability.

The problem against Reentrance

Most of the Defi platforms today, all the related functions for the transfer of money in and out of money are set to a modifier called nonReentrant developed in the openzeppelin ‘s ReentrancyGuard.sol smart contract, you can see more at here .

The use of nonReentrant is simply understood that in a transaction, the maximum number of calls to the function is set to nonReentrant , for example there is a smart contract as follows:

In a transaction, it is impossible to call function a() twice, or function b() twice, or call function a() while calling function b() .

Platforms Defi use nonReentrant to prevent calls to malicious as just withdraw() that the deposit() on or deposit() and withdraw() recursively to affect adversely the information related to money stored in the smart contract.

I really don’t understand why Delphi Akrolpolis is so confident that not using nonReentract for this reason is happening.

Attack with FAKEDAI

Enough introduction is rambling, now I will recreate the hack.

Here are the steps of the attack:

  1. Hacker calls deposit(CurverFiYProtocol, [FakeDai], [amount]) of SavingsModule
  2. SavingsModule calls back FakeDai’s transferFrom() , but in fact, inside the transferFrom() function of FakeDai, it calls the deposit function of SavingsModule (the harm of not using nonReentrant ) but at this time, it’s using DAI as deposit(CurveFiYProtocol, [DAI], [amount]) .
  3. SavingsModule mint out an amount of dDAI corresponding to the deposit with real DAI
  4. Get out of the real DAI deposit context, go back to the original FakeDai deposit context again pass the handleDeposit() function as I said above, after recalculating the parameters, SavingsModule again minted another amount of dDAI.

=> Only 1 DAI has to deposit, the hacker owns 2 dDAI corresponding to 2 DAI, with that mechanism he performed many transactions that cost 25,000 DAI but took back 50,000 dDAI corresponding to 50,000 DAI, Accumulated after many transactions, he withdrew 2 million DAI at the last shot

Surely everyone is thinking, if we want to perform a transaction, we must have a capital of 25,000 DAI, right? With many platforms that provide flashLoan services (borrow and pay immediately in 1 transaction) as today and with no nonReentrant , hackers can completely borrow 25,000 DAI to deposit() and collect 50,000 dDAI then only Use 25,000 dDAI to withdraw() to 25,000 DAI and pay the lender, after a transaction he gains 25,000 dDAI equivalent to 25,000 DAI.

Source code of FakeDai

At this FakeDai contract, I will skip flashLoan () and I will transfer 1 DAI from outside to FakeDai for capital, and the attack (withdraw) and withdraw () will be called in 2 different transactions, for the main purpose of I just reappeared the deposit() 1 DAI, but I was able to draw 2 dDAI.

The steps to setup my environment and attack I have prepared in the file attack/attack.test.js in the repo I placed above, you just need to run:

that is, it will run from start to finish and print the output to the screen:

For a normal user, the user with an initial capital of 1 DAI, deposit and withdraw 1 DAI, and the hacker with a capital of 1 DAI took back 2 DAI

After setting up the correct environment with Delphi ‘s environment, we take the following steps to attack:

  1. Deploy FakeDai contract
  2. Call setup(DAI, curveFiYProtocol, savingsModule, '1000000000000000000') function setup(DAI, curveFiYProtocol, savingsModule, '1000000000000000000')
  3. Call the attack([FakeDai], ['1000000000000000000']) function attack([FakeDai], ['1000000000000000000'])
  4. Call withdrawAttack('2000000000000000000') to get DAI to FakeDai 5. Call withdrawDAIToAttacker(attacker, '2000000000000000000') to get DAI back to attacker

summary

After this attack, with my own personal conclusions, I would like to give some of the following ideas to better secure smart contracts, because in Blockchain once incident happened, the value of damage is very great and Can not be reversed to fix:

  • Always use nonReentrant for extenal and public functions if it does not affect the business model of the system
  • When using a module of a certain party, you must carefully review it, it is safe for them, it does not mean it is safe for you.
  • Validate is clear, redundant is better than costing.

Reference links

https://peckshield.medium.com/akropolis-incident-root-cause-analysis-c11ee59e05d4

https://github.com/trinhtan/akropolis

Share the news now

Source : Viblo