Appearance
Ethernaut CTF 摩托车挑战:引擎自毁的秘密!
想象一下,你拥有一辆搭载最新可升级引擎的酷炫摩托车。它拥有无限升级的潜力,引擎可以不断迭代,性能永不落伍。但如果有一天,有人能够远程销毁这台引擎,让你的爱车瞬间变成一堆废铁,你会怎么想?
这就是 Ethernaut Motorbike CTF 挑战的核心!作为一名智能合约安全爱好者,你的任务就是利用智能合约设计中的一个巧妙漏洞,让这辆摩托车的引擎彻底“自毁”,使其无法再次启动。准备好成为智能合约界的“拆车专家”了吗?
挑战背景:可升级合约的魅力与陷阱
这个挑战围绕着当前智能合约开发中一个非常重要的概念:可升级合约(Upgradeable Contracts)。
在区块链世界,一旦合约部署,其代码就无法更改。这带来了极大的安全性和确定性,但也限制了灵活性。如果发现漏洞,或者需要增加新功能,就非常麻烦。可升级合约模式应运而生,它允许我们在不改变合约地址的前提下,升级其背后的逻辑代码。
Ethernaut 的这辆摩托车,正是采用了这种设计模式,具体是:
- 代理合约(Proxy Contract):
Motorbike合约扮演代理的角色。用户所有的交互都通过这个代理合约进行。它本身不包含业务逻辑,只负责将所有收到的调用“委托”给另一个合约。 - 实现合约(Implementation Contract):
Engine合约是真正的业务逻辑所在地。摩托车的马力、升级功能等都在这里实现。 delegatecall: 这是代理模式的核心魔法。当代理合约收到一个调用时,它会使用delegatecall指令将该调用转发给实现合约。delegatecall的神奇之处在于,实现合约的代码会在代理合约的**存储上下文(Storage Context)**中执行。这意味着,实现合约对状态变量的修改,实际上是修改了代理合约的状态。- EIP-1967 与 UUPS: 这是行业标准,定义了如何在代理合约中存储实现合约的地址(一个特定的存储槽位
_IMPLEMENTATION_SLOT),以及如何管理升级。UUPS 模式的特点是,升级逻辑本身也位于实现合约中。 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() 方法!
拆解引擎:攻击步骤
攻击流程大致如下:
- 部署你的“销毁器”合约 (
ToRemove): 我们需要一个包含selfdestruct函数的恶意合约。当它被调用时,会销毁当前执行上下文的合约。soliditycontract ToRemove { function engineGone() external { selfdestruct(payable(address(0))); // 销毁自身,并将余额发送给地址0 } } - 定位引擎合约: Ethernaut 的
createLevelInstance函数部署合约时,会使用CREATE操作码。这意味着,通过计算 Level 合约的 nonce 值,我们可以预测到Engine和Motorbike合约的地址。 - 重新初始化
Engine合约: 通过预测到的Engine合约地址,直接调用它的initialize()函数。由于Engine合约本身的_initialized状态是false,这次调用会成功。现在,你成为了Engine合约自身的upgrader! - 执行“自毁”升级: 再次通过
Engine合约的地址,直接调用upgradeToAndCall()函数:- 将
newImplementation参数设置为我们部署的ToRemove合约地址。 - 将
data参数设置为ToRemove合约中engineGone()函数的编码调用数据。
- 将
- 触发
selfdestruct: 当Engine合约执行upgradeToAndCall()时,它会:- 先进行
_authorizeUpgrade()检查,此时msg.sender(你) 正是Engine的upgrader,检查通过。 - 然后调用
_setImplementation(),更新Engine自身存储中的实现合约地址(这本身没有太大影响)。 - 最关键的一步,它会执行
newImplementation.delegatecall(data)。这意味着Engine合约会delegatecall到你的ToRemove合约,并执行engineGone()函数。 - 由于
delegatecall的特性,selfdestruct会在Engine合约的上下文中执行,导致Engine合约自身的代码被彻底销毁!
- 先进行
结果:摩托车变“僵尸”
Engine 合约的代码被销毁后,虽然 Motorbike 代理合约的地址还在,但它所指向的实现逻辑已经不复存在。任何对 Motorbike 的调用都会因为无法找到执行代码而失败,这辆摩托车也就彻底“瘫痪”了。恭喜你,成功完成了任务!
经验教训:UUPS 模式的严谨性
这个挑战揭示了 UUPS 代理模式中一个重要的安全陷阱:
实现合约即使作为被 delegatecall 的目标,也必须妥善处理其 Initializable 状态。 具体来说,如果一个 Initializable 合约被用作实现合约,它通常需要在自身的构造函数中调用 _disableInitializers(),以防止其在被直接部署后,再次被恶意初始化。否则,攻击者就可以直接与实现合约交互,劫持其控制权,甚至如本例所示,将其自毁。
在设计和实现可升级合约时,务必注意代理和实现合约之间的状态隔离、权限管理以及初始化流程的严谨性,确保没有不设防的“后门”存在。这个摩托车挑战,正是一堂生动的安全课!
下次当你看到一辆崭新的可升级摩托车时,你或许会多一份警惕:它的引擎,真的牢不可破吗?