
我在 2026 年 10 月 26 日开始撰写这项研究,目前还不完全确定何时能够完成。
在撰写本文时,关于 PHP2Shell 的信息仍然非常少,尤其是涉及漏洞和利用过程的详细技术分析方面。
说实话,我已经厌倦了那些只触及表面、却没有真正展示内部发生了什么情况的解释。我想做一些不同的事情:一份恰当、深入的技术讲解,探索整个过程,审视每一个重要细节,不仅解释发生了什么,还要解释为什么发生以及如何发生。
这项研究基于我对 PHP 源代码的理解、我的实验,以及我在试图理解这些漏洞时所进行的调查。我想把所有这些碎片拼凑起来,分享我的发现,并希望能让其中一些底层概念对其他人来说更容易理解。
我的主要目标之一是在不牺牲技术深度的前提下让内容易于理解。我希望具备基本 C 语言知识和一般编程经验的人能够跟上,即使他们之前没有任何底层内存利用方面的经验。
我也希望这项研究能帮助澄清一些目前解释不清或被误解的内容。不过,我想坦诚说明:这是我自己对这些漏洞的技术理解,并非官方分析,也不是绝对可靠的参考。我自己仍在学习、调查并串联线索。过程中可能存在错误、不准确之处,或者我误解的地方。
如果你发现任何不正确的地方、有不同的解读,或者认为某些内容值得进一步调查,请不要犹豫与我联系。我会真诚地感谢纠正、讨论和贡献,这些都有助于让这项研究更加准确,对所有人更有用。
毕竟,分享这一切的意义就在于共同学习,帮助更多人理解底层实际发生的事情。
已经熟悉基础知识了?直接跳转到你关心的部分:
如果你对堆利用还不熟悉,或者想要完整体验,继续往下读就好。我写这篇文章就是为了让人从头读到尾。
在深入之前,有几件事大家都应该记住。随着研究的推进,它们会变得特别有用。正如我之前提到的,我希望这篇讲解对所有人都易于理解,所以我会花时间解释哪怕是最基础的概念。如果你已经熟悉它们,尽管跳过!
在典型的程序中,内存被组织成不同的区域,包括栈和堆。
例如,在 C++ 中:
int main() {
int a = 30; // Typically stored on the stack
int* b = new int(30); // b is typically on the stack,
// but the integer it points to is on the heap
}
这里,a 是一个局部变量,包含值 30。指针 b 也是一个局部变量,但它包含的是在堆上分配的某个整数的地址。
PHP 主要用 C 编写,因此我们必须理解内存分配的工作原理。
操作系统为进程提供其自己的虚拟地址空间。随后,进程的内存分配器在可供其使用的内存范围内管理分配。
例如,在 C++ 中,我们有 new、malloc() 以及其他分配内存的方式。
分配器通常会尝试复用已释放的内存,而不是每次都向操作系统请求新的内存。
此外,当对象的生命周期结束时,其内存中存储的数据不一定会立即被擦除。那块内存可以简单地被标记为可供复用。
为了更好地理解这一点,让我们简要看一下以下代码:
#include <cstdlib>
#include <iostream>
int main() {
void* a = malloc(64);
free(a);
void* b = malloc(64);
std::cout << a << "\n" << b << std::endl;
free(b);
return 0;
}
在这里,当我们打印 a 和 b 时,可能会发现它们包含相同的数值内存地址。
为什么?因为在我们调用 free(a) 之后,malloc() 可能会复用之前分配给 a 的内存,从而避免再获取另一块内存。
这并不保证每次都会发生,但这是一种常见行为。
这就引出了问题的核心。我们知道内存会被回收,但那些持有这些内存地址的变量会怎样呢?
当你调用 free(a) 时,内存分配系统只是更新其内部记录,将那块内存标记为可复用。然而,该内存中的数据并不会被清除,指针变量本身也不会神奇地重置为 NULL。除非程序员显式清除它,否则指针会继续持有与之前完全相同的内存地址。
这个残留的变量就是我们所说的悬空指针(stale pointer,或称 dangling pointer)。
悬空指针可能在多个方面造成危险。其中最关键的两个是:读取不再属于原对象的数据,以及通过旧指针写入从而破坏新对象。
想象我们为一个用户账户分配内存:
struct User {
char username[24];
int is_admin;
};
int main() {
// 1. Allocate memory for a user
User* a = (User*)malloc(sizeof(User));
// Initialize the original user
strcpy(a->username, "alice");
a->is_admin = 0;
// We keep a copy of the pointer to demonstrate the bug
User* a_stale = a;
// 2. Free the memory. The object is gone, but a_stale still holds the address!
free(a);
现在,就在这之后,假设程序在别处又进行了一次分配,并且分配器复用了同一个地址:
User* b = (User*)malloc(sizeof(User));
strcpy(b->username, "jack");
b->is_admin = 1;
因此,b 现在正好位于 a 原先所在的位置。但 a_stale 仍然指向那里,因为我们释放了 a,而没有任何东西更新 a_stale。
因此,仍在使用 a_stale 的代码以为自己在查看 Alice 的数据,但实际上它可能正在访问 Jack 的数据:
if (a_stale->is_admin) {
// This may run, even though Alice was never an admin
printf("Welcome admin %s\n", a_stale->username);
}
这样,我们可能访问到本不该看到的数据,例如 Jack 的用户名,而程序可能会做出错误的授权决策。
从技术上讲,访问已释放的内存是 C/C++ 中的“未定义行为”,因此确切结果无法保证,取决于编译器和系统。但这说明了根本问题。
然而在现实中,事情并没有这么简单。你可能会想:“是啊,这挺好,但是……”
要让这生效,我们首先需要一个悬空指针。在真实攻击中,我们并不是在编写受害者的程序,所以不能像处理 a_stale 那样故意保存一个指向变量的指针。这个漏洞必须已经存在于代码内部……
攻击者只能影响最终落入那个已释放内存槽的内容。
目前,只需确保你深入理解了这一点,因为这个问题的答案将是我们正在讨论的 PHP CVE 中最重要的部分。
现在想象相反的情况:程序在内存被释放后,仍然使用旧指针向该内存写入数据。
#include <cstdlib>
#include <cstdio>
int main() {
int* a = (int*)malloc(sizeof(int));
*a = 42;
int* stale = a; // Keep another pointer to the allocation
free(a);
// Another allocation may reuse the same address
int* b = (int*)malloc(sizeof(int));
if (b != nullptr) {
*b = 100;
// BUG: stale still holds the old address.
// Writing through it is undefined behavior.
*stale = 999;
printf("%d\n", *b); // Might appear to print 999
}
free(b);
return 0;
}
如果分配器复用了该地址,stale 可能与 b 具有相同的数值地址。因此,通过 stale 进行的写入可能看起来落在了 b 的内存中,并将其值从 100 改为 999,即使代码从未显式地将 999 写入 b。
现在想象一下,b 不是一个简单的整数,而是某种重要的东西,比如 is_admin 标志、长度或函数指针。
这就是它成为潜在安全漏洞而不仅仅是 bug 的地方。
前面我说过:“这个 bug 必须已经存在于程序内部:它释放了某个东西,而代码的另一部分仍然持有指向它的指针。”
这正是我们需要为 PHP2Shell 回答的问题。PHP 不会故意把悬空指针交给攻击者……不,PHP 没那么慷慨。
那个指针是某个合法 PHP 特性与 Zend 引擎(PHP 的核心)的错误处理机制交互方式中的一个微妙缺陷所导致的结果。
如果你习惯于编写一个运行一次就退出的 main(),那么首先容易忽略的一点是:PHP Web 服务器不会为每个 HTTP 请求启动一个新进程。一小池 worker 进程 会保持存活,处理一个又一个请求,复用同一块内存空间。
One worker process, over time:
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Request #1 │ │ Request #2 │ │ Request #3 │ ...
└──────────────┘ └──────────────┘ └──────────────┘
same heap same heap same heap
same globals same globals same globals
将其与一个标准的 C 程序相比:它在函数的每次结束时分配、释放并干净地退出。而 PHP worker 是一个持续运行的循环。尽管它有一个内存管理器(Zend MM)会在请求之间清理堆,但 C 代码中的全局变量除非被显式清零,否则会保留其值。在第 1 次迭代中遗留的任何悬空内容,在第 2 次迭代中仍然存在。
谁不想要一个显示大文件上传状态的进度条呢?PHP 开发者提出了同样的问题,并构建了一个功能:在文件上传期间,一个名为 PHP_SESSION_UPLOAD_PROGRESS 的隐藏表单字段会让 PHP 自动跟踪上传进度。