1. 项目概述:从一次真实的插件漏洞审计说起
最近在复盘一个老项目时,重新审视了一个经典的WordPress插件本地文件包含漏洞。这个漏洞本身并不复杂,但它像一面镜子,清晰地映照出在PHP代码审计中,开发者常犯的几个关键性错误,以及安全研究者如何系统性地定位和利用这类问题。这个实验的核心,不仅仅是复现一个CVE编号,而是深入理解漏洞背后的代码逻辑、触发条件以及防御思路。无论你是刚入门代码审计的新手,还是想巩固PHP安全知识的中级开发者,通过这个深度解析,你都能获得一套可复用的审计方法论和实战技巧。
LFI,即本地文件包含,是PHP Web应用中的一个高危漏洞。它允许攻击者通过包含服务器本地的文件(如配置文件、日志文件、甚至上传的恶意文件)来读取敏感信息或执行任意代码。在WordPress庞大的插件生态中,由于开发质量参差不齐,LFI漏洞时有发生。本次我们聚焦的漏洞案例,将带你一步步拆解漏洞成因,从参数传递追踪到危险函数调用,再到如何构造利用链。我会分享我在审计过程中使用的工具链、思考路径以及那些文档里不会写的“踩坑”经验。
2. 漏洞原理与PHP包含机制深度剖析
2.1 LFI漏洞的核心:失控的文件路径
要理解LFI,必须先吃透PHP中几个关键的文件包含函数:include、require、include_once、require_once。它们的本质是将指定路径的文件内容读取并作为PHP代码执行。漏洞产生的根本原因,是开发者将用户可控的输入(如$_GET、$_POST、$_COOKIE中的参数)直接或经过不安全的处理后,传递给了这些包含函数的路径参数。
举个例子,一个危险的代码片段可能长这样:
$page = $_GET['page']; include('/pages/' . $page . '.php');开发者本意是让用户通过?page=about来加载/pages/about.php。但如果攻击者传入?page=../../../../etc/passwd,经过拼接后,代码试图包含/pages/../../../../etc/passwd,这实际上指向了系统的/etc/passwd文件,导致敏感信息泄露。这就是最经典的目录遍历(Path Traversal)结合文件包含。
注意:很多初级开发者会认为用
htmlspecialchars或addslashes处理用户输入就能防住文件包含,这是完全错误的。这些函数用于防御XSS或SQL注入,它们不改变文件路径的语义。防御LFI的关键在于对路径进行“白名单”校验或严格的“规范化”处理。
2.2 PHP伪协议:将LFI升级为RCE的利器
如果LFI只能读文件,那危害还算有限。但PHP内置的众多“伪协议”让LFI的危害性呈指数级增长,极易转化为远程代码执行。这是审计时必须重点关注的升级点。
php://filter协议:用于读取文件源码。当服务器配置不允许直接读取.php文件内容时(因为会被执行),可以利用该协议进行编码转换后读取。例如:?file=php://filter/convert.base64-encode/resource=index.php这会将index.php的内容进行base64编码后输出,解码即可获得源代码。这在代码审计和信息收集中极其常用。php://input协议:这是将LFI变为RCE的“王牌”。它允许访问请求的原始数据流。当allow_url_include配置为On时(虽然默认是Off,但某些环境会开启),攻击者可以这样利用:GET /vuln.php?file=php://input HTTP/1.1 ... POST数据:<?php system('id'); ?>此时,
include函数会执行POST数据流中的PHP代码。我在实际测试中,发现很多内部系统或老旧服务器仍存在此危险配置。data://协议:同样可用于代码执行。例如:?file=data://text/plain,<?php phpinfo();?>或?file=data://text/plain;base64,PD9waHAgcGhwaW5mbygpOz8+(base64编码的php代码)。
在审计WordPress插件时,如果发现存在未经验证的文件包含点,要立即检查服务器PHP环境是否支持这些危险协议,这是评估漏洞实际危害等级的关键一步。
2.3 WordPress插件环境下的特殊风险点
WordPress插件运行在WP框架内,这带来了一些独特的风险场景:
- 绝对路径泄露:包含错误可能返回WP的绝对路径,为后续攻击提供信息。
- 日志文件包含:通过包含WP的访问日志、错误日志,可能注入PHP代码(如果日志中记录了请求参数)。
- 临时文件/上传文件包含:如果插件有上传功能,且上传路径可预测或可被包含,可能实现“上传图片马+包含执行”的组合攻击。
$_SERVER变量利用:如$_SERVER[‘DOCUMENT_ROOT’]在某些配置下可能不可靠,基于它进行路径拼接可能导致包含逃逸。
3. 靶场环境搭建与审计工具链准备
3.1 实验环境快速搭建
为了安全地复现和深入研究漏洞,我强烈建议在隔离的虚拟环境中进行。我个人的首选是Docker,它轻量、可重复、且一键清理。
使用Docker Compose部署脆弱环境: 我准备了一个
docker-compose.yml文件,它包含了存在漏洞的WordPress版本和特定插件。version: '3.8' services: wordpress: image: wordpress:5.8-php7.4-apache # 选择一个与漏洞时间线匹配的版本 container_name: wp-lfi-lab ports: - "8080:80" environment: WORDPRESS_DB_HOST: db WORDPRESS_DB_USER: wpuser WORDPRESS_DB_PASSWORD: wppass WORDPRESS_DB_NAME: wpdb volumes: - ./wp-data:/var/www/html - ./vulnerable-plugin:/var/www/html/wp-content/plugins/vulnerable-plugin # 挂载存在漏洞的插件代码 depends_on: - db db: image: mysql:5.7 container_name: wp-lfi-db environment: MYSQL_DATABASE: wpdb MYSQL_USER: wpuser MYSQL_PASSWORD: wppass MYSQL_ROOT_PASSWORD: rootpass volumes: - ./db-data:/var/lib/mysql执行
docker-compose up -d,几分钟后一个完整的WordPress就运行在http://localhost:8080了。将存在LFI漏洞的插件代码放到./vulnerable-plugin目录下,然后在WP后台启用它即可。关键PHP配置调整:为了充分演示漏洞影响,有时需要修改Docker容器内的PHP配置(
php.ini)。docker exec -it wp-lfi-lab bash apt-get update && apt-get install -y vim vim /usr/local/etc/php/php.ini找到并修改以下两项(仅用于实验,生产环境严禁!):
allow_url_fopen = On allow_url_include = On修改后重启Apache服务:
service apache2 restart。这为我们测试php://input和data://协议提供了条件。
3.2 代码审计工具与核心思路
工欲善其事,必先利其器。我的审计工具链分为静态分析和动态测试两部分。
静态分析(找入口、理逻辑):
IDE + 全局搜索:PHPStorm或VSCode是首选。使用它们的全局搜索功能,直接搜索危险函数名:
include,require,include_once,require_oncefile_get_contents,readfile,highlight_file(有时用于读取文件)fopen,fread(结合用户输入也可能有问题) 搜索时,注意查看函数调用处的上下文,追踪参数来源。
专用代码审计工具:对于大型插件,可以使用
RIPS(旧版开源)、Fortify或SonarQube等工具进行初步的自动化扫描,它们能快速定位可疑的代码片段。但切记,工具只是辅助,最终必须人工确认。
动态测试(验证漏洞、构造利用):
- 浏览器开发者工具:用于观察请求和响应,修改参数。
- Burp Suite / OWASP ZAP:代理工具,用于拦截、重放、篡改HTTP请求,是测试漏洞是否存在的核心。我常用Burp的Repeater模块反复调试Payload。
- 命令行工具Curl:用于快速测试和自动化Payload发送,特别是在测试
php://input协议时非常方便。curl -X POST "http://localhost:8080/wp-content/plugins/vuln-plugin/page.php?file=php://input" --data "<?php echo system('whoami'); ?>"
我的核心审计思路:
- 入口点收集:梳理插件所有对外开放的PHP文件(通常是通过
admin_menu添加的后台页面,或通过add_shortcode注册的前端短代码处理文件)。 - 数据流追踪:从
$_GET、$_POST、$_REQUEST等超全局变量出发,看它们流向了哪里。重点关注未经过滤或过滤不严就直接用于文件操作、数据库查询、命令执行的参数。 - 敏感函数定位:如上所述,全局搜索文件包含、文件读取、命令执行(
system,exec,shell_exec)、数据库操作($wpdb->query中直接拼接变量)等函数。 - 逻辑漏洞审视:检查权限验证(
current_user_can)、Nonce校验(wp_verify_nonce)是否缺失或可绕过。很多插件漏洞出在“以为只有管理员能访问,实则未做校验”的地方。
4. 漏洞案例深度拆解与代码逐行分析
假设我们审计一个名为“Simple File Viewer”的插件,它提供了一个短代码[sfv_view],用于在前端显示指定文件的内容。插件核心代码文件simple-file-viewer.php如下:
<?php /** * Plugin Name: Simple File Viewer */ function sfv_shortcode_handler($atts) { // 短代码属性解析,默认file参数为'readme.txt' $atts = shortcode_atts(array( 'file' => 'readme.txt', ), $atts, 'sfv_view'); // 获取要查看的文件路径 $file_path = sanitize_text_field($atts['file']); // 错误的安全感来源! // 构建绝对路径:插件目录 + 文件路径 $base_dir = plugin_dir_path(__FILE__) . 'uploads/'; $full_path = $base_dir . $file_path; // 检查文件是否存在 if (file_exists($full_path)) { // 读取并高亮显示文件内容 highlight_file($full_path); } else { echo '<p>File not found.</p>'; } } add_shortcode('sfv_view', 'sfv_shortcode_handler'); ?>4.1 漏洞点定位与错误过滤分析
一眼看去,开发者似乎有安全意识,他使用了WordPress的sanitize_text_field()函数来处理$atts[‘file’]输入。这正是第一个致命误区。让我们看看这个函数到底做了什么:
sanitize_text_field()函数的主要作用是:
- 去除无效的UTF-8字符。
- 将HTML标签
<、>、&等转换为HTML实体(如<变为<)。 - 去除换行符、制表符等空白字符。
- 去除非法的控制字符。
关键问题:它不会对文件路径遍历字符../或..\进行任何处理!../在HTML上下文中不是特殊字符,sanitize_text_field()会原样放过它。因此,$file_path变量仍然可能包含../../../etc/passwd这样的恶意路径。
4.2 路径拼接与漏洞触发
漏洞触发的关键在于第13行:$full_path = $base_dir . $file_path;。
$base_dir是固定的:/var/www/html/wp-content/plugins/simple-file-viewer/uploads/$file_path是用户通过短代码属性控制的:[sfv_view file="../../../etc/passwd"]
拼接后的$full_path为:/var/www/html/wp-content/plugins/simple-file-viewer/uploads/../../../etc/passwd
路径中的uploads/../会相互抵消,最终file_exists()和highlight_file()函数尝试访问的是:/var/www/html/wp-content/plugins/simple-file-viewer/etc/passwd不,等等,这里还有一个../。让我们仔细计算:
- 原始基础路径:
.../uploads/ - 拼接用户输入:
../../../etc/passwd - 规范化后:
.../uploads/../->.../(抵消一级).../../->wp-content/plugins/(再抵消一级)wp-content/plugins/../->wp-content/(再抵消一级) - 最终路径:
/var/www/html/wp-content/etc/passwd
实际上,由于uploads目录在插件内,使用../../../可以一路回溯到网站根目录甚至系统根目录。file_exists()和highlight_file()都会接受这个路径。如果/etc/passwd文件存在且Web服务器用户有权读取,其内容就会被highlight_file()函数输出到页面上。
实操心得:在测试路径遍历时,我经常使用
../../../../../../../../etc/passwd这种“超量”的../。因为不同环境的目录深度不同,多写一些可以确保能回溯到根目录。同时,Burp Suite的Intruder模块非常适合用来模糊测试../的层数。
4.3 漏洞利用实战演示
假设这个短代码被用在了一篇ID为1的文章里。攻击者访问这篇文章的URL可能是:http://target-site.com/?p=1。
利用步骤1:信息收集(读取WP配置文件)WordPress的配置文件wp-config.php位于网站根目录,包含数据库用户名、密码、密钥等核心信息。我们可以构造短代码属性来读取它。 由于短代码属性需要嵌入文章,对于攻击者,更现实的场景是找到一个已存在该短代码的页面,然后通过参数污染或直接修改请求来利用。但为了演示,我们假设攻击者能控制短代码属性。
原短代码用法:[sfv_view file="report.pdf"]恶意构造:[sfv_view file="../../../../wp-config.php"](假设插件在wp-content/plugins/下,需要向上回溯4层到根目录)
访问该页面后,wp-config.php的源码(包含数据库密码)就会以语法高亮的形式显示在网页上。
利用步骤2:尝试升级为RCE(利用PHP伪协议)如果服务器配置了allow_url_include=On,我们可以尝试利用php://input执行命令。 但注意,我们的漏洞点是highlight_file(),不是include()。highlight_file()用于显示源码,它不会执行PHP代码。所以,直接包含php://input是无效的。
然而,我们可以利用php://filter来读取服务器上其他.php文件的源码,例如插件自身的其他文件、主题的functions.php,寻找其他漏洞(如代码注入、不安全的反序列化)。或者,如果服务器开启了目录列表,或存在文件上传功能,我们可以结合LFI读取上传的恶意文件(例如一个图片Webshell)。
利用步骤3:日志文件注入(经典攻击链)这是一种更高级的利用方式,将LFI转化为RCE。前提是Web服务器错误日志或访问日志的路径已知且可读。
- 向网站发起一个包含PHP代码的非法请求,例如访问一个不存在的页面,并将PHP代码放在User-Agent头中:
这个请求会导致404错误,但GET /non-existent-page.php HTTP/1.1 Host: target.com User-Agent: <?php system($_GET['cmd']); ?>User-Agent中的PHP代码会被记录到服务器的错误日志(如/var/log/apache2/error.log)或访问日志中。 - 利用LFI漏洞包含这个日志文件:
[sfv_view file="../../../../var/log/apache2/error.log"]此时,日志文件被highlight_file()读取并输出。由于日志文件是纯文本,PHP代码不会被服务器执行,只是显示出来。 - 关键转折:如果漏洞点不是
highlight_file(),而是include()或require(),那么当包含这个日志文件时,其中的<?php system($_GET[‘cmd’]); ?>就会被当作PHP代码执行,攻击者就可以通过?cmd=id来执行系统命令。
我们这个案例中漏洞函数是highlight_file(),所以无法直接执行日志中的代码。但这个思路在遇到include型LFI时极其有效,务必掌握。
5. 漏洞修复方案与安全编码实践
5.1 针对本案例的修复
修复这个漏洞,需要从输入验证和路径解析两方面入手。
方案一:白名单验证(最安全)如果插件只允许查看uploads/目录下有限的几种文件(如.pdf,.txt,.jpg),那么白名单是最佳选择。
$allowed_files = array('report.pdf', 'notice.txt', 'default.jpg'); $requested_file = sanitize_text_field($atts['file']); if (!in_array($requested_file, $allowed_files)) { echo '<p>Invalid file requested.</p>'; return; } $full_path = $base_dir . $requested_file;方案二:严格路径规范化与目录锁定如果需求更动态,需要允许查看uploads/下的任意文件,但必须防止目录穿越。
$requested_file = sanitize_text_field($atts['file']); // 使用 basename() 函数直接去除路径信息,只保留文件名部分 $safe_file_name = basename($requested_file); // 或者,更严格地,只允许字母数字、点、下划线、连字符 if (!preg_match('/^[a-zA-Z0-9._-]+$/', $requested_file)) { wp_die('Invalid file name.'); } $full_path = $base_dir . $safe_file_name; // 额外检查:最终路径是否以 $base_dir 开头 $real_base = realpath($base_dir); $real_full = realpath($full_path); if ($real_base === false || $real_full === false || strpos($real_full, $real_base) !== 0) { wp_die('Access denied.'); }这里realpath()函数会解析掉所有的../和符号链接,返回绝对路径。然后检查最终路径是否以允许的基目录开头,这是防御目录遍历的黄金标准。
方案三:使用WordPress内置函数如果文件是媒体库的一部分,应使用WordPress的附件ID机制和wp_get_attachment_url()、wp_get_attachment_file等函数,完全避免手动处理路径。
5.2 通用安全编码准则
- 永远不要相信用户输入:对所有来自客户端(
$_GET,$_POST,$_COOKIE,$_REQUEST,$_SERVER中部分变量)的数据进行严格的验证和过滤。验证=检查是否符合预期格式(如是否在预定义列表中),过滤=移除或转义危险字符。 - 使用“白名单”而非“黑名单”:定义什么是允许的,比定义什么是不允许的要安全得多。黑名单总会有遗漏。
- 最小权限原则:运行Web服务的用户(如www-data)应仅拥有必要文件的最小读取权限。数据库用户也应仅被授予应用所需的最小权限。
- 及时更新:保持WordPress核心、主题和插件更新到最新版本。已知漏洞的利用代码(Exploit)通常在新版本发布后很快就会出现。
- 使用安全函数:
- 对于数据库操作,永远使用
$wpdb->prepare()进行参数化查询,杜绝SQL注入。 - 输出到HTML时,使用
esc_html(),esc_attr()等函数防御XSS。 - 执行系统命令是最后的选择,如果必须,使用
escapeshellarg()或escapeshellcmd()对参数进行转义。
- 对于数据库操作,永远使用
- 进行安全审计:在插件发布前,进行代码安全审计,或使用自动化工具进行扫描。对于关键业务插件,考虑聘请专业的安全人员进行渗透测试。
6. 审计实战进阶:挖掘更深层的漏洞链
一个简单的LFI可能只是冰山一角。在真实的审计中,我们需要思考如何将它与其它漏洞串联,形成更具破坏力的攻击链。
场景:LFI + 文件上传 = 远程代码执行假设另一个插件“Easy Avatar”存在任意文件上传漏洞,允许用户上传.php文件到wp-content/uploads/目录下,但上传后的文件名是随机的(如avatar_5f8a3b2c.php)。
- 攻击者利用文件上传漏洞,上传一个Webshell(
shell.php)。 - 由于文件名随机,攻击者不知道完整的访问路径。
- 但是,如果“Simple File Viewer”插件存在LFI,并且
uploads/目录是可索引的(或者存在其他信息泄露),攻击者可能通过LFI遍历uploads/目录,或者结合时间戳猜测文件名,最终找到并包含上传的Webshell,获得代码执行权限。
场景:LFI + 配置文件读取 = 数据库接管如前所述,通过LFI读取wp-config.php,直接获取数据库凭证。攻击者可以远程连接数据库,修改管理员密码、插入恶意管理员用户、甚至植入持久化后门。
场景:LFI作为信息收集的跳板通过LFI,攻击者可以读取:
.htaccess文件:了解服务器配置和访问规则。php.ini文件:了解PHP安全配置(如allow_url_include是否开启)。- 其他插件的源码:寻找更多漏洞。
proc/self/environ文件(Linux系统):在某些特定配置下,可能包含环境变量,甚至Web进程的敏感信息。
7. 常见问题与排查技巧实录
在漏洞复现和代码审计过程中,我遇到了不少“坑”,这里分享一些典型的排查思路和解决方法。
问题1:Payload明明正确,为什么文件包含不成功?
- 检查文件权限:Web服务器用户(如
www-data)可能没有目标文件的读取权限。使用docker exec进入容器,ls -la /path/to/file查看权限。 - 检查路径计算:
../的层数可能算错了。使用绝对路径进行测试。在漏洞代码中临时添加echo $full_path; exit;来输出最终路径,确认其是否符合预期。 - 检查PHP配置:
allow_url_fopen和allow_url_include可能为Off,导致伪协议无法使用。通过创建一个phpinfo.php文件来查看配置。 - 检查函数限制:目标可能使用的是
file_get_contents()而不是include()。前者读取文件内容但不会执行PHP代码,对于包含php://input执行代码是无效的。
问题2:如何快速判断一个文件包含点是否可利用?我通常会用一个简单的测试流程:
- 基础遍历测试:
../../../../etc/passwd。如果成功,证明存在目录遍历。 - PHP Filter协议测试:
php://filter/convert.base64-encode/resource=index.php。如果返回base64编码,证明协议可用,且可以读取源码。 - PHP Input协议测试:使用Burp或Curl发送POST请求,Body中包含
<?php echo ‘test’;?>。如果页面上输出test,则存在RCE风险。注意:此测试可能产生实际影响,务必在授权环境下进行。 - 日志文件包含测试:尝试包含已知的日志路径,如
/var/log/apache2/access.log。
问题3:在代码审计中,除了include/require,还有哪些函数需要警惕?一个更广泛的“危险函数”列表:
- 文件系统类:
file_get_contents(),readfile(),fopen()/fread()/fwrite(),file(),copy(),rename(),unlink()(删除文件)。 - 命令执行类:
system(),exec(),shell_exec(),passthru(),popen(), 反引号` `。 - 代码执行类:
eval(),assert(),create_function()(已弃用),preg_replace()的/e修饰符(已弃用)。 - 反序列化:
unserialize(),如果参数可控,可能导致对象注入漏洞。 - 动态函数/变量:
$func()(变量函数),$$var(可变变量),如果用户可控制函数名或变量名,可能导致意外行为。
问题4:WordPress插件审计有哪些特别的关注点?
- Admin AJAX:插件通过
wp_ajax_和wp_ajax_nopriv_钩子暴露的端点。这些端点可能缺乏权限校验(capability和nonce)。 - Admin Post:类似Admin AJAX,通过
admin_post_钩子。 - REST API:插件注册的自定义REST端点,检查权限回调(
permission_callback)是否设置正确。 - 短代码:短代码处理函数是否对属性进行了充分验证。
- 设置页面:插件在后台保存设置时,是否对所有选项进行了安全校验。
- 数据库操作:是否使用
$wpdb->prepare()。 - 文件操作:是否使用
wp_handle_upload()等安全函数处理上传,是否对文件路径进行了校验。
问题5:遇到混淆或加密的代码怎么办?有些恶意插件或后门会使用base64_encode、gzinflate、eval或自定义加密来隐藏代码。对于这种情况:
- 搜索
eval(、base64_decode(、gzinflate(等关键字。 - 将可疑的加密字符串提取出来,在隔离环境中手动解码执行,观察输出。可以使用在线的PHP代码沙箱,但务必注意安全。
- 使用专业的代码审计工具或IDE的调试功能,动态跟踪变量值。
最后,保持耐心和细心是代码审计最重要的品质。每一个漏洞的发现,都源于对异常数据流的执着追踪和对“用户输入”的绝对怀疑。这份对LFI漏洞的深度解析,希望能为你打开PHP代码安全世界的一扇门。真正的安全,始于每一行严谨的代码。