Open Zeppelin制作的一个Web3/Solidity的对抗游戏,超级好玩,挺适合当作智能合约安全的入门教程
这里先放网址:Ethernaut (openzeppelin.com),非常推荐各位自己去尝试一下,一共30关
下面只是咱个人闯关的一些思路,算半个Writeup吧,但是tag就不放WP了
由于博客网站的代码块好像不支持Solidity格式上色,这里(和以后)我都会改成Javascript上色规则,但是代码还是Sol
那么,开始在以太坊的宇宙里翱翔吧,以航员~~
Stage 0 Hello Ethernaut
本关只是简单教你如何在Ethernaut网站使用控制台和合约进行交互,包括查看合约详情信息、调用合约内方法等。
基本跟着指南走就行了,等你输入contract.info()的时候就该开始动脑筋了~
contract.info()返回了”You will find what you need in info1().”
contract.info1()返回了”Try info2(), but with "hello" as a parameter.”
contract.info2(”hello”)返回了”The property infoNum holds the number of the next info method to call.”
contract.infoNum()返回结果是个words数组,第0位是42,说明下个info方法应该是info42(宇宙的终极秘密是吧)
contract.info42()返回了”theMethodName is the name of the next method.”
contract.theMethodName()返回了”The method name is method7123949.”
contract.method7123949()返回了”If you know the password, submit it to authenticate().”
那么password在哪呢?其实上面都说了,能查看合约详情信息,那我们输入contract看看有什么?
打开ABI那栏(不知道ABI是啥的最好先自己去了解一下),能看到存在一个属性为view的函数password,那岂不是说明我们直接contract.password()就行了?
contract.password()返回”ethernaut0”,然后我们用authenticate上交
contract.authenticate(”ethernaut0”)
等交易完成之后(一定要等完成!)再提交实例,恭喜你通过啦~~之后我也就不会那么细讲解咯~(大概)
Stage 1 Fallback
这题很简单:了解如何通过ABI/在ABI以外向合约发送ether,了解receive()
从这题开始,有源代码的题目我将直接使用Remix IDE而并非使用浏览器控制台,具体使用方法内网有做视频,或者网上自己找资源了解,使用起来不难的
简单分析合约:
令合约创建者为owner,其贡献值为1000ether;
contribute方法要求发送的ether不超过0.001ether,然后记录发送者贡献;
onlyOwner修改器要求交易发送者必须为owner;
withdraw方法由onlyOwner修改器控制,内容为取走合约内所有ether;
最下面的receive是如果交易发送者贡献大于0且向合约发送的ether大于0(因为Solidity不存在小数,所以这里的0值指代的是0 wei,而1 ether的值为1e18,即1 ether=1e18 wei),则owner易主,变为交易发送者
然后来着重讲一下receive()和fallback()两个函数,receive就是接收到外部转账的时候会调用这个方法并执行里面的内容,一般属性是receive external payable()
fallback有两种情况,一种是当调用合约时没有函数与函数适配符匹配的时候会调用fallback()(包括函数缺少/多出参数的情况,总之就是合约找不到所给函数的时候会调用),另一种是当未实现receive()方法的时候,合约从外部接收到转账的时候会调用
函数讲完了,接下来该讲讲怎么办了:
要求获取合约所有权并提取合约内所有ether,简单来说就是让owner变成自己然后调用一次withdraw(),如果是通过contribute方法也太抽象了,姑且不说能不能搞到1000ether,就是光调用也得要1e6次啊,gas都多到上天了
所以我们唯一的希望就是receive()方法,首先转账很简单,就是贡献大于0…贡献大于0我们直接用contribute不就好了?
所以思路如下:首先通过contribute方法向合约转账(1 wei都行),然后从外部转账给合约(1 wei都行),最后调用一次withdraw方法就好啦~
外部转账…你直接打开你钱包然后发送货币到合约地址不就好了?
嗯,恭喜你通关了,顺带记一下onlyOwner修改器吧,这个还是很有用的~
Stage 2 Fallout
这题的代码我就不一一分析了,总之就是一个存钱取钱的合约,但是只有owner才能提款
然后我们看到function Fal1out(),上面的注释告诉我们是constructor(构造函数)…
如果有学到C++的类的话,应该能联想到类的构造函数吧:
就比如我设置一个Animal类,那么我类里面定义的Animal函数就是这个类的构造函数(其实Solidity的合约看起来真的特别像C/C++里面的类)
不过这种构造函数的用法在Solidity 0.4.22之后就不再用了,都是直接使用constructor()来表示构造函数
回到这题,我们发现合约名称是Fallout,但是“constructor”的名称是Fal1out…
好,再来个public payable,这下所有人都能是owner了,绷不住了
所以这题非常简单:我们创建新实例,调用一下Fal1out函数,好了,提交就过关了
(如果要把给的合约源代码扔进Remix,需要把import那行改成import “@openzeppelin/contracts/math/SafeMath.sol”)
Stage 3 Coin Flip
一个“翻硬币”的合约,通过计算一个BlockValue然后除去一个因子来判断正反面,需要你连续猜对10次通关
很简单的一个伪随机数预测,基本就是一个复制粘贴就解决了
这里给出攻击合约:
1 | /// SPDX-License-Identifier: MIT |
我这边是把受害者合约的地址硬编码了,实际上如果不硬编码可以改成这样:
1 | CoinFlip public VictimContract; |
其实Solidity支持for循环,但是由于受害者合约中有个lastHash==blockValue就revert的操作,最后会导致循环过程中block.number没有改变最后导致flip函数revert的结果
(所以上面的攻击合约需要你等上个交易确认后过个几秒点一下attack,难绷)
Stage 4 Telephone
tx.origin和msg.sender之间的差异搞清楚就行了
有没有一种可能,2023第一批新生赛咱考过这个知识点呢(笑)
不多赘述,上攻击合约和通关后的小贴士:
1 | /// SPDX-License-Identifier: MIT |
小贴士:
这个例子比较简单, 混淆 tx.origin
和 msg.sender
会导致 phishing-style 攻击, 比如this.
下面描述了一个可能的攻击.
- 使用
tx.origin
来决定转移谁的token, 比如.
1 | function transfer(address _to, uint _value) { |
- 攻击者通过调用合约的 transfer 函数是受害者向恶意合约转移资产, 比如
1 | function () payable { |
在这个情况下, tx.origin
是受害者的地址 ( msg.sender
是恶意协议的地址), 这会导致受害者的资产被转移到攻击者的手上.
Stage 5 Token
看到pragma solidity ^0.6.0我就知道事情不对了(笑)
跟着Chainlink的教程学Solidity的时候就有了解到相关的东西,挺难绷的(当时他们还是用的0.6的Solidity,我自己找0.8的相关东西折磨死了,还有那个逼web3.py,受不了一点)
这题给的提示是odometer,即油量表,对应的考点是整型溢出攻击(Overflow)
简单来说就是这样:比如我有两个uint4:1011和0101,相加得到的理应是10000,但是由于uint4只有4位,所以最前面那个1会被省略,变成0000,这就和油量表一样,超过最大值9999.99就回到最小值0000.00一样
同时由于Solidity它这些uint不存在负数,所以就会出现这种溢出的情况,比如0000-1=1111的情况
所以这题的解法就很显然了:向另一个合法地址传21个token就行了,结果嘛…
如果在0.6下最好导入Openzeppelin的SafeMath.sol,当然如果你在0.8的话就不用担心这种溢出了,Solidity已经修复了
Stage 6 Delegation
一个利用delegatecall性质进行攻击的题目,有关delegatecall的各种性质和两个攻击点我都写在了那个半总结性的随想里面,由于篇幅问题,这里就不赘述了
有关delegatecall的内容比较多,而且需要一定的前置基础知识,不过可以了解一下,之后在Stage 16我们还会学习如何利用其特性攻击
这一题解题就只需要账户调用一下Delegate合约下的pwn()就好了,具体是为什么我也不清楚,主要是这个实例有两个合约,但是只有1个地址,我一开始用攻击合约攻击之后提交说我错,可能是给的地址就是Delegate合约的地址而非Delegation的地址吧…
(2024.7.16更新)做视频的时候又看了一下,整体思路还是delegatecall的攻击,就是实际上的操作是我们直接用EOA地址直接调用fallback就行,msg.data就是字符串pwn()
的函数选择器,也就是bytes4(sha3("pwn()"))
,当然可以使用abi.encodeWithSignature("pwn()")
获取到同样的结果,然后发送就行了,在Remix上的话就在Deployed Contracts
那里找到对应的合约,然后将得到的selector作为calldata进行tra
Stage 7 Force
一个只有可爱猫猫的合约~
半总结性那个博客有讲过正常情况下如果向合约转账,要么通过payable修饰后的函数,要么合约存在receive,要么存在fallback,否则EVM会报错
但是!上面的都可以被绕过!一般情况下我们有3种方法:
利用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:]
很明显,后两种攻击的构造比较困难,所以我们这里可以选择第一种攻击:
1 | // SPDX-License-Identifier: MIT |
Stage 8 Vault
一个金库,一个private的密码,一个locked表示是否上锁
你以为这个金库很安全,因为这个密码也在金库里面,但是有没有可能…这个金库是玻璃做的?(笑)
这里我们要讲一下private关键词了,其实private只能让其他合约无法访问这个被private修饰的东西,但是毕竟是放在存储里面的东西,肯定会存在链上,所以所有能访问区块链的人(也就是所有人www)都能看到这个东西的
这题有两个做法:一个是利用web3的工具getStorageAt,一个就是直接上区块链浏览器去看合约创建的交易里面合约状态的变化
我这边是用的后者,因为我目前还没有学Web3的JS开发,而且能直接搜到的不是比写一堆交互脚本更快?(笑)
(其实这题的密码也算是个小彩蛋)
(2024.4.10更新)这题输入密码的时候需要注意不要直接把得到的密码字符串输进去,Solidity不会进行数据类型转换的,因为它的参数类型已经被确定是bytes了,输入string的话会报错
e.g. password = 0x504e47,unlock的时候不要这样调用unlock("PNG")
,而是应该unlock(0x504e47)
,当然由于这里是bytes32,所以前面的0可能还得打全,变成unlock(0x0000...504e47)
Stage 9 King
一个一眼庞氏骗局的合约,就是A先往里面扔进奖池(假如是1 ether)并成为国王,然后B需要投入比奖池更多的钱(假如是2 ether)才能抢到国王,同时将B投入的钱打给A,以此类推
乍一看合约好像没有什么问题,但是transfer这个函数有一个特性,能够让我们保持王位不被夺走:transfer失败的话就会直接revert,状态直接回退
然后配合上call(失败返回bool false,但是不停止执行流),所以我们就可以这样攻击:
1 | /// SPDX-License-Identifier: MIT |
因为King合约在转移王位的时候会向上一个king发送钱,所以我们的攻击合约就有个receive,收到钱就直接revert,使交易失败,从而保持王位,也就是transfer性质导致的DoS攻击
当然,对于transfer/send两个有2300gas限制的转账方法,如果转账目标是一个合约,且该合约在receive的时候进行大量操作,同样会导致gas超出上限从而使交易失败
Stage 10 Re-entrancy
重头戏来了:单函数重进入攻击,漏洞合约多在0.6版本,不过0.8下也有可能出现,而且还有跨函数重进入,只读重进入等同类攻击
原理在于使用call转账的时候,如果转入的地址是合约地址而非EOA地址,那么就可能会调用到合约的receive或者fallback函数,而里面的代码可能会”重新进入“漏洞合约,打乱漏洞合约的逻辑
老样子,攻击合约:
1 | /// SPDX-License-Identifier: MIT |
目前(2024/3/3)要从合约里面拉出来的金额是0.001 ether,所以为了减少重进入的次数(原因我们分析完执行流再说)就让攻击的金额也变成0.001 ether
题目合约是先确认转完帐之后再进行状态变量的变化,但是转账完成要等fallback/receive执行完才能完成,所以这就出现了漏洞
让我们从头开始,我们先以0.001ether调用attack,首先我们先转账,让我们的balances变为1e15,然后我们马上withdraw
此时的漏洞合约首先进行提款金额的检查,确认没问题后转账,转到我们攻击合约的时候就调用了receive函数,此时我们的balances依旧是1e15不变,所以我们可以以1e15为最大金额再一次请求withdraw,然后就是无限的循环,直到漏洞合约没钱了,然后就和递归一样,不断地进行状态变量的更新
其实上面攻击合约的receive最好一开始检查一下漏洞合约的balance是否为0,不然可能会出问题
至于为什么要减少重进入的次数,是因为存在一个gas limit,EVM每次进行操作都会消耗gas,当gas消耗殆尽的时候就会直接revert,这是为了防止EVM和区块陷入死循环消耗资源,所以油管(B站有人转载)有个人他这题没做出来就是这个问题,他每次就提取1 wei,最后导致运行太多次消耗完gas了
当然,你可以自己提高gas limit,但是又有个自己无法操控的block gas limit,所以最好还是不要重进入太多次,尽量减少重进入次数,而且Solidity有个调用栈的最大深度:1024,而超过了这个大小就会有异常被抛出
至于为什么漏洞合约多在0.6下,是因为0.6还存在整型溢出漏洞(就是第5关那个考点),而0.8下如果整型溢出会直接revert,同时返回一个Panic Code,所以同逻辑的合约在0.8下是能预防重进入的
(有笨蛋一开始的时候忘记在donate前面加value了,存钱存0 wei,不愧是你)
Stage 11 Elevator
一个电梯,会通过Building接口调用其他合约中的isLastFloor函数,但是由于函数的修饰词只有external,所以函数内可以改变状态变量,所以攻击合约可以这么写:
1 | // SPDX-License-Identifier: MIT |
不过通过关卡之后有说也可以利用gasleft()函数定义一个view修饰的函数,能达到同样的效果,不过我现在还没那个水平,插个眼先吧
Stage 12 Privacy
和第8关类似,考点就是有关Solidity中如何在storage中存储数据的
Solidity为了优化存储分配,会将部分变量整合在一起,而不是每个变量一个slot
就比如两个bytes16会共用一个storage slot,因为16*8+16*8=128+128=256,刚好是一个storage slot的长度
具体如何分配的看这个文章:What is Smart Contract Storage Layout? (alchemy.com)
然后题目是用了格式转换,把bytes32转为bytes16,其实就是取前半段,后半段舍去,直接unlock就行了,没啥难度
看storage可以getStorageAt也可以Etherscan直接看合约创建的交易里面状态变量的变化,我懒点就Etherscan了
Stage 13 Gatekeeper One
我们的第一个4级题目!好耶!
gateOne还是很容易就能通过的,tx.origin和msg.sender不一致,用合约call就行了
先跳到gateThree,这个就是uint之间的强制格式转换了,这个还是很简单的,就是忽略高位而已
那么我们的三个判断式就能这样确认了:
1 | uint32 == uint16 => 倒数第2组16位为0,最后一组16位任意 |
最后总结一下,其实就可以用EOA地址推出_gateKey:
1 | _gateKey = bytes8(uint64(tx.origin)) & 0xFFFFFFFF0000FFFF; |
不过我懒得推,这些都是写这关Writeup的时候推的,当时做题是自己输入gateKey的,看代码就知道了(
再回到gateTwo,要求gasleft()能被8191整除,这很难,因为你不知道EVM究竟会用多少gas,所以做这题就只有两个途径:一个是利用Remix的debug自己去看EVM到gasleft()究竟消耗了多少gas,但是这个…有点太麻烦了吧…(而且可能不同EVM分支下消耗的gas数量也不同,比如Remix VM(Shanghai)消耗400+,而Sepolia的EVM消耗200+)
所以一般就是用第二种方法:爆破. 可以利用call的返回值进行爆破,如果返回值为true就可以打住了
大致思路有了,那就上攻击合约吧:
1 | // SPDX-License-Identifier: MIT |
从攻击合约调用enter就可以通过gateOne,gateTwo的爆破就是for循环,gateThree可以自己用上面推导得到的公式,也可以学我这样(笑)
Stage 14 Gatekeeper Two
水水水!直接杀了!
gateOne才做过,略过;gateThree就是一个异或运算,如果a⊕b=c,则有a⊕c=b,b⊕c=a,所以我们直接有gateKey等于另两个值的位异或
gateTwo看起来有点麻烦,但是就是个通过extcodesize判断地址是EOA/合约地址,但是由于extcodesize看的是地址保存在链上的bytecode的大小,而合约在constructor的时候并没有把bytecode保存至链上,所以gateTwo可以通过在constructor内调用enter来通过
最后就得到了这样的攻击合约:
1 | // SPDX-License-Identifier: MIT |
2023的Aurora第一批新生赛的第3题就是改编自这一题,删掉了gateThree
Stage 15 Naught Coin
得益于之前对ERC20的那次粗糙的临摹,这题也是非常快就过掉了
过关条件是用掉一开始给你铸造的1M个Naught Coin,而如果只看题目合约的话,你会发现唯一的办法就是使用题目合约的transfer方法,但是这个又被lockTokens限制住了,看起来完全没有办法了…
不过如果有去了解过ERC20的合约的话,就会发现除去使用transfer方法进行转账,还有transferFrom方法可以使用,同时又因为题目的NaughtCoin合约继承自ERC20,故NaughtCoin也具有ERC20的属性,自然可以使用transferFrom
唯一的问题是transferFrom需要allowance,这个其实approve一下就行了,总之先上攻击合约吧:
1 | // SPDX-License-Identifier: MIT |
步骤大致是这样:
- 首先我们把攻击合约部署到链上
- 然后我们使用自己的EOA地址(因为代币都铸造到这个地址了)调用NaughtCoin的approve,参数是攻击合约的地址以及
1000000 * 10 ** 18
,这样我们就给了攻击合约了allowance,这个allowance是这样的,假如我们地址是A,然后我们调用了approve(B, amount)
,那么之后B地址就能获得A地址里面amount
数量的代币的自由使用权 - 此时我们的攻击合约可以任意使用我们EOA地址上的代币,所以我们就调用
transfer_to_this_contract
,把所有的代币转移到攻击合约里面
这样我们就成功转移了所有的Naught Coin,最后提交就行啦~
Stage 16 Preservation
这题就是delegatecall潜在的危险,具体攻击的前置知识以及原理欢迎移步本人的智能合约随想博客里查看,因为咱做不到只用很小的篇幅描述攻击原理,所以此处不过多赘述
所以这里我就直接上poc了:
1 | // SPDX-License-Identifier: MIT |
用一句话来形容就是:占内存位,然后修改对应内存槽中信息
Stage 17 Recovery
这题有两种做法,我这里先讲一下逃课做法(笑)
逃课做法就是使用Etherscan查看自己创建实例时的交易详情,比如我这里查看交易的To:
我们根据题目可以知道我们创建实例会先转钱给创建实例的合约,这个合约再转钱给Recovery合约,最后Recovery合约再转账给SimpleToken合约,那么根据这个逻辑,我们知道了被红框框起来的就是遗失的SimpleToken合约,这样我们直接Remix导入调用destroy就好了
(3⭐一下变成了2⭐,乐)
那么我们再讲讲常规做法吧,题意是希望我们通过已知的Recovery合约的地址计算出SimpleToken的地址,而这确实是可行的,因为我们知道了创建者地址和nonce(因为SimpleToken是这个合约创建的第1个合约,nonce=1),那么我们就可以计算出对应的地址啦:
1 | address simple_token = address(uint160(uint256(keccak256(abi.encodePacked(bytes1(0xd6),bytes1(0x94), address(< level address here >), bytes1(0x01)))))); |
具体是为什么可以去了解一下地址预计算的相关知识,如果不嫌弃的话我隔壁博客有写,合约杂项随想3
最后destroy即可
Stage 18 MagicNumber
喜欢我手搓EVM字节码吗?(笑)
这题要求只用10个操作码返回42,说明肯定是在fallback里面了,因为如果添加函数选择器操作码就会增加,远超出限制
不过这里的10只限制deployedBytecode,也就是runtime bytecode,因为初始化的creation bytecode在创建完合约就会被丢弃,只有runtime bytecode保留在链上,不过这两个bytecode我们都是要写的
这里我就直接上字节码和解析了,有关EVM操作码的东西我确实不大了解,所以还请各位自行搜索资料
1 | // SPDX-License-Identifier: MIT |
当然,如果您会Yul的话,还可以这样写:
1 | object "Attacker" { |
Stage 19 Alien Codex
这题其实算是一道综合题,需要的前置知识是solidity ≤ 0.6的整型溢出,solidity ≤ 0.5 可对动态数组的length属性进行修改以修改数组长度,还有状态变量在内存的排布方式
这里先给出Ownable-05.sol的位置:https://github.com/OpenZeppelin/ethernaut/blob/master/contracts/src/helpers/Ownable-05.sol,我做这题的时候一开始是直接对地址用call的,但是不知道为什么总是revert,最后找到并导入了后再直接调用函数就出了,非常奇怪
先从动态数组的内存排布开始讲起,例如我们这题的合约,Storage Slot应该是这样:
1 | Slot 0: 0x(22 zeros) 01 (address owner) |
Slot 0是根据EVM的内存紧凑原则,由于address owner
仅使用20 bytes,而bool contacted
使用1 byte,20+1=21<32,故两个状态变量都放在Slot 0处
而Slot 1存放的内容是动态数组codex的长度,这样如果访问超出数组长度的内容会revert,同时存放数组长度的Slot index也很重要,因为数组内容是从Slot keccak256(Slot index)开始存放的,比如这题codex.length是在Slot 1的,故codex[0]在Slot keccak256(1)下;同理的,如果codex.length存放在Slot 11037,那么codex[0]在Slot keccak256(11037)处
不仅如此,这个版本下Solidity对内存区索引的溢出不会进行阻拦,也就是说假如我们有一个uint256 i,使得codex[i]对应的storage slot的索引是2^256-1,那么codex[i+1]对应的就会是storage slot 0了,所以假如我们让codex.length变成最大值,那么我们就获得了内存的任意读写权,非常危险
了解了上面的基础知识,现在我们就可以来解决这道题了。首先我们希望codex.length变成最大值,利用整型溢出和0.5版本可直接修改length属性的特性,我们先调用retract(),然后我们根据上面的知识构造出index,data就是我们自己的地址转成bytes32,最后调用revise(index, data)就行了,所以poc如下:
1 | // SPDX-License-Identifier: MIT |
Stage 20 Denial
这题还是好想到的,无非就那几种DoS攻击:函数表达式/调用栈溢出,0.7及以后的整型溢出,直接revert(但是这里用的是底层call,不会终止执行流),gas耗尽这些
这题很明显就是消耗所有的gas,所以搞个死循环就行了
1 | // SPDX-License-Identifier: MIT |
Stage 21 Shop
一个商店,其中购买要求卖家提供一个更高的价格才售出商品并将卖家提供的价格设定为商品价格,而题目要求是在购得商品的同时让商品价格小于初始价格
看到buy()
里面其实调用了2次price()
函数,如果price()
没有设置成view
的话,我们可以在调用第一次之后修改返回的值,但是被限制住了就无法修改状态变量了
那我们应该怎么办?其实我们前面的思路抽象化的话就是分为两个情况:购买时返回>100的值,完成购买时返回<100的值,而刚刚没有限制时的思路我们区分两种情况的依据是调用函数本身这个事件,所以如果被限制住了,我们就预先在函数保存两个值,然后在攻击合约外面找到前后状态发生改变的变量作为区分依据就可以了
看一下题目合约的两个price()
,我们发现中间有一个isSold = true;
,刚好是我们可以利用的东西,所以我们以isSold
为中心编写攻击合约:
1 | // SPDX-License-Identifier: MIT |
我们以题目合约的isSold
作为区分依据,如果值为false
就是没有售出,返回>100的值;反之返回<100的值
Stage 22 Dex
这题给的合约是一个交换两种ERC20代币的交易所,里面的setTokens()
和addLiquidity()
都是初始化的操作,而且被onlyOwner
修饰,无法操作,我们直接跳过
先看下面的两个函数approve()
和balanceOf()
,两个本质上都是调用的ERC20合约里面的函数,一个是调用方msg.sender
允许作为参数的地址address spender
调用自己一定数量amount
的代币,一个是返回对应地址address account
所拥有的对应代币的数量
approve()
直接像上面这样形容可能不太好理解,那换个例子,就假如有个代币A,两个人B和C,假如B调用了A.approve(address(C), 1000)
,那么就是B允许C任意使用B钱包里面总量为1000的代币A,所以C就可以使用A.transferFrom(address(B), to, amount)
这个方法从B的钱包转出amount
数量的代币A到任意的地址to
去啦
接下来看最长最复杂的swap()
交换代币的函数:首先强制要求只能在token1
和token2
之间转换,然后要求交易请求方拥有足够的代币进行交互,接下来就是利用getSwapPrice()
获取交换的数量,最后进行代币的交换
而getSwapPrice()
则是通过将题目合约所持有的两个代币之比乘上需要转换的数量得到最后需要交换的代币数量
看起来没什么问题,对吧,但是要记得Solidity里面只有整数,没有浮点!!!
所以使用getSwapPrice()
的时候,那个除法就会舍去所有的小数,只留下整数位!而这就是题目合约的漏洞!
我们目前拥有的Token数量是这样的:
1 | Token1 Token2 Token1C Token2C Transfer_Amount |
然后我们把所有Token1转成Token2,得到的SwapPrice
就是(10*100)/100=10
:
1 | Token1 Token2 Token1C Token2C Transfer_Amount |
此时还没什么大不了的,但是如果我们再把所有的Token2转成Token1的话,SwapPrice
就变成了(20*110)/90=24
!
1 | Token1 Token2 Token1C Token2C Transfer_Amount |
以此类推,最后我们得到的总转账表应该是这样的:
1 | Token1 Token2 Token1C Token2C Transfer_Amount |
所以我们最后可以使用下面的攻击合约:
1 | // SPDX-License-Identifier: MIT |
首先你需要自己直接调用题目合约的approve
,让你的攻击合约可以使用你的代币并且转到自己手里,attack()
之后调用retrieve_all()
来收回所有的代币到自己手里(当然你也可以直接整合到attack()
里面)
这样我们就达成了题目的要求:取走题目合约其中一种代币的所有代币,同时留下一个“坏的”余额(你怎么除掉0嘛)
To be continued…(正在激情闯关/制作当中~)
- 本文作者: 9C±Void
- 本文链接: https://cauliweak9.github.io/2024/01/21/Ethernaut/
- 版权声明: 本博客所有文章除特别声明外,均采用 MIT 许可协议。转载请注明出处!