安全:修复上传接口三个真实漏洞(可执行文件落盘 / 限流可绕过 / 真上传无限流) (#5)

## 用户提问

「上传视频和图片的接口,有没有限制?会不会所有人通过 POST 都能随意上传?」

## 结论:防护大多到位,但存在三个真实漏洞

已核实有效的:CSRF、扩展名白名单、MIME 白名单、filesize 实测大小、
is_uploaded_file、类型开关、总量上限、uploads 目录 .htaccess 禁直连禁执行、
附件只走 media.php 代理。

但以下三个是真问题:

### 1. 🔴 可执行文件能写进 uploads/(最严重)

MIME 只校验**前端上报的字符串**,从不检测文件真实内容。

实测:把 `<?php system($_GET[c]); ?>` 改名成 .png、POST 里写
`mime=image/png`,一路通过所有检查写入磁盘。已确认真实文件内容:

    00000000: 3c3f 7068 7020 7379 7374 656d 2824 5f47  <?php system($_G

虽然 uploads/.htaccess 禁了脚本执行,但那是**唯一一道防线** ——
换 nginx、改配置、目录迁移,任何一步疏忽就是 RCE。

修法:新增 fb_upload_detect_mime(),用 finfo 读文件头魔数
(getimagesize 兜底),在 **move_uploaded_file 之前**校验真实类型。
不合格返回 415 并拒绝落盘。

### 2. 🔴 伪造 X-Forwarded-For 可无限绕过所有限流

fb_client_ip() 无条件信任 CF-Connecting-IP / X-Forwarded-For / X-Real-IP,
而这些头客户端随便写。

实测(upload_per_hour=2,连发 6 次、每次换个 XFF):

    200,200,200,200,200,200   ← 上限 2 却全放行

影响面不止上传:fb_ip_hash() 用同一个函数,所以发帖限流、验证码频率、
所有基于 IP 的限制全部失效。

修法:默认只用 REMOTE_ADDR(客户端改不了)。新增 trusted_proxies 配置,
只有请求确实来自受信任代理/CDN 时才解析转发头,且只取**最右一跳**
(最左边是用户自己塞的,取最左等于把伪造的当前者)。

### 3. 真上传(action=put)没有限流

fb_rate_hit(upload) 只在领凭证时记一次,真正写盘那一段完全没有
fb_rate_check。拿到一个凭证后可反复 POST 覆盖写入。

修法:真上传独立限流 upload_put(与领凭证分开计数,避免互相挤占),
落地成功后才 fb_rate_hit。

## 验证:fb_upload_security.py,19 项

正常代码 **19/19**,覆盖:
- 无 CSRF 被拒 / 正常访客能传
- 限流生效(上限 3 连发 10 次 → 429 x8)
- 三种转发头都无法绕过
- PHP 伪装落地被拒(415,检测到 text/x-php)+ 未落盘
- .php/.phtml/.svg 全拒
- 存储关闭被拒
- 谎报 size 后落地仍用 filesize 拦住 + 未落盘
- uploads 防护文件完备(Require all denied / -Indexes / 禁脚本扩展)
- media.php 代理正常

## 反向验证(关键)

把三处修复全部回退后重跑,出现 4 项 FAIL:

    OPEN  改 XFF 不能绕过限流      上限2/6次: 200,200,200,200,200,200 放行 6
    OPEN  三种转发头都无法绕过      放行 9
    OPEN  伪装内容落地被拒         put 200
    OPEN  伪装内容没有落盘         磁盘=True      ← PHP 代码真的写进去了

证明测试确实能抓到,不是假通过。

## 测试踩的坑(写进注释)

- 领凭证必须带 ext/size/mime,缺了会返回「不支持的文件格式」,
  容易误判成「格式校验生效了」
- key 是 local/2026/11/x.png,实际落盘在 uploads/2026/11/x.png,
  直接拿 key 拼 FB_ROOT 判断「是否落盘」永远为 false → 假通过
- php -S 内置服务器不读 .htaccess,不能用 HTTP 状态验目录防护,
  改为检查防护文件内容(线上 Apache 实测 /uploads/ → 403)

## 回归

全新安装 33/33、邮件专项 73/73、配置守护 6/6。
This commit is contained in:
2026-11-03 06:56:11 +08:00
parent 605640aa90
commit a850d479f0
3 changed files with 154 additions and 9 deletions
+35
View File
@@ -226,6 +226,41 @@ function fb_upload_ext_ok($kind, $ext) {
}
/** MIME 白名单 */
/**
* 探测文件的**真实** MIME(不信任前端上报)。
*
* 🔴 为什么必须有:mime 是客户端 POST 上来的字符串,随便写。
* 把 PHP 代码改名成 .png、上报 mime=image/png,就能写进 uploads/。
* 虽然 .htaccess 禁了脚本执行,但那是唯一一道防线 ——
* 换 nginx、改配置、目录迁移,任何一步疏忽就是 RCE。
*
* 优先用 fileinfo(读文件头魔数);没装则用 getimagesize 兜底
* (只认图片,视频降级为「放行」并记录,不阻断正常视频)。
*
* @return string 探测到的 mime;无法判断返回空串
*/
function fb_upload_detect_mime($path) {
if (!is_file($path)) { return ''; }
if (function_exists('finfo_open')) {
$fi = @finfo_open(FILEINFO_MIME_TYPE);
if ($fi) {
$m = @finfo_file($fi, $path);
@finfo_close($fi);
if (is_string($m) && $m !== '' && $m !== 'application/octet-stream') {
return strtolower(trim($m));
}
}
}
// 兜底:图片用 getimagesize(读真实尺寸,伪造难度高)
if (function_exists('getimagesize')) {
$info = @getimagesize($path);
if (is_array($info) && !empty($info['mime'])) {
return strtolower(trim((string)$info['mime']));
}
}
return '';
}
function fb_upload_mime_ok($kind, $mime) {
$list = (array)fb_upload_cfg($kind . '_mimes', []);
return in_array(strtolower(trim((string)$mime)), array_map('strtolower', $list), true);