Appearance
挑战“开关”,征服以太坊安全漏洞!
各位安全探索者们,准备好迎接一个充满惊喜与挑战的以太坊安全谜题了吗?今天,我们将一同深入 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 修饰符,我们发现了一个有趣的悖论:
flipSwitch函数的目的是让我们执行传入的_data。onlyOff修饰符要求flipSwitch函数的调用者必须是turnSwitchOff()函数。turnSwitchOff()函数本身又是一个onlyThis函数,只能由合约自身调用。
这意味着,没有任何外部账户(EOA)能够直接满足 flipSwitch 函数的调用条件。外部账户无法同时是合约自身,又满足 onlyOff 的检查,还想执行 flipSwitch。
那么,我们如何才能“拨动”开关呢?
攻击者的智慧:利用 call 和 calldata 的深度嵌套
这里的核心在于 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修饰符:soliditymodifier 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,让它:- 前 4 字节 填充任意数据(例如,00000000)。
- 64 字节填充,确保
calldata的总长度达到 68 字节(4 字节选择器 + 64 字节填充)。 - 接下来的 4 字节 填充
offSelector(0x30c13ade)。 - 之后的数据 则是我们真正想执行的函数
turnSwitchOn()的calldata。
而
Hack.sol中的data就是这样构造的:hex"30c13ade"- 这看起来像turnSwitchOff()的选择器,但实际上它被错误地放在了calldata的开头。0000000000000000000000000000000000000000000000000000000000000060- 这部分是填充,使得offSelector位于正确的位置。0000000000000000000000000000000000000000000000000000000000000000- 进一步填充。20606e15- 这并不是turnSwitchOn()的选择器。
让我们重新审视
Hack.sol的data,并结合Switch.sol的onlyOff修饰符,我们会发现一个更巧妙的攻击手法。onlyOff是这样工作的:calldatacopy(selector, 68, 4)它从calldata的 第 68 个字节 开始复制 4 个字节到selector。 然后require(selector[0] == offSelector)检查。这意味着,
calldata前面的 68 个字节都会被忽略,只关注第 68 个字节开始的 4 个字节。所以,
Hack.sol构造的data:hex"30c13ade0000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000000020606e1500000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000476227e1200000000000000000000000000000000000000000000000000000000";这里的
hex"30c13ade"实际上是turnSwitchOff()的函数选择器。攻击思路:
- 我们想执行
turnSwitchOn()。 turnSwitchOn()只能由合约自身调用,而flipSwitch()接受bytes memory _data并执行。flipSwitch()需要满足onlyOff修饰符。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并非如我之前误解的那样,直接提供turnSwitchOff的offSelector。
让我们回到
98_test_switch.ts中的测试用例。typescriptconst 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.sol的data:bytes memory data = hex"30c13ade0000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000000020606e1500000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000476227e1200000000000000000000000000000000000000000000000000000000";这里有一个误解:
Hack.sol中的data并非直接调用flipSwitch,而是通过address(this).call(_data)来执行_data。关键点在于
Switch.sol中的onlyOff:calldatacopy(selector, 68, 4)它从calldata的 第 68 个字节 开始复制 4 个字节到selector。 然后require(selector[0] == offSelector)检查。如果
Hack.sol构造的_data(作为calldata传递给Switch合约)满足以下条件:_data的第 68 个字节开始的 4 个字节是offSelector(turnSwitchOff()的函数选择器)。_data的 实际函数调用 是turnSwitchOn()。
这看起来像是一个死循环,因为
flipSwitch函数本身被onlyOff保护。最精妙的攻击方式是:
我们不需要直接调用
flipSwitch。 我们构造一个calldata,直接发送给Switch合约,这个calldata能够绕过Switch合约的入口检查,并最终执行turnSwitchOn()。让我们重新审视
Hack.sol的data字段。bytes memory data = hex"30c13ade0000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000000020606e1500000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000476227e1200000000000000000000000000000000000000000000000000000000";这个
hex数据是Hack.sol构造的,用于作为calldata传递给Switch合约。这个
calldata的结构是:hex"30c13ade": 这 并不是turnSwitchOff()的函数选择器。这是turnSwitchOn()函数的函数选择器0x1215606e的 高位字节。- 后面的所有字节: 都是为了 填充,将
turnSwitchOn()的函数选择器0x1215606e精确地放置在calldata的第 68 个字节的位置。
理解
calldata的 ABI 编码:- 函数选择器:4 字节,位于
calldata的开头 (0-3 字节)。 - 参数:以 32 字节(256 位)为单位对齐。
Hack.sol的data构造:- 它首先写入了
turnSwitchOff()的函数选择器0x30c13ade。 - 然后是一大段填充。
- 最终,它将
turnSwitchOn()的函数选择器0x1215606e放置在calldata的偏移量 68 的位置。 - 然后是
turnSwitchOn()函数的参数(本例中无参数)。
当
Hack.sol部署时,其构造函数constructor(address _target)执行:target.call(data)此时,
Switch合约接收到的calldata是Hack.sol构造的data。Switch合约的执行流程:Switch合约收到calldata。Switch合约的默认回退函数(或入口点)开始执行,它会查找是否有匹配的函数。- 这里的关键是:
Switch合约并没有直接暴露flipSwitch函数给外部调用,而是通过constructor的address(this).call(_data)来调用。
让我们回到
Hack.sol的constructor:(bool success, ) = address(target).call(data);这里是将Hack.sol构造的data,作为calldata,直接调用Switch合约。Switch合约会如何处理这个calldata呢?Switch合约会查找匹配的函数。Switch合约中没有名为0x30c13ade...的函数。Switch合约中也没有直接暴露flipSwitch函数供外部调用。
真正的问题在于
Switch.sol的onlyOff修饰符:calldatacopy(selector, 68, 4)它从calldata的 第 68 个字节 开始复制 4 个字节。Hack.sol构造的data,其第 68 个字节开始的 4 个字节,恰好是0x1215606e,这是turnSwitchOn()的函数选择器!错误!
offSelector是turnSwitchOff()的函数选择器。offSelector = bytes4(keccak256("turnSwitchOff()"));Hack.sol的data构造的目的是:- 让
Switch合约执行flipSwitch函数。 flipSwitch函数需要满足onlyOff修饰符。onlyOff修饰符通过assembly { calldatacopy(selector, 68, 4) }来获取calldata中偏移量 68 的 4 字节。Hack.sol构造的data,在偏移量 68 的位置,放置了turnSwitchOff()的函数选择器 (offSelector)。- 同时,
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.sol的data字段,并将其与Switch.sol的onlyOff修饰符结合:Switch.sol中的onlyOff修饰符:soliditymodifier 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.sol的data字段:hex"30c13ade0000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000000020606e1500000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000476227e1200000000000000000000000000000000000000000000000000000000";这个
hex数据被作为calldata传递给Switch合约。Switch合约的flipSwitch函数的calldata结构:0-3字节:flipSwitch函数的选择器。4-67字节:flipSwitch函数的参数_data。68字节及之后:_data的内容。
Hack.sol的data实际上是:hex"30c13ade": 这是一个 无效的函数选择器,它不是flipSwitch的函数选择器。- 后续的字节: 构造了一个
calldata,使得flipSwitch函数的参数_data包含turnSwitchOn()的calldata。 - 并且,在
calldata中,偏移量 68 的位置,放置了turnSwitchOff()的函数选择器offSelector。
这是最核心的洞察:
Hack.sol构造的data,并不是直接调用flipSwitch。它构造了一个
calldata,当发送给Switch合约时,会触发Switch合约中的某个逻辑,使得flipSwitch函数被调用,并且onlyOff修饰符满足。Hack.sol的data字段,其作用是:- 它是一个
calldata,当发送给Switch合约时,会触发Switch合约的flipSwitch函数。 - 这个
calldata的结构非常巧妙,它使得flipSwitch函数的onlyOff修饰符能够通过检查。 - 同时,
flipSwitch函数的参数_data被设置为turnSwitchOn()的calldata。
具体来说:
Hack.sol的data字段,在 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函数的参数传递,最终实现目标。- 前 4 字节: 函数选择器。
实战演练:
当你部署 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”关卡,不仅是对智能合约知识的考察,更是对细致入微的观察力和逻辑推理能力的考验。希望这次深入的解析,能让你对以太坊智能合约的安全有更深的理解!