Appearance
智能合约的“身份欺骗”:当“锁”轻易被打开
在数字化浪潮席卷的今天,智能合约正以前所未有的方式改变着我们的生活,尤其是在安全领域。想象一下,一把由以太坊区块链驱动的智能门锁,它的开启权限由一段加密的签名来验证——这听起来安全又便捷。SlockDotIt 公司推出的 ECLocker 产品正是这样一个创新,它将物联网门锁与 Solidity 智能合约深度融合,利用以太坊的 ECDSA(椭圆曲线数字签名算法)进行授权。当一个有效的签名被发送到门锁时,系统就会触发一个“Open”事件,为授权的控制者打开大门。
然而,在产品正式发布前,安全评估是必不可少的一环。今天,我们将带你深入探索一个名为“Impersonator”的 CTF(Capture The Flag)题目,看看到底存在怎样的安全隐患,能让任何人都轻易地打开这扇“数字锁”。
漏洞揭秘:签名验证的“软肋”
题目背景:
我们的任务是评估 SlockDotIt 的 ECLocker 产品安全性,目标是找到一种方法,能够让任何人都打开门锁,而不仅仅是授权的控制器。
核心技术:
ECLocker 锁利用了以太坊的签名机制。当一个锁被部署时,它会接收一个由初始控制器签名的消息。这个签名包含了与锁 ID 相关联的特定信息。为了验证签名,ECLocker 依赖于以太坊的 ecrecover 函数,它可以根据消息哈希和签名(v, r, s)来恢复出签名者的地址。
问题出在哪里?
表面上看,ecrecover 函数似乎是安全的。但问题往往藏在细节里。在 ECLocker 合约的 _isValidSignature 函数中,我们看到了这样的逻辑:
- 签名恢复与验证:
address _address = ecrecover(msgHash, v, r, s);这一步会尝试从提供的签名中恢复出地址。 - 控制器比对:
require (_address == controller, InvalidController());随后,会比对恢复出的地址是否与当前锁的controller存储变量匹配。 - 防止重放攻击:
require (!usedSignatures[signatureHash], SignatureAlreadyUsed());并且,它会检查这个签名是否已经被使用过,以防止同一签名被重复用于解锁。
致命的逻辑缺陷:
问题就出在 _isValidSignature 函数的调用方式上。在 changeController 函数中,它会调用 _isValidSignature。这意味着,任何能够提供一个对当前 controller 有效的签名的人,都可以更改锁的 controller 为任意地址!
更糟糕的是,在 ECLocker 的构造函数 (constructor) 中,我们发现 controller 的初始化存在一个巧妙的“陷阱”。
solidity
// ...
address initialController = address(1); //<<<<< 注意这里
assembly {
// ... 签名恢复逻辑 ...
initialController := mload(0x00)
// ...
}
// ...
controller = initialController;
// ...
这里,initialController 在 assembly 代码块 之前 被初始化为 address(1)。而 assembly 代码块中的 ecrecover 调用,如果签名无效(例如,r, s, v 都是零),是不会直接 revert 的,而是返回一个默认地址(例如 address(0) 或 address(1),取决于具体实现和 Solidity 版本)。
结合以上两点,攻击链就形成了:
- 部署锁时,构造函数会恢复签名。 如果我们提供一个无效的签名(比如
v=0, r=0, s=0),ecrecover可能会返回address(1)。 changeController函数允许使用一个有效的签名来更改controller。 关键在于,_isValidSignature函数在验证签名时,只关心签名是否恢复出 某个 地址,并将其与controller变量进行比对。 它并没有在changeController函数中进行额外的检查,确保newController必须是当前的controller。- 然而,更直接的攻击方式是利用
open函数。open函数也调用了_isValidSignature。如果我们能找到一个签名,经过ecrecover后恢复出的地址,正好是当前controller的值,那么这个签名就是有效的。
但 CTF 的目标是“任何人都可以打开门”,这意味着我们需要一个更普适的方法。
真正的漏洞点在于 changeController 函数的调用。 在 ECLocker 的构造函数中,initialController 的值是通过 ecrecover 计算出来的。即使 ecrecover 恢复出的是一个非法的地址(例如,因为签名本身就是无效的),changeController 函数允许我们使用一个有效的签名去更改 controller!
攻击思路:
攻击者可以找到一个任意有效的签名,假设这个签名恢复出的地址是 A。通过调用 changeController(v, r, s, attacker_address),如果 A 恰好是当前 controller 的值,那么 controller 就会被更改为 attacker_address。
但是,CTF 中的目标是“任何人都可以打开门”。 这意味着我们不应该依赖于一个特定的“当前 controller”来完成攻击。
核心洞察:
Impersonator 合约在部署新的 ECLocker 时,它会将一个 signature(签名)参数传递给新部署的 ECLocker 构造函数。
solidity
function deployNewLock(bytes memory signature) public onlyOwner {
ECLocker newLock = new ECLocker(++lockCounter, signature);
lockers.push(newLock);
emit NewLock(address(newLock), lockCounter, block.timestamp, signature);
}
这个 signature 参数被直接传递给了 ECLocker 的构造函数,用于初始化 controller。
漏洞的真正根源:
ECLocker 合约中的 _isValidSignature 函数,在验证签名时,并没有检查 msgHash 是否是当前锁的 msgHash!
solidity
function _isValidSignature(uint8 v, bytes32 r, bytes32 s) internal returns (address) {
address _address = ecrecover(msgHash, v, r, s); // <<< 这里使用 msgHash
require (_address == controller, InvalidController());
// ...
}
这个 msgHash 是在 ECLocker 构造函数中计算的,它与 lockId 相关。
关键点:
Impersonator合约在部署新锁时,会给新锁的构造函数传递一个signature。ECLocker的构造函数会使用这个signature来计算msgHash。_isValidSignature函数使用 锁自身计算出的msgHash来恢复地址。changeController和open函数都调用_isValidSignature。
攻击路径:
攻击者可以通过 Impersonator 合约部署一个新的 ECLocker。在部署时,攻击者需要提供一个 特定的签名。这个签名,当用目标锁的 lockId(而非新锁的 lockId)进行 ecrecover 时,能够恢复出 目标锁的 controller。
CTF 题目中的 Hack.sol 攻击合约,正是利用了这一点。它通过 target.lockers(0) 获得了第一个部署的锁(locker0)。然后,它计算了一个特殊的 signature,使得 ecrecover 能够恢复出 locker0 的 controller。
更巧妙的是,Hack.sol 并没有直接调用 open,而是调用了 locker0.changeController(28, r, s28, address(0));。这里的 address(0) 是攻击者希望设置的新 controller。
这里出现了一个小误解,根据提供的 Hack.sol 代码,攻击方式是:
Hack.sol 并不直接生成一个能恢复出目标 controller 的签名。而是通过 Impersonator 合约,部署了一个新的 ECLocker。关键在于,Impersonator 合约在调用 new ECLocker(++lockCounter, signature) 时,传入的 signature 参数,被 ECLocker 的构造函数用来计算 msgHash。
Hack.sol 的真实攻击逻辑:
Hack.sol部署了一个Impersonator合约(假设为target)。Hack.sol调用target.lockers(0)获取到第一个ECLocker。Hack.sol精心构造了一个签名 (r,s27,v=27)。这个签名,当与 第一个ECLocker的lockId(即target.lockers(0).lockId,通常是 1)一起进行ecrecover时,会恢复出 第一个ECLocker的controller。Hack.sol接着调用locker0.changeController(28, r, s28, address(0));。- 这里的
v=28和s28是为了构造一个有效的签名。 r是Hack.sol中计算出的 r 值。s28是对s27的处理,使其与v=28匹配。address(0)是攻击者想要设置的新controller。
- 这里的
- 问题在于
changeController函数。 它会调用_isValidSignature。如果Hack.sol构造的签名(v=28, r, s28)恢复出的地址 正好等于locker0当前的controller,那么changeController就会成功执行,将controller改为address(0)。 - 最后,
Hack.sol调用locker0.open(0, 0, 0);。由于controller已经变成了address(0),并且open函数对v=0, r=0, s=0的调用,如果_isValidSignature能够正确执行(即使ecrecover返回address(0)并且usedSignatures[keccak256(abi.encode([uint256(r), uint256(s), uint256(v)]))]此时是 false),就能打开锁。
CTF 解决方案 Hack.sol 的解读:
Hack.sol 提供的 signature 并不是直接用来绕过 ecrecover 的,而是用来欺骗 ECLocker 的 changeController 函数。
target.lockers(0)获取第一个部署的锁。target.lockers(0).changeController(28, r, s28, address(0));这一行是关键。28, r, s28是一个精心构造的签名。address(0)是攻击者想要设置的新controller。
- 这里的“秘密”在于,
changeController函数调用_isValidSignature。如果_isValidSignature能够成功执行(即ecrecover恢复出的地址等于locker0当前的controller,并且签名未被使用),那么controller就会被更改。 locker0.open(0, 0, 0);这一步是在controller被成功更改为address(0)之后执行的。因为controller已经变成了address(0),并且open函数接受(0, 0, 0)作为参数,这会使得_isValidSignature函数调用ecrecover(msgHash, 0, 0, 0)。如果msgHash经过ecrecover恢复出的地址也是address(0),那么open就会成功。
最终的结论:
这个 CTF 题目利用了 ECLocker 合约中,_isValidSignature 函数验证签名的逻辑漏洞。通过 changeController 函数,攻击者可以利用一个合法的签名(恢复出的地址等于当前 controller)来将 controller 设置为 address(0)。之后,再调用 open 函数,使用一个特殊的签名 (0, 0, 0)(如果 ecrecover(msgHash, 0, 0, 0) 恢复出 address(0)),即可绕过验证,打开门锁。
这个漏洞的核心在于,changeController 函数的验证机制允许任何一个知道如何生成有效签名来冒充当前控制器的人,将控制权转移给攻击者。而 open 函数的调用方式,则进一步简化了攻击的最后一步。
如何防范?
- 严格的
changeController验证: 在changeController函数中,应确保新controller的地址不为address(0),并且增加对新controller的必要验证。 - 完善的签名验证: 确保
_isValidSignature函数在验证签名时,不仅检查签名本身,还检查该签名是否被恰当地与锁的lockId和msgHash关联。 - 最小权限原则: 限制
controller的更改权限,避免任何人都能轻易获取控制权。
这个“Impersonator”题目生动地展示了智能合约安全中签名验证的重要性,以及一个细微的逻辑错误可能带来的严重后果。在构建安全可靠的智能合约系统时,开发者必须时刻保持警惕,仔细审查每一个环节。