Skip to content
On this page

Unmasking the Truster: How a Flash Loan Pool's Trust Became Its Undoing

Flash loans – the siren song of DeFi, offering instant, uncollateralized capital for clever arbitrage or liquidations. But what happens when a lending pool, designed for these powerful financial primitives, trusts its users a little too much? Welcome to "Truster," a captivating challenge from Damn Vulnerable DeFi, where a seemingly innocent flash loan mechanism becomes the Achilles' heel for a million DVT tokens.

The Setup: A Generous but Vulnerable Pool

Our story begins with the TrusterLenderPool, a fresh new entrant into the DeFi landscape. Its proposition is simple: free flash loans of its native DamnValuableToken (DVT). The pool is generously funded with a cool 1,000,000 DVT tokens. Our mission, as the aspiring ethical hacker, is clear: rescue all these funds and deposit them into a designated recovery account, all within the confines of a single transaction. We start with nothing.

Let's peek under the hood of the TrusterLenderPool:

solidity
contract TrusterLenderPool is ReentrancyGuard {
    // ... other code ...

    function flashLoan(uint256 amount, address borrower, address target, bytes calldata data)
        external
        nonReentrant
        returns (bool)
    {
        uint256 balanceBefore = token.balanceOf(address(this));

        token.transfer(borrower, amount); // Transfers 'amount' to 'borrower'
        target.functionCall(data);         // Calls arbitrary 'data' on 'target'

        if (token.balanceOf(address(this)) < balanceBefore) {
            revert RepayFailed(); // Repay check: Pool's balance must not decrease
        }

        return true;
    }
}

At first glance, the flashLoan function looks standard. It transfers the requested amount to the borrower. The crucial part is the target.functionCall(data); line, which allows the borrower to specify any target contract and any arbitrary data to call on it.

Then comes the "repayment" check: if (token.balanceOf(address(this)) < balanceBefore) { revert RepayFailed(); }. This condition only checks if the pool's own DVT balance has decreased. If the balance remains the same, the loan is considered "repaid" or, in this case, the transaction is allowed to proceed.

The Vulnerability: Arbitrary Execution with the Pool's Authority

The elegance of the exploit lies in understanding the context of target.functionCall(data). When this line executes, the msg.sender for the call to target is the TrusterLenderPool itself. This means the pool can be coerced into calling any function on any contract with its own authority.

The critical insight: What if the arbitrary call made by the pool doesn't directly drain its tokens during the flash loan, but rather sets up a condition for us to drain them after the flash loan returns?

This brings us to the approve() function, a cornerstone of ERC-20 token standards. If the TrusterLenderPool could be made to approve() our attacking contract to spend its DVT tokens, then we could simply transferFrom() those tokens to the recovery account.

The Exploit Strategy: A Trojan Horse approve

Here's how we execute the attack in a single transaction:

  1. Initiate a Flash Loan (with a Twist): Our attacking contract will request a flash loan from TrusterLenderPool.

    • amount = 0: We don't actually need to borrow any tokens for the exploit itself. This ensures the initial token.transfer(borrower, amount) doesn't affect the pool's balance.
    • borrower = address(this): Our attacking contract.
    • target = address(pool.token()): This is the crucial part. We set the target for the arbitrary call to be the DamnValuableToken contract itself.
    • data = abi.encodeWithSignature("approve(address,uint256)", address(this), TOKENS_IN_POOL): This data encodes a call to the DVT token's approve function. The arguments are address(this) (our attacking contract) and TOKENS_IN_POOL (the entire 1 million DVT).
  2. The "Approval" Stealth Attack: Inside the TrusterLenderPool's flashLoan function:

    • token.transfer(borrower, 0) executes, doing nothing.
    • target.functionCall(data) executes. Since target is the DVT token contract and data is approve(ourContract, 1M), the DVT token contract's approve function is called. Importantly, the msg.sender for this approve call is TrusterLenderPool.
    • Result: The TrusterLenderPool itself, through its own DVT token contract, approves our attacking contract to spend all 1,000,000 of its DVT tokens.
  3. Bypass Repayment Check: The if (token.balanceOf(address(this)) < balanceBefore) check passes because no tokens have actually left the TrusterLenderPool's balance yet. Only an approval has been granted.

  4. Drain the Pool: Immediately after the flashLoan call returns to our attacking contract, we use the newly granted approval. Our contract calls token().transferFrom(address(target), recovery, amount) where address(target) refers to the TrusterLenderPool itself. This transferFrom call moves all 1,000,000 DVT tokens from the TrusterLenderPool to the recovery account.

The Solution Code

solidity
contract MyContract {
    TrusterLenderPool private immutable target;
    
    constructor(address _target, address recovery, uint256 amount) {
        target = TrusterLenderPool(_target);

        // Encode the approve call: Pool's DVT token contract will approve MyContract
        // to spend all the tokens. This call is made by the TrusterLenderPool itself.
        bytes memory data = abi.encodeWithSignature("approve(address,uint256)", address(this), amount);
        
        // Initiate the flash loan with amount = 0, targeting the DVT token contract
        // and executing the 'approve' call.
        bool res = target.flashLoan(0, address(this), address(target.token()), data);
        require(res, "Fail to flashLoan!");
        
        // After the flash loan, MyContract now has approval from TrusterLenderPool.
        // Use transferFrom to move all tokens from the pool to the recovery account.
        target.token().transferFrom(address(target), recovery, amount);
    }
}

The Takeaway

The "Truster" challenge is a brilliant illustration of a common vulnerability pattern: unrestricted external calls using the contract's own authority. By allowing users to specify an arbitrary target and data for an internal functionCall, the TrusterLenderPool essentially granted a back door. The crucial lesson is that a contract must be extremely careful when performing arbitrary external calls, especially when those calls originate from the contract's own address (msg.sender) and can manipulate its own assets or state. The flash loan aspect was almost a red herring; the true vulnerability lay in the pool's misplaced trust in the borrower's chosen target and data.

Built with AiAda