VIEW THIS AS

Auto mode follows the Route Engine until you choose a viewpoint.

YOU ARE HERE

ROUTE CHECK

CONNECTED TO

WHAT NEXT

Use the canonical route for this room, or HELP if you are unsure.

智能合约重入:追踪回调可见状态并检查每个入口

Three students in school uniforms work through open books at a classroom table, with textbooks and stationery nearby and study notes on the whiteboard behind them.

English version · 区块链学习导航 · 上一篇

区块链学习 · 延伸文章 BCX007

三名学生在教室桌旁一起阅读书本,身边放有课本和文具。
学习场景配图;不代表照片中的学生正在学习区块链。

合约可能在自己的操作尚未完成时,把控制权交给另一个合约。如果对方再次调用回来,新调用就可能看到已经被原操作检查、却还没有更新的状态。这样,两次单独看起来合理的容量检查,可能共同批准超出系统承载能力的工作量。

因此,重入涉及进行中的操作内部,控制流如何经过共享状态。它不要求两笔交易并行执行。评估时应跟随调用,识别每个调用帧中的调用者,并记录每次检查实际读取了什么存储。

本课使用虚构的目录预约服务,没有付款、代币、钱包、真实部署目标或可执行攻击指令。所有名称和数量都是课堂符号。练习比较几种明确规定的设计,不声称完成生产合约审计。

选择适合你的阅读路线

本篇目录

第1至3节定义不变量、入口和调用帧。第4至7节追踪原设计及两种候选防护。第8至10节保留失败行为,并检查观察边界。练习提供准确状态答案。关于帧与返回的一般概念,可阅读递归中的调用栈如何工作。

返回目录 · 下一节

1. 定义目录系统的承诺

目录C可接受十个单位的虚构数字化工作,容量为10。存储包含总接受数量reserved,以及按调用者记录数量的accepted映射。起初reserved为0,accepted[U]和accepted[H]也为0;其他条目都是0,在本练习中保持不变。

规定的边界不变量有两部分:reserved必须等于所有accepted条目的总和,而且该总量不得超过容量10。我们在完整的状态更新阶段结束时,以及控制权交给其他合约或从其他合约返回时检查它。在本模型中,一个更新阶段内连续的本地写入之间,没有外部调用或观察者介入。

接受预约时,应将相应调用者的条目和总量增加同一个正数。因容量不足拒绝请求时,不写入预约状态。权限及输入有效性也是要求,但除非案例明确另作规定,给定的U和H请求都满足这些要求。

只检查总量不够。如果reserved显示6,而accepted映射合计11,显示总量就掩盖了已接受义务。反过来,两者都为11虽然满足相等关系,却仍超过容量。两个不变量条件都重要。

返回目录 · 下一节

2. 写明两个入口,包括替代路径

原始外层预约入口允许U请求6。它检查当前reserved加6是否超过容量。如果通过,就在记录预约之前调用外部通知钩子H。H成功返回后,它把accepted[U]增加6,并把当时存储中的reserved增加6。

最后这次加法重新读取存储,不复用最初容量检查时的总量,而且H返回后不再次检查容量。这些细节决定结果;改变其中任何一项,都变成另一个程序。

第二个入口是替代预约路径,允许获授权的调用者H请求5。题目明确授予H使用该入口的权限。此路径检查当前存储的reserved加5;通过后,把accepted[H]和reserved各增加5。它不继续调用外部合约,所以题目只有一次嵌套预约,不是无限递归。

在回调案例中,H处理C的通知时调用替代入口,然后返回成功确认。外层帧先前通过检查,并不会自动预留容量。只有规定的存储更新才建立预约。

返回目录 · 下一节

3. 跟随调用者和暂停中的操作

控制顺序是U调用C的外层入口,C调用H的通知钩子,H再调用C的替代入口。钩子和嵌套调用执行期间,外层C帧处于暂停状态,只有它们返回后才继续。

在外层C帧中,给定调用者是U,请求数量为6。在嵌套的替代C帧中,调用者是H,数量为5。如果因为U发起了交易,就把两者都当作U,会记录错误的映射条目,也会模糊权限问题。

Solidity 0.8.30合约文档区分外部消息调用与内部函数调用。这里,外部钩子是让给定回调发生的控制转移边界;并非只是在同一条顺序操作中调用私有辅助函数。

固定版本的安全说明也解释,这类回调风险不限于转移以太币。本课无金融资产转移的目录模型,同样能展示共享状态问题。

返回目录 · 下一节

4. 基准情况与回调轨迹

先看独立的无回调基准。U的检查读到reserved为0,所以0+6不超过10。H直接确认,不调用C。外层更新阶段写入accepted[U]=6和reserved=6,accepted[H]保持0。边界不变量成立,六个单位被预约。

然后把所有被跟踪值重置为0,允许规定的回调。外层检查再次使用0并通过。当C把控制权交给H时,其预约尚未记录:reserved=0,accepted[U]=0,accepted[H]=0。

H的替代请求读取同一个存储总量0。检查0+5,通过。嵌套更新阶段写入accepted[H]=5和reserved=5,accepted[U]仍为0。这个边界状态一致,而且没有超出容量。H成功返回暂停中的外层帧。

外层帧从钩子之后继续。它写入accepted[U]=6,并重新读到reserved为5,再加自己的数量6。完整更新阶段结束后,reserved=11,accepted[U]=6,accepted[H]=5。相等关系仍成立,但超过容量一个单位。

这条轨迹的第一次容量失败,出现在外层完整更新阶段结束时。两次连续本地写入之间的暂时不一致是另一个问题,在给定模型中并不是外部可观察边界。原设计也没有自动的最终不变量检查或回滚。

返回目录 · 下一节

5. 缓存总量的变体以另一种方式失败

再次重置。保留相同回调和嵌套写入,但改变外层实现:最初检查时保存oldTotal=0,后来执行reserved=oldTotal+6。accepted映射仍为U增加6。

嵌套调用结束后,accepted[H]=5,reserved=5。恢复执行的外层帧使用过时的局部值,把reserved赋为6,同时accepted[U]变为6。最终accepted合计11,但reserved报告6。

这个变体并没有因为显示总量低于容量就修复问题。它在汇总账目中丢失了嵌套预约。相等关系不成立,而且已接受的总义务仍超过预期容量。

所以审查必须识别缓存值和重新读取的值,不能只说“回调发生了两次”。重新读取的原版本最终总量11;缓存版本最终总量6,但映射合计11。虽然按同一个目录承诺两者都不可接受,数值描述必须不同。

返回目录 · 下一节

6. 把完整预约更新放到外部交互之前

一个新候选先检查容量,再写入accepted[U]=6及reserved=6,然后才调用H。钩子返回后不重复这些更新。因此,在转移控制权的边界,包含映射条目的完整预约已经可见。

H随后尝试同一个获授权的替代请求5。该入口读到reserved为6,检查6+5是否超过10,因而在写入预约之前拒绝。题目明确规定,H捕获这次嵌套请求的拒绝,仍向外层返回成功确认。

外层最终以reserved=6、accepted[U]=6、accepted[H]=0完成。两个不变量条件都成立。无需否认H的身份,也无需假装回调不会发生:回调观察到了已包含U预约的状态。

这体现了Solidity安全说明中的检查、更新状态、外部交互顺序。本课展示的结果,只针对给定入口、完整更新和失败处理,不是对真实系统每个函数、依赖或外部副作用的普遍保证。

只完成一半的重排仍然不足。例如在钩子之前写reserved,却延后accepted[U],就会在边界暴露映射与总量不一致。任务是让所需状态保持协调,而不是仅移动一条方便移动的赋值。

返回目录 · 下一节

7. 比较单一入口防护与共享防护覆盖

再看一个基于原始延后更新设计的独立候选。外层入口执行期间设置忙碌标记,忙碌时阻止再次进入受保护入口。但替代入口不检查该标记。因此H仍进入替代入口,在外层更新前预约5。

结果仍为reserved=11,accepted条目分别为6和5。外层入口的防护并没有保护通向同一预约状态的未受保护路径。重要的是哪些入口遵循同一个排斥规则,而不是合约里是否有一个叫“防护器”的组件。

现在让两个预约入口都遵循同一个共享防护。外层入口活动期间,H的嵌套替代调用会在读取或改变预约状态之前被拒绝。本题规定H捕获拒绝并返回确认。外层完成原先更新,最终reserved=6、accepted[U]=6、accepted[H]=0;正常完成时释放活动标记。

OpenZeppelin Contracts 5.x文档描述了标记为nonReentrant的函数共享防护,以及一个这样的函数不能直接调用另一个的限制。本比较关注覆盖范围和调用结构,不建议安装特定实现,也不假定每个入口都自动继承保护。

防护也改变了哪些嵌套行为被允许。如果应用确实需要某种嵌套操作,其架构和失败政策就需要审查。仅仅因为阻止所有工作能避免本次超额预约,并不代表预约服务实现成功。

返回目录 · 下一节

8. 钩子失败时,保留规定的业务结果

使用一个新的“先更新、后交互”案例。H失败时,外层已经记录U的六个单位。在要求确认的版本中,C继续传播失败,让整个外层操作回滚。题目规定的事务回滚恢复所有被跟踪预约值为0。

在独立的可选通知版本中,C明确处理钩子失败,并保留已接受预约。操作完成时reserved=6、accepted[U]=6、accepted[H]=0。这可以符合尽力通知政策,却不等同于“必须取得确认才接受”的规则。

不能为了让防护显得成功,就暗中把第一种政策替换成第二种。测试必须说明通知失败究竟意味着预约被拒绝,还是预约已接受但通知失败。两个分支都需要准确报告结果。

这里的回滚描述针对模型执行状态,不声称抹掉外部观察者的记忆、独立文件或不可逆的现实动作。另见交易失败与回滚边界,了解为什么必须明确失败范围。

返回目录 · 下一节

9. 检查最终完成之前能观察到什么

最终状态快照有用,却不能代替控制转移轨迹。原设计向H暴露总量0,尽管U已经通过了数量6的容量检查。问题在于,另一个入口再次检查相同资源之前,前一个检查并未建立其承诺状态。

完整更新候选则暴露reserved为6、accepted[U]为6。观察者能够看到一个一致的进行中预约。不过,这种观察未必是不可逆最终结果:要求确认的案例仍可能让整个外层操作回滚。

只读观察即使不改变C的存储,也可能影响消费该信息的另一个系统。本练习并没有声称存在额外的只读漏洞,而是指出正确的审查问题:谁在使用哪个外部可见状态,使用时哪些外层结果尚未确定?

对每次外部交互,应记录交出控制权之前的值、控制权离开期间可进入的路径,以及返回或失败的条件。这比假定函数开头的检查在所有后续控制流中持续有效更有用。

返回目录 · 下一节

10. 把轨迹变成验收工作表

简明工作表应列出外层和嵌套调用者、入口权限、数量、容量、映射条目、缓存局部值、存储重读、更新顺序和钩子行为。缺少这些细节,两个审查者可能使用同一段故事,却在分析不同程序。

在每个边界记录reserved、accepted总和,以及两个不变量条件是否成立。另外记录允许的业务结果。一个始终拒绝有效请求、因而保住容量的实现,并不满足成功的无回调基准。

对本题资料,原始版本和缓存总量版本因不同原因失败。交互前完成全部更新,通过规定的嵌套容量案例。只有外层入口受防护的版本失败;两个入口共享防护的版本通过规定的嵌套入口案例。钩子失败时,仍须保留所选强制确认或可选通知政策。

这些是针对给定设计的有限判断。真实审查需要完整可调用接口、实际语言和编译器行为、依赖、升级权限及测试。本课不给出生产安全结论,也不与任何已部署合约交互。

返回目录 · 下一节

练习与答案

每题从其指定的独立资料开始。

  1. 无回调基准的最终状态是什么?reserved为6,accepted[U]为6,accepted[H]为0。
  2. 原设计嵌套替代预约刚结束时,存储是什么?reserved为5,accepted[U]为0,accepted[H]为5。此时两个不变量条件仍成立。
  3. 重新读取存储的外层帧恢复后产生什么?reserved为11,accepted合计11,超过容量10。
  4. 缓存总量变体产生什么?reserved为6,accepted合计11,汇总不再代表映射。
  5. 完整更新候选中,H请求5为何失败?它已经看到外层的6,因此6+5超过10。
  6. 仅外层设置防护,能阻止从不检查该防护的替代入口吗?不能。在给定的延后更新设计中,最终总量仍为11。
  7. 两个入口共享防护,且H捕获嵌套拒绝后,留下什么?只有U的六单位预约。
  8. 完整更新后钩子失败且继续传播,被跟踪状态留下什么?这次预约不留任何更新,题目规定的回滚恢复零值。

扩展题:在完整更新候选中,把H请求量改为4。检查读取6+4=10,通过。最终reserved为10,accepted[U]为6,accepted[H]为4。防护不必禁止每个回调;它必须为允许的调用保留规定状态规则和结果。

返回目录 · 下一节

常见问题

重入是否等同于并行交易执行?

不是。本例使用外层操作尚未完成时的同步嵌套控制流。嵌套调用读取外层尚未更新的共享存储。

获准调用者也能暴露状态顺序缺陷吗?

能。本题H有权限。权限回答谁可以进入,不证明那个时刻可见的状态使所有允许操作都安全。

防护器名称能证明保护完整吗?

不能。应识别检查它的入口、共享状态、支持的调用结构和失败结果。未受保护的替代入口可能仍然相关。

最终显示总量低于容量就够吗?

不够。缓存案例显示6,却记录合计11的义务。应同时检查汇总关系和容量,包括外部观察边界。

返回目录 · 下一节

来源与延伸阅读

关键习惯是询问:当前操作恢复之前,另一个入口能看到什么、改变什么?然后核实响应同时保留状态不变量与承诺的业务结果。

返回目录