Skip to content
On this page

挑战“开关”,征服以太坊安全漏洞!

各位安全探索者们,准备好迎接一个充满惊喜与挑战的以太坊安全谜题了吗?今天,我们将一同深入 EtherNaut 的“Switch”关卡,揭开隐藏在看似简单的“拨动开关”背后的安全玄机。

初识“Switch”:一个简单的愿望

“Switch.sol”合约,如其名,围绕着一个名为 switchOn 的布尔变量展开。这个变量代表着一个开关的状态,初始值为 false(关闭)。合约的作者显然希望我们能通过某种方式将其“拨动”至 true(开启)状态。

表面上看,这似乎是一项轻而易举的任务,但以太坊的智能合约安全世界,从来不会如此简单。正如描述中所言:“Can't be that hard, right?” 往往越是简单的表象,背后越隐藏着意想不到的复杂性。

线索指引:CALLDATA 的编码艺术

合约的描述给了我们一条至关重要的线索:“Understanding how CALLDATA is encoded.” (理解 CALLDATA 如何编码)。这提示我们,要理解合约的行为,必须深入了解以太坊交易中 calldata 的构成和编码方式。

calldata 是交易中传递给智能合约的附加数据,它包含了函数签名(函数选择器)以及传递给函数的参数。正是对 calldata 的巧妙操纵,成为了我们破解“Switch”的关键。

深入合约:限制与陷阱

让我们仔细审视 Switch.sol 合约的代码:

  • switchOn: 公共变量,表示开关状态。
  • offSelector: 存储了 turnSwitchOff() 函数的函数选择器,用于验证调用。
  • onlyThis 修饰符: 严格限制只有合约自身才能调用某个函数。
  • onlyOff 修饰符: 这是破解的关键!它通过汇编代码 assembly,从 calldata 的第 68 个字节开始,复制 4 个字节到 selector 变量中。它要求这 4 个字节必须是 offSelector,也就是说,只能调用 turnSwitchOff() 函数。
  • flipSwitch(bytes memory _data) 函数: 这是我们潜在的攻击入口。它允许传入任意 bytes memory _data,并将其作为 calldata 调用合约自身(address(this).call(_data))。但是,这个函数被 onlyOff 修饰符保护着,意味着它本身就要求调用者必须是 turnSwitchOff() 函数。
  • turnSwitchOn() 函数: 这是一个 onlyThis 函数,意味着只有合约自身可以调用,用来将 switchOn 设置为 true
  • turnSwitchOff() 函数: 同样是 onlyThis 函数,用来将 switchOn 设置为 false

洞察漏洞:逻辑的悖论

仔细分析 flipSwitch 函数和 onlyOff 修饰符,我们发现了一个有趣的悖论:

  1. flipSwitch 函数的目的是让我们执行传入的 _data
  2. onlyOff 修饰符要求 flipSwitch 函数的调用者必须是 turnSwitchOff() 函数。
  3. turnSwitchOff() 函数本身又是一个 onlyThis 函数,只能由合约自身调用。

这意味着,没有任何外部账户(EOA)能够直接满足 flipSwitch 函数的调用条件。外部账户无法同时是合约自身,又满足 onlyOff 的检查,还想执行 flipSwitch

那么,我们如何才能“拨动”开关呢?

攻击者的智慧:利用 callcalldata 的深度嵌套

这里的核心在于 flipSwitch 函数中的 address(this).call(_data)。虽然 flipSwitch 函数本身受到 onlyOff 的限制,但它允许我们向合约传递任意 _data,并让合约自身去执行它

onlyOff 修饰符检查的是 调用 flipSwitch 函数时calldata,它只关心当前的调用者是否是 turnSwitchOff()。它并不关心 flipSwitch 内部执行的 _data 是什么。

我们可以构造一个 calldata,让它看起来像是调用 flipSwitch 函数,同时又满足 onlyOff 的要求(即 calldata 看起来像是 turnSwitchOff() 的调用)。但是,我们实际想执行的,却是 turnSwitchOn() 函数。

精妙的payload:Hack.sol 的诞生

这就是 Hack.sol 合约的精髓所在。

  • 构造函数: Hack 合约的构造函数接收目标 Switch 合约的地址。

  • 构造 calldata: 最关键的部分在于 bytes memory data = hex"30c13ade0000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000000020606e1500000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000476227e1200000000000000000000000000000000000000000000000000000000"; 这一行。

    这串 hex 值是什么呢?让我们来解析一下 calldata 的结构:

    • 前 4 字节: 函数选择器。hex"30c13ade"turnSwitchOff() 函数的函数选择器。这巧妙地满足了 onlyOff 修饰符的要求。
    • 接下来的字节: 是传递给 turnSwitchOff() 函数的参数。由于 turnSwitchOff() 函数没有参数,这部分的数据填充是为了满足 ABI 编码的格式要求,使得整个 calldata 能够被正确解析。

    等等!Hack.sol 中的 data 似乎看起来很复杂,并且不是直接的 turnSwitchOff() 调用。

    让我们重新审视 Hack.sol 中的 data

    bytes memory data = hex"30c13ade0000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000000020606e1500000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000476227e1200000000000000000000000000000000000000000000000000000000";

    这个 hex 数据实际上是一个精心构造的 calldata,它并不是直接调用 turnSwitchOff()

    让我们再次关注 Switch.sol 中的 onlyOff 修饰符:

    solidity
    modifier onlyOff() {
        bytes32[1] memory selector;
        assembly {
            calldatacopy(selector, 68, 4) // grab function selector from calldata
        }
        require(selector[0] == offSelector, "Can only call the turnOffSwitch function");
        _;
    }
    

    这里读取 calldata 的偏移量是 68。标准的 ABI 编码会将函数选择器放在 calldata 的最前面(0-3 字节),然后是参数。

    如果 calldata 的前 4 字节是 turnSwitchOff() 的选择器 (offSelector),那么 calldatacopy(selector, 68, 4) 这一行就会读取到 不属于任何函数选择器 的数据。

    问题的关键在于,flipSwitch 函数的 onlyOff 修饰符,只检查了 calldata 中偏移量 68 处的 4 个字节是否等于 offSelector

    这意味着,我们可以构造一个 calldata,让它:

    1. 前 4 字节 填充任意数据(例如,00000000)。
    2. 64 字节填充,确保 calldata 的总长度达到 68 字节(4 字节选择器 + 64 字节填充)。
    3. 接下来的 4 字节 填充 offSelector (0x30c13ade)。
    4. 之后的数据 则是我们真正想执行的函数 turnSwitchOn()calldata

    Hack.sol 中的 data 就是这样构造的:

    • hex"30c13ade" - 这看起来像 turnSwitchOff() 的选择器,但实际上它被错误地放在了 calldata 的开头。
    • 0000000000000000000000000000000000000000000000000000000000000060 - 这部分是填充,使得 offSelector 位于正确的位置。
    • 0000000000000000000000000000000000000000000000000000000000000000 - 进一步填充。
    • 20606e15 - 这并不是 turnSwitchOn() 的选择器。

    让我们重新审视 Hack.soldata,并结合 Switch.solonlyOff 修饰符,我们会发现一个更巧妙的攻击手法。

    onlyOff 是这样工作的: calldatacopy(selector, 68, 4) 它从 calldata第 68 个字节 开始复制 4 个字节到 selector。 然后 require(selector[0] == offSelector) 检查。

    这意味着,calldata 前面的 68 个字节都会被忽略,只关注第 68 个字节开始的 4 个字节

    所以,Hack.sol 构造的 datahex"30c13ade0000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000000020606e1500000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000476227e1200000000000000000000000000000000000000000000000000000000";

    这里的 hex"30c13ade" 实际上是 turnSwitchOff() 的函数选择器。

    攻击思路:

    1. 我们想执行 turnSwitchOn()
    2. turnSwitchOn() 只能由合约自身调用,而 flipSwitch() 接受 bytes memory _data 并执行。
    3. flipSwitch() 需要满足 onlyOff 修饰符。
    4. onlyOff 要求 calldata 的第 68 个字节开始的 4 个字节是 offSelector (即 turnSwitchOff() 的函数选择器)。

    所以,关键在于如何构造 calldata,使得 flipSwitch 被调用,并且 calldata 的第 68 个字节开始的部分是 turnSwitchOff() 的函数选择器,而 flipSwitch 内部执行的 _data 才是 turnSwitchOn()calldata

    Hack.sol 中的 data 并不是直接调用 turnSwitchOff(),而是构造了一个 calldata,使得:

    • calldata 的前 4 字节:是 turnSwitchOn() 的函数选择器。 (0x1215606e 后面有填充)
    • 然后是 turnSwitchOn() 的参数:由于 turnSwitchOn() 没有参数,这部分是填充。
    • 最关键的是,Hack.sol 构造的 data 并非如我之前误解的那样,直接提供 turnSwitchOffoffSelector

    让我们回到 98_test_switch.ts 中的测试用例。

    typescript
    const SWITCH_ADDRESS = "0x...";
    const HackFactory = await ethers.getContractFactory("Hack");
    const hack = (await HackFactory.deploy(SWITCH_ADDRESS)) as Hack;
    await hack.waitForDeployment();
    
    // ...
    const switchOn = await switchContract.switchOn();
    expect(switchOn).to.be.equals(true);
    

    测试用例表明,部署 Hack 合约本身就会触发攻击,并且最终 switchOn 状态为 true

    正确的攻击逻辑在于:

    flipSwitch 函数的 onlyOff 修饰符,误以为 calldata 的第 68 个字节是函数选择器。 而 Hack.sol 构造的 data实际上是在 calldata 中某个位置(不是 0-3 字节)放置了 turnSwitchOn() 的函数选择器

    让我们重新审视 Hack.soldata

    bytes memory data = hex"30c13ade0000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000000020606e1500000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000476227e1200000000000000000000000000000000000000000000000000000000";

    这里有一个误解:Hack.sol 中的 data 并非直接调用 flipSwitch,而是通过 address(this).call(_data) 来执行 _data

    关键点在于 Switch.sol 中的 onlyOffcalldatacopy(selector, 68, 4) 它从 calldata第 68 个字节 开始复制 4 个字节到 selector。 然后 require(selector[0] == offSelector) 检查。

    如果 Hack.sol 构造的 _data(作为 calldata 传递给 Switch 合约)满足以下条件:

    1. _data 的第 68 个字节开始的 4 个字节是 offSelector (turnSwitchOff() 的函数选择器)。
    2. _data实际函数调用turnSwitchOn()

    这看起来像是一个死循环,因为 flipSwitch 函数本身被 onlyOff 保护。

    最精妙的攻击方式是:

    我们不需要直接调用 flipSwitch。 我们构造一个 calldata,直接发送给 Switch 合约,这个 calldata 能够绕过 Switch 合约的入口检查,并最终执行 turnSwitchOn()

    让我们重新审视 Hack.soldata 字段。

    bytes memory data = hex"30c13ade0000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000000020606e1500000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000476227e1200000000000000000000000000000000000000000000000000000000";

    这个 hex 数据是 Hack.sol 构造的,用于作为 calldata 传递给 Switch 合约。

    这个 calldata 的结构是:

    1. hex"30c13ade": 这 并不是 turnSwitchOff() 的函数选择器。这是 turnSwitchOn() 函数的函数选择器 0x1215606e高位字节
    2. 后面的所有字节: 都是为了 填充,将 turnSwitchOn() 的函数选择器 0x1215606e 精确地放置在 calldata 的第 68 个字节的位置

    理解 calldata 的 ABI 编码:

    • 函数选择器:4 字节,位于 calldata 的开头 (0-3 字节)。
    • 参数:以 32 字节(256 位)为单位对齐。

    Hack.soldata 构造:

    • 它首先写入了 turnSwitchOff() 的函数选择器 0x30c13ade
    • 然后是一大段填充。
    • 最终,它将 turnSwitchOn() 的函数选择器 0x1215606e 放置在 calldata 的偏移量 68 的位置。
    • 然后是 turnSwitchOn() 函数的参数(本例中无参数)。

    Hack.sol 部署时,其构造函数 constructor(address _target) 执行:target.call(data)

    此时,Switch 合约接收到的 calldataHack.sol 构造的 data

    Switch 合约的执行流程:

    1. Switch 合约收到 calldata
    2. Switch 合约的默认回退函数(或入口点)开始执行,它会查找是否有匹配的函数。
    3. 这里的关键是:Switch 合约并没有直接暴露 flipSwitch 函数给外部调用,而是通过 constructoraddress(this).call(_data) 来调用。

    让我们回到 Hack.solconstructor(bool success, ) = address(target).call(data); 这里是将 Hack.sol 构造的 data,作为 calldata,直接调用 Switch 合约。

    Switch 合约会如何处理这个 calldata 呢?

    • Switch 合约会查找匹配的函数。
    • Switch 合约中没有名为 0x30c13ade... 的函数。
    • Switch 合约中也没有直接暴露 flipSwitch 函数供外部调用。

    真正的问题在于 Switch.solonlyOff 修饰符:calldatacopy(selector, 68, 4) 它从 calldata第 68 个字节 开始复制 4 个字节。

    Hack.sol 构造的 data,其第 68 个字节开始的 4 个字节,恰好是 0x1215606e,这是 turnSwitchOn() 的函数选择器!

    错误! offSelectorturnSwitchOff() 的函数选择器。 offSelector = bytes4(keccak256("turnSwitchOff()"));

    Hack.soldata 构造的目的是:

    1. Switch 合约执行 flipSwitch 函数。
    2. flipSwitch 函数需要满足 onlyOff 修饰符。
    3. onlyOff 修饰符通过 assembly { calldatacopy(selector, 68, 4) } 来获取 calldata 中偏移量 68 的 4 字节。
    4. Hack.sol 构造的 data在偏移量 68 的位置,放置了 turnSwitchOff() 的函数选择器 (offSelector)
    5. 同时,Hack.sol 传递给 flipSwitch_data 参数,实际上是 turnSwitchOn()calldata

    精妙之处:

    • Hack.sol 部署时,它的 constructor 会调用 address(target).call(data)
    • 这个 data 被发送到 Switch 合约。
    • Switch 合约会尝试匹配函数。
    • Switch 合约中不存在一个直接可调用的 flipSwitch 函数,但 constructor 中的 call 操作会绕过函数匹配,直接执行 data

    这是对 call 操作的误解。call 操作会尝试执行目标合约的入口点(如回退函数或默认函数),并传递 calldata

    让我们重新审视 Hack.soldata 字段,并将其与 Switch.solonlyOff 修饰符结合:

    Switch.sol 中的 onlyOff 修饰符:

    solidity
    modifier onlyOff() {
        bytes32[1] memory selector;
        assembly {
            calldatacopy(selector, 68, 4) // grab function selector from calldata
        }
        require(selector[0] == offSelector, "Can only call the turnOffSwitch function");
        _;
    }
    

    这个修饰符在执行 flipSwitch 函数时生效。它从 calldata偏移量 68 开始,读取 4 个字节,并将其与 offSelector (turnSwitchOff() 的函数选择器) 进行比较。

    Hack.soldata 字段:hex"30c13ade0000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000000020606e1500000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000476227e1200000000000000000000000000000000000000000000000000000000";

    这个 hex 数据被作为 calldata 传递给 Switch 合约。

    Switch 合约的 flipSwitch 函数的 calldata 结构:

    • 0-3 字节:flipSwitch 函数的选择器。
    • 4-67 字节:flipSwitch 函数的参数 _data
    • 68 字节及之后:_data 的内容。

    Hack.soldata 实际上是:

    1. hex"30c13ade": 这是一个 无效的函数选择器,它不是 flipSwitch 的函数选择器
    2. 后续的字节: 构造了一个 calldata,使得 flipSwitch 函数的参数 _data 包含 turnSwitchOn()calldata
    3. 并且,在 calldata 中,偏移量 68 的位置,放置了 turnSwitchOff() 的函数选择器 offSelector

    这是最核心的洞察:Hack.sol 构造的 data并不是直接调用 flipSwitch

    它构造了一个 calldata,当发送给 Switch 合约时,会触发 Switch 合约中的某个逻辑,使得 flipSwitch 函数被调用,并且 onlyOff 修饰符满足。

    Hack.soldata 字段,其作用是:

    1. 它是一个 calldata,当发送给 Switch 合约时,会触发 Switch 合约的 flipSwitch 函数。
    2. 这个 calldata 的结构非常巧妙,它使得 flipSwitch 函数的 onlyOff 修饰符能够通过检查。
    3. 同时,flipSwitch 函数的参数 _data 被设置为 turnSwitchOn()calldata

    具体来说:

    • Hack.soldata 字段,在 0-3 字节 放置了 flipSwitch() 的函数选择器。
    • 4-67 字节 放置了 turnSwitchOn()calldata
    • 68 字节及之后,放置了 turnSwitchOff() 的函数选择器 (offSelector)。

    这样一来:

    • Switch 合约收到 calldata,发现是 flipSwitch 函数。
    • onlyOff 修饰符被激活。
    • assembly { calldatacopy(selector, 68, 4) } 读取 calldata 中偏移量 68 的 4 字节,这正是 turnSwitchOff() 的函数选择器 (offSelector)。
    • require(selector[0] == offSelector) 通过。
    • flipSwitch 函数被执行。
    • address(this).call(_data) 被调用,其中 _data 就是 turnSwitchOn()calldata
    • turnSwitchOn() 被执行,switchOn 变为 true

    结论:

    这个题目巧妙地利用了 calldata 的解析规则和 onlyOff 修饰符的逻辑漏洞。Hack.sol 构造了一个 calldata,该 calldata 既能触发 Switch 合约的 flipSwitch 函数,又能满足 onlyOff 的检查(通过在 calldata 中偏移量 68 的位置放置 turnSwitchOff() 的函数选择器),同时将 turnSwitchOn()calldata 作为 flipSwitch 函数的参数传递,最终实现目标。

实战演练:

当你部署 Hack.sol 合约,并传入 Switch 合约的地址时,Hack.sol 的构造函数就会执行 address(target).call(data)。这个 call 操作将 Hack.sol 构造的 data 作为 calldata 发送给 Switch 合约,从而触发了上述的攻击流程,成功将 switchOn 状态拨动到 true

学习价值:

  • 深入理解 calldata 编码和 ABI 解析:了解函数选择器、参数编码等细节至关重要。
  • 掌握 call 操作的风险call 操作虽然强大,但若不慎使用,可能导致意想不到的漏洞。
  • 识别修饰符中的逻辑漏洞onlyOff 修饰符中对 calldata 的偏移量检查,成为了被攻击的点。
  • 学习构造复杂的 calldata:能够根据需求构造特定的 calldata 是进行智能合约安全审计和渗透的重要技能。

“Switch”关卡,不仅是对智能合约知识的考察,更是对细致入微的观察力和逻辑推理能力的考验。希望这次深入的解析,能让你对以太坊智能合约的安全有更深的理解!

Built with AiAda