<100 subscribers

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.
It can be confirmed that the liquidity pool exchange method using the CAMM model based on the uniswap protocol v1 is being used.
Calculate ETH โ๏ธ ERC20 pair input price The purchase amount (sell order)
In the case of a sell order (accurate 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'))))
)
}
The uniswap protocol has separate exchange contracts for each ERC20 token.
This exchange holds all ETH and related ERC20 tokens. Users can trade prepared funds at any time. Prepared funds are pooled among the decentralized liquidity provider network that charges a fee for all trades.
Prices are automatically determined according to the market formation formula x * y = k, and are automatically adjusted according to the relative size of the two prepared funds and the incoming trade scale.
All tokens share ETH as a common pair, so ETH is used as an intermediate asset for all ERC20 โ ERC20 pair direct trades.
The variables needed 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)
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);
Uniswap exchange will start with 10 DVT and 10 ETH in liquidity
The attacker's address is being sent a value on the hardhat local chain according to the initially specified amount.
<deployer account>
- UniswapV1Exchange
- UniswapV1Factory
- DamnValuableToken
- PuppetPool
The deployer is responsible for deploying these four contracts and initializing them with the appropriate values. The deployer will also assign the initial instances of each contract.
this.token = await DamnValuableTokenFactory.deploy();
this.exchangeTemplate = await UniswapExchangeFactory.deploy();
this.uniswapFactory = await UniswapFactoryFactory.deploy();
await this.uniswapFactory.initializeFactory(this.exchangeTemplate.address);
The Uniswap protocol is used to exchange within the protocol, and the exchange contract to be used in the Uniswap factory contract is initialized in the initializeFactory contract based on the exchange contract.
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 }
);
The new exchange for the token is created and the deployed exchange address is connected to the target contract to complete the instance work.
The custom PuppetPool contract is deployed and the initial constructor work is assigned the token and uniswapExchange contract address because it is implemented using the Uniswap protocol.
The uniswapExchange contract access is set based on 10 ether, and then liquidity is added.
@payable
addLiquidity(
min_liquidity: uint256,
max_tokens: uint256,
deadline: uint256
): uint256
Liquidity is added by setting the minimum liquidity, maximum ERC 20 token value, and liquidity transaction deadline based on the uniswapV1Exchange specification, and setting the access to the uniswapExchange contract to 10 ether.
The transaction deadline, for example, is included in many Uniswap functions and sets a time when the transaction can no longer be executed. This restricts miners from holding signed trades for a long time and executing them based on market movements, and also reduces uncertainty for transactions that take a long time to execute due to gas price issues.
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
)
);
This is a test to verify that token exchanges are properly completed within the deployed liquidity pool
The getTokenToEthInputPrice method is used to check the amount of tokens to be sold, and the returned data, the amount of ETH that can be purchased, should match the value calculated by the arbitrarily implemented calculateTokenToEthInputPrice method based on the sale amount of the tokens.
PuppetPool.sol

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);
The deposits mapping structure is used to manage liquidity pool deposits for each user.
Instance allocation for dependency contracts, variable declaration, and constantization are performed.
When the Borrowed function is called, the data event query for address indexed account, uint256 depositRequired, uint256 borrowAmount data is performed in the internal logic.
constructor
constructor (address tokenAddress, address uniswapPairAddress) {
token = DamnValuableToken(tokenAddress);
uniswapPair = uniswapPairAddress;
}
In the constructor function, instances of the ERC20 token contract and the uniswap V1 Protocol are created and initialized.
function _computeOraclePrice() private view returns (uint256)
function _computeOraclePrice() private view returns (uint256) {
return uniswapPair.balance * (10 ** 18) / token.balanceOf(uniswapPair);
}
Calculate the price of the DVT-ETH token using Uniswap V1 Exchange operations.
function calculateDepositRequired(uint256 amount) public view returns (uint256)
function calculateDepositRequired(uint256 amount) public view returns (uint256) {
return amount * _computeOraclePrice() * 2 / 10 ** 18;
}
The formula is introduced to divide the deposit requirement into branches for liquidity pool calculation.
The value of the amount argument varies, and it can be seen that the value of the Oracle price and twice the value is always calculated.
function borrow(uint256 borrowAmount) public payable nonReentrant
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);
}
In order to borrow a token's borrowAmount, first deposit twice the value of ETH.
If the pool does not have enough liquidity in the token, it will fail.
The depositRequired check formula returns the remaining value to the msg.sender if the current contract call user has a larger balance. This is because the user's transaction ether value is much higher than the value to be borrowed.
Update the depositRequired value assigned to the deposits mapping data structure at the current time.
Send the borrowAmount value to msg.sender and process the event.
In the computeOraclePrice() function, it is possible to calculate the ETH-DVT ratio of the Pool and check the current exchange rate. However, when calculating the actual Oracle price, it can be seen that it is possible for the Pool to purchase a large amount of ETH in order to greatly reduce the ratio of the price of DVT tokens that are available for loan. The attacker's goal is to steal all DVT tokens from the Pool.
In normal circumstances, 2 times the amount of ETH would have to be deposited as collateral in order to borrow 100,000 DVT tokens. However, the attacker only has 25 ETH. In order for the _computeOraclePrice function to return 1 when 2 times the amount of ETH is deposited, the uniswapPair.balance and token.balanceOf(uniswapPair) must be 1:1. Currently, the Pool has 10 ETH and 10 DVT, so it always returns 1. However, the attacker can manipulate the Oracle Price calculation formula by exploiting a flaw in it. If the ETH balance (uniswapPair.balance) of the exchange is 0, the result of the calculation formula uniswapPair.balance * (10 ** 18) / token.balanceOf(uniswapPair) will be 0 or a very small number. In order to perform this attack, the attacker must exchange their DVT tokens for ETH and reduce the exchange balance to 0. Then, by borrowing a large number of DVT tokens using a small amount of ETH as collateral, the attacker can sell the DVT tokens for a higher price and make a profit.

Balance Tracking (Uniswap Exchange Pool + Attacker)
// Uniswap ETH balance: 10
// Uniswap DVT balance: 10
// Attacker initial token balance: 1000
// Attacker initial ETH balance: 25
// Uniswap ETH balance: 0.099304865938430984
// Uniswap DVT balance: 1009.999999999999999999
// Attacker token balance after swap: 0.000000000000000001
// Attacker ETH balance after swap: 34.900560102794869806
Thank you for the @tinchoabbate that made a good wargame.

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.
It can be confirmed that the liquidity pool exchange method using the CAMM model based on the uniswap protocol v1 is being used.
Calculate ETH โ๏ธ ERC20 pair input price The purchase amount (sell order)
In the case of a sell order (accurate 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'))))
)
}
The uniswap protocol has separate exchange contracts for each ERC20 token.
This exchange holds all ETH and related ERC20 tokens. Users can trade prepared funds at any time. Prepared funds are pooled among the decentralized liquidity provider network that charges a fee for all trades.
Prices are automatically determined according to the market formation formula x * y = k, and are automatically adjusted according to the relative size of the two prepared funds and the incoming trade scale.
All tokens share ETH as a common pair, so ETH is used as an intermediate asset for all ERC20 โ ERC20 pair direct trades.
The variables needed 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)
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);
Uniswap exchange will start with 10 DVT and 10 ETH in liquidity
The attacker's address is being sent a value on the hardhat local chain according to the initially specified amount.
<deployer account>
- UniswapV1Exchange
- UniswapV1Factory
- DamnValuableToken
- PuppetPool
The deployer is responsible for deploying these four contracts and initializing them with the appropriate values. The deployer will also assign the initial instances of each contract.
this.token = await DamnValuableTokenFactory.deploy();
this.exchangeTemplate = await UniswapExchangeFactory.deploy();
this.uniswapFactory = await UniswapFactoryFactory.deploy();
await this.uniswapFactory.initializeFactory(this.exchangeTemplate.address);
The Uniswap protocol is used to exchange within the protocol, and the exchange contract to be used in the Uniswap factory contract is initialized in the initializeFactory contract based on the exchange contract.
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 }
);
The new exchange for the token is created and the deployed exchange address is connected to the target contract to complete the instance work.
The custom PuppetPool contract is deployed and the initial constructor work is assigned the token and uniswapExchange contract address because it is implemented using the Uniswap protocol.
The uniswapExchange contract access is set based on 10 ether, and then liquidity is added.
@payable
addLiquidity(
min_liquidity: uint256,
max_tokens: uint256,
deadline: uint256
): uint256
Liquidity is added by setting the minimum liquidity, maximum ERC 20 token value, and liquidity transaction deadline based on the uniswapV1Exchange specification, and setting the access to the uniswapExchange contract to 10 ether.
The transaction deadline, for example, is included in many Uniswap functions and sets a time when the transaction can no longer be executed. This restricts miners from holding signed trades for a long time and executing them based on market movements, and also reduces uncertainty for transactions that take a long time to execute due to gas price issues.
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
)
);
This is a test to verify that token exchanges are properly completed within the deployed liquidity pool
The getTokenToEthInputPrice method is used to check the amount of tokens to be sold, and the returned data, the amount of ETH that can be purchased, should match the value calculated by the arbitrarily implemented calculateTokenToEthInputPrice method based on the sale amount of the tokens.
PuppetPool.sol

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);
The deposits mapping structure is used to manage liquidity pool deposits for each user.
Instance allocation for dependency contracts, variable declaration, and constantization are performed.
When the Borrowed function is called, the data event query for address indexed account, uint256 depositRequired, uint256 borrowAmount data is performed in the internal logic.
constructor
constructor (address tokenAddress, address uniswapPairAddress) {
token = DamnValuableToken(tokenAddress);
uniswapPair = uniswapPairAddress;
}
In the constructor function, instances of the ERC20 token contract and the uniswap V1 Protocol are created and initialized.
function _computeOraclePrice() private view returns (uint256)
function _computeOraclePrice() private view returns (uint256) {
return uniswapPair.balance * (10 ** 18) / token.balanceOf(uniswapPair);
}
Calculate the price of the DVT-ETH token using Uniswap V1 Exchange operations.
function calculateDepositRequired(uint256 amount) public view returns (uint256)
function calculateDepositRequired(uint256 amount) public view returns (uint256) {
return amount * _computeOraclePrice() * 2 / 10 ** 18;
}
The formula is introduced to divide the deposit requirement into branches for liquidity pool calculation.
The value of the amount argument varies, and it can be seen that the value of the Oracle price and twice the value is always calculated.
function borrow(uint256 borrowAmount) public payable nonReentrant
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);
}
In order to borrow a token's borrowAmount, first deposit twice the value of ETH.
If the pool does not have enough liquidity in the token, it will fail.
The depositRequired check formula returns the remaining value to the msg.sender if the current contract call user has a larger balance. This is because the user's transaction ether value is much higher than the value to be borrowed.
Update the depositRequired value assigned to the deposits mapping data structure at the current time.
Send the borrowAmount value to msg.sender and process the event.
In the computeOraclePrice() function, it is possible to calculate the ETH-DVT ratio of the Pool and check the current exchange rate. However, when calculating the actual Oracle price, it can be seen that it is possible for the Pool to purchase a large amount of ETH in order to greatly reduce the ratio of the price of DVT tokens that are available for loan. The attacker's goal is to steal all DVT tokens from the Pool.
In normal circumstances, 2 times the amount of ETH would have to be deposited as collateral in order to borrow 100,000 DVT tokens. However, the attacker only has 25 ETH. In order for the _computeOraclePrice function to return 1 when 2 times the amount of ETH is deposited, the uniswapPair.balance and token.balanceOf(uniswapPair) must be 1:1. Currently, the Pool has 10 ETH and 10 DVT, so it always returns 1. However, the attacker can manipulate the Oracle Price calculation formula by exploiting a flaw in it. If the ETH balance (uniswapPair.balance) of the exchange is 0, the result of the calculation formula uniswapPair.balance * (10 ** 18) / token.balanceOf(uniswapPair) will be 0 or a very small number. In order to perform this attack, the attacker must exchange their DVT tokens for ETH and reduce the exchange balance to 0. Then, by borrowing a large number of DVT tokens using a small amount of ETH as collateral, the attacker can sell the DVT tokens for a higher price and make a profit.

Balance Tracking (Uniswap Exchange Pool + Attacker)
// Uniswap ETH balance: 10
// Uniswap DVT balance: 10
// Attacker initial token balance: 1000
// Attacker initial ETH balance: 25
// Uniswap ETH balance: 0.099304865938430984
// Uniswap DVT balance: 1009.999999999999999999
// Attacker token balance after swap: 0.000000000000000001
// Attacker ETH balance after swap: 34.900560102794869806
Thank you for the @tinchoabbate that made a good wargame.
Share Dialog
Share Dialog
No comments yet