文件类漏洞审计
覆盖题库文件操作类考点:文件包含(LFI/RFI)、任意文件读取/写入/删除、路径穿越、Zip Slip。统一按「通用原理 → 各语言 Sink 与案例 → 防御」组织。 本篇按零基础自学重写:每个概念先讲「是什么、为什么会出现、审计时怎么找」,再带读真实漏洞代码。
一、跨语言通用原理
1.0 先把「文件类漏洞」讲明白
是什么:程序要读写文件时,文件路径里混进了用户输入,又没检查,于是用户让程序读写什么文件,程序就照办。
生活化类比:你是个仓库管理员,规矩是「只收发 3 号货架的包裹」。但你看单只看收货地址栏——有人把地址写成「3 号货架……旁边的保险柜」,你也照送。路径穿越里的 ../ 就是那句「旁边」,.. 在文件系统里就是「上一级目录」。
为什么会出现:开发者写「下载文件」「加载页面模板」「解压上传的压缩包」这类功能时,最省事的写法就是把用户传来的文件名直接拼进路径。他脑子里想的是「用户只会传正常文件名」,不知道攻击者会传 ../../../../etc/passwd。这不是技术难,是默认信任输入的思维误区。
一句话本质(背):文件路径(或压缩包内条目名)来自用户可控输入,进入文件系统操作前未做规范化与白名单校验。所有子类型都是这一句的变体。
1.1 四个子类型
| 子类型 | 本质 | 典型后果 |
|---|---|---|
| 文件包含(LFI/RFI) | 路径可控进入「包含并执行」语义(PHP 特有高发),本地包含 LFI / 远程包含 RFI | 读源码 → 代码执行 |
| 任意文件读/下载 | 路径可控进入读操作(readfile、new FileInputStream、open()) | 读 /etc/passwd、源码、配置、密钥 |
| 任意文件写/删 | 路径可控进入写/删操作(file_put_contents、unlink) | 写 Webshell、覆盖配置、删 install.lock 重装 Getshell |
| 路径穿越 / Zip Slip | ../ 上跳逃出预期基目录;Zip Slip 是其在「解压」场景的变体——恶意 zip 条目名为 ../../evil.jsp,解压时写到目录外 | 读写任意位置文件 → RCE |
四者关系:路径穿越是总病根——能穿越,就能任意读写;任意读写在 PHP 里再加 include 语义,就是文件包含;穿越发生在解压场景,就是 Zip Slip。
1.2 通用审计动作(照这个顺序做)
- 定 Source:一切 HTTP 入参(query/form/header/cookie/上传文件名/压缩包条目名)都是 Source。二次数据流也算——从数据库、缓存、队列里取出来的旧值,当年可能就是用户写进去的。
- 搜 Sink:按下文各语言 Sink 表全局 grep。比如 PHP 项目先跑一遍:
grep -rn "include\|require" --include="*.php" . | grep '\$' # 带 $ 变量的 include 才有戏,写死路径的不用看 - 看校验链:Source 到 Sink 之间有没有做「规范化 + 基目录前缀校验」(Java 的
getCanonicalPath、Python 的realpath后startswith)?只做了单次str_replace('../','')这类黑名单过滤的,一律视为可绕过。 - 判定结论三问:① 输入可控?② 过滤可绕?③ 落点是否可执行 / 可读敏感文件?三者齐备才报高危,并在报告注明利用前提(如 PHP 的
allow_url_include开关、日志是否可写)。
通用绕过套路(背,逐条讲透)
- 单次替换:过滤代码是
str_replace('../', '', $x)。输入....//,替换掉中间那对../后,剩下的字符又拼回../(下文 PHP 案例有逐字符推演)。同理..././也行。- 绝对路径击穿拼接:Python
os.path.join('/base', '/etc/passwd')的结果是'/etc/passwd'——join 遇到绝对路径参数会丢弃前面所有部分。这是 join 最坑的语义,很多人以为它「只是在中间加个斜杠」。- 编码绕过:URL 双重编码
%252e%252e%252f(服务器解两次码后才变../,过滤只在第一次解码前做就失效)、超长 UTF-8 变形编码。- 空字节截断:
%00是 C 语言字符串结束符。PHP 底层用 C 字符串,shell.php%00.jpg在文件系统层面被截成shell.php。仅 PHP < 5.3.4 有效(此后 PHP 处理了 null byte),老靶场题高频,新项目基本遇不到。
二、PHP
2.0 先铺垫:PHP 为什么是文件包含重灾区
是什么:PHP 的 include "xxx.php" 会把指定文件的内容读出来当场当 PHP 代码执行。类比:主持人照着提词卡念稿,卡片上写什么他念什么——如果有人能把卡片内容换成「把保险柜钥匙给我」,他就照念(照执行)。
为什么会出现:PHP 做「多页面站点」的标准土办法就是 include $_GET['page'] . '.php'——首页写一次导航栏,点不同菜单包含不同内容文件,开发者觉得这样「复用模板」很自然。问题是他把 URL 参数直接当成了文件名。include 不管路径指向的是 .php 还是 .log 还是 /etc/passwd,只要内容里有 <?php ... ?> 就执行,这就是 LFI 能升 RCE 的根本原因。
审计时怎么找:全局搜 include/require,看括号里的字符串是不是拼了变量;拼了变量就回头追变量从哪来。
2.1 Sink / 成因
| 类型 | Sink 函数 | 典型后果 |
|---|---|---|
| 文件包含 | include() require() include_once() require_once() | LFI/RFI → 代码执行 |
| 文件读 | file_get_contents() fread()/fgets() readfile() file() highlight_file() show_source()(前者别名) parse_ini_file() | 任意文件读取 |
| 文件写 | file_put_contents() fwrite() copy() | 写 Webshell / 覆盖配置 |
| 文件删/改 | unlink() rmdir() rename() | 任意删除(如删 install.lock 重装 Getshell) |
成因补充:include 失败仅告警、脚本继续跑;require 失败是致命错误、脚本中断——对攻击者无所谓,审计时两者都按包含点处理,不用区分。
伪协议(PHP 文件类漏洞的灵魂):
「伪协议」就是 PHP 文件函数接受的一类特殊字符串。开发者以为参数是路径,攻击者给的却是 php://filter/...,函数照样处理。
| 伪协议 | 用途 | 前置条件 |
|---|---|---|
php://filter/convert.base64-encode/resource=xxx | 把目标文件 base64 编码后输出,用来读 PHP 源码(不 base64 的话源码会被当代码执行掉,页面反而看不到内容) | 无 |
php://input | 把 POST 原始 Body 当文件包含——Body 里写马直接 RCE | allow_url_include=On |
data://text/plain;base64,... | 把内联数据当文件包含执行 | allow_url_include=On |
phar://xxx.phar/yyy | 访问 phar 包内文件,触发 phar 元数据反序列化(反序列化篇详讲) | 能上传/写入 phar 文件 |
zip:// / compress.zlib:// | 包含压缩包内文件 | 能上传压缩包 |
file:///etc/passwd | 绝对路径直接读本地文件 | 无 |
关键点:带 allow_url_include 的两条默认关闭(php.ini 默认 Off),所以纯靠 php://input 打 RCE 的场景不多;php://filter 无任何前提,是读源码的万能钥匙,必背。
2.2 案例:LFI → 代码执行(完整数据流带读)
一个典型的小站首页,想实现 ?page=home 加载 home.php、?page=news 加载 news.php:
<?php
// index.php —— 漏洞代码:多页面复用的小站首页
$page = $_GET['page'] ? $_GET['page'] : 'home'; // ① Source:URL 参数 ?page= 完全由用户控制,没传就默认 home
$page = str_replace('../', '', $page); // ② 开发者自以为的过滤:把 ../ 删掉。注意:只替换一次!
include $page . ".php"; // ③ Sink:把拼出来的路径当 PHP 代码载入执行,无白名单数据流四步走:
① 用户输入从哪进来:$_GET['page'],攻击者发 GET /index.php?page=任意值,值原封不动进 $page。
② 经过什么处理:str_replace('../', '', $page)——从左到右扫一遍字符串,见到 ../ 就删掉,扫完一遍就结束,不会回头再扫。
③ 到达哪个危险函数:include($page . ".php"),路径里残留的 ../ 会跳出站点目录;路径指向的文件若含 PHP 代码就会被执行。
④ 为什么防线失效:单次替换挡不住「套娃输入」。看逐字符推演:
输入: . . . . / /
↑↑↑
str_replace 从左往右扫,在第 2~4 个字符处发现一对 "../",删掉
剩下: . . / ← 拼接后正好又是一个 "../"
所以 ?page=....//....//etc/passwd%00 过滤后变成 ../../etc/passwd + 空字节截断,最终 include "../../etc/passwd"(%00 把拼接的 .php 后缀截掉,仅 PHP<5.3.4 有效)。
利用链(由浅到深):
- 读源码(无前提,最实用):
?page=php://filter/convert.base64-encode/resource=config。 逐段拆解这个 payload:php://—— 告诉 include「这不是普通路径,是伪协议」;filter/convert.base64-encode/—— 在读取时挂一个 base64 编码过滤器,文件内容先被转成 base64 再返回;resource=config—— 目标文件名(代码里还会拼上.php后缀 → 实际读config.php)。 页面回显一串 base64,本地base64 -d解码即得config.php源码,里面通常有数据库账号密码。为什么不直接 include config.php? 因为那样源码会被执行掉,页面上什么都看不到——base64 编码就是为了让代码「现出原形」变纯文本。
- 老版本截断读任意文件:
?page=../../../../etc/passwd%00(PHP<5.3.4)。 - 升级 RCE:
- 日志投毒:先发一个请求把
User-Agent头改成<?php system($_GET['c']);?>,Nginx/Apache 会把 UA 写进 access.log;再用 LFI 包含/var/log/nginx/access.log,日志里那句 PHP 代码就被执行了。(注意本案例有.php后缀拼接,包含日志需配合截断或无后缀场景,审计时如实记录前提。) php://input:前提是allow_url_include=On。POST Body 直接写<?php system($_GET['c']);?>,包含即执行。- session 文件包含:PHP session 默认存在
/tmp/sess_<sessionid>,内容部分可控(如用户名),包含之。 - pearcmd 利用:PHP 自带 pear 工具脚本,通过 LFI 以特定参数调用
pearcmd.php可在服务器任意位置写文件(写马)。
- 日志投毒:先发一个请求把
- 后缀拼接在现代 PHP 下还能玩高级 trick(php_filterchain 链式过滤器 RCE),属于进阶内容,知道存在即可。
审计结论写法:LFI 成立,单次 str_replace 过滤可用 ....// 绕过;能否 RCE 取决于 allow_url_include、日志/session 可写性等环境条件——报告必须注明利用前提,不能一句「存在文件包含」完事。
2.3 防御与修复
$allow = ['home', 'news', 'about']; // ✅ 白名单:能被包含的页面全列在这
$page = $_GET['page'] ?? 'home'; // 取参数,没传默认 home
if (!in_array($page, $allow, true)) die('404'); // ✅ 严格模式比对(第三参数 true),不在名单直接拒绝
include "pages/$page.php"; // 走到这里 $page 只可能是名单里的值逐条讲为什么这么修:
- ✅ 白名单是唯一推荐姿势:攻击面从「全世界所有路径」缩到「三个枚举值」,
../、伪协议、编码全都无处下手。 - ✅
in_array必须带第三参数true(严格比较,连类型一起比)。松散比较的坑:PHP 7 下'0' == 'home'为 true(非数字字符串转数字得 0),?page=0就能绕过in_array($page, $allow);PHP 8 修复了这个比较语义,但写代码别赌运行环境。 - ✅
open_basedir:php.ini 里圈定 PHP 可访问的目录范围,越界文件操作直接被引擎拒绝——这是纵深防御(多一道保险),存在已知绕过技巧,不能替代白名单。 - ✅ php.ini 保持
allow_url_include=Off(默认即 Off);allow_url_fopen视业务关闭,可掐断data://、http://类远程包含。 - ❌ 黑名单替换
../不可靠:单次替换....//绕过,循环替换也有编码变形能绕。审计报告里看到黑名单过滤,直接标「防御不足」。
三、Java
3.0 先铺垫:Java 没有 include,坏在另外两处
是什么:Java 的文件类漏洞几乎全是「路径拼接」和「解压」两种形态。类比:Java 是个谨慎的管家,不会把别人的稿子拿来念(没有 include 语义),但他取快递只看单号不看地址范围——给他一个写着 ../../保险柜 的单号,他照样去开保险柜。
为什么会出现:写「文件下载」功能的开发者,直觉写法就是 new File(下载目录, 用户传的文件名);写「解压上传的 zip」功能的开发者,直觉写法就是按 zip 条目名直接落盘。两个直觉都默认了「文件名/条目名是好人」。
审计时怎么找:搜 new File( 看第二个参数是不是变量;搜 ZipInputStream / ZipFile 看条目名有没有校验。看到 request.getParameter 的值最终流进这些构造器,就是活靶子。
3.1 Sink / 成因
| 类型 | Sink 关键字(grep 用) | 说明 |
|---|---|---|
| 路径穿越读/下载 | new File(baseDir, request.getParameter("name"))、Paths.get(可控串)、FileInputStream、Files.read/newInputStream、FileUtils.readFileToString、RandomAccessFile | 文件名参数未归一化校验,../ 上跳即任意文件读 |
| 路径穿越写 | FileOutputStream、Files.write/copy、MultipartFile.transferTo(上传落盘路径) | 写到目录外 → 覆盖配置/落 Webshell |
| Zip Slip | ZipInputStream.getNextEntry() / ZipFile.getEntry() 后按 entry 名直接 new File(destDir, entry.getName()) | 恶意条目名 ../../../../webapps/ROOT/shell.jsp,解压即写到目录外 |
成因细节(含一个易错点):
new File(parent, child)只是字符串拼接,不做归一化。child 含..时,操作系统打开文件时才解析..→ 逃逸 parent。- ⚠️ 修正一个常见讹传:类 Unix 系统下 child 是绝对路径并不会逃逸——
new File("/data/files/", "/etc/passwd")的结果是/data/files//etc/passwd(只是拼接)。真正会被绝对路径击穿的是 Python 的os.path.join和 Java 里的Path.resolve(入参为绝对路径时直接返回入参)。Windows 下盘符路径(D:\evil)另算,会逃逸。审计报告里别把这条写错。 - 压缩包条目名本质是「压缩包自带的相对路径字符串」,由打包者任意填写——用户上传 zip,等于用户直接递给你一沓路径。
3.2 案例 1:路径穿越下载(带读)
@Controller
public class DownloadController {
// ❌ 漏洞代码:GET /download?name=用户可控文件名
@RequestMapping("/download")
public void download(String name, HttpServletResponse resp) throws Exception {
// ① Source name 进 Sink:new File 只做字符串拼接,child 里的 ../ 不会被拦
File f = new File("/data/files/", name);
// ② 按最终路径读文件,内容直接写进 HTTP 响应 → 攻击者拿到文件内容
Files.copy(f.toPath(), resp.getOutputStream());
}
}数据流四步走:
① 用户输入从哪进来:GET /download?name=../../../../etc/passwd,Spring 把 name 参数绑定到方法入参,无任何校验。
② 经过什么处理:无。new File("/data/files/", name) 拼出 /data/files/../../../../etc/passwd——Java 在这一步不解析 ..,只是存着这串字符。
③ 到达哪个危险函数:Files.copy(f.toPath(), ...) 触发系统调用打开文件,操作系统在这一步把 .. 归一化:每对 目录名/.. 抵消一层,/data/files/ 上跳 4 层后指向 /etc/passwd。
④ 为什么防线失效:根本就没有防线——Source 到 Sink 之间零校验。同类写法 Paths.get(base, name)、new FileInputStream(base + name) 同样判定。
审计结论:Source 直达 new File 且无校验,路径穿越任意文件下载成立,高危。可进一步尝试读源码(.java/.class)、配置文件(application.properties 里的数据库口令)、/root/.ssh/id_rsa。
3.3 案例 2:Zip Slip 解压穿越(带读)
先懂概念:zip 包里每个文件存一个「条目名」,就是它在包内的相对路径,比如 docs/readme.txt。这个字符串是打包时随便写的,没有任何规范禁止把它写成 ../../../../shell.jsp——解压器如果老实照这个路径落盘,就写到了目标目录外。2018 年 Snyk 给这类漏洞起了个名字叫 Zip Slip,一票知名库(当时连一些 Apache 项目)都中招。
攻击者怎么造恶意 zip(一行 Python,Linux 自带的 zip 命令反而不容易造出 ../ 条目名):
python3 -c "
import zipfile
z = zipfile.ZipFile('evil.zip', 'w')
z.writestr('../../../../tomcat/webapps/ROOT/shell.jsp', '<% Runtime.getRuntime().exec(request.getParameter(\"c\")); %>')
z.close()
"
# 条目名 '../../../../tomcat/webapps/ROOT/shell.jsp' ← 这就是 payload 本体漏洞代码带读:
// ❌ 漏洞代码:解压用户上传的 zip 到 destDir
ZipInputStream zis = new ZipInputStream(upload.getInputStream()); // ① Source:用户上传的 zip,内容完全可控
ZipEntry entry;
while ((entry = zis.getNextEntry()) != null) {
// ② entry.getName() 取出条目名字符串——攻击者写的是 ../../../../tomcat/webapps/ROOT/shell.jsp
File out = new File(destDir, entry.getName()); // ③ Sink:恶意条目名直接拼进落盘路径,零校验
Files.copy(zis, out.toPath()); // ④ 写出文件 → 落到 destDir 之外,JSP 马进 Web 根目录
zis.closeEntry();
}数据流四步走:① 用户上传 evil.zip(Source 是条目名,不是 zip 内容本身,这点想通很关键);② getNextEntry() 把条目名原样交给代码;③ new File(destDir, 恶意名) 拼出逃逸路径;④ Files.copy 落盘。防线失效原因:全程没有任何一处检查条目名里有没有 ..。ZipFile + entries() 遍历的写法同样存在,审计时两种 API 都搜。
审计结论:凡按 zip 条目名直接构造落盘路径、未校验 .. 与绝对路径者,即 Zip Slip。这是 Java 解压审计的必查点,搜 ZipInputStream / ZipFile.getEntry 后逐处核对条目名校验。
3.4 防御与修复
// ✅ 归一化 + 基目录前缀校验(读/写/解压通用模板)
File base = new File("/data/files/").getCanonicalFile(); // 基目录先算出自己的绝对真实路径
File f = new File(base, name).getCanonicalFile(); // 拼接后立刻归一化:../ 在这一步被算掉
if (!f.getPath().startsWith(base.getPath() + File.separator)) {
throw new SecurityException("path traversal"); // 最终结果不在基目录内 → 拒绝
}
// 走到这里,f 一定在 /data/files/ 之下逐条讲:
- ✅
getCanonicalPath()做归一化——把..、.、重复斜杠全部算成最终真实路径,先归一化再判断,顺序不能反(先判断再归一化等于没判断)。 - ✅ 前缀比较必须拼上
File.separator:不拼的话/data/files-evil/x也满足startsWith("/data/files"),前缀撞车白给。 - ✅ 解压 zip 时对每个 entry 名套上面同一段校验:禁
..、禁绝对路径、禁 Windows 盘符与 UNC(\\host\share),落盘前过 canonical 前缀检查。 - ✅ 更稳的姿势:文件名根本不接受用户输入——用户只传一个 id,服务端查表映射到真实存储名。
- ✅
MultipartFile.transferTo的目标路径同理校验(上传文件名getOriginalFilename()也是用户可控的)。 - ❌ 只
name.replace("..", "")黑名单:可被编码、双写(....//思路同 PHP)绕过。
四、Python
4.0 先铺垫:Python 的风险面在哪
是什么:Python 没有 include 语义,文件类漏洞集中在「读文件返回给前端 / 提供下载」和「写文件 / 解压」。类比:一个文件柜管理员,你递纸条他取文件——纸条上写 ../../财务室/账本 他也照取。
为什么会出现:Flask 写个下载路由就三行,教程全教你 send_file(路径),没人提醒路径不能拼用户输入。os.path.join 名字听起来像「安全拼接路径」,很多人误以为用它就安全了——这是 Python 侧最经典的误判。
审计时怎么找:搜 open(、send_file(、os.path.join(,看参数里有没有 request.args / request.form / request.values 来的变量。
4.1 Sink / 成因
| 类型 | Sink | 判定要点 |
|---|---|---|
| 路径穿越 / 任意文件读写 | open(path)、os.path.join(base, user)、send_file/send_from_directory、shutil/zipfile 解包(Zip Slip) | os.path.join('/base', '../../etc/passwd') 会被上跳击穿;os.path.join('/base', '/etc/passwd') 直接被绝对路径击穿(返回后者);拼接后未做 realpath 前缀校验即漏洞 |
os.path.join 的两个坑,动手验证一次终身记住:
>>> import os
>>> os.path.join('/var/www/files', '../../etc/passwd')
'/var/www/files/../../etc/passwd' # 坑1:join 不归一化,../ 原样保留,打开时逃逸
>>> os.path.join('/var/www/files', '/etc/passwd')
'/etc/passwd' # 坑2:遇到绝对路径参数,前面所有部分被丢弃!4.2 案例:open() 拼接路径穿越(Flask 下载,带读)
from flask import Flask, request, send_file
app = Flask(__name__)
@app.route('/read')
def read():
name = request.args.get('name', '') # ① Source:URL 参数 ?name= 完全可控
# ❌ 危险:用户输入直接字符串拼进路径,open 照单全收
with open('/var/www/files/' + name) as f: # ② 无校验拼接 → ③ Sink:open 读文件
return f.read() # ④ 文件内容作为响应返回给攻击者
@app.route('/dl')
def dl():
name = request.args.get('name', '')
# ❌ os.path.join 挡不住 ../,更挡不住绝对路径(见 4.1 两个坑)
return send_file(os.path.join('/var/www/files', name))数据流四步走:
① 用户输入从哪进来:request.args.get('name'),即 GET /read?name=任意值。
② 经过什么处理:第一种写法是纯字符串拼接,第二种是 os.path.join——两者都不归一化、不校验。
③ 到达哪个危险函数:open() / send_file() 按最终路径读文件。
④ 为什么防线失效:os.path.join 名字里的「join」给了开发者虚假安全感。?name=../../../../etc/passwd 靠 ../ 上跳出基目录;?name=/etc/passwd 更省事,绝对路径直接让 join 返回 /etc/passwd,基目录形同虚设。
利用价值排序:读 app.py 等源码找硬编码密钥 → 读 /etc/machine-id(配合泄露的 MAC 地址可算 Werkzeug 调试器的 console PIN,直接 RCE)→ 读 SSH 私钥、云凭据文件。
审计结论:open() / send_file 的路径参数出现任何用户输入拼接、且后续无 realpath 前缀校验,即路径穿越高危。
4.3 Zip Slip(Python 形态)
Python 的情况和 Java 不一样,先分清库的行为:
- CPython 的
zipfile:extract/extractall内部已对条目名做净化(剥离..、盘符、前导分隔符),直接用官方解包函数 Zip Slip 风险较低。真正的风险在自实现的解包循环——有人嫌extractall不好用,自己for name in zf.namelist(): open(os.path.join(dest, name), 'wb'),这就回到了裸拼接。 tarfile才是重灾区(CVE-2007-4559):条目名../、绝对路径、符号链接逃逸(包内放一个指到/etc的软链再往里写文件)都可触发。Python 3.12 才引入filter参数,且 3.12/3.13 默认仍不过滤(只对裸用发 DeprecationWarning),必须显式写filter='data';3.14 起才默认'data'。写报告时版本描述别搞错。
审计动作:搜 extractall( / extract( / namelist(,手写解包循环逐个看条目名校验;tarfile 调用没写 filter= 的一律标风险。
4.4 防御与修复
import os
from flask import send_from_directory
BASE = '/var/www/files'
@app.route('/dl')
def dl():
name = request.args.get('name', '')
# ✅ 先 join 拼接,再 realpath 归一化(../ 在这一步被算掉,软链接也被解析)
path = os.path.realpath(os.path.join(BASE, name))
# ✅ 强制前缀校验:归一化后的路径必须以「基目录 + 分隔符」开头
if not path.startswith(os.path.realpath(BASE) + os.sep):
return 'invalid', 400
return send_file(path)
# ✅ 更省事:直接用框架安全 API
# return send_from_directory(BASE, name) # 内部走 Werkzeug 的 safe_join,拒绝 .. 和绝对路径,不安全就返回 None逐条讲:
- ✅ 套路和 Java 一模一样:归一化在前,前缀校验在后,
startswith记得拼os.sep防前缀撞车。 - ✅ Flask 场景优先用
send_from_directory(内部safe_join已处理..、绝对路径、反斜杠变形),别自己造轮子。 - ✅ 解压 zip/tar 逐条校验条目名(禁
..、绝对路径、符号链接);Python ≥3.12 的 tarfile 直接extractall(filter='data'),老版本自己实现等价校验。 - ❌ 两个经典误判,审计看到直接标:
name.replace('..', '')黑名单(编码/双写可绕)、用了os.path.join就安全(4.1 已证伪)。
五、面试题眼速答
| 面试题 | 一句话答案 | 展开两三句 |
|---|---|---|
| 文件包含函数与区别? | include/require/include_once/require_once;include 失败告警继续、require 失败致命错误 | 利用链:php://filter base64 读源码、日志/session 投毒包含、phar:// 触发反序列化。审计时 include/require 一视同仁都按包含点处理,_once 后缀只影响重复包含,不影响漏洞判定 |
| LFI 如何升 RCE? | 日志投毒、session 文件包含、php://input、pearcmd 写文件、配合上传包含图片马 | 日志投毒:UA 头写马 → 包含 access.log 执行;php://input 需 allow_url_include=On(默认关);pearcmd 利用 PHP 自带脚本任意写文件;有后缀拼接时用 %00(老版本)或 php_filterchain(新版本) |
| LFI 怎么防御? | 包含目标白名单枚举;open_basedir 纵深;关 allow_url_include;绝不黑名单替换 ../ | 白名单是唯一推荐姿势,in_array 必须第三参数 true 严格比较(PHP7 下 '0'=='home' 松散比较可绕过)。黑名单单次替换被 ....// 绕过是必考点 |
| 任意文件读/写/删搜什么 PHP 函数? | 读:file_get_contents/readfile/fread/file/highlight_file;写:file_put_contents/fwrite/copy;删改:unlink/rmdir/rename | 删 install.lock 触发 CMS 重装 Getshell 是经典考点——很多 CMS 安装逻辑只检查锁文件存在与否,删掉就能重新走安装向导写配置 |
| 路径穿越单次过滤怎么绕? | ....// 或 ..././ 绕过单次 str_replace;PHP 还能 %00 截断后缀(仅 PHP<5.3.4) | 原理:替换从左到右扫一遍不回头,....// 删掉中间那对 ../ 后剩余字符又拼回 ../。答面试题时顺手把逐字符推演画出来,立刻显得是真懂 |
| Java 文件类 Sink 搜什么? | new File(、FileInputStream/FileOutputStream、Files.read/write/copy、Paths.get、RandomAccessFile;解压搜 ZipInputStream/ZipFile.getEntry;上传搜 MultipartFile.transferTo | 追到 Source 是 request.getParameter 或上传文件名即可判定。注意类 Unix 下 new File(parent, child) 的绝对路径 child 只是拼接不逃逸,逃逸靠 ..——别和 Python os.path.join 的语义搞混 |
| Zip Slip 是什么? | 压缩包条目名是自带相对路径(如 ../../webapps/ROOT/shell.jsp),解压时按条目名直接落盘即写出目录外 | 防御:对每个 entry 名做 canonical 归一化 + 基目录前缀校验(禁 ../绝对路径/盘符/UNC)。Java 是重灾区;Python zipfile 内置净化但 tarfile 默认不过滤(CVE-2007-4559) |
| Java 路径穿越的标准修复? | 拼接后 getCanonicalPath() 归一化,强制 startsWith(基目录 + File.separator);文件名白名单映射最稳 | 两个细节:先归一化再校验(顺序反了等于没校验);前缀必须拼分隔符,否则 /data/files-evil 撞车绕过 |
| Python 路径穿越要点? | open() 拼接、os.path.join 可被 ../ 和绝对路径双击穿;send_file 路径可控即洞;修复 realpath + startswith(基目录+os.sep) | Flask 直接用 safe_join / send_from_directory。os.path.join('/base','/etc/passwd') 返回 /etc/passwd 这个坑能背出来就是加分项 |
| Python 解压安全? | CPython zipfile 内置条目名净化;风险主要在 tarfile.extractall 与自实现解包循环 | tar 条目名 ../、绝对路径、符号链接逃逸都可触发;3.12 引入 filter 参数但默认不过滤,须显式 filter='data'(3.14 起才默认 data)。自实现 namelist() 循环写盘等于裸拼接 |
| 文件类漏洞通用审计判定? | Source(含二次数据流)可控 → Sink 表命中 → 中间校验可绕 → 落点可读敏感文件或可执行,四者齐备报高危 | 黑名单、单次替换、os.path.join 都不可信。报告必须注明利用前提(allow_url_include、日志可写性、PHP 版本等),这是专业报告和扫描器报告的区别 |