一篇简单的智能合约学习随想,遇到什么写什么,所以什么都有可能提到,应该会很乱
目前都在学Solidity,Yul,Move和Rust(正在学,什么奇美拉)还没有学过,慢慢&随缘更新(当然欢迎催更)
由于博客对Solidity代码块的着色问题,所有Solidity代码块的着色规则都选用的是Javascript
智能合约审计随想
这部分就是一些智能合约的漏洞的分析、攻击方法和规避方法(可能会有),来源很多,从比赛/靶场的题目到某管上的视频、从各种论坛到Github上的电子文档,咱只是负责搬运并以自己的理解描述出来而已,大佬们轻点喷QAQ
1. 整型溢出(Solidity <=0.6特性)
我个人习惯叫这种漏洞为“油量表”,因为它确实和油量表蛮像,下面细说:
由于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,同样无法通过此漏洞攻击
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 |
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,区块数之类的,这里我们假设这个随机数为:
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()和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 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.3.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.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的参数进行修改操作,比如下面的例子:
1 | function addOne(uint[] calldata numbers) public pure returns (uint[] memory) { |
因为我们需要修改numbers中的数据,所以我们需要先将numbers复制到memory中的newNumbers里再修改
刚刚我们提到的都是存储临时数据的位置,那么相对的我们需要一个永久存放数据的位置,而这就是存储:storage. 它里面的数据能被合约中的任意函数访问并修改
5.3.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总量,那么这个方法也是不可行的
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()来预防这种情况的出现
合约安全性提升随想
博客搬到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. ERC20
目前对整个ERC20协议还是不是很了解,而且还有个NFT的ERC721协议,之后遇到可能会再补充补充
和ERC20接触的原因是最近几天被爹地赶去当帕鲁,在2天之内抽空赶出来了一道Blockchain的红包题(怎么过年都能被PUA啊)
当时爹地的要求是搞个代币,所以就想着搞了个ERC20的同质化代币出来. 当时题目本来是想出上面提到的油量表的,但是最后由于ERC20.sol的版本是^0.8.0,只能作罢,搞了个简单的抛硬币出来
合约我放这里,核心代码都是从Ethernaut的第三关(CoinFlipping)直接搬的,就只是套了个简单的ERC20的壳:
1 | // SPDX-License-Identifier: MIT |
(等我测完题发到链上之后才意识到忘记搞个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的两个合约进行了一个比较粗糙的临摹:
1 | // SPDX-License-Identifier: MIT |
其实就是一些基础的功能:铸造,烧毁,转账还有上面提到的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神的合约:
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的链上实现
如果希望复现,可以使用
(已经在后悔开这个部分了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
的参数
Under Construction…
- 本文作者: 9C±Void
- 本文链接: https://cauliweak9.github.io/2024/02/11/SmartContractSecurity/
- 版权声明: 本博客所有文章除特别声明外,均采用 MIT 许可协议。转载请注明出处!