跳转至

PHP 安全与 CTF

本篇只用于安全编码、代码审计、本地靶场和明确授权的 CTF 环境。 内容解释漏洞成因、无害验证思路和防护,不提供可武器化 payload、扫描或真实目标利用步骤。 若发现真实系统漏洞,应停止进一步访问并遵循授权范围与负责任披露流程。

统一分析模型

审计 PHP 应用时依次回答: 1. 数据来自查询、表单、Cookie、请求头、上传、数据库还是消息队列? 2. 数据是否经过了与业务语义匹配的类型、长度、格式和范围验证? 3. 数据进入的是 SQL、HTML、路径、文件包含、shell、反序列化还是日志上下文? 4. 程序是否把客户端状态误当作可信身份或授权结论? 5. 错误路径是否泄露 SQL、堆栈、绝对路径、密钥或会话标识? 6. 资源消耗是否有请求体、文件、数组、递归、超时和并发上限? 安全缺陷通常来自边界混淆,而不是某个字符串“看起来危险”。

输入验证与输出编码

验证回答“这个值是否符合业务要求”,编码回答“如何安全放入目标上下文”。 两者不能互相替代: - 整数 ID 验证为正整数并限制范围。 - 枚举值通过固定允许列表严格匹配。 - SQL 数据通过 PDO 参数绑定。 - HTML 文本通过 htmlspecialchars() 按 UTF-8 编码。 - 路径通过固定根目录和允许列表解析。 不要建立一个全局 sanitize() 函数试图同时解决所有上下文。

弱类型与宽松比较

PHP 会在特定运算和 == 比较中转换类型。 如果认证代码把字符串令牌、数字和空值混在一起,可能让不同输入被视为相等。 无害观察:

var_dump('10' == 10);
var_dump('10' === 10);
var_dump('0' == false);
var_dump('0' === false);
PHP 8 改进了部分字符串与数字比较规则,但宽松比较仍不适合安全边界。 防护措施: - 对身份、角色、状态、哈希和令牌使用 === / !==。 - 先验证输入类型和格式,再进入业务判断。 - 密码使用 password_hash() 与 password_verify()。 - 固定长度秘密比较使用 hash_equals()。 - in_array() 和 array_search() 开启严格模式。 不要自行比较密码哈希,也不要依赖“魔术哈希”式边界行为。

SQL 注入

成因是把外部文本拼接到 SQL 结构,使数据被数据库解释为语法。 错误模式:

// 错误:外部输入进入 SQL 结构。
$sql = "SELECT id FROM users WHERE email = '" . $email . "'";
正确方式:
$statement = $pdo->prepare(
    'SELECT id FROM users WHERE email = :email'
);
$statement->execute(['email' => $email]);
参数化查询之外还需: - 表名、列名、排序方向只从代码允许列表选择。 - 数据库账户使用最小权限。 - 对列表长度、分页范围和查询复杂度设置上限。 - 错误页不显示 SQL、驱动异常和连接信息。 手工转义、删除引号和黑名单替换都不是可靠替代方案。

文件包含

include、require 会把目标作为 PHP 文件处理;若路径由用户控制,可能加载非预期代码或泄露文件。 危险设计是把页面参数直接拼成文件名,即使追加扩展名也可能存在编码和路径问题。 防护应让外部值只选择固定映射:

$pages = [
    'home' => __DIR__ . '/pages/home.php',
    'help' => __DIR__ . '/pages/help.php',
];
$key = $_GET['page'] ?? 'home';
$path = $pages[$key] ?? null;
if ($path === null) {
    http_response_code(404);
    exit;
}
require $path;
同时关闭不需要的远程包含能力,限制文件权限,并避免上传目录可被解释执行。 不要尝试用字符串替换 ../ 修补动态包含,正确设计是不接受路径。

命令注入

成因是将外部文本拼入 shell 命令,shell 会解释元字符、重定向和子命令语法。 首选防护是完全不用 shell,改用 PHP API: - 文件复制使用 copy(),删除使用 unlink()。 - 压缩使用受维护的库或扩展。 - HTTP 请求使用受控客户端库。 - 图像处理使用经过加固和资源限制的库。 确需启动进程时,固定可执行程序和操作类型,参数经过严格允许列表与长度限制, 使用能够绕过 shell 并分离参数的进程 API或成熟库。 escapeshellarg() 受平台和调用方式影响,只是纵深防御,不会使任意命令设计变安全。 Web 进程还应使用低权限账户、受限环境、超时和资源限制。

不安全反序列化

unserialize() 可根据数据创建对象并触发 __wakeup()、__unserialize()、析构等魔术方法。 当输入可控且代码库中存在具有副作用的对象链时,可能造成严重后果。 不可信数据优先使用 JSON:

try {
    $data = json_decode($raw, true, 16, JSON_THROW_ON_ERROR);
} catch (JsonException) {
    throw new InvalidArgumentException('数据格式无效');
}
随后验证字段、类型、长度和嵌套结构。 如果兼容旧数据不得不反序列化,应先迁移格式,并至少禁止实例化对象:
$value = unserialize($trustedLegacyData, ['allowed_classes' => false]);
这不是处理任意敌对数据的完整安全边界,仍需完整性保护、大小限制和格式迁移。 永远不要对 Cookie、请求参数或未知缓存内容直接 unserialize()。

路径穿越

成因是把外部文件名与目录拼接后访问,攻击者可能通过路径段、绝对路径、编码差异或符号链接逃离预期目录。 最稳妥方案是让客户端提交数据库记录 ID,由服务端查出随机存储名。 必须处理相对名称时只允许单个受限文件名:

if (!preg_match('/\A[A-Za-z0-9][A-Za-z0-9._-]{0,63}\z/', $name)) {
    throw new InvalidArgumentException('文件名无效');
}
$path = $fixedDirectory . DIRECTORY_SEPARATOR . $name;
固定目录由配置提供,不能来自请求。 若文件必须已存在,可对目录和候选文件使用 realpath(),再按路径分隔符边界确认候选位于根目录内。 仅用 str_starts_with($candidate, $root) 不够,必须避免 /safe 与 /safe-other 的前缀混淆。 还要考虑符号链接和检查后替换的竞态;高风险场景应使用隔离存储和操作系统级权限。

文件上传风险

上传风险不止脚本扩展名,还包括解析器漏洞、超大图片、压缩炸弹、恶意文档和覆盖文件。 防护组合: - Web 服务器、PHP 和应用三层限制大小与数量。 - 检查 UPLOAD_ERR_*,只接受 is_uploaded_file() 认可的临时文件流程。 - 使用 Fileinfo 和专用解析器识别允许类型。 - 随机命名并保存到 Web 根目录之外。 - 上传目录不可执行,下载通过应用鉴权。 - 高风险内容进入隔离扫描与异步处理流程。 原始文件名只作为显示元数据,输出时仍需 HTML 编码。

XSS 与响应安全

存储型或反射型 XSS 的根因是把不可信数据作为可执行 HTML/脚本上下文输出。 普通 HTML 文本:

echo htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
JavaScript、CSS、URL 和 HTML 属性具有不同规则,优先避免动态拼接这些上下文。 使用默认自动转义的模板引擎,审计所有“原样输出”功能。 内容安全策略、HttpOnly Cookie 和安全响应头属于纵深防御,不能替代正确编码。

CSRF、认证与授权

CSRF 利用浏览器自动携带 Cookie 发起状态变更请求。 防护包括随机 Session 令牌、严格方法检查、合适的 SameSite 和敏感操作再认证。 认证回答“你是谁”,授权回答“你能否操作这个对象”;二者必须在服务端分别执行。 任何记录 ID、下载 ID 或用户 ID 都可能被修改,查询后仍要验证对象归属或权限。 登录成功和权限变化后调用 session_regenerate_id(true),注销时销毁完整会话。

错误处理与信息泄露

生产环境关闭错误展示、开启受保护日志:

display_errors=Off
log_errors=On
error_reporting=E_ALL
对外返回通用错误与关联 ID,对内记录时间、操作、异常类型和必要上下文。 不得记录密码、会话 Cookie、重置令牌、完整银行卡数据或大段原始请求体。 日志内容也需防止换行伪造,并限制访问、保存期限和导出范围。 隐藏错误不是修复,开发和测试环境仍应让异常充分可见。

密码与随机数

$hash = password_hash($password, PASSWORD_DEFAULT);
if (!password_verify($candidate, $hash)) {
    throw new RuntimeException('认证失败');
}
数据库字段应为未来算法保留足够长度,并在登录后检查 password_needs_rehash()。 令牌和不可预测标识使用 random_bytes(),不要使用 rand()、时间戳或可预测递增值。 密码重置令牌只存哈希、设置短过期时间、单次使用并绑定账户与操作。

授权 CTF 的学习方式

在隔离本地环境中,可围绕以下问题练习审计: - 哪个超全局变量进入了敏感函数? - 比较是严格还是宽松,预期类型是什么? - SQL 结构与数据是否真正分离? - 页面选择是固定映射还是动态路径? - 上传后文件位于哪里,服务器是否可能执行它? - Session 何时创建、更新、重生和销毁? - 异常是否泄露部署路径和数据库细节? 验证应使用无害标记值、本地临时文件和测试数据库,不连接未授权目标。 不编写批量扫描、持久化、凭据尝试、绕过检测或自动利用脚本。

防御测试清单

  • 为 null、空字符串、'0'、零、布尔值和数组输入编写类型边界测试。
  • 为最大长度、超长值、非法 UTF-8 和重复字段编写拒绝测试。
  • 验证所有查询均参数化,动态 SQL 片段来自允许列表。
  • 验证路径不能离开固定根目录,上传文件不能由 Web 服务器执行。
  • 验证 Session 固定攻击防护、CSRF 失败路径和对象级授权。
  • 验证生产错误页不含堆栈、SQL、绝对路径和秘密。
  • 对解析器设置时间、内存、文件大小、嵌套深度和数量上限。
  • 运行依赖审计、静态分析和针对关键边界的自动化测试。

小结

PHP 安全的主线是保持数据与代码、SQL、路径、命令和对象构造之间的边界。 严格比较、参数化、固定映射、隔离存储、上下文编码与最小权限应组合使用。 回到学习路线复习基础,并只在本地或明确授权环境验证安全假设。