智能合约的几个方向
ethernaut puzzles汇总
ethernaut puzzles 案例类型解析
solidity语言特性
与区块链本身的特性的相关点
合约之间的相互调用
第三方合约的使用
gas消耗攻击
业务逻辑的创新与新的可能性
合约代理更新机制
Bytescode级别
个人看法
其他资料
Ethernaut资料
相关链接
自从开始接触智能合约,有关智能合约编程的几个方面一直让我印象深刻,1 是安全,这可以从以太坊经典事件DAO攻击以及此起彼伏的链上黑客攻击体会安全性在整个行业的重要性;2 是gas优化,对于gas优化可能现在由于不是牛市,以及layer2层逐渐完善导致的gas费降低,对这方面的关注在变淡,但我认为gas是基础,某些情况下其重要性可能会凸显,我猜想可能类似于量化投资中对于速度的要求,如果未来随着用户量的指数级上升,将会有可能再次引起人们对于gas的关注,另外一方面,智能合约每一步的运行最终都会转化为opcode,而每一个opcode都会消耗一定量的gas,这是整个EVM的运行的基础,也是了解以太坊核心的基础。3 则是智能合约之间的复杂调用关系,很多情况下随着业务复杂度的上升其调用关系也变得复杂,甚至有点扑朔迷离。我这里面所指的是CA合约CA合约之间的调用关系。
CA(Contract account): 该类型合约由代码控制。
EOA(Externally-owned account):该类型合约由拥有私钥者控制。
我们在看到单个智能合约本身往往是静态的变量与方法逻辑阐述,而涉及到很多复杂合约之间的相互调用关系时,往往则比较复杂。比如如下的合约之间的相互调用。
智能合约之间的调用关系往往也是很多黑客事件主要的发生源,如经典的重入攻击。
4 则是基于智能合约基础之上可能展现的新的业务特性,我们知道智能合约本身就是建立在区块链本身的透明,不可篡改,永久持续运行的特点上,基于这些特点还有智能合约本身的特性将会使其与过往编程以及思考方式产生很多不同,比如基于开放式的基础设施上的开发,测试,以及以链上合约为基础单元的思考模式,比如如何更新链上合约的逻辑... 我认为这种新的环境或者方式需要逐渐去适应与揣摩,借鉴别人的思考去理解更多的可能性。
基于如上的理解,我一直尝试让自己去适应以及感受这与传统编程方式有何不同?而openzeppelin部署的ethernaut CFT,则是一个很好的测试自身以及增强自己对于此的理解。
如下是基于个人理解的对于ethernaut CFT的汇总
说明: 如下的puzzles案例类型介绍并不是针对每一个puzzle从头至尾的分析与代码演示,我认为已经有很多人写的很详细了。同时不少puzzles并不是那么容易解决的,其中不少puzzles我也是花了好几个小时才慢慢找到思路,甚至有些还需要借助网上别人提供的思路。我个人觉得如果是对初次接触智能合约的人来说,这可以算作一个提前性的参考,随着逐渐深入,如下提及的很多方面肯定都会遇到;如果是对智能合约有一定了解甚至是高阶的合约开发者,我觉得如下的内容可以算作是一个梳理性的操作,我认为其中的一些方面在未来还会继续衍化,某些方面我的描述可能过于压缩与不准确,欢迎讨论。
ethernaut CFT 主要以puzzles的形式去展示安全问题的各种情况,一方面可以据此去感受与实践在智能合约的编程中的潜在的安全问题; 二虽然呈现方式是安全问题,但据此可以在很多其他方面增强一定程度的理解,如solidity语言本身的特性,建立在区块链基础上的所衍生的问题,基于智能合约的业务设计;三则是建立一定的熟悉度,并且体会这种新架构下的与传统web2开发方式的不同。
这里仅仅就如下几个方面进行具体说明:
1.我个人在解决过程中碰到的难点问题
2.我觉得具有一定代表性的问题, 并且未来肯定会继续遇到相关的问题。如solidity语言特性。
3.生态发展肯定会涉及的一些事项,如代理更新机制。
3.1 solidity语言特性
1.overflow/underflow check
如针对于unit256类型的加减运算,如果没有进行校验,将会导致溢出。 可参靠 Token puzzle 。 不过solidity 在0.8之后已经默认加了 overFlow check。
2. fallback
如果调用一个智能合约,但是调用的方法没有匹配到智能合约的任何方法或者收到ETH时但是合约中没有定义receive 方法而是存在Fallback,则会默认调用Fallback方法。根据此特性,Fallback在不少特殊场景下都会用到,如重入攻击时,攻击者地址在收到eth之后,可以继续在Fallback方法中继续调用被攻击地址的合约。又比如涉及到代理机制时,业务逻辑合约只负责逻辑,但是并不存储最终的业务数据,这时如何调用业务逻辑合约的方法,就是通过代理合约中的Fallback方法进行中转,使用deleagteCall调用业务逻辑合约的方法。
3.Customer error
Good Samaritan案例中涉及到在调用中合约中捕获异常,但是抛出的异常对于捕获异常的合约却是无法确定是谁抛出的。
4.selfdestruct 方法
该方法的作用是销毁一个智能合约,同时将对应的eth发送到指定的合约上。其设计初衷是为了鼓励节省gas费。但是该方法同时却造成了任何一个合约都无法保证自己有能力不接收eth。如果将自己的合约逻辑建立在自身的eth余额上,如address(this).balance == 0。 该漏洞将会可能被黑客利用。 最新的solidity版本中并不建议使用,https://eips.ethereum.org/EIPS/eip-6049,
3.2 与区块链本身的特性
1.透明性
在solidity中定义变量可见性时,有public,private,internal. 但是将变量定义为private并不意味着该变量并不是不可见的,只是针对链上其他合约是不可见的,依然有方法可以获取其对应slot的值,如根据ethers.js 提供的方法 provider.getStorageAt( addr , pos [ , blockTag = latest ] ) 。 可参考Vault ,Privacy 。
当然如果要实现链上存储加密信息可使用零知识证明,即可以证明自己知道该隐私参数,但是却不会泄露该隐私参数。
2.随机数
由于链上的数据, 即便是被定义为private的变量也是可见的,同时矿工可以控制一些数据如区块哈希,时间戳,是否包含某个交易,导致直接链上生成随机数是存在隐患及不安全的,这个时候可以通过引入链下随机数来实现,比如chainlink。 对应案例可参考Coin Flip 当然有时智能合约需要的不仅仅是随机数,还需要更多业务数据,比如股票价格,这些也可以通过类似于chanlink的第三方来获取。
而自从Ethereum merge之后,新的opcode:PREVRANDOA, 也会生成随机数并且比blockhash由更强的随机性。
3.地址生成
可以通过keccak256(address, nonce)来生成地址。具体可参考https://swende.se/blog/Ethereum_quirks_and_vulns.html. 这样将导致给一个没有私钥的合约发送eth,但是却可以用上述方法去获得这些eth。可参考案例Recovery。
4.合约状态存储数据结构
合约状态存储数据结构如下。合约中有2^256个slot,每个slot中存储的最大长度为bytes32.
动态数组存储的结构如下所示
参考案例Alien Codex
Solidity如何存储动态数组的值?,其对应的合约中的slot位置存储的是动态数组的长度,但是接下来存储动态数组的值并不是按照slot递增进行存储,而是根据该slot的值获取对应的keccakHash值,其对应的动态数组的第一个值存储的位置,动态数组后续的值则在此值后面依次排列。如图所示:
bytes32[] public codex 状态值存储在第三个slot值。此时对应的slot-2值存储的的是codex数组的长度。
数组中第一个值存储的位置则为location0 = keccak256(abi.encode(2))。 依次类推第二个值存储的位置则为location1 = location0 +1。然后根据sload(location)获取到对应的codex中的每一个值。
案例 Alien Codex 解决该puzzle则是需要更改该合约的owner地址为我们自己的合约地址。
如上合约创立的时候,默认已经设定好owner地址, 如下所示第一个slot的值则为该合约的owner地址。
可见,只要将slot0的位置更改为自己的地址,即可成为该合约的所有者。
起初我的想法则是,要使得keccakHashde(abi.encode(index))的值为0,那么对应的index值应该是多少?但是找了半天没有找到。
此时对于动态数组如何存储的规律就可以派上用场了,既然codex[0]值对应的slot位值已经知道了,同时整个合约存储的最大slot值为2^256, 那么 slot[2^256- array[0]]则就会对应合约中最后一个slot存储的位置2^256的值。
假设此时codex动态数组长度为的最大值为2^256。那么只要将codex[ 2^256- array[0]+1]的值设置为我们的想要的合约地址,那么此时合约中的第一个slot对应的值则为我们想要的地址,此时即对该合约拥有了所有权。
至于如何将codex动态数组长度为的最大值为2^256,不再此赘述,可参考如下方法。
3.3 合约之间的调用
我将智能合约的调用主要实现功能分为两大类,一类是发送ETH,一类是链上合约调用逻辑实现。当然两者可以结合起来,在实现链上智能合约逻辑时同时发送ETH.
关于发送与接收ETH,在solidity语言特性已经提到了destroy,Fallback。关于ETH发送的方法的如何选择历史上也是经历了一番讨论。如send,transfer,call几个方法都可以发送eth, 但是现在只是推荐使用call,同时需要防止重入攻击。
使用send,transfer时,对应的gas(2300)是固定的,由于实际区块消耗的gas费并不是一成不变的, 方法调用中也并不适合固定gas费.参考(https://consensys.net/diligence/blog/2019/09/stop-using-soliditys-transfer-now/)
可参考案例 King可参考案例 King
合约与合约之间的调用底层对应的调用方法有 call,staticcall,delegateCall
call可以直接调用被调用合约的方法,delegateCall也是直接可调用被调用合约的方法,但与call明显的一点不同则是delegateCall会修改调用合约方的state。由于delegatecall常用于代理更新机制方面,此处放在代理及更新机制进行说明。staticcall则是指再调用其他合约方法时并不改变整个区块的的状态值。
2. 合约调用中的变量变化
tx.orgin则是指一次合约调用中的初始方,经常则是EOA合约。
msg.sender:则是在整个合约调用链条中,被调用合约的调用方合约地址。
可参考案例 Telephone
合约调用可以是EOA,也可以是CA。而在CA中定义攻击逻辑也是常用的手段。
3. 业务逻辑依赖
涉及案例 Elevator,shop,Re-entrancy
业务逻辑依赖所导致的问题可能是最会频繁遇到的问题,无论是调用方还是被调用方的合约可能是CA合约,并且在CA合约中可以自定义任意逻辑,如果合约的相关业务逻辑依赖于外部,并且没有最好一定程度的校验,那么则是潜在的隐患。
案例 Elevator
其中 ! building.isLastFloor(_floor) 的实现依赖于外部调用方的合约逻辑,则攻击者则可以控制两次调用时的判断结果。
案例Shop
_buyer.price() 的逻辑,在攻击者合约中也可以进行控制。
而在实际的链上合约复杂的调用关系,这种依赖关系可能会更普遍也更复杂,有时想要发现其中的问题也不是一件容易的事情。
案例Re-entrancy 经典的重入攻击
当 msg.sender.call{value:_amount}(""); 调用方可以在自身的合约中再次调用该合约,这样会导致递归调用,
形如:合约(caller)B=》合约(called)A=》合约B(caller)=》合约A(called)... 从而提取所有合约A中的eth。
如下是攻击合约提取完被攻击合约所有的ETH的示例。
攻击合约A:0x.........d653666d
被攻击合约B:0x.........90ac810
可以看到如下的递归调用关系: 合约A => 合约B =>合约A=> 合约B...
链接地址
3.4 第三方合约使用
第三方如openzeppelin提供的标准库有很多现成可用的合约可以拿来即用,但是如果缺乏一定的了解,有可能导致某些方面的隐患。
Naught Coin
可以看到针对transfer方法,其有LockToken进行限制,但是集成的ERC20而言,发送ETH不仅仅有transfer方法,还有transferFrom()方法,如果攻击方直接调用该方法,则可以绕过LockToken的限制。
3.5 gas消耗攻击
gas消耗攻击也可以理解为合约逻辑依赖于外部逻辑,只不过这里指的是外部合约消耗完此次调用所有的gas。
案例Denial
在withdraw中方法中,其中有调用call但是并没有指定使用多少gas,如果攻击者合约此时消耗掉所有的gas,那么该withdraw方法将永远不可能完成。
3.6 业务逻辑的创新与新的可能性
在我看来defi起初建立的公式基础则是x*y=k。 其中x为tokenA的数量,y为tokenB的数量,k则是常数。tokenA与tokenB的之间的价格则是基于这个公式进行变化推算出来,如tokenA/tokenB的价格则为:y/x。
但是如果仔细考虑一些情况,则有潜在的风险。
如案例Dex中可以看到如下代码:
该puzzled的要求则是只要可以完全提取tokenA或tokenB在其中任意一个在池子中的数量即可。由于池子中tokenA或者tokenB的数量我们可以去调节,从而影响其价格,只要价格达到可以完全提取完任意一种token的数量即可。
如下所示,可以通过swap来调节tokenA与tokenB池子中的数量,当我将池子中tokenA/tokenB数量比为110/45时,同时当前自己又拥有超过45tokenB时,此时则可以将全部tokenA通过45个tokenB全部置换出来。
如上图所示,我总计进行了5次swap,通过第5次swap,将池子中tokenA与tokenB的调整为110/45. 此时我只要需45个tokenB即可兑换处所有的tokenA。
由此可见,单纯依靠池子的数量来定义价格,可能会被第三方控制,这时可以借助第三方的价格信息如chanlink的提供的价格信息,以避免价格被控制。
又比如案例Dex2,其目的是将两个token都全部提取完,dex2在dex的基础上只是省去了对于交易对token地址的校验。具体解决时感觉有些搞笑了。
此时我们可以构建自己新的交易对,比如构建tokenC,然后存入dex2合约地址10个tokenC,此时TokenC转换成tokenA的的比率则为100/10,可以直接通过10个tokenC获得100个tokenA。同理通过20个tokenC可以获得100个TokenB.
3.7 合约代理更新机制
由于链上合约代码的不可更改特性,但是某些情况下需要升级代码功能,如何处理?此时代理更新机制便应运而生。代理更新机制简单讲就是是在不改变原先代码的情况下去升级代码本身。简单来讲,代理更新机制涉及两个合约,一个是代理合约(proxy contract),其用来存储合约运行的最终状态,另一个是业务逻辑实现合约(implemention contract),合约的业务调用逻辑都通过该合约来实现。
当前代理更新机制分为两类Transparent 与 UUPS. 其主要区别是升级合约时前者是通过与代理合约(proxy)交互来实现,后者是通过与业务逻辑合约(implemention contract)交互来实现.
如案例Puzzle Walle,其是Transparent类型,代理合约权限修改及业务逻辑升级都是通过与代理合约交互来实现。该puzzle最终的实现是通过获取代理合约的所有者。
其代理合约为PuzzleProxy, 业务逻辑合约PuzzleWallet.
可以看到
代理合约PuzzleProxy,slot0,与slot1的分别存储pendingAdmin,admin.
业务逻辑合约PuzzleWallet,slot0,与slot1的分别存储owner,maxBalance
可见
业务逻辑合约(PuzzleWallet)-slot0:owner =>代理合约(PuzzleProxy)->slot0:pendingAdmin.
业务逻辑合约(PuzzleWallet)-slot1:maxBalance =>代理合约(PuzzleProxy)->slot1:admin.
此时只要将maxBalance设置为我们的的合约地址,即拥有了PuzzleProxy合约的所有权。接下来可以通过对multicall以及deposit方法的使用提取完代理合约中所有eth,最后执行setMaxBalance(uint256 _maxBalance)方法修改为我们想要的合约地址。
具体如何利用multicall,deposit,setMaxBalance,网上现在已经有很多人写的很详细了,在此不再赘述。
这里所展示的是使用代理升级机制时一个不容忽视的注意事项,即代理合约负责存储最终状态,但是执行业务逻辑合约时需要注意业务合约的合约变量状态不能与代理合约的变量状态相违背。
案例Motorbike
Motorbike 使用的则是UUPS 类型. 这样合约更新可以直接通过业务逻辑合约来实现,同时也减少了原先部署代理合约的gas费。这里解决该puzzle是让其业务逻辑合约Engine不能正常使用。
代理合约为Motorbike,业务逻辑实现合约为Engine.并且可以通过调用upgrateToAndCall的方法来进行业务逻辑合约的更新或者升级。
但是这里的问题Motorbike初始化业务逻辑合约Engine时,业务逻辑合约Engine本身的initialize()方法未执行,从而其对应的horsePower以及upgrader的状态也未更新。
从该点出发,无论是谁调用了Engine合约地址的initialize()方法,即可成为Engine合约的所有者。那么也就意味着可以调用Engine合约的upgradeToAndCall()方法,从而可以破坏Engine业务逻辑合约。
如下图所示,此时创建一个合约其包含销毁自身的方法(killMySelf), 如前所示我们当前可以直接调用upgradeToAndCall()方法,让Engine合约直接调用该Hacker address的KillMySelf,于是Engine最终会销毁自身。
回到开始的motorbike,由于其对应的业务逻辑合约已经销魂,故该代理合约已经无法调用任务方法。
可见UUPS 带来更好的灵活性同时,但使用时也要谨慎。
可参考如下讨论
ethernaut.openzeppelin
而针对https://ethernaut.openzeppelin.com/ 其部署合约时则用的TransparentUpgradeableProxy模式,业务逻辑的合约升级或者更新直接通过代理合约来实现。当我完成所有puzzles的时候,想要通过合约直接查询历史交互数据,一开始是直接通过业务逻辑合约的abi接口来访问业务逻辑合约的合约地址,大家知道实际业务数据是存在的代理合约中的,所以一开始怎么也查不到数据,当我意识到ethernaut是通过TransparentUpgradeableProxy来部署合约时,通过业务逻辑合约abi的接口直接访问代理合约的地址,于是数据就查出来了。如下
3.8 Bytescode级别
链上存储的合约代码实际一串16进制字符串。实际运行时EVM根据预先设定好的操作规则,在堆栈中执行其对应的opcode,同时修改memory或者storage中的数据状态,从而完成该笔tx。
案例MagicNumber
而针对于MagicNumber puzzle,其涉及则是对于EVM如何处理bytescode级别的数据。对于合约的理解程度达到bytescode的级别是合约编程高阶的必然要求,很多情况下都会涉及这方面的操作,比如通过Yui或者assembly直接操作memory,再很多头部协议的代码中如uniswap都可以看到。
具体可参考如下链接
该领域这两年获得不小的发展,如第三方工具包或者工具的完善,如大家常常用到的openzeppelin,chainlink,还有开发环境工具hardhat,foundary. 在我尝试完成上述puzzles的时候,ethnaut经常会提供一些更多的参考资料从而可以了解更多、更深入的这个领域的信息。如关于合约代理更新机制,如下讨论的时间基本都是在两年之内。
比如DoubleEntryPoint 中提到了forta,其由分布式网络的节点组成,扫描区块信息,并且可以监控对应的链上事件,并且当需要时,如发现潜在的风险事件可以同步告诉订阅者,并且触发订阅者一些自定义方法的执行。这一切都发生在链上。我的理解这是链上基础设施。与此类似基础设施如thegraph,通过对链上合约方法或者相关数据建立索引,方便开发者快速查询。
又比如对于安全事故的解析
这些散落在不同社群中的讨论,以及不断出现的新的工具或者底层设计,或者是整个社群中不断有人总结梳理的最新有价值的分享或内容,是个人跟进这个领域发展的核心源头,也是提高自己相应技能栈不可或缺的养料。
5.其他资料
1. ethernaut资料
ethernaut,github地址
ethernaut提供的解决办法
CFT 创建puzzle及提交puzzle流程图
挑战者每次创建对应的puzzle时,都会通过ethernaut合约调用对应的level工厂从而创建对应的实例。同时初始化对应的挑战者及挑战的puzzle数据。
每次提交时,都会通过ethernaut合约调用level工厂去检查对应的实例是否已经解决,如果解决同步相应统计数据。
业务逻辑合约代码 Statistics.sol(https://github.com/OpenZeppelin/ethernaut/blob/master/contracts/contracts/metrics/Statistics.sol);对应的链上地址https://goerli.etherscan.io/address/0x7000e0f2f5a389df14b50c6f84686123f19b27f6#code
代理合约 ProxyAdmin.sol:(https://github.com/OpenZeppelin/ethernaut/blob/master/contracts/contracts/proxy/ProxyStats.sol) ;对应的链上地址https://goerli.etherscan.io/address/0x7ae0655F0Ee1e7752D7C62493CEa1E69A810e2ed#code.
2.相关链接