ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

PHP代码审计实战:eval函数引发的敏感信息泄露漏洞全解析

PHP代码审计实战:eval函数引发的敏感信息泄露漏洞全解析

做PHP代码审计这几年,eval函数几乎每次都会出现在高危名单里。eval的作用是把字符串当作PHP代码来执行,听起来很方便,但审计时看到它,第一反应应该是“这里有没有可能被外部输入污染”。以“eval函数引发的敏感信息泄露漏洞”来说,问题往往不是eval本身有多神秘,而是动态执行给了攻击者一个合法的代码入口,他可以把file_get_contents、include、readfile这些文件读取动作直接写进来,把应用的配置、密钥、数据库口令、业务数据一股脑带走。这篇分享不聊教科书理论,我从代码审计的实操视角出发,拆解eval泄露敏感信息的完整链路,给出定位、验证、修复、排查的一整套方法。适合需要处理PHP代码安全的开发人员和安全测试人员,也适合刚入门代码审计、想快速建立危险函数排查思路的朋友。

安全提示:文中的所有验证动作,都请限定在你自己有权限的系统或模拟项目里进行,不要对未授权的目标尝试任何payload。

1. eval导致敏感信息泄露的逻辑起点

1.1 一个看上去合理却把入口打开的高危写法

很多开发写eval不是为了炫技,而是想让业务“灵活”一点。比如做表单联动、动态报表、自定义计算公式,最容易出现这种代码:

<?php // 接收前端提交的公式或模板片段,直接执行 $code = $_POST['code']; eval($code); ?>

这段代码的问题一眼就能看出来:攻击者只要POST一个echo file_get_contents("config.php");,配置内容就会回到响应里。更常见的是从一个数据库字段或缓存文件里读出“可执行片段”,再传给eval。这类写法初看没有那么危险,因为开发者会告诉自己“内容来自后台配置,用户碰不到”。但后台配置如果存在导入功能、编辑器漏洞,或者员工账号被盗,外部输入照样能顺着链路摸进来。

我平时审计的第一步,就是先分清eval出现在哪个场景:是纯代码执行入口,还是被包在业务逻辑里做参数拼接。场景不同,病毒的传染面积完全不同。纯入口好定位,拼接场景才容易藏着绕过点。

1.2 eval不是函数,它的危害在于拿到了整个执行环境

eval在PHP里是语言构造,和include、isset、list是一类,不是普通函数。这意味着两件事:第一,disable_functions禁不掉它,很多运维以为把eval写进禁用列表就安全了,实际上对语言构造并没有效果;第二,eval在执行时共享当前脚本的变量范围,也就是攻击者不只在字符串里“写代码”,而是直接进入了这个PHP进程的角色。

为什么要强调这一点?因为敏感信息泄露未必是攻击者自己写一句file_get_contents那么直接,他可以使用当前上下文里已经存在的变量、数据库连接对象、全局配置数组。比如脚本开头就加载了$dbConfig,eval里写一句var_export($dbConfig);,数据库账密直接就出来了。代码审计时如果只盯最终执行点,不看执行环境里已经有哪些敏感对象,就会低估危害。

1.3 敏感信息一般藏在哪些位置

审计过程中,确认eval可被利用之后,要快速评估“能读到什么”。我通常会把目标文件按危害级别分成几类:

文件类型常见路径潜在泄露内容
应用配置config.php、config/database.php、.env数据库账号密码、缓存密码、日志等级、调试开关
密钥与证书cert/private.key、config/jwt/secret签名密钥、支付回调密钥、HTTPS私钥
数据库备份backup/.sql、dump/.sql全量业务数据、管理员口令Hash
会话文件/tmp/sess_、runtime/session/管理员登录态、用户会话
缓存与日志runtime/cache/.php、logs/.log内部接口返回、调试堆栈、部分参数

这些文件不一定都在web根目录里,有些部署会把配置放在项目更上层。可一旦eval能执行任意PHP代码,路径遍历就只是小事,readfile、include、file都可以顺着相对路径往上翻。审计验证时,优先读配置文件和备份文件,通常几分钟就能确认漏洞的真实危害到底有多大。

2. 代码审计中定位eval的几种实用姿势

2.1 正则粗扫加AST精查,把eval调用点从代码堆里捞出来

面对一个几十万行代码的老项目,不可能一行一行肉眼找eval。最直接的粗扫方式是命令枚举,比如在Linux环境里执行:

grep -rn "\beval\s*(" --include="*.php" 项目目录

这个方式能快速得到一批文件列表,但会产生两类误报:一类是注释里提到eval,另一类是用变量拼出来的eval_abc标识符。更严谨一些,应该借助AST语法解析工具来定位真正的Expr_Eval节点,然后输出文件路径和行号。实际用的时候,我往往先用正则缩小范围,再对结果文件用AST工具二次确认,这样误报率能压到很低。

如果你所在的团队没有审计工具链,也可以在IDE里对“eval”做全局搜索,配合函数列表看上下文。重要的是别只看eval(这种标准形态,还要注意assert、create_function、preg_replace+/e修饰符这些同类执行点,它们的危害和eval基本等同。

2.2 沿参数回溯污点,判断eval内容是否真的可控

定位到eval之后,核心问题是“内容从哪里来”。这就要做数据流回溯,我习惯用污点分析的方式把问题串起来。先简化成三段:

$input = $_REQUEST['action']; // 入口:外部可控 $code = base64_decode($input); // 变换:编码转换 eval($code); // 汇点:代码执行

在这个链路里,外部数据经过base64解码后进入eval,攻击者只要把自己的代码先做一次编码发送就能绕过一些简单的关键字拦截。审计时要特别留意中间层的“清洗函数”:

  • addslashes、htmlspecialchars、mysqli_real_escape_string对eval几乎没用,因为它们处理的是字符串内容,而eval需要的是PHP代码结构,攻击者照样可以构造phpinfo();这种纯代码;
  • intval、(int)、preg_match('/^\d+$/')这类约束是有效的,但前提是eval拼接的业务确实只允许数字场景;
  • 黑名单过滤file_get_contents之类关键字时,攻击者可以用字符串拼接、十六进制转义、注释符拆分等方法绕过。

所以判断“是否可控”时,我会把入口到eval之间的每一层变换都列出来,评估它到底是“约束了代码语义”还是“只做了表面转义”。前者可能还能接受,后者基本就等于裸奔。

2.3 和文件操作函数做交叉关联,评估泄露影响面

eval只是执行点,真正把敏感信息带出去的还是文件读取或网络请求。审计时我会把eval当前文件里的函数调用关系画一遍,重点关注有没有现成的“可利用操作器”。也就是看eval所在作用域里,有没有已经定义好的、能够接收路径参数并输出文件内容的函数。

举个例子,项目里可能本来就有这样一个函数,用于后端模板读取:

function render_template($path){ echo file_get_contents($path); }

如果eval执行时能够调用render_template(),那攻击者不需要自己写完整的文件读取语句,只需写一行render_template('../config.php');就完成了信息泄露。这个交叉关联视角在审计里非常重要,因为很多开发者会认为“eval没有直接调用危险函数就不严重”,实际上代码上下文里到处是顺手可用的杠杆。

另外还要看eval执行点是否在循环或异步任务里,那意味着攻击者可以在一次请求里读取多个文件,把整个应用目录结构翻一遍。即使最终没有拿到WebShell,光是这份目录和配置泄露,就足够支撑后续定向攻击了。

3. 从执行点到数据离场的利用链怎么看

3.1 验证:传一段读文件的代码确认危害

验证eval敏感信息泄露,不需要一开始就构造很复杂的payload。我会先读取一个必然存在且能证实“代码已执行”的文件,比如应用的配置文件:

echo file_get_contents('../../config/database.php');

在授权环境下把这个文件内容作为请求值提交,然后观察响应里是否出现了配置内容。如果没有回显,还可以尝试把内容写进日志、写入临时文件再用另外的接口读取,甚至用include引入目标文件,利用PHP语法错误信息把部分内容带出来。

要提醒的是,前端页面对输入框往往有长度限制、编码转换或富文本过滤器,这些都会干扰测试。遇到这种情况,我建议直接走接口层,把请求体内容设置为原始字符串,并且关闭前端的自动转码功能。数据发送过程中如果做了URL编码,先用日志确认服务端拿到的是解码前还是解码后的内容,避免误判漏洞不存在。

3.2 沿着上传点做组合,提升影响

敏感信息泄露很少孤立存在,它经常会和其他功能组合成更大的风险。eval能执行任意PHP代码,本质上已经具备写入能力,常见组合是:

先通过eval读取目录结构或日志文件,找到web可写目录;然后利用eval执行文件写入,把一段持久化后门落到可写目录;再请求落地的文件,建立稳定访问通道。这套路径里,信息泄露是第一步,也是帮助攻击者判断“写哪里不会被发现”的关键。

审计时评估组合风险,不能只看eval一个点,要问三个问题:当前应用有没有文件上传或写入接口?web目录是否对PHP进程可写?eval作用域里有没有可用的文件操作函数?三个问题里有肯定的答案,漏洞评级就至少要往高危走。反之,如果php进程被限制在只读目录、可写目录不允许执行PHP,那么单纯的信息泄露影响范围会小很多。

3.3 泄露出的数据能干什么,决定了漏洞评级

同样是敏感信息泄露,泄露内容不同,危害完全不一样。数据库口令泄露意味着攻击者可以直接连库拖取完整业务数据;调试开关为true意味着可能会暴露堆栈和内部路径;第三方接口密钥泄露意味着攻击者可以伪造请求,以应用身份调用支付、短信等服务。审计报告里对漏洞定级,通常就是看这些数据离核心资产有多近。

我还习惯把泄露数据分两类:一类是直接能用的,比如账密、Token、私钥;另一类是间接帮助的,比如目录结构、组件版本、内网地址。后者单独看危害不高,但如果配合某个已知框架漏洞,就能把攻击链往前走一大步。所以不要因为在验证阶段只读到了日志、没拿到数据库密码就降低漏洞等级,信息泄露的评估要结合整个系统的暴露面来定。

4. 修复和加固不能只盯着eval

4.1 代码级修复:消除动态执行,用映射表代替字符串代码

最彻底的修复方式是让“动态执行”彻底消失。很多业务功能看起来一定要eval,其实是在用执行代码来替代本应枚举的分支逻辑。以动态调用方法为例,与其写:

$func = $_POST['method']; eval('$result = ' . $func . '();');

不如用映射表:

$action = $_POST['action'] ?? 'default'; $handlers = [ 'default' => 'handleDefault', 'detail' => 'handleDetail', 'export' => 'handleExport', ]; if (isset($handlers[$action])) { $result = call_user_func($handlers[$action]); }

这样一来,外部输入的取值只能落在固定的三个键里,即使攻击者传入execute、file_get_contents也匹配不到任何方法名。对于计算公式类场景,建议引入成熟的表达式解析器或公式库,把自由度限制在表达式语法范围内,而不是让任意PHP代码进入执行环境。修复之后,还要把旧数据里以“代码片段”形式存储的那部分内容做迁移或清理,否则历史库里还会有可执行字符串。

4.2 如果不得不保留eval,如何做输入和沙箱约束

有些遗留系统短期内无法大改,那只能在eval入口前做约束。首先是格式白名单:如果业务只允许数字和运算符,就用正则把内容严格锁定到字符集;如果允许调用函数,就限定函数名和参数长度。这部分要做得很细,因为一旦字符集允许字母和括号,就等于允许调用任意函数。

更接近安全的方案是在eval之前用token_get_all解析代码结构,拒绝包含文件操作、命令执行、网络请求这类危险节点。这个方法能防住一部分直接利用,但并不能完全避免绕过,因为PHP语法灵活性很高,字符拼接、匿名函数、反射调用都可以把代码拆散。所以我一般会把这种方案作为临时加固,目标仍然是后续改造消灭eval。

如果运行环境允许,尽量给PHP进程开启目录限制能力,让它只能访问业务需要的目录。这个限制对eval读取配置内容会产生直接阻断效果,前提是配置文件不在可访问范围之内。

4.3 加固运行环境,让信息就算被读到也传不动

即使eval暂时除不掉,也可以在系统层面把伤害压到最低。我常用的几个加固项包括:

  • 配置文件移出web根目录,数据库账密从配置中心或环境变量读取;
  • 为应用设置最小可用PHP进程权限,明确哪些目录可读、哪些目录绝对禁止触碰;
  • 文件系统层面收紧权限,日志和备份文件不要放在应用代码同一层级;
  • 数据库账号按最小权限分配,生产环境不用root级账号连接,避免一次泄露导致全库可拖;
  • 严格限制出网和网络隔离,即使拿到内网地址也没有跳板可用。

这些措施的本质是纵深防御:攻击者通过eval已经获得了代码执行能力,我们不能阻止他执行,但可以让他执行之后发现“读不到想要的、传不出去”。实际加固时,一边要做到目录分级,一边要验证业务功能仍然正常。尤其open_basedir配置,如果限制太严,图片上传、日志写入都可能会出问题,需要逐项测试。

4.4 检测响应:不把全部希望放在修复上

修复代码需要周期,攻击探测却可能随时发生。这段时间里要有监测手段兜底。常见做法是给关键应用加运行期告警,当eval参数长度异常、包含文件读取函数特征或者连续出现多个请求时,就把请求Log下来并通知负责人。很多规则引擎都抽象了“代码执行函数+敏感文件访问”的模式,可以用来做实战监测。

代码仓库层面,可以把危险函数扫描放进发布流水线,用正则或AST工具自动检查每次提交,一旦出现eval、assert、system、exec、文件上传后的代码落盘,直接阻塞合并。开发同学看到报错后必须写清楚使用原因,再由安全负责人人工确认。这个门禁看起来多了一道工序,但能避免很多人为遗漏。

5. 实战排查中的踩坑与确认清单

5.1 一个模拟项目排查的完整流程

我去年评估过一个后台带模板配置功能的系统,功能大概是根据不同渠道动态加载不同页面模板。后端一个文件里写了类似这样的逻辑:

$tplCode = get_config('template_logic'); eval($tplCode);

从代码静态看,get_config()读的是数据库配置,外部用户碰不到,所以我们起初判断“虽然危险,但利用条件苛刻”。后来继续追数据流,发现后台有一个配置导入功能,允许管理员通过上传JSON文件批量更新配置项,而导入功能没有校验字段白名单。这就形成了一条完整链路:上传恶意JSON -> 写入template_logic字段 -> eval处执行。

复现的时候,我用导入功能写入了一段读取环境变量和部署路径的代码,然后请求触发页面,配置信息直接在错误信息里回显。修复方案分了两步:第一步去掉配置里的template_logic字段,把模板选择改成固定枚举值;第二步清理历史数据里所有被写入的代码片段。整个过程最大的教训是:一个看似来自数据库、来自后台的输入,如果上游存在导入或同步机制,依然可能被间接污染,审计不能只看eval的直接上层。

5.2 容易漏掉的变异写法

排查eval时,最怕盯着标准写法不放,漏掉各种变形。比如拼接写法:

$reader = "file"."_get_contents"; eval("echo ".$reader."('config.php');");

这类代码在编辑器里看到的是变量赋值加eval,直接用关键字搜索file_get_contents不一定能命中。再比如经过多重编码的处理,先用gzdecode解压、再用base64_decode还原,最后才进入eval。审计时如果只看最终eval参数的原文,会误判为“加密字符串,不可控”。

还有一类变异是把执行能力藏在别的结构里:assert($code)在部分PHP版本中可以执行表达式;preg_replace加/e修饰符能导致代码执行;call_user_func配合外部传入函数名,再加上可控参数,也是半个执行点。处理这些变体时,我建议把“能执行代码”和“能读取文件”两类能力分开画,再找交叉点,比单纯搜eval更不容易漏。

5.3 修完以后别急着上线:回验清单

一次修复是否有效,不能靠“把eval删了”就下结论。我习惯按下面的清单过一遍,确认安全性和业务可用性都满足:

  • 用原始攻击路径重新提交同一payload,确认响应不再包含敏感数据;
  • 对同一入口做大小写混写、编码绕过、注释拆分的变体测试,确认加固规则没有明显漏口;
  • 检查代码库里是否还有第二个eval、第三个动态执行点,同类问题往往不止一处;
  • 回归测试业务功能,特别是模板解析、配置读取相关页面,确认替代方案行为与旧逻辑一致;
  • 清理历史数据里的恶意代码片段、临时文件或者备份文件,避免复活;
  • 更新修复说明和审计报告,记录漏洞时间线、利用条件和验证结果。

回验过程中如果发现绕过,不要急着把正则规则调严,先回头看业务本身能不能去掉动态执行这个设计。规则补丁只会让猫鼠游戏越来越复杂,源头上的简化才是长期有效的。

最后分享一个屡试不爽的小习惯:每次修完像eval这种危险执行点,我会回头把同一个方法里的函数列表重新看一遍,把file_get_contents、file_put_contents、include这些常跟eval搭伙的函数在脑海中连成一条线。很多高危漏洞的形态虽然千变万化,但底层逻辑无非是可控输入穿过了执行或读取的节点,只是换了个样子。把这条经验放进日常评审里,比临时翻手册要有用得多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进