ThinkPHP文件上传漏洞防护需四层协同:服务端后缀白名单校验(ext)、MIME类型检测(finfo_open)、文件头内容验证、非Web可访问路径存储,并配合服务器禁用执行权限。

ThinkPHP 的文件上传漏洞不是“会不会出”的问题,而是“在哪一层漏掉就会被利用”的问题。光靠 validate(['ext' => 'jpg,png']) 这类配置,等于把门锁换成贴纸——看着像防了,实际一揭就开。
后缀校验为什么必须配合 MIME 和文件头检测
ext 规则只检查客户端传来的文件名后缀,不读文件内容。攻击者上传一个内容为 <?php @eval($_POST[a]);?> 的文件,改名为 shell.jpg,就能绕过所有基于后缀的判断。
-
$_FILES['file']['type']完全由浏览器伪造,不可信 -
getimagesize()不能识别伪装图片,对含 PHP 标签的 JPEG 或 GIF 会返回正常尺寸 - 正确做法是用
finfo_open(FILEINFO_MIME_TYPE)读取临时文件头:$finfo = finfo_open(FILEINFO_MIME_TYPE); $realType = finfo_file($finfo, $file->getTmpName()); finfo_close($finfo);
- 必须在
$file->move()之前调用,否则临时文件可能已被清理或权限变更 - 白名单只允许明确安全的类型,例如
['image/jpeg', 'image/png', 'image/gif'],禁用application/x-php、text/plain等危险类型
上传路径和文件名怎么处理才不踩坑
ThinkPHP 不自动清理$_FILES['file']['name'],它可能带 ../../etc/passwd 或 shell.php%00.jpg,直接拼路径就是路径遍历。
- 用
basename()截断是第一步,但不够:basename('shell.php%00.jpg')返回shell.php%00.jpg,后续若用pathinfo(..., PATHINFO_EXTENSION)会被空字节截断 - 必须紧接着做白名单过滤:
preg<em>match('/^[a-zA-Z0-9</em>.-]+$/', $safeName) - 绝对不要用原始名存文件,强制重命名:
$newName = uniqid('up_') . '.' . $allowedExt; - 存储路径必须脱离 Web 可访问范围,比如
runtime/upload/或storage/app/uploads/,而非public/uploads/
服务器层禁用执行权限才是最后一道保险
就算你代码里校验再严,只要上传目录在 Web 根下且没关解析,攻击者就可能靠双后缀(shell.php.jpg)、Nginx 配置错误(fastcgi_split_path_info 匹配缺陷)或 Apache AddHandler 触发执行。
- Apache:在上传目录加
.htaccess,写入:php_flag engine off RemoveHandler .php .phtml .php3 .php4
- Nginx:在对应
location块中禁止匹配:location ~ ^/uploads/.*\.php$ { return 403; } - Linux 权限上,上传目录对 Web 进程应是
755,且不属www-data用户,避免写入即执行
临时文件残留和 move() 跨分区失败容易被忽略
$file->move() 底层调用的是 move_uploaded_file(),它在 Linux 下跨磁盘分区会失败(返回 false),但 ThinkPHP 默认不抛异常,容易静默失败并留下临时文件。
- 检查
$file->getTmpName()是否存在,再调moveTo() - 若目标路径与临时路径不在同一挂载点,手动
copy()+unlink() - 异常退出(如
exit、fatal error)时,临时文件不会被自动清理,需确保脚本结束前完成移动或加register_shutdown_function()清理 - 不要用用户输入拼接任何路径,包括日志、下载 URL、前端返回字段——所有输出给前端的文件名,都必须过
basename()+ 白名单正则
真实风险不在“能不能上传”,而在“上传后有没有人能执行它”。每层校验都可能被绕过,只有隔离存储 + 禁止执行 + 内容验证三者同时生效,才算真正落地。



















