L04c 调用其他合约与安全:把家门钥匙交给一支上门的装修队
一行外部调用就是一次交权,对方能在你记账之前反手调回来。
一句话版
一行外部调用就是一次交权:对方的代码能在你落笔记账之前,反手再调你一次。
一个类比:把家门钥匙交给一支上门的装修队
叫装修队上门,第一件要想的事是这支队伍你认不认识。熟人组的队你知道他们会干什么;小广告叫来的队,报价单写着刷墙,人进门之后干什么你管不了。这两种对应 new(自己在链上创建合约)和把一个已存在的地址强转成合约类型。
第二个分岔是:你去他们店里干活,还是让他们进你家干活。前者用他们的材料改他们的墙,门卫登记的来访人是你;后者用你家的水电改你家的墙,门卫登记的还是最初按门铃那个人。call 和 delegatecall 的差别就在这里。第三件事最容易翻车:结账。钱递过去,账本上“还欠多少”那栏还没划掉,对方接过钱就伸手又要一次,你低头一看数字还在,于是又付一次。重入攻击就是这个动作在同一笔交易里连做十几遍。
类比在哪里失效:现实里装修队再坏也得一件一件干,中间你有反应时间。链上这串嵌套调用全发生在同一笔交易内部,等你能看到时交易已经上链或者整体回滚。另一处:现实里换一家装修公司不影响你家墙的位置,delegatecall 换一份代码却可能把承重墙当隔断拆掉——它按存储槽位编号写,不认变量名。
概念卡
1. 叫谁来干活:new 与地址强转(Calling Other Contracts)
人话定义:拿到另一个合约的引用有两条路——用 new 当场在链上创建一个(p.64),或把一个已存在的地址强制转换成合约类型(p.65)。
例子:第一种最安全,那份代码是你亲手部署的,你确切知道那个地址上跑的是什么:
contract Token is Owned {
Faucet _faucet;
constructor() { _faucet = new Faucet(); }
}
第二种是给已存在的实例套上一个已知接口。课件警告写得很重:必须百分之百确定那个实例确实是你假设的类型(p.65)。
constructor(address _f) {
_faucet = Faucet(payable(_f));
_faucet.withdraw(0.1 ether);
}
p.66 把风险收成三条:调用其他合约非常有用但潜在危险;风险来自你对调用对象、或对调用你的那一方所知甚少;没有任何东西能阻止恶意合约调用你的代码或被你的代码调用。
常见误解
以为 Faucet(payable(_f)) 会让编译器替你验一下对方是不是 Faucet → 它什么都不检查,只是说”按 Faucet 的 ABI 编码调用数据”。具体反例:_f 指向恶意合约时,_faucet.withdraw(0.1 ether) 会去调它上面选择器等于 withdraw(uint256)(0x2e1a7d4d)的函数,函数体里写什么都行;EIP-165 就是为了让“对方确实实现了这个接口”可以被验证。另一个误解:以为 new 只是拿个引用 → 它在部署时真的花 gas 造一个新合约。
2. call 与 delegatecall:代码在谁的存储上跑(Low-level Calls)
人话定义:call 让对方在对方自己的存储上执行;delegatecall 把对方的代码搬过来,在我的存储上执行(p.64–73)。
例子:同一份 Lib.setValue(42) 代码,两种调法改的是两个不同合约的变量:
课件用”用户 A 通过合约 B 调用合约 C”这个场景把两者并排(p.69、p.70),合并成一张表就是最该背的形式:
| call | delegatecall | |
|---|---|---|
| 代码来自 | 合约 C | 合约 C |
| 执行上下文 / 存储 | 合约 C | 合约 B |
msg.sender | 合约 B | 用户 A |
msg.value | 本次调用传的值 | 保持原值 |
address(this) | 合约 C | 合约 B |
| 谁的状态被改 | 合约 C | 合约 B |
一句话记:call 是去别人家用别人的数据,delegatecall 是把别人的代码搬回自己家跑。后者保持 msg.sender 与 msg.value 不变,这是代理模式的全部基础——代理合约保管存储,逻辑合约随时可换。调用数据由 abi.encodeWithSignature("f(uint256,address)", _x, _addr) 生成,签名规则和函数选择器一样:只有类型、没有参数名、没有空格(p.71)。
常见误解
以为 raw call 失败会像普通函数调用那样自动回滚 → 不会,失败时只是 success 为 false,函数继续往下跑。具体反例:课件 p.73 的 testCallFoo 只把 (success, data) emit 出来,没有 require,照抄进生产代码就是”钱没转成功,账本已经记成转成功”。第二个致命点是槽位错位:delegatecall 按槽位编号写不按变量名写,调用方第 0 槽是 owner、被调方第 0 槽是 value,一次“改 value”就把 owner 覆盖掉,Parity 多签钱包损失几十万 ETH 源于此。第三条纪律:被 delegatecall 的合约等于拿到调用方全部权限,绝不对不可信地址使用。
3. 重入:在你记账之前反手调回来(Reentrancy 与 CEI)
人话定义:外部调用把控制权交到对方手里,对方能在你更新状态之前再调你一次(p.66)。检查-生效-交互就是把”更新状态”挪到”外部调用”前面(p.74 补充)。
例子:同样三行,顺序决定生死。
// 危险:交互在前,生效在后
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "transfer failed");
balances[msg.sender] = 0;
// 安全:checks -> effects -> interactions
balances[msg.sender] = 0;
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "transfer failed");
用 call 转账时,收款方若是合约就会触发它的 receive 或 fallback,它能在那里回头再调你的提款函数。第二道防线是重入锁,用修饰器写成 locked = true; _; locked = false;,进入时先 require(!locked)——这用上了 _; 能放在中间、让修饰器在函数体前后各跑一段的性质(p.43–44),OpenZeppelin 的 ReentrancyGuard 就是它。
常见误解
以为函数开头那句 require(amount > 0, "nothing to withdraw") 能拦住重入 → 拦不住。它读的 balances 要到清零那行才改。具体反例:嵌套的十几层 withdraw 里每层读到的 amount 都是同一个原始本金,require 每次都放行;错的是”生效”排在”交互”后面。第二个误解是把 transfer 当修复:它只给 2300 gas,receive 里确实调不动 withdraw,但 2019 年伊斯坦布尔升级之后这个额度已不可靠。
4. 最佳实践七条与版本变更(Good Practices)
人话定义:p.74 把整讲的坑收成七条,每条背后都对应前面某一页。
| 实践 | 对应哪里 | 为什么 |
|---|---|---|
| 避免动态数组 | p.19 | 遍历成本随长度线性增长 |
| 避免调用其他合约 | p.66 | 不知道对方代码会做什么 |
| 优雅地处理失败 | p.26、p.57、p.73 | 低层调用不自动回滚 |
| 明智地使用修饰器 | p.43 | 前置条件抽出来,可审计 |
| 避免不必要的循环 | p.19 | gas 失控的主要来源 |
| 用最新版编译器和库 | p.7 | 新版本堵死整类漏洞 |
| 正确格式化代码 | p.48 | 0.1 ether 比 100000000000000000 安全 |
课件没列但要记的两条:用检查-生效-交互的顺序防重入;用 OpenZeppelin 的现成实现,不手写 ERC-20 和权限控制。版本口径另有一处:selfdestruct 在 EIP-6780 之后不再删除代码与存储,只转走余额(p.5、p.41 + 补充)。
常见误解
以为”避免动态数组”只是一条性能建议 → 在合约里它是功能性故障。具体反例:uint[] public numbers 靠 numbers.push(_number) 一直往里加,遍历它的函数在数组够长之后 gas 超过区块上限,于是永远无法成功执行,锁在合约里的钱再也取不出来。传统程序里”数组大所以慢”只是性能问题,这里是死锁——七条里的第一条和第五条是同一件事的两面。
把它们串起来
这一段课件回答的是同一个问题的三层:你的代码跟一个你没写过的合约打交道时,到底把什么交了出去。
第一层是身份不明。new 和地址强转的区别就是”这份代码我部署的”和”这个地址我信它是那个类型”,后一种编译器什么都不验(p.64–65)。第二层是执行环境不明:call 和 delegatecall 的差别不在语法,在这段代码改的是谁的存储、msg.sender 记的是谁(p.68–70);代理升级为什么可行、Parity 钱包为什么被锁死,是同一张表的正反两面。
第三层是时序不明。控制权一旦交出去,对方能立刻掉头回来,而你的状态还停在旧值。检查-生效-交互用顺序消灭这个窗口,重入锁用状态位再补一道(p.43–44、p.74 补充)。七条里的“避免调用其他合约”和“优雅地处理失败”说的是同一件事:每次外部调用都是一次交权,非交不可就先把账记清楚。
课件里的坑
- [前后矛盾] p.5 说合约可以被
SELFDESTRUCT删除,p.41 又说它已被弃用。两页分别站在 EIP-6780(2024-03-13 Dencun 升级)的两侧:升级后selfdestruct只转走余额,不再删除代码与存储,唯一例外是合约在同一笔交易内被创建(p.5、p.41 + 补充) - [遗留未定义] p.65 的示例写
contract Token is mortal,而mortal在整份课件里从未定义过,p.64 的同一段代码写的是is Owned。这是从原书摘录留下的痕迹(p.64、p.65) - [教学代码勿抄] p.73 的
testCallFoo/testDelegatecallFoo只把(success, data)emit 出来,没有require(success, ...);同一页的delegatecall例子碰巧安全,只因为Receiver.foo不写存储,foo一旦写状态变量就会写进Caller的存储槽,而Caller根本没声明对应变量(p.70、p.73) - [课件未点名] p.66 整页描述的正是重入攻击的成因,但 reentrancy 这个词从头到尾没出现;p.74 的七条里也没有检查-生效-交互,两处都要自己补(p.66、p.74 + 补充)
课后 10 分钟:考点复习
这 10 分钟怎么用:先把 call / delegatecall 那张六行表默出来,抓住”代码在谁的存储上跑”这句,六行都能推回去;再把下面那道重入题的攻击过程在纸上跑一遍,数字对不上说明还没想透;最后背七条最佳实践。
必背
- call 与 delegatecall 的差别在于代码在谁的存储上执行:call 在被调合约自己的存储上跑、msg.sender 变成调用方合约;delegatecall 把被调合约的代码搬到调用方的存储上跑,msg.sender 与 msg.value 保持为原始调用者——这是代理模式与可升级合约的基础。
- delegatecall 要求调用方与被调方的状态变量声明顺序完全一致,否则会按槽位错位写入;raw call 与 delegatecall 都只返回 (bool success, bytes data) 而不会自动回滚,必须自己检查返回值。
- selfdestruct 在 EIP-6780(2024 年 3 月 Dencun 升级)之后不再删除合约代码与存储,只转走余额;仅当合约在同一笔交易内被创建时才仍执行完整销毁。
- 重入的成因是 p.66 那条总纲:没有任何东西能阻止任意复杂、甚至恶意的合约调用你的代码或被你的代码调用;用 call 转账会把控制权交给收款方并触发它的 receive 或 fallback,它可以在那里回头再调你的函数。
- 检查-生效-交互(checks-effects-interactions)顺序:先检查前置条件、再更新自己的状态、最后才做外部调用;第二道防线是重入锁,用修饰器写成 require(!locked); locked = true; _; locked = false;。
- 最佳实践七条:避免动态数组、避免调用其他合约、优雅处理失败、明智使用修饰器、避免不必要的循环、使用最新版本的编译器和库、正确格式化代码;第一条与第五条同源——gas 失控在合约里是功能性死锁而不是性能问题。
完整例题
题面(补充例题,课件只在 p.66 给了成因):下面的 Bank 让用户存钱取钱。指出漏洞,写出攻击过程,给出修复。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.21;
contract Bank {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0, "nothing to withdraw");
(bool ok, ) = msg.sender.call{value: amount}(""); // ← 交互
require(ok, "transfer failed");
balances[msg.sender] = 0; // ← 生效,写在交互之后
}
}
contract Attacker {
Bank bank;
constructor(address _bank) { bank = Bank(_bank); }
function attack() external payable {
bank.deposit{value: msg.value}();
bank.withdraw();
}
receive() external payable {
if (address(bank).balance >= 1 ether) {
bank.withdraw(); // ← 在收款时反手再调
}
}
}
-
定位漏洞:
withdraw先转账、后清零。call把控制权交给收款方,收款方是合约就触发它的receive;清零那行执行之前,balances[attacker]一直是原来的数。 -
设定初始状态:
Bank里已有其他用户存的 10 ether,攻击者本金 1 ether。 -
attack{value: 1 ether}():先deposit,balances[attacker] = 1 ether,Bank余额 11 ether,紧接着调withdraw。 -
第 1 次
withdraw:amount = 1 ether,转出 1 ether,Bank剩 10 ether,控制权进入receive。 -
嵌套:
receive看到 10 ≥ 1 ether,再调withdraw;清零那行还没执行,amount仍读到 1 ether,再转 1 ether,Bank剩 9 ether,又进receive,如此往复。 -
终止:第 11 次转出后
Bank降到 0,receive的条件不再成立,11 层withdraw依次跑完各自的清零语句。攻击者用 1 ether 本金取走 11 ether,Bank归零。The DAO 就是这个形状。 -
require(amount > 0)为什么没拦住:它检查的是存储里的balances,而存储要到最后一行才改,错的是顺序。 -
修复一:调整顺序——把清零挪到转账之前:
uint256 amount = balances[msg.sender]; require(amount > 0, "nothing to withdraw"); // checks balances[msg.sender] = 0; // effects (bool ok, ) = msg.sender.call{value: amount}(""); // interactions require(ok, "transfer failed");再走一遍攻击:第 2 次
withdraw读到amount = 0,require失败、内层回滚,call返回ok = false,外层require(ok)也失败,整笔交易回滚。 -
修复二:重入锁——修饰器写
require(!locked); locked = true; _; locked = false;,第 2 次withdraw在锁上被拦住。两种修复可叠用。
变式题(先自己做)
(1) 换一组初始条件:Bank 里其他用户存了 5 ether,攻击者本金 2 ether,Attacker.receive 的阈值同步改成 address(bank).balance >= 2 ether。withdraw 嵌套几层?攻击者取走多少?Bank 最后剩多少?(2) 有人提议把 msg.sender.call{value: amount}("") 换成 payable(msg.sender).transfer(amount),理由是 transfer 只给 2300 gas。为什么这不算推荐修复?
提示
第一问照着例题第 4–6 步一层层记账,每层只写两个数:这层转出多少、转完还剩多少。每层读到的 amount 都是同一个本金,因为清零那行始终没轮到执行;终止判断用的是转账之后的余额。第二问想两件事:2300 gas 这额度是不是永久不变,以及就算挡住了,withdraw 里那个顺序有没有改过来。
参考答案与自检(非官方评分标准)
自检要点:① 记账从 deposit 之后的 7 ether 起算,漏掉本金是最常见的算错;② 终止条件比的是转账后余额与阈值 2 ether;③ 第二问要答出额度已不可靠、顺序没被修正两层。
(1) deposit 之后 balances[attacker] = 2 ether,Bank 余额 5 + 2 = 7 ether。第 1 次 withdraw 转出 2、剩 5,receive 判断 5 ≥ 2 继续;第 2 次转出 2、剩 3,3 ≥ 2 继续;第 3 次转出 2、剩 1,1 < 2 停。一共嵌套 3 层 withdraw,攻击者取走 6 ether,扣掉 2 ether 本金净赚 4 ether,Bank 最后剩 1 ether ✓。剩下这 1 ether 是阈值写死成 2 ether 的结果。
(2) 两个理由。第一,2300 gas 这个额度不是永久承诺,2019 年伊斯坦布尔升级之后它已经不可靠,社区不再把它当推荐做法。第二,它治的是症状:withdraw 里”生效排在交互之后”这个顺序一个字都没改,防线全押在”对方没 gas 干坏事”上。真正的修复是把清零挪到转账前面,或者加重入锁 ✓。
闪卡自测
1. delegatecall 和 call 一句话的差别?
代码在谁的存储上跑。call 在被调合约自己的存储上跑,msg.sender 变成调用方合约;delegatecall 把被调方代码搬到调用方的存储上跑,msg.sender 和 msg.value 保持为最初那个用户(p.68–70)。
2. 用 delegatecall 时最容易出的致命错误是什么?
状态变量声明顺序对不上。它按存储槽位编号写入,不按变量名:调用方第 0 槽是 owner、被调方第 0 槽是 value,一次”改 value”就把 owner 覆盖了(p.70)。
3. selfdestruct 现在还能删掉合约吗?
一般不能了。EIP-6780(2024 年 3 月 Dencun 升级)之后它只把余额转给指定地址,代码和存储原样留在链上,唯一例外是合约在同一笔交易内被创建(p.5、p.41 + 补充)。
4. 检查-生效-交互为什么能防重入?
外部调用是唯一能让控制权跑到别人代码里的地方。先检查、再更新状态、最后才调外部,对方在收钱时反手调回来时账本已经改完,重复提款的条件不再成立(p.74 补充)。
5. 调用其他合约的两种方式,各自的风险在哪?
new 最安全,代价是部署时真花 gas 造一个新合约;地址强转时编译器完全不检查类型,那个地址上若是恶意合约,你以为在调 withdraw,实际调的是它选择器相同的任意函数(p.64、p.65)。
6. abi.encodeWithSignature 的签名里多打一个空格会怎样?
算出完全不同的哈希,前 4 字节对不上任何函数,调用落到对方的 fallback 上。编译器不报错,只有运行时才能发现,这是低层调用最常见的 bug(p.71)。
7. low-level call 失败了会自动回滚吗?
不会。两者都只返回 (bool success, bytes memory data),失败时 success 为 false,函数继续往下执行。生产代码必须写 require(success, "call failed")(p.73)。
8. 为什么绝不能对不可信地址做 delegatecall?
被调方代码在调用方的上下文里执行,等于拿到调用方的全部权限:能改任何一个存储槽、能动用调用方的余额。这一行等于把合约钥匙交给那个地址(p.70)。
9. "避免动态数组"和"避免不必要的循环"为什么是同一条建议?
两者都指向 gas 的不可预测性。数组长到一定程度,遍历所需 gas 超过区块 gas 上限,函数从此永远调不成功——在合约里这是功能性死锁(p.19、p.74)。
10. 重入锁用修饰器怎么写?
靠 _; 能放在中间这条性质:require(!locked); locked = true; _; locked = false;。函数体执行前上锁、执行后解锁,重入进来的第二次调用在 require 处被拦住(p.43–44)。
下一讲
语法和安全都铺完了,下一步是把它们跑起来:写测试、估 gas、发到测试网,再用前端连上去。
下一讲的通俗笔记上完课会补,先回 COMP5565 课程页。
个人整理的学习笔记,不是官方材料;数字与结论以课件和讲师为准。