一、漏洞一
contract Lib {
address public owner;
function pwn() public {
owner = msg.sender;
}
}
contract HackMe {
address public owner;
Lib public lib;
constructor(Lib _lib) {
owner = msg.sender;
lib = Lib(_lib);
}
fallback() external payable {
address(lib).delegatecall(msg.data);
}
}
contract Attack {
address public hackMe;
constructor(address _hackMe) {
hackMe = _hackMe;
}
function attack() public {
hackMe.call(abi.encodeWithSignature("pwn()"));
}
}
我们创建了一个库合约Lib
,这里面的owner变量是一种形式变量,并不参与实际的运算,仅仅用来占用storage内存的slot位置,只有当内存位置和使用库合约的合约位置相同,才能用于delegatecall。
库合约的功能很简单,就是给owner设置为msg.sender。应用合约HackMe
,在fallback中提供了delegatecall的用法,可以对传入的msg.data
进行调用。
在攻击合约Attack
中,我们给pwn()
函数签名作为msg.data传了过去,HackMe合约拿到后,经过了fallback,然后又用库合约执行pwn()
,执行完,HackMe
合约上的owner值就变成了Attack的合约地址了。
试想一下,如果这个owner值是开放关键权限的一个变量的话,如果被人随意修改,最后就会导致资产的流失。
二、漏洞二
delegatecall还有一个危险的地方,就是如果开发者没有真正理解delegatecall和内存布局的关系的话,很容易发生一些危险的行为。一旦库合约的变量顺序和应用合约的变量顺序不统一时,内存布局就是不统一的:
contract Lib {
address public owner;
...
}
contract HackMe {
Lib public lib;
address public owner;
...
}
比如上面的合约,如果我们HackMe
的合约中lib和owner的顺序颠倒了,此时slot0的变量就是lib,而库合约Lib
的slot0的位置还是owner。此时如果进行delegatecall调用lib中的函数的话,那么最后HackMe中被修改的其实不是owner,而是lib。
试想一下,如果是一些其他涉及资产逻辑的变量可以被修改的话,那么整个合约最后就会被hack得面目全非。
三、预防手段
使用library
关键字创立库函数。这确保了库合约是无状态(Stateless)且不可自毁的。强制让 library 成为无状态的,可以缓解本节所述的存储环境的复杂性。无状态库也可以防止攻击者直接修改库状态的攻击,以实现依赖库代码的合约。作为一般的经验法则,在使用时delegatecall
时要特别注意库合约和调用合约的可能调用上下文,并且尽可能构建无状态库。
在我们之前的文章《Solidity中library的机制和内幕》中,有这么一段话:
当library因为有public而单独部署时,相比proxy pattern,都是利用另一个合约承载逻辑,但方式不同,一个是利用存储布局,一个是直接传递storage的引用,上下文变量都保持在调用者一边。调用者以delegatecall调用,由于library没有成员,被调用者只操作传入的参数,因此delegatecall不是像proxy pattern中的那样通过兼容存储布局利用另一合约逻辑的作用,而是通过操作storage属性的参数利用另一合约的逻辑。
四、真实世界示例:Parity Multisig Wallet(Second Hack)
Parity 多签名钱包第二次被黑事件是一个例子,说明了如果在非预期的环境中运行,良好的库代码也可以被利用。
我们来看看这个合约的相关方面。这里有两个包含利益的合约,库合约和钱包合约。
先看 library 合约,
contract WalletLibrary is WalletEvents {
...
// throw unless the contract is not yet initialized.
modifier only_uninitialized { if (m_numOwners > 0) throw; _; }
// constructor - just pass on the owner array to the multiowned and
// the limit to daylimit
function initWallet(address[] _owners, uint _required, uint _daylimit) only_uninitialized {
initDaylimit(_daylimit);
initMultiowned(_owners, _required);
}
// kills the contract sending everything to ` _to ` .
function kill(address _to) onlymanyowners(sha3(msg.data)) external {
suicide(_to);
}
...
}
再看钱包合约,
contract Wallet is WalletEvents {
...
// METHODS
// gets called when no other function matches
function() payable {
// just being sent some cash?
if (msg.value > 0)
Deposit(msg.sender, msg.value);
else if (msg.data.length > 0)
_walletLibrary.delegatecall(msg.data);
}
...
// FIELDS
address constant _walletLibrary = 0xcafecafecafecafecafecafecafecafecafecafe;
}
请注意,Wallet 合约基本上会通过 delegate call 将所有调用传递给 WalletLibrary。此代码段中的常量地址 _walletLibrary
,即是实际部署的 WalletLibrary 合约的占位符(位于 0x863DF6BFa4469f3ead0bE8f9F2AAE51c91A907b4
)。
这些合约的预期运作是生成一个简单的可低成本部署的 Wallet 合约,合约的代码基础和主要功能都在 WalletLibrary 合约中。不幸的是,WalletLibrary 合约本身就是一个合约,并保持它自己的状态。你能能不能看出为什么这会是一个问题?
因为有可能向 WalletLibrary 合约本身发送调用请求。具体来说,WalletLibrary 合约可以初始化,并被用户拥有。一个用户通过调用 WalletLibrary 中的 initWallet()
函数,成为了 Library 合约的所有者。同一个用户,随后调用 kill()
功能。因为用户是 Library 合约的所有者,所以修改传入、Library 合约自毁。因为所有现存的 Wallet 合约都引用该 Library 合约,并且不包含更改引用的方法,因此其所有功能(包括取回 Ether 的功能)都会随 WalletLibrary 合约一起丢失。更直接地说,这种类型的 Parity 多签名钱包中的所有以太都会立即丢失或者说永久不可恢复。