上传键生成规则:年月/日/随机hash 为什么能防目录遍历
学完这篇你能拿到一套可直接抄的上传键生成函数,并弄明白「年月/日/随机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())` 当随机源。这些都可预测或可碰撞,攻击者猜到别人附件的键就能越权下载。
第三步:为什么这套规则能防目录遍历
关键不在于「过滤了 `../`」,而在于用户输入根本没参与路径拼接。
- 目录部分 全部来自 `date()`,字符集只有数字和 `/`,用户控制不了。
- 文件名部分 来自 `bin2hex(random_bytes())`,字符集只有 `0-9a-f`,不可能出现 `..`、`/`、`\`、`\0`。
- 扩展名 走白名单,`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 上传上限是两回事,报错先看具体提示。
转载请注明出处,版权归原作者所有。
星耀SVIP
管理员
黑卡会员






