一篇简单的智能合约学习随想,遇到什么写什么,所以什么都有可能提到,应该会很乱
目前都在学Solidity,至于Yul,Move和Rust还没有学过,慢慢&随缘更新(当然欢迎催更)
由于博客对Solidity代码块的着色问题,所有Solidity代码块的着色规则都选用的是Javascript
智能合约审计随想
这部分就是一些智能合约的漏洞的分析、攻击方法和规避方法(可能会有),来源很多,从比赛/靶场的题目到某管上的视频、从各种论坛到Github上的电子文档,咱只是负责搬运并以自己的理解描述出来而已,大佬们轻点喷QAQ
1. 整型溢出
我个人习惯叫这种漏洞为“油量表”,因为它确实和油量表蛮像,下面细说:
由于Solidity语言不支持小数,所以我们一般见到的1 Token在数值上一般是1e18。举个例子:有了解过以太币换算的应该都知道1ETH=$10^{18}$ wei,而wei是最小计数单位(ETH和wei中间还有个Gwei,1ETH=$10^9$Gwei,1Gwei=$10^9$wei)
好了扯得有点远,回到题目,我们假设在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):
1 | // SPDX-License-Identifier: MIT |
就是一个简化的代币合约,构造函数铸造代币,有个转账功能,一个查看用户余额的函数
正常来说,转账里面那个require应该是没什么问题的,很符合逻辑,毕竟我有20块不可能给别人21块吧(笑),但是对于0.6及更低版本的Solidity就完蛋了,比如这里的uint应该是会自动转换成uint256(多少我忘了,反正蛮大,就先按256写吧),那么20-21得到的结果实际是$2^{256}$-1,非常幽默,我给别人转账,他拿钱了,我也拿钱了,我们都有美好的未来
不过这个漏洞仅在<=0.6版本下有效,而且合约里如果使用了OpenZeppelin的SafeMath.sol,同样无法通过此漏洞攻击
需要注意的是,现在整型上溢或者下溢会导致revert,而攻击者可能会利用这一点进行DoS攻击
1.1 显式类型转换导致的数据截断
虽然现在对uint256而言已经是不能进行上/下溢操作了,但是在编写智能合约的过程中,开发者仍旧可能出现一点“小小的失误”,例如:
1 | function test(uint256 _input) public{ |
这种将范围较大的数据类型截断为较小数据类型的操作很容易导致由截断产生的数据丢失,比如这里如果我们的_input
是2的192次方,最后得到的some_data
实际上是0,原理其实就是和整型上溢是一样的,而这可能会带来一些未曾预想过的结果,因此在编写智能合约的时候,尽量避免将范围较大的数据类型截断为范围较小的数据类型,如果确实需要这么做,最好添加一点数据校验
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()来判断一个地址是用户地址还是合约地址:如果存在和地址相关联的代码,那么它就是合约地址;反之则是用户地址
所以我们可能会遇到
1 | assembly{size := extcodesize(_addr)} |
这样判断用户/合约地址的代码
但是!我们先讲讲其他东西:
2.2.1 bytecode & deployedBytecode
我们一般提到bytecode,都是在合约被发送到以太坊节点进行合约创建的时候,但是这个deployedBytecode是什么鬼?
看名字,后者是“已部署的(合约的)字节码”,那么可以合理猜测它会在每次合约被调用的时候执行;而前者是合约首次被创建的时候执行的. 两者区别其实就只有一个:是否含有与构造函数相关的字节码,至于谁有谁没有,估计都知道了吧(笑)
解释一下,由于构造函数只会被执行一次,那么这段代码在之后的执行无用,所以以太坊节点没必要保留相关的字节码,所以以太坊节点上保留的是deployedBytecode,而非bytecode
好!回到extcodesize,考虑到节点保存的是deployedBytecode,那么肯定是执行完构造函数,剩下的代码才会被节点保存,对吧?
那么假如我们在构造函数里面进行调用,由于此时链上没有保存这个合约的相关代码,所以extcodesize返回的值为0,和对用户地址执行的结果一致,所以我们就很成功地绕过了这一步!
那么我们到底应该如何分辨用户地址和合约地址?还记得msg.sender和tx.origin的关系吗?如果我们只允许用户地址调用函数,那么我们就在函数里面require(msg.sender == tx.origin)就好了,反之就不等于
放个自己在社团23届新生赛出的最后一题(没人做QAQ):
1 | //SPDX-License-Identifier:MIT |
WTH要求被修饰的函数必须由合约调用,而WTF要求调用者的extcodesize值为0,所以我们在攻击合约的构造函数里面调用setFlag就行了!
1 | //SPDX-License-Identifier:MIT |
⚠于2025年3月即将进行的Pectra升级中,一个新的EIP(EIP-7702)将被正式启用,这可能会让EOA地址临时拥有字节码,从而导致上述的extcodesize检验失效或不准确
3. 构造函数命名错误(Solidity <=0.6特性)
我一直都把学习Solidity当作在学习面向对象编程(主要是合约真的太像类了)
在0.6及以下版本,Solidity的构造函数是和合约名字一致的,也就是你有一个contract Clwk9,那么你的构造函数就应该是function Clwk9()
但是就和我举的例子一样,假如这个函数因为有些字符难区分导致打错了,比如从Cl(小写L)wk9写成了CI(大写i)wk9,那么这个函数就变成了一个普通的函数. 又假如这个函数有些敏感的东西,比如我很喜欢在构造函数里面加的owner=msg.sender,那么这可能会导致极其严重的后果
不过和上面的整型溢出一样,这个也是0.6及以下版本的漏洞,现在大伙都用constructor()了(如果你用Remix IDE的话构造函数不用constructor会直接给你上红色波浪线www)
4. (伪)随机数预测(操控)
假如我们要写一个抛硬币的合约,那么我们需要找到一个随机数来保证其随机性,而在以太坊里面我们一般用keccak256算法来生成一个值,并把这个值作为随机数使用,至于参数可能是各种东西,比如区块的时间戳,上面提到的msg.sender,区块数之类的,这里我们假设这个随机数为:
1 | random_num = uint256(keccak256(abi.encodePacked(tx.origin, block.timestamp))) |
看起来很吓人,对吗?但是不用担心,因为区块链它的公开性,绝大部分的数据是可确认或者可控的,比如这里的时间戳就是确定的,而看似模糊的tx.origin其实也是可控的:假如我们用的是一个合约来攻击它,那这里的tx.origin不就是我们自己的地址吗?
所以在这种看似随机的结果背后,其实结果都是可操控的,基本上可以以此达到连胜的结果
我们这里拿Geekchallenge2023的区块链压轴题(stage)举例:
1 | ///SPDX-License-Identifier: MIT |
这里主要说stage2,stage1上面已经讲过了,stage3下面会讲
stage2要求我们猜对一个随机数,这个“随机数”是由区块时间戳对100求余然后+1得到的
所以非常简单,我们按它的要求做:(这里只给stage2的核心解题代码)
1 | function hack() public{ |
按照合约构造出随机数然后传进去就行,说白了这就是复制粘贴的事,没啥难度
如果合约确实需要生成随机数,推荐使用Chainlink VRF,目前是出到v2了
5. call,staticcall,multicall和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的核心代码就出来了:
1 | function getNumber() external payable returns(uint256){ |
1 | ///SPDX-License-Identifier: MIT |
既然都提到fallback()了,那就简单讲一下:
5.2 fallback,receive
当智能合约被调用了一个无法正确匹配到的函数,或者合约在未被提供数据的时候被外部转入ETH的时候,fallback()内的语句会被执行
后半句我们先不谈,前半句应该还是很好理解的,就比如参数的个数类型出错、名字拼写错误、没有找到对应名字的函数,这些都会导致fallback的执行
receive()就和它的名字一样,当合约接收到外部转账的时候,receive()内的语句会被执行. 这里的“外部转账”就是直接转帐给合约地址,操作就和用户之间转账一样,每个钱包都能实现(填写转账地址的时候,那个地址可以填写用户地址,也可以填写合约地址)
再回到fallback,如果一个合约接收到外部转账,而且合约没有receive()的时候,就会执行fallback()内语句,后半句翻译一下就是这样
那假如合约既没有fallback(),又没有receive(),那么合约接收到外部转账就会回退
fallback还有种写法,就是没有函数名,也就是function () public {}
这样的
5.3 multicall
正如其名,multicall就是多重调用的意思,也就是说我在某一次交易中同时进行对多个不同地址上合约的调用,而想要达到这个目的其实很简单,我们只需要编写一个合约,里面有那么一个函数,函数传入的参数是一个address数组和一个bytes数组,address存放的是想要调用的地址,而bytes中则是对应地址的calldata数据,例如:
1 | // Original code at https://solidity-by-example.org/app/multi-call/ |
上面的代码使用的是staticcall,但是你可以自行替换为call或者之后我们要提到的delegatecall,根据自己的需求即可,因此如果你的Multicall合约只希望多次调用某一个特定地址的函数,你可以舍去address数组
5.4 delegatecall
好,让我们来到delegatecall,委托调用
什么是delegatecall?一句话:让你的代码在我的上下文里运行,上下文包括但不限于内存,msg.sender,msg.value等
还记得我们在msg.sender和tx.origin的链条吗?假如我们现在的链条变成了这样:
用户A→合约B→(delegatecall)→合约C
这里合约C中的msg.sender其实是A的地址,同时msg.value来源也是A,因为是合约B委托了合约C,所以C执行用的是B的数据
回到delegatecall,这个方法有两个攻击点:delegatecall会保留上下文、发起委托的合约和被委托的合约的存储排布必须一致
5.4.1 delegatecall的保留上下文
假如我们有这样一个合约:
1 | contract Hackme{ |
我们的目的是把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易主,而这就是“保留上下文”
那么根据上面所讲的,我们可以这样编写攻击合约:
1 | contract Attack{ |
这样我们的攻击合约就获取到了Hackme合约的所属权
5.4.2 存储排布不一致可能导致的问题
刚刚我们的Hackme中的第一个变量都是address public owner,存储排布是一致的,那么假如我们变量的顺序发生了改变,会导致什么呢?在进入这个之前我们先插句题外话,聊一下Solidity中storage,memory和calldata三者之间的关系:
5.4.2.1 storage,memory,calldata
我们先说memory和calldata. Memory,内存,一个存储临时数据的位置;Calldata,很明显是call产生的data,那么这个数据也很明显是临时的,也就是说calldata它也是一个存储临时数据的位置
两者的区分在于存储的数据是不一样的,memory存储的是在函数执行的时候会使用到的变量,可能是函数参数、局部变量之类的东西,而calldata存储的是外部调用者传进函数的参数
但是有一个问题:memory是可读可写的,但是calldata只可读,所以如果我们需要在函数执行的时候修改一个存放在calldata中的函数参数,那么我们需要先将这个参数复制到memory中再对memory的参数进行修改操作,比如下面的例子:
1 | function addOne(uint[] calldata numbers) public pure returns (uint[] memory) { |
因为我们需要修改numbers中的数据,所以我们需要先将numbers复制到memory中的newNumbers里再修改
刚刚我们提到的都是存储临时数据的位置,那么相对的我们需要一个永久存放数据的位置,而这就是存储:storage. 它里面的数据能被合约中的任意函数访问并修改
5.4.2.2 Solidity合约中状态变量的存储排布
有了上面有关三种存储数据的位置的基础知识,我们现在再说说Solidity中存储(storage)里面状态变量是如何存储的,方便之后理解delegatecall的第二个攻击点
在Solidity中,一个合约的存储空间会被分成$2^{256}$个32字节的存储槽,这些槽可以简单理解成一个数组,其索引从0开始,且所有存储槽一开始都会被初始化为0
状态变量的存储如下图所示:

所以可以简单理解为我每个状态变量按照存储槽的索引顺序一一放入
当然,Solidity存储其它变量的方式也会有所不同,详细的可以看这篇文章:What is Smart Contract Storage Layout? (alchemy.com)
让我们回到delegatecall的第2个攻击点,假如我们有下面两个合约:
1 | contract Lib{ |
很明显,我们的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
于是我们可以编写下面的攻击合约:
1 | contract Attack{ |
详细拆解一下:首先我们复制粘贴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:
1 | // 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 |
要求一周内没有提过款,且提款金额也被限制的很好,看起来无懈可击,不是吗?
但是如果我告诉你漏洞在第17行的msg.sender.call.value(_weiToWithdraw)()
呢?
我们来看攻击合约:
1 | // 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 |
直接这样看我们可能不太能理解,那就让我们分析一下代码的执行路径吧:
首先,向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合约:
1 | // 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 |
我们添加了一个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出现了一点小小的问题,这里先给漏洞合约和攻击合约:
1 | contract Example { |
爹地是在漏洞合约存了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
后记:

6.1.3 间幕——有关调用栈攻击的一些简短的废话
其实上面的攻击失败还有种可能,就是调用栈溢出,但是由于没到栈最大深度,所以上面就没提到,不过这里就简单展开说下好了
Solidity存在表达式栈和调用栈(两者并不相关),在Remix VM上调试的时候就有看见函数的调用栈是如何的,两个栈的最大深度都是1024,而超过了这个值Solidity就会抛出异常,所以还有一种叫调用栈攻击的潜在漏洞,就是与合约交互前提前消耗大量调用栈,最后使得交易或者函数调用异常的攻击手段,但是2016年提出的EIP-150说明自从桔哨(Tangerine Whistle)硬分叉之后提出的63/64法则就让这种攻击不切实际
6.2 跨函数重进入
虽然上面的重进入哨兵能很有效地预防重进入,但是如果这个合约有多个函数共用一个状态变量的时候,就有可能产生跨函数重进入
直接这么说不太直观,我自己也没看懂,不过例子我还是能给出来的:
1 | // This code has been slightly modified, original post at https://scsfg.io/hackers/reentrancy/ |
这里的withdraw我稍微修改了一下,添加了一个哨兵,这样攻击者就无法在withdraw函数进行重进入攻击了(大概,主要担心改变哨兵状态太早或太晚可能会导致DoS),但是这个transfer…有点危险,假如我们的攻击合约长这样:
1 | contract Attack{ |
我们跟踪一下:假如我们的攻击合约一开始就已经有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)
6.4 跨链重进入(Under Construction)
虽然我知道有那种跨链的合约,但是这种重进入是真的不敢想,好像说目前在野的这类重进入还没有,总之先放个链接插个眼,等研究完之后再在博客详细写一下原理什么的
Cross-chain re-entrancy. Multiple contracts with different… | by Mateocesaroni | Medium
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就会报错. 但是有三种情况可以绕过上面的情况:
利用selfdestruct()向合约地址发送ether
合约仍旧可以接受Coinbase转账或挖矿奖励,攻击者可以进行PoW挖矿然后设定受害者合约,从而使合约强制收款
在合约创建之前提前获取其地址,然后向该地址进行转账,从而使合约强制收款
合约的地址是先对交易发送者的地址和交易的nonce进行RLP编码,然后进行keccak256哈希运算,取最右侧160位为地址,用一句Python代码形容就是:
1
2def mk_contract_address(sender, nonce):
return sha3(rlp.encode([normalize_address(sender), nonce]))[12:]
这里我们就只针对selfdestruct的攻击方法进行阐述
一般使用selfdestruct时,我们的受害者合约都会使用address(this).balance来进行一定的判断,比如下面这个合约:
1 | // SPDX-License-Identifier : MIT |
我们先分析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关:
1 | // Original code at https://ethernaut.openzeppelin.com/level/0x6DcE47e94Fa22F8E2d8A7FDf538602B1F86aBFd2 |
它通过Building接口调用其他合约中的isLastFloor函数,但是由于接口中的函数没有设定view,所以函数可以进行状态变量的改变,从而使得下面的goTo被调用的时候top可以被设定为true
当然不仅如此,即使是使用了view限制住了函数,我们仍然可以利用一些其他的函数判断输入并返回不同的值,比如gasleft()
正常情况下使用staticcall会使得gasleft()得到的结果比使用call多,所以可以设定一个基准值_value,当gasleft() > _value
的时候返回一个值,否则返回另一个值,不过确实不太方便,得不断调整_value,而且如果call的时候设定gas总量,那么这个方法也是不可行的
8.2.1 gasleft()
使用案例:冷访问/热访问
在此由衷感谢ChaMd5团队的q1ngying师傅:封神台CTF blockchain 美梦成真
EVM为了节省gas费用,会在编译的时候进行一些小的优化,其中一个就是冷/热访问(Cold/Warm Access)。根据Ethereum Yellow Paper后面的Appendix G,我们看到有以下的和变量/帐户访问相关的Opcode:
Name | Value | Description |
---|---|---|
$G_{coldsload}$ | 2100 | Cost of a cold storage access. |
$G_{coldaccountaccess}$ | 2600 | Cost of a cold account access. |
$G_{warmaccess}$ | 100 | Cost of a warm account or storage access. |
$G_{accesslistaddress}$ | 2400 | Cost of warming up an account with the access list. |
$G_{accessliststorage}$ | 1900 | Cost of warming up a storage with the access list. |
也就是说,我们初次访问一个地址的时候,需要消耗2600gas,而之后再去访问这个地址,我们就只需要花费100gas,因此我们可以利用这点,比对访问地址前后的gas剩余量,然后根据剩余量判断这个view函数是否被调用过,然后根据这一点返回不同的值
具体的案例的话可以直接看上面q1ngying师傅的Writeup,此处就不再举例了
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作为手续费
最终的手续费计算应该是这样的:
1 | fee = current_gas_price * gas_spent |
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
比如我们有个竞价的合约:
1 | // This code has been slightly modified, original post at https://stackoverflow.com/questions/66099356/a-security-issue-with-requiresend-in-solidity |
这是一个非常正常的竞价合约,价高者成为Leader,同时向上一个Leader返还他支付的钱
但是问题就出在第11行,如果能保证无法向currentLeader转账,那么是否就能一直保持currentLeader的所有权?
比如我们的攻击合约长这样:
1 | contract Attack{ |
那我们通过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的发生率肯定会大幅上升)
就比如你向所有资助者返还资金:
1 | // 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 |
如果是这样,假如你要发送的资助者人数众多,那么很有可能你操作时消耗的gas就超出了block gas limit,最终导致交易失败,而人数过多又导致这个函数每次被调用就一定会revert,最后就形成了一个DoS,所以原出处的代码在while的条件里面加了个gasleft()来预防这种情况的出现
10. 这是你吗?——签名延展以及签名复用
10.1 引子——Ethereum签名的原理简述
Ethereum中的签名有多个方案,而且到现在都没有确认一个公认统一的签名方案,但是基础的原理大伙还是统一了的:使用的是我们的ECDSA,即椭圆曲线数字签名算法(Elliptic Curve Digital Signature Algorithm),使用的曲线是SECP256k1曲线,这个曲线的方程是这样的:$y^2=x^3+7$,那么我们能很明显的知道这个曲线关于X轴对称(对于曲线上的点,给定任意的x,都有绝对值相等的两个y与其对应,初中难度的证明我就不证了)
OK,来到关键部分了:在ECDSA中,一个签名由一组值(r,s)
表示,其中r
是签名过程中计算出的公钥的X坐标,而这个点是由(x,y) = k * G
这样计算得到的,其中k
满足1 < k < n
(n
表示的是SECP256k1中规定的点的个数,即0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141
),而这里签名使用的k一般是通过私钥计算得到的;而G
则是我们所说的生成点,这个点使用SEC格式(高效加密标准,The Standards for Efficient Cryptography)进行表示的结果是0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798
;s
则是通过公式s = k^-1 * (e + d*r) mod n
计算得到的,其中e
表示hash(msg)
,而d
表示签名者私钥
但是还记得我们刚刚提到的对称性吗?只知道X坐标的话我们能得到2个合法的点,因此我们还需要一个额外的恢复值v
来决定签名者的公钥
说了这么多,最后关键的点就只有一句话:如果(r,s)
是一组合法的签名,那么根据SECP256k1的X轴对称性,(r, -s mod n)
也必然是一组合法的签名
10.2 签名延展 (Signature Malleability)
其实以太坊黄皮书在附录F中已经规定了一个合法的签名需要满足:0 < s < n/2+1, v∈{27,28}
,但是实际上EIP-2中仍然允许满足满足0 < s < n
的s
参与交易,而因此我们就有了签名延展这一可以被实现的操作
那么根据引子中的理论,我们能够有如下的Python脚本生成同一个私钥的两个合法签名:
1 | from web3 import Web3 |
这两个签名得到的vrs三个参数加上message_hash
传入ecrecover
,最后能得到的地址都是同一个地址,即签名者的地址,由此可能会导致一些未预料到的函数的二次调用,当然解决方法很简单,就是验证s
是否满足黄皮书中的条件,或者使用OpenZeppelin的ECDSA库中的recover
,里面已经帮你检查过了
10.3 签名复用
这个就是纯粹的逻辑漏洞:就好像一个JWT Token,如果不加上有效时间,那么我就能够不断利用这同一个Token;这里也是同理的,就是合约代码中规定的被签名的message
中缺少有效时间,但是并没有任何检查签名有效性的操作,这就可能导致攻击者使用同一个签名不断进行操作(比如提款)最终导致未预想过的损失
解决这个问题很简单,只需要设计签名的有效性验证操作就行了,只是需要注意验证操作还是不能出现逻辑漏洞(笑)
合约安全性提升随想(暂时不进行填坑)
博客搬到CSDN之后,AI在评论区说可以侧重一下这方面,这才发现我上面基本上讲的都是合约的漏洞攻击,所以这里就开一个新的大目录,写一下怎么避免上面的漏洞吧
-1. 紧跟时代的步伐
这段就算是整个板块的一个开场白了,正如小标题所示,请务必!紧跟!时代!
因为高版本会对很多低版本的漏洞与问题进行修复,其中一定会有一些低版本的漏洞在高版本下更难甚至无法实现(比如说最经典的整型溢出漏洞),所以请务必时刻关注有关Solidity版本的最新动态以及EIPS新提出的提案,有的时候它们能为你省下大量的安保措施所需要耗费的成本(比如gas之类的)
当然,还是要擦亮眼睛,毕竟也不是没有像EIP-1014这种提出CREATE2操作码那样的骚东西的,所以还是需要自己有一定的判断力
0. 温馨提示:不要相信任何人
区块链技术最大的特点就是它的去中心化,其实在我看来核心就是两个字:不信
不相信的倒不是区块链这个技术,而是人,技术这个东西从来就不是一个非黑即白非好即坏的东西,就像把刀,重点还是在使用这把“刀”的人上
智能合约,它其实也是个合约,终究还是人写的,指不定这个合约哪个“条款”就能把你宰到裤子都不剩,而且区块链上匿名性极强,你就知道一个地址,又不知道它背后的人是谁,想追究责任都找不到人
所以编写智能合约的时候,一定要贯彻这一点:不相信任何人和任何合约,只相信你自己,尽量遵从“检查-修改-交互”的模式(Check-Effect-Interaction Pattern)
0.1 一个简单的例子
我们这里拿Ethernaut的第21关举例子吧
1 | // SPDX-License-Identifier: MIT |
我这里设置了一个接口,依赖的是_buyer的price()函数,本意是根据_buyer提供的价格修改商品的价格,而且接口里的函数还使用了view,这下子_buyer合约连状态变量都不能修改了,只能返回一个值,多安全啊
但是即使_buyer不修改自己的状态变量,你Shop改了,本质上也是_buyer改了,因为我可以根据你Shop修改的状态变量来返回不同的值啊,就比如这题的攻击合约里面的price()函数:
1 | function price() external view returns(uint256){ |
你“检查”时的isSold是false,“修改”时的isSold是true,那我就根据这个返回不同的值就行了,所以上面提到的CEI模式还是不够安全
所以编写智能合约,第一个建议就是:不要相信并依赖任何未经过验证的外部的合约!!!
因为你永远不知道和你的合约交互的合约会是什么牛鬼蛇神…
1. 安能辨我是雄雌——EOA地址和合约地址的分辨
有的时候,我们编写的合约可能需要辨别EOA地址和合约地址,从而据此进行不同的操作,而上面审计专题里面的extcodesize里面就提出了利用EOA地址和合约地址两者字节码数量的不同来分辨两种地址
这样固然是一种办法,但是上面也给出了绕过检测的方法:在合约的constructor里面调用对应的函数就行了,原理上面也有说,此处不多赘述
那么我们就真的没有办法了吗?其实还是有的,上面有关msg.sender和tx.origin的介绍里面就已经给出了最好的答案:
1 | modifier onlyContract{ |
因为合约调用函数的时候,其msg.sender和tx.origin必然不同,所以可以利用这个modifier进行设置
同理,如果是要求EOA地址,那么就让msg.sender == tx.origin
就行了
2. 你永远不知道你有多少钱——避免依赖address(this).balance
有的时候你写的合约可能需要根据合约所持有的ETH的多少进行不同的操作,比如ERC20的实时汇率转化啊之类的,但是如果你的合约依赖于address(this).balance
的话,那么你的合约就存在一定的风险,而这个风险来自于selfdestruct()
正如上面所说,虽然Cancun硬分叉已经无法利用SELFDESTRUCT操作码销毁合约了,但是它强制转账的功能依旧存在,所以依赖于address(this).balance
的合约很有可能被selfdestruct()
干扰,从而出现预期以外的效果
还是举个例子吧,假如我有个小游戏:
1 | contract simple_game{ |
游戏很简单,一次只能投1 ether,第一个使得合约余额达到10 ether的人就能获得所有的钱,而破坏这个游戏也很简单,我们只需要向这个合约转账1 wei,就足以让任何人都拿不到钱,因为这样就不可能有人通过正常的play()函数将合约的余额改变至恰好10 ether
解决方法很简单,避免使用address(this).balance作为验证的逻辑即可:
1 | contract simple_game{ |
其实上面transfer的使用还是有隐患的,不过这个留到下一节再说吧
3. 不是,兄弟,你这钱包有问题啊——如何安全地转账
在这里我们已经介绍过了三种常规的转账函数,其中transfer()
和send()
两个函数的2300 gas限制的提出其实是为了防止使用call()
方法可能导致的重进入攻击的,但是我们也提到了,正是这2300 gas的限制,导致我们可能在使用这两个函数转账的时候因为gas耗尽导致回退,而这个问题很有可能导致DoS
这里我就只举文字例子了(因为我这菜的要死的水平确实没法写出代码),就我们有一个合约钱包,然后有一个合约银行,我们要从银行取钱到我们的合约钱包里面,那么假如使用的是transfer()
,那么由于合约钱包进账之后会调用receive()
函数,可能我的钱包要在receive()
里面进行一些操作,而好巧不巧,这些操作有点多,gas用完了
哦吼,回退了,钱没了,这就是最终结局,而且你无论尝试多少次都拿不到钱了,这就是DoS
那么使用send()
呢?它不终止执行流,但是不终止的是调用者的执行流而非被调用者的,所以它还是会把gas用完,还是会回退,最后你还是拿不到钱
所以最好的办法是什么呢?还是使用call()
,没想到吧,不过你最好设置一个合理的gas值,这样既能避免上面的问题还能避免重进入,一举两得
再重复一遍,最好的转账方式是:address.call{value: ***, gas: ***}("")
当然,如果你确认你的转账对象一定是EOA地址,那么请随意使用transfer()
,2300 gas绰绰有余
4. 你不许参加银趴——如何防护重进入
5. 智能合约里的“黑中介”——警惕delegatecall()
智能合约杂项随想
下面的就不用看了,就是一个啥都不会的菜鸡的大惊小怪,既没有什么深度和水平,也不是主要更新的部分,分出杂项这一部分单纯是为了区分,别把知识点搞太乱了www
1. 各种代币合约
由于开发者的需求,有各种各样的代币标准被提出,这里从OZ的repo里面随便找一点出来研究研究
1.1 谁都能发行的虚拟纸币——ERC-20
和ERC20接触的原因是最近几天被爹地赶去当帕鲁,在2天之内抽空赶出来了一道Blockchain的红包题(怎么过年都能被PUA啊)
当时爹地的要求是搞个代币,所以就想着搞了个ERC20的同质化代币出来. 当时题目本来是想出上面提到的油量表的,但是最后由于ERC20.sol的版本是^0.8.0,只能作罢,搞了个简单的抛硬币出来
合约我放这里,核心代码都是从Ethernaut的第三关(CoinFlipping)直接搬的,就只是套了个简单的ERC20的壳:
1 | // SPDX-License-Identifier: MIT |
(等我测完题发到链上之后才意识到忘记搞个burn的函数了,但是已经晚了QωQ)
1.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.1.2 粗糙的临摹
前段时间EthernautCTF2024开了,因为技术确实还是不过关,而且同期还有其他事情,所以当时就只是看了一下,现在也是在学习复现上面的题(oz官方的wp真的太赞了,非常详细,可比某Blaz细多了)
里面第3题是一个Auction,使用的是一个自己写的WETH,不过看了一眼,基本上和ERC20的机制差不多,所以就简单结合了那道题还有ERC20的两个合约进行了一个比较粗糙的临摹:
1 | // SPDX-License-Identifier: MIT |
其实就是一些基础的功能:铸造,烧毁,转账还有上面提到的allowance机制
当时在学的时候发现_approve()是internal的,当时就在想红包题那题为什么我能直接调用,然后突然想起来ERC20是abstract的,红包题又是直接继承的,自然可以内部调用
1.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)
1.1.4 转账前,转账后
好像对于OpenZeppelin实现的每一类代币合约(不管是ERC20还是721还是其它的),都一定会包含一个_beforeTokenTransfer
和一个_afterTokenTransfer
函数用于代币合约在代币转账前后执行一定的操作,这点确实能方便开发者
1.2 数字藏品——ERC-721
相比于ERC20的同质化代币标准,ERC721所规定的是非同质化代币的标准,也就是NFT(Non-Fungible Token),简单来说就是拿出两个任意的NFT代币,它们两个不能看作同一个代币(对比之下纸币作为同质化代币,两张编号不同的红色毛爷爷都是100元,没有区别),而且NFT是可分辨的,也就是说每个NFT代币的所有权是独立的
好吧我不太会表达,所以你为什么不去看看EIP是怎么写的呢?https://eips.ethereum.org/EIPS/eip-721
1.2.1 safeTransferFrom,ERC721TokenReceiver
相比于ERC20中直接的transferFrom,我们的ERC721标准提出了安全转账safeTransferFrom,同时提出了支持安全转账的合约应用必要的实现,也就是我们的ERC721TokenReceiver接口,里面必须要包含一个onERC721Received
函数,而对于一个成功的NFT转账,必定会返回魔术值bytes4(keccak256("onERC721Received(address,address,uint256,bytes)"))
,即function selector,对于返回非魔术值的任意情况都必定导致交易的回滚,除此之外函数内可以通过override进行修改以满足开发者预期的目的
让我们回到safeTransferFrom
上,我们发现相比于ERC20,ERC721抛弃了transfer(address to, uint256 amount)
的public/external函数,而只保留了transferFrom
和它的安全方法,也就是说ERC721将ERC20中的两个转账函数集成在了同一个函数下
1.2.2 全权批准setApprovalForAll
ERC721仍旧保留了approve和相关的机制,但是众所周知,每一个NFT都是独立的,也就是说我们不可能用什么transferFrom(from, to, amount)
的形式去进行转账,或者使用approve(to, amount)
这种形式去approve,而是得指定具体的NFT ID进行操作
这个操作看似没有什么太大的问题,但是当NFT持有者持有的NFT很多的时候,如果他想将自己所有的NFT全权代理给另一个人,那就需要多次调用approve(to, tokenId)
,而这会消耗大量的gas,因此除去approve
还有另一种setApprovalForAll
的函数,而这就是用于全权批准的函数:只改变一个状态变量,然后转账的时候检查状态变量的值即可,这能帮助我们节省大量的gas费用
1.2.3 ERC721拓展:Metadata和Enumerable
在EIP中还提出了两个拓展,第一个就是NFT的元数据(Metadata)的拓展:对于每一个NFT,它都可以有属于它的独立的URI,而这个URI可能会指向一个满足ERC721 Metadata JSON Schema
的文件,这个文件包含了这个NFT的属性,如下是EIP中对这个JSON架构的具体内容的表述:
1 | { |
OpenZeppelin已经在他们的ERC721合约里面直接实现了Token URI,同时还在extensions里面实现了IECR721Metadata
,主要就是预设一个基准URI,而不同的NFT的URI是由基准URI和TokenID拼接得到的,当然有需要的可以自行override
第2个拓展是可枚举的(Enumerable)ERC721,它的作用是能够让开发者公布本ERC721合约的所有Token并让它们可发现(原文make them discoverable
),想了一下,主要还是因为现实中mint一个NFT并不一定是按数字顺序作为tokenId进行mint的,一般可能是经过某些哈希处理的,而如果是这样那想要列出所有的NFT就需要一个额外的主键,这也应该是可枚举拓展想要解决的
OpenZeppelin在对可枚举ERC721的实现中还额外实现了对特定用户所持有所有NFT的枚举
1.2.4 对NFT的批量处理——ERC-2309
如果我们只是进行少量的NFT的操作,基础的ERC721就已经非常够用了,但是如果我们需要进行大规模的NFT的操作呢?比如某位富哥一次性铸造了上万个NFT,那么理论上我们就需要调用上万次_mint
,这会消耗大量gas费用以及时间(因为全部放一起的话,由于ERC721规定NFT每次出现所有权转移时都需要emit一个Transfer
事件,而发送事件时gas费用会因为emit的事件数量过多从而消耗殆尽,进而导致revert,因此需要分成多个交易,而可能这些交易被分散在多个区块中,整个操作的完成是由最新的区块的Confirmation决定的),成本太大,因此提出了ERC-2309:连续转账拓展
整个ERC的核心很简单:提出一个新的Event规范,当对一个或多个连续代币(即TokenId连续)进行铸造/转账/销毁操作时,emit这个Event,同时当这个Event被emit的时候,ERC721规定的Transfer
事件不再emit,具体的Event如下:
1 | event ConsecutiveTransfer(uint256 indexed fromTokenId, uint256 toTokenId, address indexed fromAddress, address indexed toAddress); |
在OpenZeppelin对ERC2309的实现中,我们发现OZ只允许在合约创建的时候进行连续NFT的铸造(同时铸造单个NFT也是被禁止的,而这个操作在完成合约创造的时候解封),而对于铸造的连续NFT,使用了位图(Bitmap)保存连续NFT是否被销毁,同时使用了Checkpoint锚点保存铸造连续NFT时连续NFT的最后一个NFT的Owner(这样从第一个到最后一个就都是同一个Owner了)
1.3 过度设计的ERC20 Plus——ERC-777
这是一个非常大胆的想法:将ERC20代币进一步升级,让它的各种操作和以太坊最底层的货币Ether的操作类似,比如让任意地址在收到代币的时候也会进行类似receive()/fallback() payable
函数的操作,同时可以向另一个操作器(一般是一个经过认证的合约)进行授权并在发生代币所有权转移时触发它的逻辑
1.3.0 ERC777的基础函数定义以及和ERC20的区别
其实ERC777和ERC20有很多相似之处(毕竟ERC777是支持ERC20的向后兼容性的),比如传统的代币姓名啊标志啊之类的,但是相比ERC20新增了一个granularity
,也就是指定ERC777的最小单位(必须大于等于1),任何使用该ERC777代币的操作中,被操作的代币总量必须为最小单位的乘数,比如我设定granularity
为3,那么如果我处理value为114514的ERC777就是不合规的,会直接revert
对于剩下的铸造/转账/销毁的操作,ERC777和ERC20大致一样,不过ERC777并没有特别规定对代币铸造函数的格式,对于上述的三个操作,它们都有属于自己的独立的Event(比如铸造就一定要emit一个Minted
事件),而send
和burn
两个函数还额外添加了一个bytes calldata
类型的参数,这个参数我们在之后会再细说
ERC777并没有延用ERC20的approve
相关机制,而是自己设计了一个Operator的机制,同时专门为Operator的代币操作设置了2个函数:operatorSend
和operatorBurn
(至于没有mint的原因我刚刚说了:ERC777并没有规定mint函数格式),除此之外还有一系列让用户对指定的Operator进行授权以及撤回授权的函数和相关的事件
不过整个合约的核心在于代币持有者对代币的进一步控制,具体一点就是持有者(可以是EOA地址)对转账和接收代币的hook,不过众所周知EOA地址本身是无法执行代码的(当然在2025.3发生的Pectra升级可能会发生一定的变化),因此我们需要另外一个合约去存储哪个人支持什么hook并且从对应的地址执行hook,而这就是ERC-1820要干的事了
1.3.1 一切的基础:通用注册表智能合约ERC-1820
ERC-1820规定了一个固定的通用注册表智能合约,其用途是让任意地址(无论是合约地址还是EOA地址)都能公布其能够执行的函数,对于合约地址的话其公布的函数一般是它本身实现的函数,而对于无法执行代码的EOA地址则需要一个proxy合约帮助它执行对应的代码
所以本身合约的逻辑很简单,就是调用这个合约的setInterfaceImplementer
函数,告诉这个合约“我支持xxx函数,而这个函数具体会由xxx地址代替我执行”,这样这个合约就知道对应关系了,然后如果有人问它,它就能告诉这个人去找谁去执行这个函数,但是每一个地址只能有1个“管理员(Manager)”,也就是说假如有个地址A,它的Manager是B,那么能调用setInterfaceImplementer(A, interface_hash, implementer)
的人只能是B,默认情况下每个地址就是它自己的Manager
可是问题就在于如果是由某一个具体的人进行这个合约的部署的话,那么我们可以合理地怀疑部署者可能会在合约里面夹带私货,比如说偷偷修改一些记录啥的,因此提出这个ERC的人用了一个非常天才的操作:
1.3.1.1 Nick’s keyless deployment method
最先提出这种操作的大佬在2016年就写了一篇文章:How to send Ether to 11,440 people | by Nick Johnson | Medium,里面就提及了如何在不知道某个人私钥的情况下发送交易,同时在文章的结尾给出了相关的Python代码(使用的是已经弃用的ethereum
库)
简单来说这种交易部署方法利用的是在EIP-1559以前的交易格式,又称为Legacy Transactions(交易类型为0x0
),对于这种交易,它里面包含的数据只有nonce
,gasPrice
,gasLimit
,to
,value
,data
总共6个字段,签名的时候会将这6个字段进行RLP编码,然后使用私钥进行签名得到vrs三个参数,最后再重新将上述6个字段和签名的三个参数重新进行RLP编码,最后得到的就是Legacy的Raw Transaction数据了,以ERC1820的交易举例:
1 | from web3 import Web3 |
上面的代码很好地证明了Legacy Transaction的完整构造原理,同时也说明我们可以任意指定一组合法的签名数据,在没有该签名恢复出的地址的私钥的时候,以该地址作为from
地址发送交易,不过为了保证合约能够部署,gasPrice
一定得够高,且gasLimit
得够用,在此ERC中两者的乘积为0.08ETH,也就是说如果要执行这个交易,首先得根据上面的操作恢复出from
地址,然后向这个地址转入至少0.08ETH才能保证交易能够被挖出来并确认
1.3.2 ERC777的问题
让我们回到ERC777本身,OpenZeppelin曾经实现过ERC777的具体合约,但是在v4.9的时候将ERC777和ERC1820一同弃用了,这里就简单分析一下为什么OZ会决定弃用(当然你们可以看看这个issue而不是看我胡说八道:Deprecate ERC777 implementation)
ERC777本身希望的是给ERC20拓展一个hook功能,使得代币所有者对代币的掌控权更为灵活,但是如果我们将ERC777类比成ETH,那么我们会发现ERC777实际上就是会有一些合约钱包的典型漏洞,比如说重进入,或者是DoS
这里对刚刚提出的2种漏洞分别举一个例子:对于重进入,ERC777会出现和ERC721之类的代币一样的问题:一般来说,这些代币在Transfer之后会调用接收者地址的onERCxxxReceived
或者tokensReceived
函数,并且让它们返回函数的selector以检测接收者是否接收到了代币,但是这就好像ETH里面调用address(xxx).call()
一样,接收者可以override这些接收函数从而进行重进入攻击,当然如果只是这样的话那也还好说,问题就是ERC777还有个tokensToSend
函数(这里给出OZ实现的相关操作):
1 | // From IERC777Sender.sol |
我们可以看到ERC777中会调用tokensToSend
,而这个函数也是有很大的重进入风险的,更何况这个函数在ERC777的_send
函数里面是优先调用的,然后才是_move
函数,即进行ERC777的所有权转移,这完全不符合CEI模式(忘记这是啥的跳到安全性提升第0部分看看),因为它是先交互再检查的
如果能很好理解ERC777的重进入风险,那么对DoS的理解就更简单了,我只需要在tokensToSend
之类的函数直接revert就能使得任何包含自己地址的交易全部回退,假如进行的操作是批量转账,那这个DoS就能让所有人都收不到ERC777,而且revert还会消耗gas,导致资源的浪费
如果仅仅是这样那也就算了,问题就在于ERC777对ERC20的向后性兼容,而且使用ERC20的接口进行操作,因此会导致用户在进行转账的时候,以为他仅仅是进行了一次ERC20的转账,但是里面执行的可能是ERC777的操作,而这不仅从潜在的风险、资源的消耗以及其它方面来说都是问题重大的,而且大量开发者也提到ERC777对他们来说是frustrating
的,是over-designed
的,因此经过讨论后OZ决定弃用这个ERC
1.3.3 一个新的出路——ERC-1363
虽然处理了一个一个问题较大的ERC,但是还是有大量开发者希望添加类似的ERC20拓展,以满足合约在进行ERC20转账的时候能够进行一定的处理而不是傻愣愣地待在那里,所以出现了一个新的ERC:ERC-1363(OZ已于2024.1.24实现了该ERC)
如果了解过ERC721的话,这个ERC就非常简单了:实际上就是对原先的ERC20合约添加了一个功能,使得在转账之后会调用接收者的checkOnERC1363TransferReceived
,在授权操作之后会调用使用者的checkOnERC1363ApprovalReceived
,实际的逻辑和ERC721的onERC721Received
是完全一致的,同时上面提到的两个Received函数也是必须实现才能进行正常的ERC1363转账的,瞬间感觉比ERC777简约太多了,不是吗?
1.4 Tokens All In One——ERC-1155
考虑一个搭建在Web3上的游戏,游戏里面有多种代币,比如金币、钻石之类的;同时游戏中可能还会有一些独特的物品(实际上就可以看作一个NFT),但是这样的话我们就要单独对每一种代币创建一个新的合约,同时在游戏内进行处理的时候又要和多个合约分别进行交易操作,不仅耗时间(挖矿确认),同时还耗钱(各种gas费),那么我们能不能提出一个新的合约,将各种代币统整在一个合约内呢?
1.4.1 你是…?——_id
属性
如果我们需要将不同种类的Token一起放在一个合约里面,那么首先我们就需要区分谁是谁,那么最简单的方法自然是像ERC-721那样使用一个_id
属性去分辨,不同的Token ID对应不同的Token
这样确实很好,但是对于NFT呢?其实操作也很简单:它本质上就是一个totalSupply
值为1的同质化代币,因此我们只需要对每一类NFT只执行一次_mint(_id,1)
就行了
当然,由于这种_id
属性的引入,我们的balanceOf
自然也需要修改,一般是修改成balance(address account, uint256 _id)
的形式,以表示account地址所持有的ID为_id的代币的量
1.4.2 ERC1155代币的各种操作
ERC1155的转账从前面提到的ERC20、ERC721和ERC777都吸取了一点思路,那么最核心也是最能看出来这一点的自然是对代币的各种操作了,当然本质上铸造/转账/销毁三个操作就是转账操作,只是铸造和销毁是特殊的转账而已(一个from
是0x0
黑洞地址,一个to
是0x0
黑洞地址)
ERC1155的转账和ERC721一样,只有safeTransferFrom
,同时也有ERC721的setApprovalForAll
向指定的operator设置许可,但是除此之外还从ERC2309中提取了一点经验,设置了批量处理的safeBatchTransferFrom
:通过传入一个id数组和value数组,指定多种类的代币的转移,也因此ERC1155还有_burnBatch
和_mintBatch
两种批量处理的函数
当然,最核心的自然还是转账完成之后调用的checkOnERC1155Received
和checkOnERC1155BatchReceived
,这些很明显是从ERC777学习而来的:在每次完成转账操作的时候,会尝试调用接收者的这两个函数之一(具体调用哪个取决于转账的时候是否为批处理),传入的不仅有基础的from
、to
、id
、value
,还有一个data
用于让接收者进行处理操作
至此,ERC1155的风险也就很明显了:转账后发生的重进入,还有权限控制问题
1.4.3 精简版ERC1155——ERC-6909
当然,并不是所有的代币都需要进行callback操作和批处理操作,同时以太坊的高gas费也是一个很重要的问题,因此23年提出了一个更为精简的多代币合约:ERC-6909,其对于代币区分的思路还是和ERC1155一样的:通过ID对不同的代币进行分类和指定
ERC-6909消去了代币转账成功之后调用Receiver函数的操作,同时删除了批量处理操作,仅保留了如同ERC20的最基础的transfer
/transferFrom
以及allowance机制,除此之外就基本上一致了
2. 字符串比对
是的我知道这很没技术含量,但是由于是很早之前写的,我也懒得删掉然后改序号,所以你就当这部分是个padding就行
上面有提到storage,memory和calldata之间的区别,但是在写合约的时候发现string还分为string memory,string calldata,string storage ref之类的,它们之间是没法使用”==”和”!=”运算符的
瞬间头大,那怎么对这些东西进行比对啊… 然后去了StackExchange看了下,才想起来keccak256这个东西…
所以就有了下面在群里面调侃E神的合约:
1 | contract Nope{ |
3. RLP编码和合约地址预计算
在[审计随想7.1](#7.1 强制地向合约地址转账)的时候,我们有提到对合约地址的预计算,这里就详细展开RLP编码和合约地址是怎么计算的吧
3.1 RLP编码
RLP编码用于在Ethereum的执行层对对象进行序列化,利用节省空间的格式标准化节点间数据的传输
如果我们需要对字典进行RLP,那么有两种方法:一种是将其转换为[[k1, v1], [k2, v2], …]的格式,另一种是使用基数树
我们假如输入为p,那么我们对p进行一系列分类讨论:(这一部分的“字符串”指的是一定数量个字节的二进制数据,也就是说譬如地址,uint256,bytes4这些数据在这里都是“字符串”)
假如p是非值,比如
null
,false
,空字符串''
,整型0
: RLP(p) =
[0x80]
假如p是一个空列表
[]
: RLP(p) =
[0xc0]
假如p长度为1个字节,且值为
0x00
~`0x7f`: RLP(p) =
[p]
,例如RLP(0x4a) =[0x4a]
,RLP(‘s’) =[0x74]
假如p是一个长度len = 1~55的字符串:(注意当长度为1时,需要字符串的值>0x7f)
RLP(p) =
[0x80+len, p]
,例如RLP(qsdz) =[0x84, 0x71, 0x73, 0x64, 0x7a]
假如p是一个长度len > 55的字符串:
- 先用bytes的形式表示len,比如我们有个1024长度的字符串,此时len =
0x0800
,需要2个字节表示 - 假如len需要
x
个字节进行表示,那么我们有RLP(p) =[0xb7+x, len, p]
,在这个例子里RLP(p) =[0xb9, 0x08, 0x00, p]
- 先用bytes的形式表示len,比如我们有个1024长度的字符串,此时len =
假如p是一个列表,列表中所有元素依次经过RLP编码后,整个payload长度len = 1~55:
RLP(p) =
[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(p) =[0xcc, 0x84, 0x71, 0x73, 0x64, 0x7a, 0x86, 0x54, 0x72, 0x69, 0x6f, 0x64, 0x65]
假如p是一个列表,列表中所有元素依次经过RLP编码后,整个payload长度len > 55:
先用bytes的形式表示len,比如我们的payload长度为11037,此时len =
0x2b1d
,需要2个字节表示然后我们把所有元素经过RLP编码后得到的结果连接起来,记为
concat(RLP(p[i]))
假如len需要
x
个字节进行表示,那么我们有RLP(p) =[0xf7+x, len, concat(RLP(p))]
,在这个例子里RLP(p) =[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里面就可以这样计算新合约的地址啦:
1 | address new_contract = address(uint160(uint256(keccak256(abi.encodePacked(bytes1(0xd6), bytes1(0x94), address(this), bytes1(0x01)))))) |
当然,需要注意要根据nonce和创建者地址适当修改上面的Solidity代码,不然计算可能出错
3.3 合约地址预计算(CREATE2)
相比于上文的计算,CREATE2的地址计算就显得简单了许多,在EIP-1014官方文档里面是这样定义的:
1 | address new_contract = keccak256( 0xff ++ address ++ salt ++ keccak256(init_code))[12:] |
其中address是合约创建者地址,salt是任意的uint256值,init_code是构成了合约constructor的字节码,或者用[2.1](#2.1 msg.sender和tx.origin)的话来讲,就是bytecode里面在deployedBytecode前的字节码,用Solidity来写就是:
1 | bytes memory init_code = type(new_contract_2_be_deployed).creationCode; |
这样最外层keccak256里面数据的长度就是固定的1+20+32+32=85个字节
之前使用CREATE2的话要通过assembly进行,不过现在只需要传一个salt就行了:
1 | // using CREATE opcode |
4. 变形合约(Metamorphic Contract)(当前已无法在主网/测试链复现)
⚠请注意!EIP-6780对SELFDESTRUCT操作码的功能变更(不删除字节码)使得变形合约无法利用CREATE2字节码重新部署在原来的地址上,从而导致变形合约当前无法在主网,Sepolia测试链等EVM版本已经大于等于Cancun的链上实现
如果希望复现,可以自行搭建Shanghai或更早的硬分叉的私链并在上面进行操作
(已经在后悔开这个部分了QAQ,原理和字节码相关,我又不会逆向QAQ)
4.1 引子:使用CREATE/CREATE2
的合约部署
我们首先来看这三个合约:
1 | // Original post at https://medium.com/coinmonks/dark-side-of-create2-opcode-6b6838a42d71 |
从下往上,能很明显知道是CreatorFactoryContract
利用CREATE2
操作码生成一个CreatorContract
合约(记为合约A),然后CreatorContract
又使用CREATE
操作码生成了Target
合约(记为合约B)
我们的合约A和B都有一个destroy方法能够利用selfdestruct()
自毁,那么如果我们将两个合约都调用destroy()
方法,然后再使用CreatorFactoryContract
的deployCreator()
,使用相同的salt进行调用,你会发现新的合约A和旧的合约A地址完全一致!而使用新合约A的deployTarget()
,新的合约B又和旧的合约B地址完全一致!
如果你看了上面有关CREATE/CREATE2
预计算合约地址的内容的话,你就会知道这是必然的:因为使用CREATE2
操作码进行合约部署的话,新合约的地址只依赖于新部署的合约的init code和salt两个部分,那么我们合约不变,init code自然一样,salt不变,那么最后部署得到的新合约地址自然会和旧的合约地址一致
至于合约B为什么地址也不变就更简单了,因为它的部署使用的是CREATE
操作码,使用它部署的合约的地址只依赖于部署者地址和部署者的nonce,那么这里的部署者是A,其地址不变,而nonce也都是1(因为是第1个合约嘛),自然B的地址也不会发生变化
那么这就引出来一个问题了:假如我用A部署一个和B不一样的的合约(假定为C),那么我们新的合约C的地址根据CREATE
操作码的参数,好像…和旧的B的地址也是一样的…?
4.2 正篇:弗兰肯斯坦的诞生(未填完の坑)
上面引出了一个和CREATE
有关的新的问题,而这个的实现其实是很容易做到的,我们只需要在第2次部署A之后部署C(而并非B)就可以达成目标:前后两个合约代码完全不同,但是地址一致,方法也很简单,我的A可以有两个合约部署函数,一个部署B,一个部署C,这样就可以实现上面的情况
那么假如新旧合约都有同一个函数名的函数,但是新合约的函数有些恶意的操作,那么有可能会出现这样的情况:我在和合约交互,但是交易途中合约“变形”了,同一个地址被一个新的合约替换掉了,然后这个新的合约取走了我所有的钱…
当然,
既然如此,是时候使用CREATE2
而非CREATE
进行类似效果的操作了,那么我们就开始正式了解变形合约的底层原理吧…
变形合约的核心逻辑是这样的:由于CREATE2
的参数
4.3 题外话
2024的DAS十月赛出了一道题(Open sesame),解题思路其实和变形合约真的非常相像,都是使用selfdestruct()
控制恶意合约的地址相同但是执行的代码不同,唯一的差别就是DAS的都进行了extcodesize
检查,所以一切的操作都必须在constructor()
里面完成,当然也有可能是因为Cancun链更新的迫不得已(笑),反正这一点在当时确实很大程度帮助了我构建思路
5. 合约的创建过程(挖坑未填)
在前面的屎山中,我们提到了一些简单的交易结构和合约地址计算等和合约创建相关的东西,但是零零散散也太难找了,那么为什么不单独拉出来作为一部分用于水博客呢?
5.0 交易的结构
在前面写ERC-777的时候,我们提到了合约的无密钥部署方法(杂项1.3.1.1),在那里我们解构了版本为0x0的Legacy Transaction的组成,但是现在我们使用的交易绝大多数都是ERC-1559之后提出的交易,也就是版本0x02的交易,为的主要是以太坊网络中gas费的节省,目前有4种交易版本,但由于0x0已经在前面讲过了,这里就不再赘述
5.0.0 交易的封装结构:ERC-2718
现在所有交易的基础结构都是ERC2718定义的,而这个ERC定义的结构也非常简单:TransactionType || TransactionPayload
,说白了就是将交易类型号和交易的具体内容直接拼接起来
5.0.1 版本0x01
——ERC-2930
5.0.2 版本0x02
——ERC-1559
5.0.3 版本0x03
——ERC-4844
5.1 Proxy合约
6. DeFi协议(挖坑未填)
6.1 Uniswap
7. 签名和其简单应用(挖坑未填)
有关以太坊签名原理的部分已经在前面的审计部分([10.1](#10. 这是你吗?——签名延展以及签名复用))大致提到了,这里只是会简单讲一下相关的EIP还有应用什么的
7.1 以太坊签名标准:EIP-191 & EIP-712
我们从上面的审计部分知道我们只需要知道签名和它对应的原文(不一定是hash)就能通过ecrecover
恢复出公钥,从而知道签名者的地址,但是究竟要对什么数据进行签名呢?
总的来说,EIP-191提出了签名数据的定义:
1 | 0x19 <1 byte version> <version specific data> <data to sign>. |
一共只有4个部分,其中最开头的0x19是为了让整个签名数据无法是一个独立的RLP编码结构,从而使得签名数据永远不可能成为一个交易
而版本号目前只有3个:
Version byte | Description |
---|---|
0x00 |
预期验证者+数据 |
0x01 |
结构化的数据 |
0x45 |
个人签名(personal_sign )信息 |
7.1.1 EIP-191:版本0x00 & 0x45
首先我们来看版本0x00的具体结构:
1 | 0x19 <0x00> <intended validator address> <data to sign> |
非常简单,版本特定数据部分填充的是预期的验证者地址,然后最后拼接上任意的数据即可
这里的预期验证者地址指的是预期中用于验证这个签名的地址,也就是说假如我们设定预期验证者是合约A,那么将同一个签名发送给另一个合约B,它并不能帮我们验证这个签名,因为预期验证者并不是合约B
而版本0x45的就更加简单了:
1 | 0x19 <0x45 (E)> <thereum Signed Message:\n" + len(message)> <data to sign> |
其实就是0x19开头表示是签名数据,后面接上Ethereum Signed Message:\n
和签名数据的长度,然后接上任意的数据,比如:\x19Ethereum Signed Message\n12Hello World!
就是一个标准的EIP-191签名数据(如果我没搞错的话)
7.1.2 EIP-712:版本0x01
7.2 Multisig钱包
多重签名钱包(Multi-Signature Wallet),是一种需要多个签名对钱包进行的某个交易进行授权的钱包(好吧我不是很会简述),简单来说就是这个钱包是由多个人共同持有的,任何人都可以提交交易详情,而这个交易需要经过一定数量的持有者的许可才会被进行,下面这个合约就是一个例子:
1 | // Original code at https://www.cyfrin.io/glossary/multisig-wallet-solidity-code-example |
肢解一下其实并不难,就是将刚刚提到的提交、许可、执行以函数的形式写了出来,最后还有个撤回许可的函数,没有太复杂的东西,但是如果这样去写会非常麻烦,因为这需要每一个持有者自己去confirm一次,最后还得自己调用execute,单从手续费这点来看就非常的不值,那么我们是否可以直接把合约部分简化为“验证签名+执行”呢?(签名我们可以在外部进行操作,成本会低很多)
多重签名,那么肯定需要一个bytes数组传入不同的签名,然后对签名列表中的每一个签名执行ecrecover
操作(或者你用的OpenZeppelin的ECDSA.sol里的recover
也OK),将恢复得到的签名和持有者列表进行比对,比对无误并达到阈值就直接执行交易操作
7.3 EIP-2612
虽然EIP-2612并没有被广泛接受并使用,但是由于它能更好地帮助我们理解接下来将要提到的Permit2授权标准,因此这里还是把这个EIP简单介绍一下
7.3.1 Permit2
Permit2是于2022年由Uniswap提出的一个基于EIP-2612的授权标准
8. Pectra升级(挖坑未填)
时隔一年重新进行腹泻式更新,很大程度上就是因为看到了这个升级,不过考虑到Pectra升级很多和Dencun升级有关(Dencun升级提出了blob的概念,Pectra很多EIP都是为其服务的),因此更新这一部分的时候我们可能会开个新的Dencun的新坑(当然可能就是单独以blob水个小节出来了)
8.1 EIP-7702
(如果有EOA向另一个EOA转账,是否会调用到fallback并发生一些未曾预想过的结果呢?我还没看这个EIP,这只是个猜想)
Under Construction…
9. 零知识证明(Zero-Knowledge Proofs)(一点没填的超大坑,短期之内不会填)
本人数学很差,而且还没有深入了解这一部分(起码等我学点离散对数问题再说),因此ZK这一部分暂时不会填,现在挂上来只是为了push一下自己而已
9.? NP完全问题
9.? 算术电路(Arithmetic Circuit)
9.? zkSNARK
9.?.? Groth16
Solana笔记(Temporary)
这一部分用于记录本人学习Solana智能合约开发/安全的简易笔记,短期之内会放在这里,但是之后熟练之后会删去并将部分内容整合到本文内
0. Rust的问题
找资料的时候看了一下这个视频:Why NOT Learn Rust As Blockchain Developer,感觉这位说的很对,就是Rust作为一个新兴的语言,而且注重于内存安全性,感觉Rust更多的是用于系统编程,而且整个社区还不够成熟,因此缺少学习的资料,甚至文档都可能不是很够
而且这位说Rust进行Solana链开发的话,需要大量的代码编写,因为缺少成熟的框架,所以大部分功能的实现都得要自己手搓
视频的核心是这样的:如果你是一个纯粹的新手(没有任何编程基础的),那么不推荐你从Rust入手学习智能合约开发
1. 速通Rust基础
没学过,但是粗略过一下得了,凑合着过吧
1 | let x: i32 = 42; // Define variable |
?. Rust中的整型溢出
很神奇的是,Rust在Debug模式和Release模式下对整型溢出的处理是不一样的:如果在Debug模式下,那么整型溢出会导致panic;而在Release模式下,整型溢出会被忽视,虽然它仍然溢出。当然,你确实可以在Release模式下启用overflow-checks,不过这点还是需要注意的
一般的,我们可以将基础运算符改为checked_add
等方法,或者saturating_pow
- 本文作者: 9C±Void
- 本文链接: https://cauliweak9.github.io/2024/02/11/SmartContractSecurity/
- 版权声明: 本博客所有文章除特别声明外,均采用 MIT 许可协议。转载请注明出处!