⭐ 推荐:社区规则条款 V1.0

上传键生成规则:年月/日/随机hash 为什么能防目录遍历

CLARA轻量论坛系统
CLARA轻量论坛系统 星耀SVIP星耀SVIP管理员管理员 黑卡会员黑卡会员
发布于 2026-10-01 20:00 ·95 浏览 ·0 回复
内容摘要

上传键按「年月/日/随机hash.扩展名」生成,目录分片控制单目录文件数并便于按时间归档清理,随机部分用 random_bytes 经 bin2hex 只输出 0-9a-f,扩展名走白名单,原始文件名仅用于取扩展名而不参与路径拼接,因此最终字符串在字符集上无法拼出可上跳的片段,比替换「../」的黑名单方式更可靠。

学完这篇你能拿到一套可直接抄的上传键生成函数,并弄明白「年月/日/随机hash」这套命名规则到底是靠哪几层设计堵死目录遍历的。

第一步:先分清「原始文件名」和「存储键」

用户上传时浏览器会带一个 filename="../../../etc/passwd" 或 filename="shell.php"。这个值属于用户输入,永远不可信。

正确做法是:原始文件名只用来取扩展名(还要过白名单),真正落盘的位置由服务端自己算出来,这个算出来的字符串就是上传键。

原始文件名:我的照片.jpg
生成的上传键:202506/07/9f2c4a8b1e6d4f03a7c5b8e2d1a94f60.jpg
落盘路径:/www/site/uploads/202506/07/9f2c4a8b1e6d4f03a7c5b8e2d1a94f60.jpg
访问 URL:https://example.com/uploads/202506/07/9f2c4a8b1e6d4f03a7c5b8e2d1a94f60.jpg

第二步:写生成函数,三段结构各管一件事

function uploadKey(string $originalName): string {
    $ext = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));

    // 白名单:不在名单里直接拒绝,不做「替换危险字符」这种黑名单
    $allow = ['jpg','jpeg','png','gif','webp'];
    if (!in_array($ext, $allow, true)) {
        throw new RuntimeException('不允许的扩展名');
    }

    $rand = bin2hex(random_bytes(16)); // 32 位十六进制,字符集仅 0-9a-f
    return date('Ym') . '/' . date('d') . '/' . $rand . '.' . $ext;
}
  • 年月 + 日:目录分片。一个目录塞几十万文件,ls、备份、rsync 都会明显变慢,MySQL 无关,纯文件系统问题。按天切割后单目录文件数可控,同时天然按时间可归档、可清理。
  • 随机 hash:文件名不可猜、不重复。random_bytes 是密码学安全随机源;bin2hex 输出只含 0-9a-f,不含点、斜杠、反斜杠、冒号,从字符集上就不可能拼出 .. 或 ../。

注意:别用 rand()、mt_rand()、uniqid()、md5(time()) 当随机源。这些都可预测或可碰撞,攻击者猜到别人附件的键就能越权下载。

第三步:为什么这套规则能防目录遍历

关键不在于「过滤了 ../」,而在于用户输入根本没参与路径拼接。

  1. 目录部分 全部来自 date(),字符集只有数字和 /,用户控制不了。
  2. 文件名部分 来自 bin2hex(random_bytes()),字符集只有 0-9a-f,不可能出现 ..、/、\、\0。
  3. 扩展名 走白名单,php、phtml、php5、user.ini 这些根本进不了名单。

三层叠加后,最终拼接出来的字符串里不可能包含任何能够让路径上跳的片段。这比「把 ../ 替换成空」强得多——黑名单永远有绕过方式:....//、URL 编码 %2e%2e%2f、双重编码、宽字节、Windows 短文件名、NTFS 数据流、Unicode 归一化差异,每一种都能打穿基于替换的过滤。

第四步:落盘前再加一道 realpath 兜底

即便生成规则已经足够严,写文件前仍建议校验最终路径没跑出 uploads 根目录:

$base   = realpath(__DIR__ . '/uploads');          // 必须是绝对路径
$target = $base . '/' . uploadKey($name);
$dir    = realpath(dirname($target));

if ($dir === false) {
    mkdir(dirname($target), 0755, true);           // 递归建目录
    $dir = realpath(dirname($target));
}
if ($dir === false || strpos($dir, $base) !== 0) {
    throw new RuntimeException('路径越界,已拒绝');
}

move_uploaded_file($_FILES['file']['tmp_name'], $target);

strpos($dir, $base) !== 0 判断的是「最终目录是否以 uploads 根开头」,配合 realpath 解掉符号链接和 ..,是最后一道保险。

第五步:目录层再断一刀,别让上传目录能执行

路径安全了,还要防止「传上去的图片其实能被当脚本执行」:

location ^~ /uploads/ {
    location ~ \.(php|php5|phtml|phar)$ { deny all; }
}

Apache 则在 uploads/.htaccess 写 php_flag engine off 或 Require all denied 搭配静态放行。同时图片要做二次校验,用 getimagesize() 或 exif_imagetype() 确认真的是图片,防止改后缀绕过。

注意:date() 依赖时区。上线前在入口文件设 date_default_timezone_set('Asia/Shanghai'),否则跨零点归档目录会和运维预期错开一天。

第六步:在 Clara BBS 上对应检查

  • 后台「系统设置→上传」里有两个互相独立的字段:「允许的扩展名」管图片,「附件允许的扩展名」管附件,改图片白名单不会影响附件,反之亦然。
  • uploads 目录必须可写,否则会报「目录不可写」。
  • PHP 的 upload_max_filesize、post_max_size 要大于实际文件体积(宝塔默认 2M,手机照片常超,建议调到 30M 以上)。
  • 上传失败时页面会直接给出原因(超 PHP 上限 / 白名单不符 / 目录不可写),按提示改即可,不用瞎猜。

注意:如果一天内上传量特别大(比如几十万张),按天分片仍然会让单目录文件过多,可以在日后面再多切一层,例如用 hash 前两位:202506/07/9f/9f2c...jpg。

小结

  • 上传键 =「年月/日」目录 + 随机 hash 文件名 + 白名单扩展名,三段各自负责分片、唯一、合法。
  • 防目录遍历的核心是用户输入不参与路径拼接,而不是事后过滤 ../;黑名单总有绕过方式。
  • 随机源必须用 random_bytes/bin2hex,不要用 rand()、uniqid()、md5(time())。
  • 落盘前用 realpath + 前缀校验兜底,目录层再禁掉脚本执行,图片做二次校验。
  • 时区要显式设置,上传白名单与 PHP 上传上限是两回事,报错先看具体提示。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-671.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 0

还没有回复,来抢沙发~