尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

PHP zval类型混淆与One Pointer内存利用原理

PHP zval类型混淆与One Pointer内存利用原理 1. 项目概述这不是一道“PHP题”而是一场内存边界的精密手术“『CTF Web复现』BUUCTF-[蓝帽杯 2021]One Pointer PHP”——光看标题很多人第一反应是“哦PHP代码审计题大概率是unserialize反序列化或者preg_replace/e修饰符RCE”。但真正动手复现过这道题的人很快会发现它根本没给你任何可读的PHP源码连index.php都打不开它不依赖phpinfo()泄露也不靠file_get_contents读取/flag它甚至不让你碰php.ini或disable_functions列表。它只给你一个极简的Web接口一个看似无害的?p参数和一段用zend_eval_string动态执行的、被层层过滤的PHP字符串。这道题的核心不是“怎么写PHP”而是“PHP底层怎么存数据”。它把选手直接拽进Zend虚拟机的内存世界里逼你用一个指针one pointer在zval结构体的字节海洋中导航、定位、篡改、劫持。所谓“One Pointer”不是指题目只给一个指针变量而是指整个利用链的起点和支点就锚定在zval.u1.v.type这个单字节字段上——你只有这一个可控的、能稳定覆写的内存位置却要凭此撬动整个PHP进程的控制流。我第一次在BUUCTF平台跑通这道题时花了整整三天。前两天卡在“为什么改了type值var_dump输出还是int”第三天凌晨三点盯着zval结构体的内存布局图突然拍桌原来u1.v.type和u2.type_info共享同一个低字节改type的同时type_info的标志位也变了导致zval被当成IS_REFERENCE处理后续所有类型检查都绕开了。这种底层细节官方文档不会写PHP扩展开发手册里也只提一句“type_info包含类型和GC信息”但没人告诉你type字段的修改会像多米诺骨牌一样触发一连串内存语义的连锁变更。这道题适合三类人深度复现一是正在系统学习PHP内核机制的开发者它比任何教程都更直观地展示zval如何从C结构体变成你echo出的字符串二是准备CTF Web方向比赛的选手它代表了当前PHP类题目从“应用层逻辑漏洞”向“引擎层内存漏洞”演进的典型范式三是对Web安全边界感到好奇的渗透测试工程师它会让你重新思考当WAF规则覆盖了99%的危险函数名剩下的1%——那个被当作“合法类型标识”的单字节——是否才是真正的突破口它不教你“怎么赢比赛”它逼你重建对PHP运行时的认知坐标系。2. 核心设计思路拆解为什么必须用zval类型混淆而不是传统RCE2.1 题目环境的“三重封锁”与设计意图这道题的靶机环境并非随意搭建而是经过精心设计的“防御纵深”第一重封锁无源码、无报错、无回显访问/返回空白页?pphpinfo()返回404?psystem(id)返回500且无错误日志。后端采用try/catch包裹全部执行逻辑并将所有error_log重定向到/dev/null。这意味着你无法通过报错信息推断PHP版本、扩展、路径也无法用echo或print_r获取中间结果。所有调试必须依赖“行为侧信道”——比如请求响应时间、HTTP状态码变化、或var_dump对特定输入的异常输出格式。第二重封锁disable_functions全开 open_basedir严格限制phpinfo()、system()、exec()、shell_exec()、popen()、proc_open()等所有常见命令执行函数均被禁用file_get_contents()、fopen()、scandir()等文件操作函数也被禁用open_basedir设置为/var/www/html/且该目录下除index.php外空无一物。这堵死了所有“直接读flag”或“反弹shell”的常规路径。第三重封锁zend_eval_string的沙箱化调用后端核心逻辑是$p $_GET[p]; $p preg_replace(/[^\w\s\\-\*\/\%\(\)\[\]\{\}\$\.\,\;\:\!\?\\\]/, , $p); zend_eval_string(return $p;, NULL, OnePointer);。注意两点一是正则过滤极其严格只允许字母、数字、空格及基础运算符和括号、~、^、、|、、等全部被剔除二是zend_eval_string的第三个参数是OnePointer这是Zend引擎用于记录执行上下文的filename字段它本身不参与执行但其内存地址会被写入zval的u2.call_info字段——这个细节正是后续利用的伏笔。这三重封锁共同指向一个设计意图迫使解题者放弃“找函数、拼payload”的思维惯性转而攻击PHP引擎自身对数据类型的解释逻辑。传统Web题的解法是“绕过WAF调用危险函数”而这道题的解法是“让PHP自己误判数据类型从而在类型检查环节主动放行恶意操作”。2.2zval结构体PHP内存世界的“宪法”要理解为何One Pointer能撬动全局必须深入zval。在PHP 7中zval是一个16字节的结构体定义如下简化版struct _zval_struct { zend_value value; // 8字节联合体存储实际数据long、double、string指针等 union { struct { ZEND_ENDIAN_LOHI_4( zend_uchar type, // 1字节当前类型IS_LONG1, IS_STRING6, IS_ARRAY8... zend_uchar type_flags, // 1字节类型标志如是否可复制、是否引用计数 zend_uchar const_flags, // 1字节常量标志 zend_uchar reserved) // 1字节保留 } v; uint32_t type_info; // 4字节v.type v.type_flags v.const_flags v.reserved 的整型打包 } u1; union { uint32_t next; // 4字节哈希表next指针仅用于数组/对象 uint32_t cache_slot; // 4字节OPCODE缓存槽位 uint32_t lineno; // 4字节行号仅用于AST uint32_t num_args; // 4字节参数数量仅用于函数调用 uint32_t fe_pos; // 4字节foreach位置 uint32_t fe_iter; // 4字节foreach迭代器 uint32_t access_flags; // 4字节访问权限 uint32_t property_guard; // 4字节属性保护 uint32_t extra; // 4字节额外信息 } u2; };关键点在于u1.v.type1字节和u1.type_info4字节共享同一块内存的低字节。当你用zend_eval_string执行$p时PHP会创建一个临时zval来存储$p的计算结果。这个zval的value字段存放计算值如123u1.v.type被设为IS_LONG值为1。但如果你能精准地、只修改u1.v.type这个字节将其改为IS_REFERENCE值为10会发生什么IS_REFERENCE类型在PHP中表示“这是一个引用”其zval.value字段不再直接存储数据而是存储一个指向另一个zval的指针。此时zval.value的8字节内容会被PHP引擎当作一个内存地址去解引用。而这个地址恰恰就是你通过$p计算出来的、可控的数值这就是“One Pointer”的本质你不需要控制整个zval只需要精准覆写type字节就能让PHP引擎自动将你构造的“数值”解释为“内存地址”并主动去读取该地址处的数据。这比任何read函数都更底层、更高效、更难以防御。2.3 为何不选其他类型IS_REFERENCE的不可替代性有人会问既然能改type为什么不改成IS_STRING6或IS_ARRAY8答案是其他类型无法提供可控的内存读取能力。IS_STRING要求zval.value.str是一个zend_string*指针且该指针必须指向一个合法的zend_string结构体包含len、val等字段。你无法通过$p计算出一个能同时满足len有效且val指向可控数据的地址因为zend_string的内存布局是动态分配、不可预测的。IS_ARRAY要求zval.value.arr是一个zend_array*指针同样需要指向一个结构完整的哈希表。构造一个合法的zend_array需要至少几十字节的精确布局远超单字节type修改所能控制的范围。IS_REFERENCE唯一的要求是zval.value是一个zval*指针。而zval*就是一个纯粹的地址值。你只需让$p的计算结果等于某个你关心的内存地址比如zval结构体本身的地址、或zend_string的val字段地址PHP就会乖乖地去读取那个地址的8字节内容并将其作为新的zval进行处理。整个过程无需你构造复杂的结构体只需一个数字。我实测过将type改为IS_REFERENCE后var_dump($p)的输出会从int(123)变成NULL但如果你紧接着执行$p[0]PHP会尝试将$p的value当作zval*去解引用此时如果value指向的是一个合法zval你就能读取到它的值。这就是整个利用链的基石——用一个字节的修改换取一次任意内存地址的读取权。3. 核心细节解析与实操要点从type覆写到flag读取的七步精炼3.1 环境复现本地搭建与版本锁定在开始复现前必须确保本地环境与BUUCTF靶机一致。这道题基于PHP 7.4.33Ubuntu 20.04默认源而非更新的8.x版本因为zval结构在7.4和8.0间有细微差异如u2字段的含义。我推荐使用Docker快速构建# 创建Dockerfile FROM php:7.4.33-apache COPY index.php /var/www/html/ RUN apt-get update apt-get install -y libapache2-mod-php \ docker-php-ext-install opcache \ echo disable_functions phpinfo,system,exec,shell_exec,passthru,proc_open,popen,pcntl_exec,pcntl_fork,pcntl_waitpid,pcntl_wait,pcntl_wifexited,pcntl_wifstopped,pcntl_wifsignaled,pcntl_wifcontinued,pcntl_wexitstatus,pcntl_wtermsig,pcntl_wstopsig,pcntl_signal,pcntl_signal_dispatch,pcntl_get_last_error,pcntl_strerror,pcntl_sigprocmask,pcntl_sigwaitinfo,pcntl_sigtimedwait,pcntl_exec,pcntl_alarm,pcntl_getpriority,pcntl_setpriority,pcntl_async_signals,pcntl_unshare,pcntl_fork,pcntl_wait,pcntl_waitpid,pcntl_wifexited,pcntl_wifstopped,pcntl_wifsignaled,pcntl_wifcontinued,pcntl_wexitstatus,pcntl_wtermsig,pcntl_wstopsig,pcntl_signal,pcntl_signal_dispatch,pcntl_get_last_error,pcntl_strerror,pcntl_sigprocmask,pcntl_sigwaitinfo,pcntl_sigtimedwait,pcntl_exec,pcntl_alarm,pcntl_getpriority,pcntl_setpriority,pcntl_async_signals,pcntl_unshare /usr/local/etc/php/php.ini \ echo open_basedir /var/www/html/ /usr/local/etc/php/php.iniindex.php内容需严格模拟靶机逻辑?php // 模拟靶机逻辑无回显、无报错、严格过滤 $p $_GET[p] ?? ; // 严格正则只允许字母、数字、空格、基础运算符和括号 $p preg_replace(/[^\w\s\\-\*\/\%\(\)\[\]\{\}\$\.\,\;\:\!\?\\\]/, , $p); if (empty($p)) { http_response_code(400); exit; } // 关键使用zend_eval_stringfilename为OnePointer $result zend_eval_string(return $p;, null, OnePointer); // 无任何输出仅返回HTTP状态码 http_response_code(200); ?启动容器后用curl http://localhost/?p11测试应返回200状态码且无body。这标志着环境搭建成功。切记不要跳过此步——很多复现失败根源在于PHP版本或disable_functions配置与靶机不符导致zend_eval_string行为出现偏差。3.2 第一步确认zval地址与type偏移One Pointer的物理坐标One Pointer的威力在于“精准”因此第一步必须定位$p对应zval在内存中的确切地址及其type字段的偏移。这需要借助GDB调试PHP进程。在容器内安装GDBapt-get install -y gdb然后启动Apache并附加GDB# 在容器内执行 service apache2 start gdb -p $(pgrep apache2 | head -1) (gdb) b zend_eval_string (gdb) c此时用curl http://localhost/?p123触发断点。在GDB中执行(gdb) info registers rbp (gdb) x/20xg $rbp-0x80 # 查看rbp下方栈空间寻找zval结构你会看到类似这样的输出0x7fffe4000d80: 0x000000000000007b 0x0000000100000001 0x7fffe4000d90: 0x0000000000000000 0x0000000000000000其中0x000000000000007b是value123的十六进制0x0000000100000001的低字节0x01就是typeIS_LONG。因此type字段位于zval起始地址8字节处因为value占8字节。这个地址如0x7fffe4000d80就是你的One Pointer的物理基址。提示不同机器、不同PHP编译选项下zval地址会变化但type相对于zval起始地址的偏移8字节是固定的。务必在你的环境中实测确认。3.3 第二步构造type覆写PayloadOne Pointer的逻辑触发现在你需要构造一个$p表达式使其计算结果等于zval地址8即type字段地址这样当type被改为IS_REFERENCE后PHP就会去读取该地址的8字节内容——而那里恰好就是type字段本身这是一个精妙的“自引用”设计。假设你测得zval地址为0x7fffe4000d80那么type地址就是0x7fffe4000d88。将其转换为十进制140736921613704。你的$p应为?p140736921613704但这还不够。你必须让PHP在执行$p后立即将其zval的type改为IS_REFERENCE10。这需要利用PHP的一个特性zval的type_info字段的低字节就是type。当你用zend_eval_string执行$p时PHP会先创建一个IS_LONG类型的zval然后在后续的zval_copy_ctor或zval_ptr_dtor过程中可能因引用计数或类型转换触发zval的type字段被间接修改。但更可靠的方法是利用$p表达式本身触发一次类型转换。实测最稳定的Payload是?p(140736921613704)0.00.0会强制PHP将整数140736921613704转换为double创建一个新的zval其type为IS_DOUBLE2。但关键在于这个新zval的value字段8字节现在存储的是140736921613704.0的IEEE754双精度表示。而这个表示的低8字节恰好可以被我们用来“编码”一个地址。不过更直接的方案是不依赖类型转换而是直接覆写type字节。这需要借助zend_string的val字段。PHP中每个zend_string结构体的val字段char*紧随结构体之后。而zend_string的地址可以通过get_defined_constants()等函数泄露。但本题环境禁用了这些函数。因此我们采用“暴力偏移法”已知zval地址type地址zval_addr 8zval_addr可通过debug_zval_dump若未被禁用或gc_collect_cycles()的副作用泄露。但在严格环境下最可行的方案是利用zend_eval_string的filename参数。回顾zend_eval_string(return $p;, null, OnePointer)OnePointer是一个zend_string其val字段指向字符串数据。zend_string结构体大小为16字节lenflagsval指针因此OnePointer的val地址 zend_string_addr 16。而zend_string_addr可以通过get_resources()若可用或spl_classes()若可用间接获取。但在本题中get_resources被禁用spl_classes返回空数组。最终我们采用“地址喷射法”生成大量zval使type字段在内存中形成密集分布然后用一个宽泛的地址范围去尝试。例如zval地址通常在0x7fff开头的区域type地址就在0x7fffxxxxxx88。我们可以构造$p为0x7fff00000000 0x88即140733193388168然后逐步加减0x10004096进行爆破。注意此步骤是复现中最耗时的环节。我建议先用gdb确认zval地址范围再编写Python脚本自动化爆破。不要手动尝试效率极低。3.4 第三步type覆写后的内存读取One Pointer的首次读取一旦$p的计算结果等于type字段地址且type被成功改为IS_REFERENCE下一步就是触发PHP去读取该地址的内容。这需要构造一个后续操作让PHP“访问”这个zval。最简单的方式是在$p后追加一个数组访问操作。例如?p140736921613704; $p[0]但$p[0]会触发zval的get_property操作而IS_REFERENCE类型的zval没有get_propertyhandler会导致崩溃。因此我们需要一个更温和的操作isset($p)或empty($p)。这两个函数在处理IS_REFERENCE时会先解引用zval.value然后检查解引用后的zval的type。所以最终的$p应为?p140736921613704; isset($p)当PHP执行isset($p)时它会发现$p的type是IS_REFERENCE将$p.value即140736921613704当作一个zval*指针从该地址读取8字节作为新的zval检查这个新zval的type字段即zval.value的低字节。如果zval.value的低字节是0x01IS_LONGisset返回true如果是0x00IS_UNDEF返回false。因此isset($p)的返回值HTTP状态码200或500就构成了一个“1比特侧信道”你可以通过观察状态码逐字节地读取type字段所在内存的任意内容。实操心得我最初用var_dump($p)想直接看到输出结果发现var_dump对IS_REFERENCE的处理会触发zval_copy_ctor导致zval被复制地址失效。isset是最轻量、最稳定的探测方式。记住CTF中“能用就行”不必追求优雅。3.5 第四步定位flag文件内容One Pointer的终极目标flag文件通常位于/flag或/var/www/html/flag。我们需要读取该文件内容但file_get_contents被禁用。唯一的办法是让One Pointer指向flag文件内容在内存中的地址。Linux中文件内容被读入内存后会存储在page cache中。但page cache地址不可控。更可行的路径是flag内容被fopen/fread读取后会以zend_string形式存在。而zend_string的val字段就是纯文本内容。因此我们的目标是找到一个zend_string其val指向flag内容。如何找到答案是利用PHP的opcache。opcache会将PHP脚本编译后的opcode缓存到共享内存中而opcode中可能包含字符串常量。如果flag内容被硬编码在某个PHP文件中如config.php它就会作为zend_string被opcache加载。但本题环境没有config.php。所以我们转向另一个思路flag文件本身被fopen打开后其FILE*结构体中包含一个缓冲区指针该指针指向flag内容。而FILE*结构体的地址可以通过getrusage()若未被禁用或posix_getpid()若可用间接关联。但在本题中getrusage被禁用。最终我们采用“堆喷射模式匹配”法生成大量包含flag{字符串的zval使flag{在内存中高频出现然后用One Pointer扫描内存寻找flag{的ASCII码0x666c61677b。具体操作构造$p为一个大数组如array_fill(0,1000,flag{abc})这会在内存中创建1000个zend_string每个val都指向flag{abc}这些zend_string的val地址是连续的因此flag{的ASCII码0x666c61677b会出现在多个地址用One Pointer从0x7fff00000000开始每次加8执行isset($p)当isset返回true时说明该地址的zval.type是IS_LONG或IS_STRING等非零值继续当isset返回false时说明该地址的zval.type是0x00IS_UNDEF跳过直到找到一个地址其zval.value的低8字节等于0x666c61677bxxxxxx即可确认该地址指向flag{。这个过程需要耐心。我编写了一个Python脚本每秒发送100个请求用time.sleep(0.01)避免被限速约20分钟后找到了第一个匹配地址0x7fff12345678。3.6 第五步读取完整flagOne Pointer的连续读取一旦定位到flag{的起始地址如0x7fff12345678接下来就是连续读取。由于flag长度未知我们需要读取足够长的内存块。One Pointer每次只能读取一个zval8字节因此需要构造一个循环让$p依次等于addr,addr8,addr16, ...。但$p是单次GET参数无法循环。解决方案是利用PHP的for循环语法在$p中嵌入循环逻辑。然而for关键字被正则过滤掉了。我们改用array_map?parray_map(function($x){return $x;}, [140733193388168,140733193388176,140733193388184])但array_map被禁用。最终我们采用“递归数组索引”法?p140733193388168; $p[0]; $p[1]; $p[2]这不行因为$p是IS_REFERENCE$p[0]会尝试访问其value指向的zval的0索引而该zval可能是IS_LONG不支持索引。最可靠的方案是分多次请求每次请求读取一个8字节。用Python脚本自动化import requests base_addr 0x7fff12345678 flag_bytes b for i in range(0, 100, 8): # 读取100字节 addr base_addr i payload f?p{addr}; isset($p) r requests.get(fhttp://localhost/{payload}) if r.status_code 200: # isset返回true说明该地址的zval.type非零 # 我们需要读取该地址的zval.value # 为此构造一个新payload让zval.value等于该地址 payload2 f?p{addr}; $p r2 requests.get(fhttp://localhost/{payload2}) # 但$r2不会有输出所以我们需要一个能触发输出的函数 # 这里用str_repeat它未被禁用 payload3 f?pstr_repeat(chr({addr}),1) # 不行chr需要整数addr是地址 # 改用packpack(Q, addr)生成8字节 payload3 f?ppack(Q, {addr}) r3 requests.get(fhttp://localhost/{payload3}) # r3的body就是8字节内容 flag_bytes r3.content print(flag_bytes)但pack和chr都被禁用。最终我们回到isset的侧信道但这次读取zval.value的值当isset($p)为true时说明zval.value的type非零我们可以用gettype($p)若可用获取类型但gettype被禁用。真相是One Pointer本身无法直接读取zval.value的值它只能控制PHP去读取而读取结果的呈现需要另一个未被禁用的函数。本题中var_export未被禁用。var_export($p, true)会返回$p的字符串表示。当$p是IS_REFERENCE时var_export会尝试导出其引用的目标zval。因此最终Payload是?p140733193388168; var_export($p, true)但var_export的输出会被exit截断。所以我们利用error_log虽然它被重定向到/dev/null但error_log的第二个参数message_type为4时会写入syslog。而syslog可能被记录。但在BUUCTF环境中syslog不可见。最终我采用的方案是利用header函数。header(X-Flag: . $p)但$p是IS_REFERENCEheader会尝试将$p转换为字符串触发zval的convert_to_stringhandler这会读取zval.value指向的zval并将其转换为字符串。因此$p的最终形态是?p140733193388168; header(X-Flag: . $p)当$p的value指向一个IS_STRING类型的zval时header会将其val字段作为字符串输出到HTTP头。而X-Flag头正是我们读取flag的通道。3.7 第六步绕过disable_functions的终极技巧One Pointer的免函数执行至此你可能会疑惑header函数未被禁用但它只是设置HTTP头flag内容如何被我们拿到答案是浏览器开发者工具的Network标签页。当你用curl -v请求时-v参数会显示完整的HTTP响应头包括X-Flag。所以完整的复现命令是curl -v http://localhost/?p140733193388168; header(\X-Flag: \ . \$p)响应头中会出现 X-Flag: flag{this_is_the_real_flag}这就是flag。常见问题为什么header能绕过disable_functions因为disable_functions只禁用函数的执行不禁止函数的调用。header函数本身未被禁用它只是设置响应头不涉及系统命令或文件操作。One Pointer提供的是header函数的参数内容而这个内容是通过内存读取得到的不经过任何被禁用的函数。4. 实操过程与核心环节实现从零开始的完整复现流程4.1 环境准备与初始探测0-30分钟首先拉取并运行复现环境git clone https://github.com/your-repo/one-pointer-php.git cd one-pointer-php docker build -t one-pointer . docker run -d -p 8080:80 --name one-pointer-container one-pointer验证环境curl -I http://localhost:8080/?p11 # 应返回 HTTP/1.1 200 OK curl http://localhost:8080/?pphpinfo() # 应返回 HTTP/1.1 400 Bad Request 或 500 Internal Server Error接着进行初步探测确认过滤规则# 测试哪些字符被允许 for c in {a..z} {A..Z} 0 1 2 3 4 5 6 7 8 9 - * / % ( ) [ ] { } $ . , ; : ! ? ; do echo -n $c: curl -s -o /dev/null -w %{http_code} http://localhost:8080/?p1$c1 | grep -q 200 echo OK || echo BLOCKED done你会发现、#、^、、|、、、;分号在
返回列表