安全:修复上传接口三个真实漏洞(可执行文件落盘 / 限流可绕过 / 真上传无限流) (#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:
@@ -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);
|
||||
|
||||
+87
-9
@@ -22,17 +22,94 @@ function fb_fmt_time($ts) {
|
||||
return date('Y-m-d H:i', $ts);
|
||||
}
|
||||
|
||||
/** 访客 IP(兼容反向代理 / CDN) */
|
||||
/**
|
||||
* 访客 IP。
|
||||
*
|
||||
* 🔴🔴 这里曾经是**无条件信任** X-Forwarded-For / X-Real-IP / CF-Connecting-IP:
|
||||
*
|
||||
* $keys = ['HTTP_CF_CONNECTING_IP', 'HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'REMOTE_ADDR'];
|
||||
* foreach ($keys as $k) { ... return 第一个合法 IP ... }
|
||||
*
|
||||
* 而这些请求头是**客户端可以随便写的**。任何人加一个
|
||||
* `X-Forwarded-For: 1.2.3.4` 就换了个「IP」,于是:
|
||||
* - 上传限流形同虚设(实测:上限 2 次,改头后 6 次全放行)
|
||||
* - 发帖限流、验证码频率、所有基于 IP 的限制全部失效
|
||||
*
|
||||
* 正确做法:
|
||||
* 1. 默认只用 REMOTE_ADDR(客户端改不了)
|
||||
* 2. 只有请求**确实来自受信任的反向代理/CDN** 时,才解析转发头
|
||||
* 3. 解析时取**最右一跳**——那是最近一层代理写进去的,
|
||||
* 最左边的(用户自己塞的)最不可信。取最左等于把伪造的当前者。
|
||||
*
|
||||
* 受信任来源通过后台配置 `trusted_proxies` 指定(逗号分隔 IP 或 CIDR)。
|
||||
* 空 = 不信任任何转发头(默认,最安全)。
|
||||
*/
|
||||
function fb_client_ip() {
|
||||
$keys = ['HTTP_CF_CONNECTING_IP', 'HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'REMOTE_ADDR'];
|
||||
foreach ($keys as $k) {
|
||||
if (!empty($_SERVER[$k])) {
|
||||
$v = explode(',', (string)$_SERVER[$k]);
|
||||
$v = trim($v[0]);
|
||||
if (filter_var($v, FILTER_VALIDATE_IP)) { return $v; }
|
||||
}
|
||||
$remote = isset($_SERVER['REMOTE_ADDR']) ? trim((string)$_SERVER['REMOTE_ADDR']) : '';
|
||||
if (!filter_var($remote, FILTER_VALIDATE_IP)) { $remote = ''; }
|
||||
|
||||
// 没有配置受信任代理 → 只能用 REMOTE_ADDR,转发头一律忽略
|
||||
$trusted = fb_trusted_proxies();
|
||||
if (!$trusted || $remote === '' || !fb_ip_in_ranges($remote, $trusted)) {
|
||||
return $remote !== '' ? $remote : '0.0.0.0';
|
||||
}
|
||||
return '0.0.0.0';
|
||||
|
||||
// 来自受信任代理,才读转发头;且只取最右一跳
|
||||
$candidates = ['HTTP_CF_CONNECTING_IP', 'HTTP_X_REAL_IP', 'HTTP_X_FORWARDED_FOR'];
|
||||
foreach ($candidates as $k) {
|
||||
if (empty($_SERVER[$k])) { continue; }
|
||||
$parts = array_filter(array_map('trim', explode(',', (string)$_SERVER[$k])));
|
||||
if (!$parts) { continue; }
|
||||
$last = end($parts); // ★ 最右一跳
|
||||
if (filter_var($last, FILTER_VALIDATE_IP)) { return $last; }
|
||||
}
|
||||
return $remote;
|
||||
}
|
||||
|
||||
/** 受信任的反向代理列表(后台配置,逗号分隔 IP / CIDR)@return string[] */
|
||||
function fb_trusted_proxies() {
|
||||
static $cache = null;
|
||||
if ($cache !== null) { return $cache; }
|
||||
$raw = (string)fb_cfg('trusted_proxies', '');
|
||||
$out = [];
|
||||
foreach (preg_split('/[\s,]+/', $raw) as $one) {
|
||||
$one = trim($one);
|
||||
if ($one !== '') { $out[] = $one; }
|
||||
}
|
||||
$cache = $out;
|
||||
return $cache;
|
||||
}
|
||||
|
||||
/**
|
||||
* IP 是否落在某个 CIDR / 精确 IP 列表里。支持 IPv4 与 IPv6 精确匹配,
|
||||
* IPv6 只做精确比较(不做前缀,够用且不易出错)。
|
||||
*/
|
||||
function fb_ip_in_ranges($ip, array $ranges) {
|
||||
$ipBin = @inet_pton($ip);
|
||||
if ($ipBin === false) { return false; }
|
||||
foreach ($ranges as $r) {
|
||||
if (strpos($r, '/') === false) {
|
||||
$b = @inet_pton($r);
|
||||
if ($b !== false && $b === $ipBin) { return true; }
|
||||
continue;
|
||||
}
|
||||
list($net, $bits) = explode('/', $r, 2);
|
||||
$netBin = @inet_pton(trim($net));
|
||||
if ($netBin === false) { continue; }
|
||||
$bits = (int)$bits;
|
||||
// 只处理同族(长度一致)的情况
|
||||
if (strlen($netBin) !== strlen($ipBin)) { continue; }
|
||||
if ($bits <= 0) { return true; }
|
||||
$maxBits = strlen($netBin) * 8;
|
||||
if ($bits > $maxBits) { $bits = $maxBits; }
|
||||
$bytes = intdiv($bits, 8);
|
||||
$rem = $bits % 8;
|
||||
if ($bytes > 0 && substr($netBin, 0, $bytes) !== substr($ipBin, 0, $bytes)) { continue; }
|
||||
if ($rem === 0) { return true; }
|
||||
$mask = 0xFF << (8 - $rem) & 0xFF;
|
||||
if ((ord($netBin[$bytes]) & $mask) === (ord($ipBin[$bytes]) & $mask)) { return true; }
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 访客 IP 的加盐哈希(不保存明文 IP) */
|
||||
@@ -392,6 +469,7 @@ function fb_setting_keys() {
|
||||
'footer.show_credit', 'footer.credit_url',
|
||||
'installed_at',
|
||||
'attachment.storage',
|
||||
'trusted_proxies',
|
||||
'upload.image_on', 'upload.video_on',
|
||||
'upload.image_max_per_post', 'upload.image_max_bytes',
|
||||
'upload.video_max_per_post', 'upload.video_max_bytes',
|
||||
|
||||
Reference in New Issue
Block a user