发布于5小时前5小时 背景最近在一个 PHP 项目审计里遇到一类比较典型的问题:业务基于 ThinkPHP 风格组织代码,控制器里同时存在文件上传、用户资料更新、缓存读取等逻辑。单个点看起来都不复杂,但组合起来会产生比较实际的风险,比如上传文件可被解析、反序列化入口不明显、权限校验只做在前端菜单层。这类问题在 PHP 项目里很常见,尤其是历史项目:控制器承担过多逻辑,公共方法里混合处理 request 参数,模型层直接拼接 SQL,上传目录又暴露在 Web 根目录下。下面按一次真实审计思路拆开讲,重点放在怎么定位、怎么验证、怎么修。问题定位审计 PHP 项目时,我一般先看四类入口:路由与控制器:确认哪些方法可被外部访问,尤其是未显式鉴权的 action。上传处理:检查扩展名、MIME、保存路径、文件名生成方式。序列化相关函数:搜索 unserialize、__destruct、__wakeup、__toString。数据库调用:关注 query、execute、where 字符串拼接。常用排查命令如下,先把敏感函数和危险写法扫出来:grep -RIn --exclude-dir=vendor "unserialize\|serialize\|__destruct\|__wakeup\|__toString" app/ public/ grep -RIn --exclude-dir=vendor "move_uploaded_file\|request()->file\|\$_FILES" app/ public/ grep -RIn --exclude-dir=vendor "Db::query\|Db::execute\|->query\|where(.*\." app/ grep -RIn --exclude-dir=vendor "\$_GET\|\$_POST\|input(" app/如果是 ThinkPHP 项目,还要重点看 route.php、中间件、基类控制器,例如 app/common/controller/Base.php 或 app/admin/controller/Base.php,确认鉴权是不是所有敏感控制器都继承并生效。技术分析下面用一个常见的头像上传功能举例。问题代码大概是这样:<?php namespace app\admin\controller; class Profile extends Base { public function uploadAvatar() { $file = request()->file('avatar'); if (!$file) { return json(['code' => 1, 'msg' => 'no file']); } $ext = strtolower(pathinfo($file->getOriginalName(), PATHINFO_EXTENSION)); if (!in_array($ext, ['jpg', 'png', 'gif'])) { return json(['code' => 1, 'msg' => 'invalid ext']); } $name = md5(time() . mt_rand()) . '.' . $ext; $path = public_path() . 'uploads/avatar/' . $name; $file->move(dirname($path), basename($path)); return json(['code' => 0, 'url' => '/uploads/avatar/' . $name]); } }表面上看做了扩展名限制,但审计时不能只看这一层。需要继续确认几个点:uploads/avatar 是否位于 Web 可访问目录下。Nginx 或 Apache 是否会把异常后缀、双后缀、路径穿越后的文件交给 PHP 解析。框架上传对象 move 是否会覆盖已有文件,保存文件名是否可控。是否只检查后缀,没有检查真实文件头和内容。是否存在历史兼容配置,例如 Nginx 的 fastcgi_split_path_info 配置不严导致解析问题。Nginx 需要重点看类似配置:location ~ \.php$ { fastcgi_pass 127.0.0.1:9000; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; include fastcgi_params; }如果配置写得更宽,例如 location ~ \.php,再叠加错误的 PATH_INFO 处理,就可能导致某些上传文件路径被误解析。现在新环境相对少见,但老项目里仍然能碰到。反序列化入口排查PHP 反序列化问题不一定出现在明显的接口参数里,有些会藏在缓存、Cookie、消息队列字段中。例如:<?php class CacheService { public function getUserConfig($uid) { $raw = cache('user_config_' . $uid); if (!$raw) { return []; } return unserialize($raw); } }这段代码是否存在风险,关键不只在 unserialize 本身,而是 $raw 是否可能被用户影响。审计时需要顺着数据来源回溯:缓存值是否来自用户提交的配置项。缓存 key 是否可预测或可覆盖。是否存在后台低权限用户可写入该缓存。项目内是否存在可利用的魔术方法链,例如 __destruct 写文件、删除文件、调用模板渲染。可以先搜索魔术方法链的关键点:grep -RIn --exclude-dir=vendor "function __destruct\|function __wakeup\|function __toString\|call_user_func\|eval\|include\|file_put_contents\|unlink" app/ extend/如果项目允许,建议不要直接对用户可控数据使用 unserialize。确实需要反序列化时,至少限制可实例化类:$data = unserialize($raw, ['allowed_classes' => false]);更稳妥的做法是把业务配置改成 JSON 存储:$config = json_decode($raw, true); if (!is_array($config)) { $config = []; }SQL 注入排查方法PHP 项目里 SQL 注入经常不是出在最明显的登录接口,而是后台筛选、导出、排序字段。尤其是排序字段、表名、字段名这类位置,很多人误以为框架参数绑定能完全覆盖。典型风险代码:$keyword = input('get.keyword'); $order = input('get.order', 'id desc'); $list = Db::name('user') ->where("username like '%" . $keyword . "%'") ->order($order) ->select();这里有两个问题:where 使用字符串拼接,$keyword 直接进入 SQL。order 完全由请求参数控制,即使 where 修了,排序字段仍可能造成注入。修复时不要只做 addslashes,建议使用查询构造器的参数化能力,并对白名单字段做限制:$keyword = input('get.keyword', '', 'trim'); $orderField = input('get.order_field', 'id', 'trim'); $orderType = strtolower(input('get.order_type', 'desc', 'trim')); $allowFields = ['id', 'username', 'created_at']; if (!in_array($orderField, $allowFields, true)) { $orderField = 'id'; } if (!in_array($orderType, ['asc', 'desc'], true)) { $orderType = 'desc'; } $query = Db::name('user'); if ($keyword !== '') { $query->whereLike('username', '%' . $keyword . '%'); } $list = $query->order($orderField, $orderType)->select();权限校验检查很多 PHP 后台系统的权限问题不是没有登录校验,而是只在菜单显示层做了限制,接口本身没有做权限判断。审计时可以直接看控制器基类:<?php class Base { protected function initialize() { if (!session('admin_id')) { throw new \Exception('not login'); } } }这只能说明用户登录了,不能说明用户有权限调用当前方法。比较稳的做法是在基类里根据当前控制器和方法统一校验:protected function initialize() { $adminId = session('admin_id'); if (!$adminId) { throw new \Exception('not login'); } $controller = strtolower(request()->controller()); $action = strtolower(request()->action()); $node = $controller . '/' . $action; $publicNodes = ['index/index', 'profile/info']; if (in_array($node, $publicNodes, true)) { return; } $rules = session('admin_rules') ?: []; if (!in_array($node, $rules, true)) { throw new \Exception('permission denied'); } }测试时不要只用管理员账号点页面,可以准备一个低权限账号,直接请求敏感接口,比如用户删除、配置修改、导出接口。很多越权问题就是这样暴露出来的。操作步骤第一步:梳理路由,确认外部可访问的控制器和方法。第二步:搜索危险函数,重点看上传、反序列化、SQL 执行、文件操作。第三步:回溯输入来源,确认参数是否来自 GET、POST、Cookie、Header、缓存、数据库。第四步:结合 Nginx/PHP-FPM 配置判断漏洞是否可达,不要只看代码。第五步:使用低权限账号验证权限边界,避免只测管理员流程。第六步:修复后补回归用例,例如上传非法后缀、异常 MIME、越权访问、排序参数注入。风险与修复建议风险点常见原因修复建议上传风险只校验后缀,上传目录可执行检查 MIME 与文件头,上传目录禁止 PHP 解析,文件名随机化反序列化对用户可控数据调用 unserialize改用 JSON;必要时设置 allowed_classes=falseSQL 注入where/order 字符串拼接使用参数化查询,字段名和排序方向使用白名单越权访问只隐藏菜单,不校验接口在控制器基类或中间件统一做节点权限校验XSS后台富文本或昵称直接输出输出时转义,富文本使用白名单过滤上传目录可以在 Nginx 层加一道硬限制:location ^~ /uploads/ { location ~ \.php$ { return 403; } } location ~ \.php$ { fastcgi_pass 127.0.0.1:9000; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; include fastcgi_params; }同时建议在 PHP 配置里关闭不必要能力,例如线上环境禁用错误回显:display_errors = Off log_errors = On expose_php = Off file_uploads = On upload_max_filesize = 5M post_max_size = 8M总结PHP 代码审计不能只靠搜索危险函数,也不能只看框架是否自带安全机制。更有效的方式是从入口、数据流、执行点、环境配置四个层面交叉确认。上传要结合 Web 服务器解析规则看,反序列化要追数据来源和魔术方法链,SQL 注入要特别注意排序和字段名,权限问题则要直接请求接口验证。实际修复时,优先做几件事:上传目录禁止执行 PHP、数据库查询改参数化、反序列化改 JSON、权限校验下沉到中间件或基类。这样即使业务代码后续有小问题,也能降低被连成完整利用链的概率。 代码审计、调用链与关键函数定位示意
5小时前5小时 这类审计建议不要只盯着控制器里的危险函数,重点是把“入口—数据流—敏感操作”串起来。ThinkPHP 项目里可以先从路由和控制器方法入手,再反向追踪参数是否经过统一请求封装、模型保存或公共基类处理。 我通常会先做两步: 确认实际可达的控制器方法,尤其注意是否存在未鉴权的上传、导入、回调接口。 对用户可控变量做全局检索,重点找文件写入、反序列化和动态调用。 grep -RInE 'unserialize|file_put_contents|move_uploaded_file|copy\(|rename\(|include|require|eval\(|create_function|call_user_func' application app extend vendor 2>/dev/null grep -RInE 'Request::instance|input\(|param\(|request->|file\(|upload' application app 2>/dev/null 上传点不要只看扩展名判断。比较常见的问题是: 仅使用客户端传入的 $_FILES['type'] 或后缀判断类型; 随机文件名可预测,或者文件名仍包含用户可控路径片段; 上传目录位于 Web 根目录下,且服务器会解析其中的 PHP; 校验发生在保存之后,导致临时文件或缩略图处理阶段仍可被利用; 图片处理、压缩包解压、CSV/Excel 导入等二次处理没有单独审计。 安全实现至少应做到服务端重新判断 MIME 和文件内容,使用白名单后缀,随机生成文件名,并将存储目录放到 Web 根目录之外。即使业务必须放在站点目录下,也要禁止脚本执行: // 仅示意,实际还应结合 finfo、尺寸和业务规则校验 $allowed = ['jpg', 'png', 'gif', 'pdf']; $ext = strtolower(pathinfo($file->getInfo('name'), PATHINFO_EXTENSION)); $finfo = new finfo(FILEINFO_MIME_TYPE); $mime = $finfo->file($file->getRealPath()); $mimeMap = [ 'jpg' => 'image/jpeg', 'png' => 'image/png', 'gif' => 'image/gif', 'pdf' => 'application/pdf', ]; if (!isset($mimeMap[$ext]) || $mime !== $mimeMap[$ext]) { throw new \RuntimeException('invalid file'); } $safeName = bin2hex(random_bytes(16)) . '.' . $ext; // $safeName 不应拼接原始文件名或用户输入的目录 # Nginx:上传目录仅允许静态文件,禁止交给 PHP location ^~ /uploads/ { try_files $uri =404; location ~ \.php(?:$|/) { return 403; } } 反序列化部分要重点确认“数据来源”,而不是看到 unserialize() 就直接下结论。需要追踪数据是否来自 Cookie、POST、缓存、数据库字段或队列消息,并确认是否能被普通用户写入。如果代码确实只反序列化服务端固定数据,风险等级和可利用性会明显不同。 // 风险点:$data 可能来自 Cookie、POST 或数据库可控字段 $data = $request->post('data'); $obj = unserialize($data); 如果只是保存业务状态,优先改成 JSON,并对字段做结构校验;无法立即移除时,至少使用白名单限制允许实例化的类,同时注意这不是完整替代方案,因为已有类的 __wakeup()、__destruct()、__toString() 等魔术方法仍需单独检查。 $value = json_decode($raw, true, 512, JSON_THROW_ON_ERROR); if (!is_array($value) || !isset($value['id'])) { throw new \InvalidArgumentException('invalid payload'); } // PHP 7+ 的临时缓解措施,不能代替移除反序列化 $obj = unserialize($raw, ['allowed_classes' => ['App\\Dto\\ImportData']]); 审计 POP 链时,可以从反序列化入口开始,检查项目及依赖中实现了上述魔术方法的类,特别关注析构阶段是否调用文件删除、写文件、命令执行、模板渲染或动态回调。不要只看业务目录,ThinkPHP 的扩展和 Composer 依赖也可能提供可达的 gadget,但最终仍要验证调用链是否满足属性初始化和访问修饰符条件。 最后建议把每个发现整理成一条完整链路:入口 URL/控制器方法、鉴权条件、参数来源、关键代码位置、可控变量、最终 sink、实际影响和修复建议。这样比单独列出“存在上传”或“存在 unserialize”更容易判断是否是真漏洞,也方便后续复测。
4小时前4小时 补一点 ThinkPHP 审计里容易漏掉的地方:上传和反序列化很多时候不是直接出现在控制器里,而是藏在中间层、事件、模型修改器或者队列任务里。 可以重点查这几类位置: grep -RInE 'set[A-Z].*Attr|get[A-Z].*Attr|event\(|listen\(|hook\(|behavior|middleware|queue|job' application app extend 2>/dev/null grep -RInE '__wakeup|__destruct|__toString|__call|__invoke|Serializable|unserialize|phar://' application app extend vendor 2>/dev/null 上传这块建议确认三个点: 1. 文件最终保存目录是否在 Web 可访问路径 比如 `public/uploads` 如果能直接访问,就要看是否允许 `.php`、`.phtml`、`.phar` 或双后缀绕过。 2. 是否只做了客户端 MIME 或扩展名校验 这种校验意义有限,最好同时做白名单、服务端 MIME、图片重编码或内容检测。 3. 文件名是否可控 如果使用原始文件名保存,除了覆盖风险,还可能引入路径穿越、特殊后缀、解析差异问题。 可以全局看一下保存逻辑: grep -RInE 'getInfo\(|getSaveName\(|rule\(|validate\(|move\(|Filesystem|UploadedFile' application app extend 2>/dev/null 如果是 ThinkPHP5 常见写法,建议至少类似这样限制: $file = request()->file('file'); $info = $file ->validate([ 'size' => 2 * 1024 * 1024, 'ext' => 'jpg,png,gif', ]) ->rule('uniqid') ->move(ROOT_PATH . 'runtime' . DS . 'upload'); if (!$info) { throw new \Exception($file->getError()); } 如果业务必须放到 `public/uploads`,建议服务器层面禁止脚本执行,例如 Nginx: location ^~ /uploads/ { autoindex off; } location ~* ^/uploads/.*\.(php|php5|phtml|phar)$ { deny all; } 反序列化这块除了直接搜 `unserialize`,还要看是否存在间接入口,比如缓存、Cookie、Session、自定义加密参数解密后再反序列化: grep -RInE 'cookie\(|session\(|cache\(|decrypt|decode|base64_decode|json_decode' application app extend 2>/dev/null 有些项目会这样写: $data = unserialize(base64_decode(input('param.data'))); 这种就比较危险。修复时不要只加正则,优先改成 JSON,并且按字段白名单取值: $data = json_decode(input('param.data', ''), true); if (!is_array($data)) { throw new \Exception('invalid data'); } $allow = [ 'id' => 'intval', 'type' => 'strval', ]; $result = []; foreach ($allow as $key => $filter) { if (isset($data[$key])) { $result[$key] = $filter($data[$key]); } } 另外,审计 ThinkPHP 项目时我一般会顺手看一下 `config` 里的几个配置: grep -RInE 'app_debug|default_filter|url_route_on|url_html_suffix|session|cookie|cache|template' application config app 2>/dev/null 尤其是: - `app_debug` 是否线上开启; - `default_filter` 是否被设置成危险过滤或根本没生效; - 模板缓存目录是否可写且可被访问; - Session / Cache 是否使用了文件驱动且路径可控; - 路由是否存在隐藏回调、测试接口、导入接口。 还有一个实战经验:不要只看 `application/index/controller`,很多后台接口、开放接口会放在 `api`、`admin`、`common`、`service`、`command` 下面,甚至通过路由绑定暴露出来。建议先导出路由表或直接查路由文件: grep -RInE 'Route::|route\(|bind\(|resource\(|miss\(' application app route config 2>/dev/null 最后可以按调用链整理成表:入口 URL、参数名、过滤点、落点函数、是否鉴权、是否可控。这样比单纯列危险函数更容易判断漏洞是否真实可达,也方便后续复现和修。
创建帐户或登录后发表意见