尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

Solidity 函数修饰符 Modifier 从入门到安全实战:权限校验与重入锁

发布时间:2026/9/28 15:13:59

资讯中心
01
ARTICLE

Solidity 函数修饰符 Modifier 从入门到安全实战:权限校验与重入锁

Solidity 函数修饰符 Modifier 从入门到安全实战:权限校验与重入锁
从零开始学 Solidity 函数修饰符最让我意外的是很多写了半年合约的人对 Modifier 的理解还停留在“只是把 require 提出来复用”这个层面。直到合约被别人白嫖、或者被重入攻击打穿才意识到这东西其实是一把双刃剑用好了是权限校验和防漏洞的骨架用错了就是在给攻击者递刀。这篇第十三篇我想把 Modifier 一次讲透。不止是语法还包括执行顺序、return 的真实行为、重入锁为什么必须这么写、以及我在审计和靶场“知攻善防”里反复见到的失守点。无论你是刚看完官方文档一脸懵还是已经能写合约但总感觉差点火候这篇都值得看下去。1. 先从权限校验的魔改现场说起1.1 一段让人抓狂的重复代码你在没有 Modifier 的时候写合约大概率会经历这个阶段每个需要权限的函数开头都要贴一遍相同的检查。function withdraw(uint256 amount) public { require(msg.sender owner, only owner); require(block.timestamp unlockTime, too early); // 业务逻辑 } function emergencyStop() public { require(msg.sender owner, only owner); require(!stopped, already stopped); // 业务逻辑 } function updateConfig(uint256 newValue) public { require(msg.sender owner, only owner); require(newValue 0, invalid); // 业务逻辑 }当合约里有十几个函数时这问题就开始恶心了。首先同样的逻辑复制十几遍一旦要增加一个“暂停状态下禁止操作”的公共校验你得跑到每个函数里手动改漏一个就是一个漏洞。其次代码可读性越改越差核心业务逻辑淹没在一堆检查里后人接手想改业务根本分不清哪段是校验、哪段是真正的功能。我在某次审一个早期的项目时就见过一个函数忘写require(msg.sender owner)结果任何人都能调用管理接口改合约参数最后被脚本批量刷走了一大笔资产。这种低级问题恰恰是传统 CRUD 开发里不存在的因为传统后端有中间件统一拦截权限而 Solidity 没有框架层的“路由拦截器”所以合约作者必须自己保证每个函数都做了同样的防护。1.2 Modifier 到底在解决什么问题Modifier中文一般叫函数修饰符本质是一种可以嵌入到目标函数里的代码模板。它解决的正是上面这个烦人问题把反复出现的校验逻辑抽成统一的“关卡”然后挂在函数声明上。它的用法很直白定义一个修饰符modifier onlyOwner() { require(msg.sender owner, not owner); _; }使用的时候直接在函数声明后面加上修饰符名字function withdraw(uint256 amount) public onlyOwner { // 业务逻辑 }这里面的下划线_是灵魂。它表示“把被修饰的函数体原封不动地嵌入到这个位置”。也就是说当有人调用withdraw时实际执行顺序是先进入onlyOwner执行require判断然后执行到_此时才跳回函数体里执行业务逻辑。给你打个比方修饰符是餐厅门口的迎宾闸机_就是那道旋转门。客人进来先过闸机闸机放行后顺着旋转门走进餐厅如果闸机不放行直接报错退出后面的业务一步都跑不到。这个模型能解释 90% 的 Modifier 行为剩下的 10% 坑都在这个旋转门的位置和数量上后面第二、四部分会详细展开。2. Modifier 的核心语法与执行逻辑2.1 定义一个 Modifier 并不难先看一个最简单的完整例子// SPDX-License-Identifier: MIT pragma solidity ^0.8.0; contract BasicModifier { address public owner; constructor() { owner msg.sender; } modifier onlyOwner() { require(msg.sender owner, only owner); _; } function setValue(uint256 newValue) external onlyOwner { // 只有 owner 能执行 } }注意几个细节modifier关键字后面跟名字参数列表和函数一样可以为空也可以传参。require失败时整个调用回滚函数体不会执行。修饰符不能直接调用其他修饰符除非通过函数间接调用但通常没人这么干这点和函数不同。这里我建议新手一开始就把 modifier 的命名规范定好。比如onlyOwner、onlyAdmin、whenNotPaused、nonReentrant一看名字就知道它管什么。我见过不少项目把修饰符写成check()、auth()这种含义模糊的名字后来读代码时根本看不出到底 check 了什么、auth 了谁安全隐患极大。2.2 下划线 _ 是修饰符的灵魂很多人第一次写完 modifier 后很困惑那个_到底干什么的为什么到处都要写它_的责任是“把被修饰函数的函数体插入到这里”。理解透这句话你就能推出一切。_写在最后修饰符先做前置检查再执行函数体这是最常见的模式。_写在开头先执行函数体再执行修饰符后面的逻辑这通常用于后置清理或记录用得少但偶尔有用。_写在中间前面一部分做检查中间调用函数体后面一部分做收尾这就是重入锁、状态重置等高级修饰符的基础。_写多次Solidity 允许但我不建议。因为同一个函数体会被执行多次除了故意做对称加锁这种极少数场景基本都会造成逻辑混乱。还有一个容易忽略的点修饰符中_前后的代码对函数体内变量的可见性不同。放在_之前的代码执行时函数体还没有运行所以它无法访问函数体内定义的局部变量但可以读取合约状态变量和修饰符自己的参数。放在_之后的代码依然不能访问函数体内的局部变量因为作用域是分开的。别指望修饰符能帮你读取函数里算好的中间结果做不到的。2.3 带参数、多重修饰符修饰符可以带参数。比如做一个基于价格的限购modifier cost(uint256 price) { require(msg.value price, not enough ether); _; } function buyItem(uint256 itemId) external payable cost(0.1 ether) { // 只有支付了 0.1 ether 以上才能进入 }带参数时要注意参数是在调用点评估的也就是说0.1 ether这个表达式是传入修饰符后求值的。如果参数里放了有副作用的表达式求值顺序就会按修饰符从右到左的顺序发生这一点到了多重修饰符时尤其需要注意。多重修饰符挂在同一个函数上长这样function withdrawAll() external onlyOwner whenNotPaused nonReentrant { // ... }执行顺序不是“同时生效”而是嵌套。先进入最左边的onlyOwner执行到_后进入whenNotPaused再执行到_后进入nonReentrant最后才是函数体。函数体执行完再一层层返回。为了让你有画面感假设三个修饰符代码分别是modifier onlyOwner() { log1(A1); _; log1(A2); } modifier whenNotPaused() { log1(B1); _; log1(B2); } modifier nonReentrant() { log1(C1); _; log1(C2); }那么调用withdrawAll时日志顺序是A1 → B1 → C1 → 函数体 → C2 → B2 → A2这个方向特别重要。如果你想做“先暂停检查再权限检查”就把whenNotPaused放最左边如果你把nonReentrant放中间那么进入重入锁之前可能已经执行了一层外部代码逻辑反而不安全。我在真实项目中见过因为修饰符顺序颠倒导致重入锁形同虚设的案例。3. 实操用 Modifier 搭一个小型保险库3.1 需求拆解理论讲再多不如跑一个完整例子。我们做一个迷你版“时间保险库”需求如下仅 owner 可以暂停/恢复合约、调整提款限额。任何人只有在解锁时间到达后才能提款。合约全局暂停时所有资金操作冻结。每个地址每日提款有上限防止一次性被抽干。先列个表格把功能和对应 Modifier 对应起来功能约束条件使用到的 Modifier暂停/恢复仅 owneronlyOwner充值合约未暂停whenNotPaused提款合约未暂停、时间已到、未超日限whenNotPausedafterUnlock调整日限仅 owner且新值不为 0onlyOwner这么一拆写代码时思路就非常清晰了。核心业务函数只保留自己的业务逻辑公共检查全部交给修饰符。3.2 核心代码实现// SPDX-License-Identifier: MIT pragma solidity ^0.8.0; contract TimeVault { address public owner; bool public paused; uint256 public unlockTime; uint256 public dailyLimit 1 ether; uint256 public lastWithdrawDay; uint256 public withdrawnToday; modifier onlyOwner() { require(msg.sender owner, not owner); _; } modifier whenNotPaused() { require(!paused, paused); _; } modifier afterUnlock() { require(block.timestamp unlockTime, too early); _; } constructor(uint256 _unlockTime) { owner msg.sender; unlockTime _unlockTime; } function pause() external onlyOwner { paused true; } function unpause() external onlyOwner { paused false; } function setDailyLimit(uint256 newLimit) external onlyOwner { require(newLimit 0, invalid limit); dailyLimit newLimit; } function addFunds() external payable whenNotPaused {} function withdraw(uint256 amount) external whenNotPaused afterUnlock { require(amount 0, zero amount); uint256 day block.timestamp / 1 days; if (day ! lastWithdrawDay) { lastWithdrawDay day; withdrawnToday 0; } require(withdrawnToday amount dailyLimit, over daily limit); withdrawnToday amount; payable(msg.sender).transfer(amount); } receive() external payable {} }这段代码很短但信息密度不低。我常说“读合约先读修饰符列表”你在函数声明上一眼扫过去就知道这个函数受谁控制、什么时候能跑。这就是 Modifier 带给工程最大的价值。3.3 部署测试要点与踩坑在 Remix 里部署时_unlockTime的第一个值是 Unix 时间戳而不是“多少天”。很多人上来填个 60以为 60 秒后就能提款结果unlockTime被设成了 1970 年左右的时间提款永远可以执行。正确做法是传block.timestamp 60。如果你在本地测试环境可以直接看当前块时间然后加上你想要的时长再填入。测试时建议按顺序走这几条用例用非 owner 地址调用pause()必须 revert。用 owner 暂停后调用addFunds和withdraw都会 revert。解锁时间前调用withdraw必须 revert。解锁后第一次提款正常提款累计超过dailyLimit时 revert。换一天再提款日额度自动恢复。第三条常被忽略但恰恰是最容易在合约上线后引发用户资产锁死的问题。原因很简单构造函数的unlockTime如果依赖区块时间而非链上已确定的常量一旦部署时代码里写的是“部署后第 N 天解锁”那么该时间点以前用户无法取出任何资产必须明确写进文档和前端交互提示里。这段还有一个经验提款用transfer虽然简单但它固定只有 2300 gas一旦接收方是合约且逻辑稍复杂就会失败。现实项目里更推荐用call(bool ok, ) payable(msg.sender).call{value: amount}(); require(ok, transfer failed);当然用call就要更加小心重入问题所以生产环境里withdraw前面几乎都会再挂一个nonReentrant第五节专门说这个。4. 常见问题与避坑指南4.1 _ 放的位置决定了检查是前置还是后置修饰符里的_放哪决定了修饰符里的检查是前置还是后置。新手容易用错modifier badCheck() { _; require(msg.value 0, no value); }这种写法表示先执行函数体再检查msg.value。表面上看“反正最后 revert 整个事务回滚了结果不是一样吗”结果确实回滚了但两个实际问题会出现第一函数体里可能已经发起了外部调用虽然最终回滚但外部合约可能观察到这次调用并做了某些收尾或记录脏数据清理起来很麻烦。第二如果函数体里有事件日志revert 时日志也会回滚但你在 debug 时看 traces 会被这些“幽灵日志”干扰判断错误点更难。所以我定的规矩是所有状态校验、权限校验必须放在_之前_之后的代码只用来做状态收尾、标记更新、加锁解锁这类“函数体完成后需要保证执行”的动作。4.2 return 不能直接终止整个函数这是 Modifier 使用过程中最容易踩的坑没有之一。很多新手以为在修饰符里写return就能让“整个函数调用直接返回”实际完全不是。modifier earlyReturn() { return; _; } function f() public earlyReturn { // 永远不会执行 }这个例子中函数体确实不执行但这只是因为_在return后面压根没机会执行还不是最大的坑。真正的坑是下面这种modifier guard() { if (!allowed[msg.sender]) { return; } _; emit SkippedCheck(after); } function f() external guard { doSomething(); }当allowed[msg.sender]为 false 时return退出的是修饰符当前的执行帧函数体确实不会执行了但_之后的emit SkippedCheck仍然会执行。如果那后面不是日志而是改状态变量你就会被这个“看似提前终止实际跑了半截代码”的行为坑惨。正确做法是想提前拦截用require或revert不要用return。只有一种情况可以刻意用 return就是你确定_之后没有任何代码需要执行常见的 in 修饰符里if包住_。4.3 require 写进 Modifier还是留在函数体里很多人在两个风格之间来回摇摆风格 A把所有 require 全部抽到 Modifier。风格 BModifier 只做权限类检查业务参数校验全留在函数体。我个人的实践结论是往风格 B 靠。理由有三个第一可复用性。只有onlyOwner、whenNotPaused、nonReentrant这种跨函数都适用的检查才值得抽出来。而newValue 0这种只属于某个业务函数的校验抽出去反而让函数失去上下文。第二错误信息定位。Modifier 里统一抛require(false, denied)当 revert 发生时查日志只能看到一个泛化信息要定位具体哪个业务子句失败还得再刨一层。不划算。第三Gas 与代码可读性的平衡。Modifier 每次调用确实有额外跳转开销虽然很小但一个函数挂三四个 Modifier 时累积起来也不可忽略。尤其是高频调用的合约能合并的检查尽量合并。整理成一张速查表场景推荐做法原因权限控制、全局开关Modifier跨函数复用统一防护业务参数边界函数体 require和业务上下文强相关防重入Modifier必须包裹整个函数体白名单校验Modifier 或函数体均可取决于白名单是否为全局配置4.4 视图函数与继承中的限制Modifier 存在两个很容易被编译器报错但原因不好找的限制。第一如果 Modifier 里修改了状态变量而它修饰的函数被声明为view或pure编译器会直接报错。这是合理的因为 Modifier 代码会“内联”到函数体中等于在 view 函数里改状态Solidity 不允许。换句话说Modifier 并不是魔法它无法突破函数本身的可变性约束。第二构造函数不能用 Modifier。Solidity 0.7.0 之后的版本构造函数不允许使用修饰符。想给构造过程加校验只能在构造函数内部手动 require。这是很多人升级到新版编译器后突然发现constructor() public onlyOwner报错的原因。此外Modifier 本身可以被继承和重写。基础合约里定义modifier onlyOwner() { ...; _; }子合约可以用override重新实现。这在多合约项目里很有用但也要注意如果子类 Modifier 的_位置换了父类所有依赖这个 Modifier 的函数的执行顺序也会跟着变算是一种隐式依赖不建议新手折腾。5. 安全场景中的 Modifier 实战5.1 重入锁为什么必须用 Modifier重入攻击是 DeFi 里的经典漏洞核心原因是“外部调用期间合约状态尚未更新攻击者再次进入同函数”。Modifier 天然是解决这个问题的绝佳工具因为它可以“包裹”整个函数体在函数体执行前上锁执行后解锁。OpenZeppelin 的 ReentrancyGuard 核心逻辑简化后长这样uint256 private _status 1; modifier nonReentrant() { require(_status ! 2, reentrant call); _status 2; _; _status 1; }执行过程很清楚进入修饰符后如果此刻_status已经是 2说明函数正在执行中直接拒绝。否则将状态置为 2表示“我已经进入 locked 区域”。执行_也就是你写的整个函数体包括里面可能有的对外部合约的call。即使外部合约在调用过程中再次调用本合约的同一个函数由于此时_status 2重入调用会在第一步被挡掉。函数体正常结束后把_status恢复为 1。关键点在于必须先置锁再调用外部函数绝不能反过来。如果写成modifier brokenNonReentrant() { require(_status ! 2, reentrant call); _; _status 2; _status 1; }那么攻击者在函数体执行外部调用时重入的调用进入后会发现_status仍然是初始值 1锁形同虚设这正是不少“假安全”合约被一晚上打穿的原因。我在靶场“知攻善防”里见过一个很有意思的题目合约把nonReentrant挂在了函数上但在函数体里又调用了另一个内部函数这个内部函数再次调用了同一个外部目标。因为nonReentrant锁的是“入口函数级别”不是“地址级别”内部二次调用仍会被锁挡住这算锁是对的但反过来如果入口函数没挂锁只是某个内部函数挂了锁攻击者从外部绕过入口直接调用带锁的内部函数往往还是安全的可一旦入口函数逻辑复杂到能绕过锁就要逐行审计了。5.2 白名单 Mint 与限频控制白名单 Mint 是 NFT 项目里最常见的 Modifier 应用场景mapping(address bool) public whitelisted; modifier onlyWhitelisted(address buyer) { require(whitelisted[buyer], not in whitelist); _; } function whitelistMint() external onlyWhitelisted(msg.sender) { _mintTo(msg.sender); }注意这里我传入了msg.sender作为参数而不是在函数里再取一次。好处是测试和工具调用时可以显式传入任意地址来验证修饰符逻辑。生产代码里通常直接onlyWhitelisted空参数内部用msg.sender但调试时传地址的方式更友好。另一个常见字段是“一次性校验”比如确保一个地址只能领一次空投modifier once(address account) { require(!claimed[account], already claimed); _; claimed[account] true; }这个写法把claimed[account] true放在了_之后好处是无论函数体里有多少个分支只要没有 revert最后都会把状态标记为已领取。比起在函数体末尾手动赋值少写一行也不容易漏。但这里有个隐患如果函数体里在_之后的逻辑之前有显式returnreturn 只会退出函数体本身_之后的标记代码仍然会执行。也就是说即便某些分支提前回来了它仍然会被标记成已领取。到底这是否符合预期取决于业务定义。如果不想提前 return 也算领取那就用这个写法如果想让提前 return 的分支不消耗领取资格就得把标记放在函数体里 return 之前。这个细节我说过多次Modifier 不是交易级的作用域return不退出整个调用轨迹。5.3 靶场里最常见的 Modifier 失守点聊到“知攻善防”其实很多攻防训练的入口都在 Modifier 误用上。我总结几个高频失守点。第一权限判断放错对象。require(msg.sender owner)是正确的但有些合约写成require(tx.origin owner)。tx.origin是整条交易的原始发起人只要用户带着恶意合约交互你的合约收到的tx.origin仍然是用户地址而msg.sender是那个恶意合约地址。攻击者可以构造一个合约让 owner 无意中调用从而绕过你的权限检查。这种代码在靶场里几乎是送分题。第二修饰符漏写_。定义了一个 Modifier忘了在末尾写_结果被修饰的函数体永远不会执行。表面上调用不报错但啥也没发生达到一种“静默失败”的效果。比直接 revert 更难发现。我自己刚上手时也犯过写了个onlyOwner忘记_那段时间一直以为合约里“任何地址都能调用但没效果”排查了很久才发现是修饰符吞掉了函数体。第三修饰符内部逻辑依赖可变状态。如果检查的是whitelist[msg.sender]这样一个 mapping而项目里存在“删除白名单”的管理函数攻击者可以先调用业务函数趁白名单还生效时完成操作再让管理函数把自己删掉。这类问题的本质是检查与操作之间存在 TOCTOUTime-Of-Check to Time-Of-Use窗口Modifier 只是把检查放在函数体之前并不能保证整个函数执行期间状态不变。要真正确保中间状态不被篡改通常还得配合重入锁或更细粒度的状态机。第四升级合约时新的状态变量未初始化。有些项目把bool public allowlistEnabled true写在状态变量声明处但代理合约升级时新变量的值不会通过构造函数初始化必须走initialize。这时候如果 Modifier 检查allowlistEnabled旧数据可能在升级后直接变成 false导致全部权限锁定或全部放开。这一点不是 Modifier 本身的问题但 Modifier 会把这种隐患放大——因为权限判断在 Modifier 里一旦判断条件的数据损坏影响范围是全局的。我个人在实际操作中的一点体会Modifier 这种语法刚学时是语法题写多了是设计题。它本质上是一份“函数进入协议”进来之前必须过哪些关卡关在函数体外面还是夹在前后卡关的策略依据是什么。真正成熟的合约项目Modifier 的命名和清单本身就是一份安全自检表。我每接手一个陌生项目第一件事就是把合约里所有 Modifier 全列出来再对应到每个函数上一一核对这比读业务代码重要得多。如果你想把知识彻底练透可以在本地开个小测试合约故意写几个错误版本修饰符漏_、把return当提前退出、把_放错位置、把nonReentrant的锁放错时机然后用 Hardhat 写测试用例逐个验证它们的行为差异。这种“主动制造 Bug”的练习比多看十篇教程都有效。等你在这些错误用法里吃过亏再回来看合约里那些权限修饰符一眼就能分辨出哪些只是摆设哪些是真的能护住资产。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。