Skip to content

ethereumvex/Akropolis-delphi-exploit

Repository files navigation

Akropolis Delphi Exploit

Summary

  • On November 12th, 2020, an attacker exploited a chain of vulnerabilities in the Akropolis SavingsModule contract.
  • Over the course of several transactions they were able to extract more than 2 million dai from their pools.

Background

The Akropolis Delphi project was created to allow users an easy interface into underlying DeFi protocols like Curve.fi. Users were rewarded Delphi governance tokens for doing so, and some innovative features were to be added to the platform.

The deposit interface on their website constrained users to depositing the appropriate tokens for the underlying protocols; unfortunately the underlying SavingsModule contract itself did not enforce this check. By interacting directly with the contract, a user could deposit any token to the system in exchange for the underlying pool tokens.

A second vulnerability existed where SavingsModule contract was open to re-entrance via depositToProtocol. Given that this method calls a token's transferFrom method, theoretically a token contract could re-enter SavingsModule.

This repository

The repository is a Hardhat based re-construction of one example of the exploit. To use it, clone the repository and then install the dependencies with npm install. Rename the .env-sample file to .env and paste in your Alchemyapi url. Running npx hardhat run scripts/deploy.js from the root will execute a transaction similar to that of the exploiter. This transaction is run against a forked mainnet version uses Hardhat's Solidity console.log to print results of the process to the console. A proper test setup is still in progress.

Details of the vulnerability

The deposit method on the SavingsModule contract is used to deposit funds into an underlying strategy via depositToProtocol, after which pool tokens are minted and returned to the depositor. Prior to calling depositToProtocol there are no obvious checks performed on the parameters passed to deposit; no whitelisting of accepted tokens or otherwise. There is one early call to normalizeTokenAmount that could perform a check against the tokens mapping, however as we will see further below this method performs no checks and made some questionable assumptions. The deposit code below has been simplified and some comments added (see the original code here):

function deposit(address _protocol, address[] memory _tokens, uint256[] memory _dnAmounts)
    public operationAllowed(IAccessModule.Operation.Deposit)
    returns(uint256) 
    {

        uint256 nAmount;
        for (uint256 i=0; i < _tokens.length; i++) {
            // normalizeTokenAmount might have been a place to check for valid 
	    // or whitelisted _tokens
            nAmount = nAmount.add(normalizeTokenAmount(_tokens[i], _dnAmounts[i]));
        }
				
        uint256 nBalanceBefore = distributeYieldInternal(_protocol);
	// depositToProtocol calls transferFrom which could be used for re-entry
	depositToProtocol(_protocol, _tokens, _dnAmounts);
        uint256 nBalanceAfter = updateProtocolBalance(_protocol);
				
        PoolToken poolToken = PoolToken(protocols[_protocol].poolToken);
        
	uint256 nDeposit = nBalanceAfter.sub(nBalanceBefore);

        uint256 cap;
        if(userCapEnabled) {
            cap = userCap(_protocol, _msgSender());
        }

	// pool tokens are minted here; both cases are used in the exploit
        uint256 fee;
        if(nAmount > nDeposit) {
            fee = nAmount - nDeposit;
            poolToken.mint(_msgSender(), nDeposit);
        } else {
            fee = 0;
            poolToken.mint(_msgSender(), nAmount);
            uint256 yield = nDeposit - nAmount;
            if (yield > 0) {
                createYieldDistribution(poolToken, yield);
            }
        }

        emit Deposit(_protocol, _msgSender(), nAmount, fee);
        return nDeposit;
    }

The following method is where control is passed back to the token contract and a malicious contract could reenter SavingsModule:

function depositToProtocol(address _protocol, address[] memory _tokens, uint256[] memory _dnAmounts) internal {
	require(_tokens.length == _dnAmounts.length, "SavingsModule: count of tokens does not match count of amounts");
	for (uint256 i=0; i < _tokens.length; i++) {
		address tkn = _tokens[i];
		// safeTransferFrom is called on the token that was passed in by the user.
		IERC20(tkn).safeTransferFrom(_msgSender(), _protocol, _dnAmounts[i]);
		IDefiProtocol(_protocol).handleDeposit(tkn, _dnAmounts[i]);
		emit DepositToken(_protocol, tkn, _dnAmounts[i]);
	}
}

Excluding other possibilities, normalizeTokenAmount is where a check using mapping(address => TokenData) tokens; could have reverted any unlisted tokens. See comments in the code:

function normalizeTokenAmount(address token, uint256 amount) private view returns(uint256) {
        // tokens is a mapping that could have served as a sanity check on the 
	// address token parameter, requiring that the token was present in the mapping:
	uint256 decimals = tokens[token].decimals;
        // below, a dangerous assumption whereby if there is no record of the token
	// in the mapping, the final "else if" is used as the condition is true: tokens
	// not in the tokens mapping return 0 for tokens[token].decimals. 
	if (decimals == 18) {
            return amount;
        } else if (decimals > 18) {
            return amount.div(10**(decimals-18));
        } else if (decimals < 18) {
            return amount.mul(10**(18 - decimals));
        }
 }

It is often the case that a few small bugs or conditions chained together can become a major issue. In this exploit a re-entrance opportunity spotted by a malicious actor would encourage them to dig deeper. It just so happens that the lack of a token sanity check meant that the system would accept a token with a malicious transferFrom method. Of course it could have been the other way around: spot the lack of check, and then look for more opportunity. So how did they exploit this vulnerability?

Details of the exploit

Examining the bytecode of the contract published by the exploiter showed that they had a few different methods they used, for example a() here and b() here. But the main method seemed to be 0x02908e5f (see transaction below), which decompiled to a long complex construction.

The first seven storage slots for the contract and the decompiled bytecode provided some clues to help deduce what the attacker was doing with 0x02908e5f, but the most simple manner to see what was happening was using https://oko.palkeo.com/ on this transaction, where, along with some event logs, you could follow the different actions undertaken by the code. Looking at that last link you can pull out a central process that gets looped thru a few times:

  1. Call savingsModule.poolTokenByProtocol
  2. Call balanceOf on dyPoolToken
  3. Approve spending of contract's dai by savingsModule
  4. Check balance of dai
  5. Call savingsModule.deposit sending 5000000 of address(this) (the fake token).
  6. Re-entered into address(this).transferFrom and calls savingsModule.deposit again, this time to deposit real dai.
  7. Transfer 1 dai to the Akropolis curveYPool.
  8. Call savingsModule.normalizedBalance.
  9. Call balanceOf on dyPoolToken.
  10. Call savingsModule.withdraw for close to double the pool tokens they would receive if they only deposited their dai.
  11. Approve 0 dai.

This process enables the exploiter to deposit their token to the system, re-enter the contract via their malicious transferFrom where they deposit flash-loaned dai, and then wind that out receiving ±50,000*10e18 pool tokens, an amount worth twice what they would recieve if they simply deposited the dai. Then they are able to withdraw the whole amount as dai, even though half of that represents the fake token. This is the sequence undertaken in this repository and it was able to withdraw roughly 23,000 dai per loop after paying back the flash loan.

There are some inconsistencies in the system that I was not able to deduce and that the hacker appeared to understand. For some reason you could not withdraw by sending all of your pool tokens to savingsModule.withdraw; indeed, as a Delphi user I was not able to send all of my tokens to their interface either. The exploiter has a calculation that you can see in the bytecode whereby they would withdraw 99% of their pool token balance rather than 100%.

While rebuilding the exploit it was also interesting to see that you could send fake tokens to the contract and have the address(this).transferFrom return true, and then withdrawing your resulting tokens you could actually get dai out of the system, albeit very little.

It is worth noting that the code in the exploits removed the dai approval to SavingsModule after each loop; looking at the timeline below, the contract held the dai until they were finished exploiting, and then they moved the dai to their wallet. The exploiter seemed to be careful so as not to let any of the approved contracts reclaim their dai, perhaps!

Timeline of events

Relevant links

About

Reconstruction of the Akropolis Delphi reentrancy exploit

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published