使用Paradigm的CTF框架创建以太坊CTF挑战

  • zellic
  • 发布于 2022-11-15 13:22
  • 阅读 96

本文介绍了如何使用Paradigm的CTF框架创建以太坊智能合约的CTF挑战。文章详细讲解了从智能合约的漏洞设计、框架的设置、Docker环境的搭建到进行远程测试的每个步骤,适合想要了解区块链CTF的开发者与安全研究人员。

背景

Zellic 最近赞助了 CSAW 2022↗。 CSAW 是一个每年在纽约布鲁克林由 NYU 主办的网络安全活动。多年来,它已成为美国/加拿大 CTF 场景的中心。每年,CSAW CTF 都吸引着顶级的高中和大学 CTF 玩家。事实上,我的联合创始人 Jazzy 和我实际上是在 CSAW 2017 上相遇的——在那儿我们随后组成了一个团队,最终成为了 perfect\ blue↗。因此,我们把 CSAW’22 视为回馈 CTF 社区的机会。

作为赞助商,我们有机会为 CTF 贡献一个挑战。我决定创建一个简单的以太坊智能合约挑战。这个挑战将围绕着利用一个脆弱的智能合约进行。然而,CTF 玩家需要能够与智能合约进行互动,而不将他们的解决方案或利用方式泄露给其他玩家。这是创建区块链 CTF 挑战时的一个常见问题。

幸运的是,samczsun↗ 和 Paradigm 其余团队开发了一个出色的框架,用于按需部署和托管这类 CTF 挑战。它通过为每个参与者按需部署私有的分叉区块链实例(使用 Anvil)来解决这个问题。以下是实际操作的样子:

$ nc ctf.example.xyz 31337
1 - 启动新实例
2 - 杀死实例
3 - 获取旗帜
action? 1
票据请输入: ticket

你的私有区块链已部署
将在 30 分钟后自动终止
以下是一些有用的信息
uuid:           12341234-12341234-12341234
rpc 端点:      http://ctf.example.xyz:8545/12341234-12341234-12341234
私钥:         0xabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcd
设置合约:     0x12341234123412341234

这个框架易于使用,尽管文档并不是非常详尽。希望这篇文章能提供一个关于如何为你自己的 CTF 设置框架的实际示例。

智能合约

在我们深入使用 CTF 框架之前,让我们快速讨论一下智能合约本身。这将有助于之后提供有用的上下文。

智能合约是一个简单的保险库,允许用户存入和提取 USDC 和 DAI。然而,在提取时,如果保险库所需代币的余额不足,它会在 Uniswap 上进行交换,以获得更多的代币。

contract Chal {
    // ...
    function withdrawDAI(uint amountOut) public {
        require(balanceOf[msg.sender] >= amountOut);
        balanceOf[msg.sender] -= amountOut;

        if (dai.balanceOf(address(this)) < amountOut*10e12) {
            address[] memory path;
            path = new address[](2);
            path[0] = USDC;
            path[1] = DAI;

            uint[] memory amounts = router.swapExactTokensForTokens(
                usdc.balanceOf(address(this)), 0, path, address(this), block.timestamp
            );
        }

        dai.transfer(msg.sender, amountOut*10e12);
    }
    // ...
}

这里的问题是缺少滑点检查。攻击者可以通过使合约重复进行不利价格的交易轻松地耗尽合约。例如,这可以通过价格操纵来完成。攻击者从中获利,从而造成价格影响。

为了利用这个合约,攻击者需要 USDC 和 DAI。因此,我们为 CTF 玩家提供的测试网应为主网的分叉,以便为他们提供通常的 DeFi 设施(Uniswap、WETH 等)。

我们的挑战合约(Chal.sol)由一个设置合约进行部署和设置。以下代码可以为我们链上合约如何与 CTF 框架相连接提供一个思路,因此请不要忽略它:

pragma solidity ^0.8.13;

import "./Chal.sol";

contract Setup {
    Chal public immutable TARGET; // 玩家将攻击的合约

    // 硬编码常量(与主网相同)
    address private constant UNISWAP_V2_ROUTER = 0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D;
    address private constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2;
    address private constant DAI = 0x6B175474E89094C44Da98b954EedeAC495271d0F;
    address private constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48;

    IUniswapV2Router private router = IUniswapV2Router(UNISWAP_V2_ROUTER);
    IERC20 private dai = IERC20(DAI);
    IERC20 private usdc = IERC20(USDC);
    IWETH9 private weth = IWETH9(WETH);

    // 用于跟踪玩家是否已解决挑战
    uint private initialBalance;

    constructor() payable {
        // 部署受害者合约
        TARGET = new Chal();

        // 我们的设置合约将由我们的 CTF 框架中的设置脚本调用,并提供 100 个以太。
        // 仔细检查是否提供了 100 个以太。
        //
        // 这也让 CTF 玩家了解设置合约调用时传入的内容,从而消除了猜测问题。
        //
        // 我们不会与玩家共享设置脚本,但我们会将该设置合约作为挑战分发的一部分提供给他们。
        require(msg.value == 100 ether);

        // 我们希望_INITIALLY_ 在保险库中持有一些 DAI 和 USDC,供玩家盗取。
        // 使用各种 DeFi 原语将 100 个以太转换为 USDC 和 DAI。
        weth.deposit{ value: 100 ether }();
        weth.approve(address(router), type(uint256).max);
        dai.approve(address(router), type(uint256).max);
        usdc.approve(address(router), type(uint256).max);

        address[] memory path;
        path = new address[](2);

        // 在 uniswap 上用一半的初始 eth 交换为 usdc
        path[0] = WETH; path[1] = USDC;
        amounts = router.swapExactTokensForTokens(
            50 ether, 0, path, address(TARGET), block.timestamp
        );

        // 在 uniswap 上用一半的初始 eth 交换为 dai
        path[0] = WETH; path[1] = DAI;
        amounts = router.swapExactTokensForTokens(
            50 ether, 0, path, address(TARGET), block.timestamp
        );

        initialBalance = curTargetBalance();
    }

    // 辅助函数
    function curTargetBalance() public view returns (uint) {
        return usdc.balanceOf(address(TARGET)) + dai.balanceOf(address(TARGET))/10e12;
    }

    // 我们的挑战在 CTF 框架中将调用此函数来检查玩家是否已解决挑战。
    function isSolved() public view returns (bool) {
        return curTargetBalance() < (initialBalance / 10);
    }
}

在开发挑战的智能合约时,我使用了 Foundry↗ 进行本地测试。很容易通过只需进行 Foundry 测试来测试我自己对挑战的解决方案。

pragma solidity ^0.8.13;

import "forge-std/Test.sol";
import "../src/Exploit.sol";

contract CSAWTest is Test {
    Setup chal;

    function setUp() public {
        chal = new Setup{value: 100 ether}();
    }

    function testExploit() public {
        Exploit exploit = new Exploit{value: 100 ether}(chal);
        require(chal.isSolved());
    }
}

设置 CTF 框架

首先,我只是在 DigitalOcean 创建了一个新的虚拟服务器(对我来说,是一个新的滴水)。我超额配置了服务器,因为便宜(只会持续 48 小时),并且意味着我不必太担心玩家过载它。

我还在 Quicknode 中创建了一个新项目,以便拥有可以使用的 RPC 端点。Alchemy/Infura 等也可以。

在这所有过程中,我都是以 root 身份完成的。我不在乎,因为这只是一个单用途的虚拟机,并将在 CTF 结束后被丢弃。

根据 Paradigm CTF 2022\ repo↗ 中的说明,安装依赖项:

  • 安装 Docker(从 这里↗ 复制粘贴命令)
  • mpwn: git clone https://github.com/lunixbochs/mpwn↗
  • python3: apt install -y python3 python3-dev python3-pip
  • libgmp: apt install -y libgmp-dev build-essential
  • 其他依赖: pip install yaml ecdsa pysha3 web3

每个挑战都基于 这个 Docker\ image。克隆这个 repo,并将该目录拿走。

$ git clone https://github.com/paradigmxyz/paradigm-ctf-infrastructure
$ mv paradigm-ctf-infrastructure/images/eth-challenge-base my-chal
$ cd my-chal

将以下内容添加到 Dockerfile 的末尾(取自 Paradigm\ CTF 中的一个挑战\ ↗):

COPY deploy/ /home/ctf/

COPY contracts /tmp/contracts

RUN true \
    && cd /tmp \
    && /root/.foundry/bin/forge build --out /home/ctf/compiled \
    && rm -rf /tmp/contracts \
    && true

创建一个名为 contracts 的目录。你的合约将在此目录中存放。

$ mkdir contracts

还要创建一个名为 deploy 的目录,并将该内容存放在 deploy/chal.py 中。这是你的设置脚本,用于部署你的设置合约。

import json
from pathlib import Path

import eth_sandbox
from web3 import Web3

def deploy(web3: Web3, deployer_address: str, player_address: str) -> str:
    rcpt = eth_sandbox.sendTransaction(web3, {
        "from": deployer_address,
        "value": Web3.toWei(100, 'ether'), # 我们的设置合约期望 100 个以太。因此让我们给它 100 个以太。
        "data": json.loads(Path("compiled/Setup.sol/Setup.json").read_text())["bytecode"]["object"],
    })

    return rcpt.contractAddress

eth_sandbox.run_launcher([\
    eth_sandbox.new_launch_instance_action(deploy),\
    eth_sandbox.new_kill_instance_action(),\
    eth_sandbox.new_get_flag_action() # 此实现按 Setup 合约调用 isSolved()\
])

现在你可以这样构建 Docker 镜像:

$ docker buildx build --platform linux/amd64 -t mytag .

注意 mytag 可以是你想要的任何名称。并且 ' .' 只是指当前 Dockerfile 所在的目录。

现在,你可以用以下命令运行用于我们挑战的 Docker 镜像:

## 根据需要进行调整
IMAGE=mytag
PORT=31337
HTTP_PORT=8545

exec docker run \
    -e "PORT=$PORT" \
    -e "HTTP_PORT=$HTTP_PORT" \
    -e "ETH_RPC_URL=$ETH_RPC_URL" \
    -e "FLAG=$FLAG" \
    -e "PUBLIC_IP=$PUBLIC_IP" \
    -p "$PORT:$PORT" \
    -p "$HTTP_PORT:$HTTP_PORT" \
    "$IMAGE"

现在你应该有一个可以工作的 CTF 挑战。你可以通过 netcat 连接到挑战正在监听的端口(在这个例子中,端口 31337),你应该会被 CTF 框架迎接。

设置密钥(重要)

设置环境变量 SECRET_KEY 为随机、私密值非常重要。否则,参与者可以直接向 Anvil 实例进行 RPC 调用,破解挑战。有关更多信息,请查看 server.py 和 auth.py 的实现。

感谢 rkm0959↗ 的 Super Guesser 指出这一点!

针对远程进行测试

所有优秀的 CTF 都必须通过真实解决脚本对其挑战进行健康检查。这对于质量控制和 CTF 期间的存活性极为重要。

我们可以使用 Forge 测试我们实时的远程环境,用于测试攻击合约:

RPC_URL="http://1.3.3.7:8545/f2e36d63-78fa-4e55-9319-ac072868497d" \
PRIVATE_KEY="0xf42b8f8e5cbb128b54327182c5399c01dc90a0349239a86963aa7c94a2e1c4db" \
SETUP_CONTRACT="0xa56B24969e7f742e4EF721d5FD647896F0758A48" \
forge create Exploit.sol:Exploit --rpc-url $RPC_URL --private-key $PRIVATE_KEY --constructor-args $SETUP_CONTRACT --value 100ether

请注意,这并未实施与启动的 netcat 服务器的任何交互(即,监听在端口 31337 上的那个)。理想情况下,你还应该有一个脚本来与其通信,启动新的实例并解析通信。

自定义票据系统(防 DoS)

在 Paradigm CTF 中,他们使用“票据”系统来防止滥用/DoS。他们通过为 CTF 网站(即 ctf.paradigm.xyz、CTFd、等等)中的每个帐户分配一个票据来实现。然后,每个票据一次只能部署一个区块链实例。这意味着你需要为 CTF 注册多个帐户,可能需要通过类似 Captcha 的方式进行。

将其连接到 CTF 框架的方法是调用某个网络端点以检查票据是否有效。该代码位于 eth_sandbox/launcher.py 中:

def check_ticket(ticket: str) -> Ticket:
    if ENV == "dev":
        return Ticket(challenge_id=CHALLENGE_ID, team_id="team")

    ticket_info = requests.get(
        f"https://us-central1-paradigm-ctf-2022.cloudfunctions.net/checkTicket?ticket={ticket}"
    ).json()
    if ticket_info["status"] != "VALID":
        return None

    return Ticket(
        challenge_id=ticket_info["challengeId"], team_id=ticket_info["teamId"]
    )

因此,你需要将此硬编码 URL 替换为你自己的端点。

对于我来说,我不想费心,所以我完全用一些通用的 CTF PoW 替换了这个功能。这纯粹是作为一个说明性的例子:

def check_ticket(ticket: str) -> Ticket:
    if len(ticket) > 100 or len(ticket) < 8:
        print('无效的票据长度')
        return None
    if not all(c in 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' for c in ticket):
        print('票据必须是字母数字')
        return None
    m = hashlib.sha256()
    m.update(ticket.encode('ascii'))
    digest1 = m.digest()
    m = hashlib.sha256()
    m.update(digest1 + ticket.encode('ascii'))
    if not m.hexdigest().startswith('0000000'):
        print('PoW:sha256(sha256(票据) + 票据) 必须以 0000000 开头')
        print('(摘要是 ' + m.hexdigest() + ')')
        return None
    print('此票据是你的团队密钥。请不要分享!')
    return Ticket(challenge_id=CHALLENGE_ID, team_id=ticket)

请注意,这个 PoW 没有随机数。这样玩家只需解决 PoW 一次,之后可以重复使用他们的秘密票据,这样可以提升用户体验。在这个基本方案中,没有什么可以阻止玩家并行处理多个 PoW 解决器,从而获得大量有效票据以进行 DoS 攻击。

如果你想的话,可以攻击一些附加的带内注册/速率限制机制(按 IP 地址,Captcha 等)。我不觉得需要,所以这留给读者作为练习。

结论

在这篇博文中,我们探讨了 Paradigm 的 CTF 框架如何轻松托管智能合约 CTF 挑战。我们希望这能帮助任何制作 CTF 挑战的人!

最后,本文中使用的所有代码均可在 这里↗ 公开获取。

关于我们

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

开发人员、创始人和投资者信任我们的安全评估,以快速、自信地发布,且没有关键漏洞。凭借我们在实际攻击安全研究方面的背景,我们发现了其他人所遗漏的内容。

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

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

0 条评论

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