Skip to content
On this page

Ethernaut CTF 摩托车挑战:引擎自毁的秘密!

想象一下,你拥有一辆搭载最新可升级引擎的酷炫摩托车。它拥有无限升级的潜力,引擎可以不断迭代,性能永不落伍。但如果有一天,有人能够远程销毁这台引擎,让你的爱车瞬间变成一堆废铁,你会怎么想?

这就是 Ethernaut Motorbike CTF 挑战的核心!作为一名智能合约安全爱好者,你的任务就是利用智能合约设计中的一个巧妙漏洞,让这辆摩托车的引擎彻底“自毁”,使其无法再次启动。准备好成为智能合约界的“拆车专家”了吗?

挑战背景:可升级合约的魅力与陷阱

这个挑战围绕着当前智能合约开发中一个非常重要的概念:可升级合约(Upgradeable Contracts)

在区块链世界,一旦合约部署,其代码就无法更改。这带来了极大的安全性和确定性,但也限制了灵活性。如果发现漏洞,或者需要增加新功能,就非常麻烦。可升级合约模式应运而生,它允许我们在不改变合约地址的前提下,升级其背后的逻辑代码。

Ethernaut 的这辆摩托车,正是采用了这种设计模式,具体是:

  1. 代理合约(Proxy Contract): Motorbike 合约扮演代理的角色。用户所有的交互都通过这个代理合约进行。它本身不包含业务逻辑,只负责将所有收到的调用“委托”给另一个合约。
  2. 实现合约(Implementation Contract): Engine 合约是真正的业务逻辑所在地。摩托车的马力、升级功能等都在这里实现。
  3. delegatecall 这是代理模式的核心魔法。当代理合约收到一个调用时,它会使用 delegatecall 指令将该调用转发给实现合约。delegatecall 的神奇之处在于,实现合约的代码会在代理合约的**存储上下文(Storage Context)**中执行。这意味着,实现合约对状态变量的修改,实际上是修改了代理合约的状态。
  4. EIP-1967 与 UUPS: 这是行业标准,定义了如何在代理合约中存储实现合约的地址(一个特定的存储槽位 _IMPLEMENTATION_SLOT),以及如何管理升级。UUPS 模式的特点是,升级逻辑本身也位于实现合约中。
  5. Initializable 合约: 在可升级合约中,我们不能使用构造函数来初始化状态(因为实现合约只部署一次,之后都是通过 delegatecall 调用)。因此,OpenZeppelin 提供了一个 Initializable 基础合约,配合 initialize() 函数和 initializer 修饰符,确保初始化逻辑只被执行一次。

引擎的致命弱点:不设防的后门

我们的目标是让摩托车“无法使用”,这通常意味着需要破坏其核心逻辑——Engine 合约。描述中明确提示要“selfdestruct 它的引擎”。但如何才能做到呢?

让我们深入分析 Engine.sol

solidity
contract Engine is Initializable {
    // ...
    address public upgrader; // 升级者角色
    // ...
    function initialize() external initializer {
        horsePower = 1000;
        upgrader = msg.sender; // 谁调用初始化,谁就是升级者
    }

    function upgradeToAndCall(address newImplementation, bytes memory data) external payable {
        _authorizeUpgrade(); // 只有upgrader才能升级
        _upgradeToAndCall(newImplementation, data);
    }
    // ...
}

这里面有一个关键信息:upgrader 角色拥有升级合约的权限,而这个角色是在 initialize() 函数中被设置的。这个 initialize() 函数被 initializer 修饰符保护,理论上只能被调用一次。

核心漏洞就在于此!

虽然 Motorbike 代理合约在部署时会通过 delegatecall 调用 Engine.initialize() 来初始化自身状态,并设置它的 upgrader。但是,Engine 合约本身作为实现合约,在部署时并没有在自己的构造函数中调用 _disableInitializers()

这意味着什么?这意味着,尽管 Motorbike 代理合约已经被正确初始化,但 Engine 合约作为一个独立的合约,它自身的 _initialized 状态变量仍然是 false

因此,我们可以直接调用 Engine 合约的 initialize() 方法!

拆解引擎:攻击步骤

攻击流程大致如下:

  1. 部署你的“销毁器”合约 (ToRemove): 我们需要一个包含 selfdestruct 函数的恶意合约。当它被调用时,会销毁当前执行上下文的合约。
    solidity
    contract ToRemove {
        function engineGone() external {
            selfdestruct(payable(address(0))); // 销毁自身,并将余额发送给地址0
        }
    }
    
  2. 定位引擎合约: Ethernaut 的 createLevelInstance 函数部署合约时,会使用 CREATE 操作码。这意味着,通过计算 Level 合约的 nonce 值,我们可以预测到 EngineMotorbike 合约的地址。
  3. 重新初始化 Engine 合约: 通过预测到的 Engine 合约地址,直接调用它的 initialize() 函数。由于 Engine 合约本身的 _initialized 状态是 false,这次调用会成功。现在,你成为了 Engine 合约自身的 upgrader
  4. 执行“自毁”升级: 再次通过 Engine 合约的地址,直接调用 upgradeToAndCall() 函数:
    • newImplementation 参数设置为我们部署的 ToRemove 合约地址。
    • data 参数设置为 ToRemove 合约中 engineGone() 函数的编码调用数据。
  5. 触发 selfdestructEngine 合约执行 upgradeToAndCall() 时,它会:
    • 先进行 _authorizeUpgrade() 检查,此时 msg.sender (你) 正是 Engineupgrader,检查通过。
    • 然后调用 _setImplementation(),更新 Engine 自身存储中的实现合约地址(这本身没有太大影响)。
    • 最关键的一步,它会执行 newImplementation.delegatecall(data)。这意味着 Engine 合约会 delegatecall 到你的 ToRemove 合约,并执行 engineGone() 函数。
    • 由于 delegatecall 的特性,selfdestruct 会在 Engine 合约的上下文中执行,导致 Engine 合约自身的代码被彻底销毁!

结果:摩托车变“僵尸”

Engine 合约的代码被销毁后,虽然 Motorbike 代理合约的地址还在,但它所指向的实现逻辑已经不复存在。任何对 Motorbike 的调用都会因为无法找到执行代码而失败,这辆摩托车也就彻底“瘫痪”了。恭喜你,成功完成了任务!

经验教训:UUPS 模式的严谨性

这个挑战揭示了 UUPS 代理模式中一个重要的安全陷阱:

实现合约即使作为被 delegatecall 的目标,也必须妥善处理其 Initializable 状态。 具体来说,如果一个 Initializable 合约被用作实现合约,它通常需要在自身的构造函数中调用 _disableInitializers(),以防止其在被直接部署后,再次被恶意初始化。否则,攻击者就可以直接与实现合约交互,劫持其控制权,甚至如本例所示,将其自毁。

在设计和实现可升级合约时,务必注意代理和实现合约之间的状态隔离、权限管理以及初始化流程的严谨性,确保没有不设防的“后门”存在。这个摩托车挑战,正是一堂生动的安全课!

下次当你看到一辆崭新的可升级摩托车时,你或许会多一份警惕:它的引擎,真的牢不可破吗?

Built with AiAda