php 内核源码二次开发 语法特征新增/定制 内核漏洞修复完整流程 完整代码 全部大白话解释

php 内核源码二次开发 语法特征新增/定制 内核漏洞修复完整流程 完整代码 全部大白话解释 一份完整的 PHP 内核也就是 PHP 解释器本身C 语言写的那套源码二次开发指南。我会从内核长什么样讲起再给一个 从词法到执行全链路、能跑起来的新语法实现例子最后讲漏洞修复的完整流程。全程大白话代码都是真能用的。---一、先搞清楚PHP 内核到底是什么 你平时写的.php 文件是剧本PHP 内核就是那个演员导演舞台。它是用 C 语言 写的一套程序负责读你的代码 → 拆成单词 →拆成语法结构 →编译成字节码(opcode)→用虚拟机一条条执行。 源码目录里最关键的几块去 https://github.com/php/php-src 下载php-src/├── Zend/←核心引擎语言本身在这里实现 │ ├── zend_language_scanner.l 词法分析器用 re2c 写的把字符变成 token │ ├── zend_language_parser.y 语法分析器用 bison 写的把 token 变成语法树 │ ├── zend_ast.h/zend_ast.c 抽象语法树 AST 的定义 │ ├── zend_compile.c 把 AST 编译成 opcode字节码 │ ├── zend_vm_def.h 所有 opcode 的行为定义虚拟机指令 │ ├── zend_vm_execute.h 由上面那个文件自动生成的执行器 │ └── zend_vm_gen.php 用来重新生成上面那个执行器的脚本 ├── main/←SAPI 基础设施、启动流程 ├── ext/←标准函数库strlen、array_map 全在这 └── sapi/←运行模式cli、fpm、apache 等 一句话记住整条流水线 你的代码 → scanner.l(词法)→ parser.y(语法)→ AST语法树 → zend_compile.c(编译)→ opcode字节码 →zend_vm(虚拟机执行)→ 结果 想加/改语法就是去动这条流水线的前几个环节。---二、先把 PHP 从源码编译出来不编译啥都白搭 内核开发标准环境是 LinuxWindows 上建议用 WSL2原生 Windows 编译要用 php-sdkVisual Studio麻烦得多。 #1.装编译工具Ubuntu/Debian 为例 sudo apt update sudo apt install-y build-essential autoconf bison re2c \ libxml2-dev libsqlite3-dev pkg-config git #2.拉源码 git clone https://github.com/php/php-src.gitcd php-src git checkout PHP-8.3# 选一个稳定分支别在 master 上学 #3.生成 configure 脚本./buildconf--force #4.配置--enable-debug 一定要开调试和 ASAN 都靠它./configure--enable-debug--disable-all--enable-cli #5.编译-j 后面是你的 CPU 核数 make-j$(nproc)#6.验证./sapi/cli/php-v 大白话解释这几步-buildconfPHP 用了一堆.m4 宏脚本这步把它们拼成标准的./configure。---enable-debug开了之后内核会带断言检查、内存追踪崩溃时能给你有用的信息。生产编译才关掉它。---disable-all先把所有扩展关掉编译快专注核心。-re2c 和 bison分别是用来把.l 词法文件和.y 语法文件翻译成 C 代码的工具改了语法必须装这俩。---三、完整实战给 PHP 加一个新运算符^^逻辑异或 PHP 有and/or/xor这种英文逻辑运算符也有/||但偏偏没有^^逻辑异或。我们就来加一个。$a^^$b 的效果两个布尔值不同则为true。 这个例子好在它要走完词法→语法→编译全链路但可以复用已有的异或 opcode不用动虚拟机最适合入门。后面我再补上如果要全新 opcode 怎么办。 第1步词法分析器 ——让内核认识^^这两个字符 打开 Zend/zend_language_scanner.l找到处理运算符的区域搜^或||附近加一条规则ST_IN_SCRIPTING^^{RETURN_TOKEN(T_LOGICAL_XOR);}大白话ST_IN_SCRIPTING表示在 PHP 代码区里不是在 HTML 或字符串里。这条规则说一看到^^这两个字符就吐出一个名叫 T_LOGICAL_XOR 的 token。这个 token 名是 PHP 里xor本来就用的所以我们直接复用——相当于让^^成为xor的同义词。 ▎ ⚠️顺序很重要这条规则要放在单个^按位异或规则前面否则 re2c 会先匹配单个^。re2c ▎ 默认贪婪匹配最长的但保险起见放前面。 第2步语法分析器 ——告诉内核^^怎么组成表达式xor的语法规则已经存在我们的 token 复用了它所以这一步通常不用改。验证一下 Zend/zend_language_parser.y 里有这条搜 T_LOGICAL_XOR expr:...|expr T_LOGICAL_XOR expr{$$zend_ast_create_binary_op(ZEND_BOOL_XOR,$1,$3);}...大白话 这条规则说表达式 ^^/xor 表达式 还是一个表达式并且生成一个 AST 节点类型是二元运算具体操作是 ZEND_BOOL_XOR逻辑异或。$1和 $3就是左右两边的子表达式。 因为我们复用了 T_LOGICAL_XOR这条规则自动对^^生效编译和虚拟机环节也全都复用现成的啥都不用再动。 第3步重新编译 cd php-src make-j$(nproc)make 会自动检测到.l 文件变了调用 re2c 重新生成 zend_language_scanner.c 再编译。如果你改了.y它会调 bison。 第4步测试./sapi/cli/php-rvar_dump(true ^^ false);// bool(true)./sapi/cli/php-rvar_dump(true ^^ true);// bool(false)./sapi/cli/php-rvar_dump(false ^^ false);// bool(false)成了。你刚刚给 PHP 加了一个原生运算符。---四、进阶如果新语法需要全新行为得加新 opcode 上面的例子偷了懒复用xor。如果你要加的是 PHP 里根本没有的运算比如加一个-风格的全新操作就得走完整流程多动两个地方。 假设我们要加一个运算符~含义是整除并向下取整$a~$bfloor($a/$b)。4.1词法scanner.l——加新 token 先在 Zend/zend_language_parser.y 顶部声明新 token%token T_FLOORDIV~ (T_FLOORDIV)再在 zend_language_scanner.l 加规则ST_IN_SCRIPTING~{RETURN_TOKEN(T_FLOORDIV);}4.2语法parser.y——加语法规则优先级 在%left/%nonassoc 优先级表里给它定个优先级跟*/同级比高%left*/%T_FLOORDIV 加表达式规则|expr T_FLOORDIV expr{$$zend_ast_create_binary_op(ZEND_FLOORDIV,$1,$3);}4.3定义新 opcode 常量 在 Zend/zend_compile.h 里找到 opcode 编号列表#define ZEND_POW...那一片加一个没被占用的编号#defineZEND_FLOORDIV205/* 选一个当前最大编号 1 */4.4实现 opcode 行为zend_vm_def.h——真正干活的地方 在 Zend/zend_vm_def.h 里照着 ZEND_DIV 抄一个改ZEND_VM_HANDLER(205,ZEND_FLOORDIV,CONST|TMPVAR|CV,CONST|TMPVAR|CV){USE_OPLINE zval*op1,*op2;zend_free_op free_op1,free_op2;op1GET_OP1_ZVAL_PTR_UNDEF(BP_VAR_R);op2GET_OP2_ZVAL_PTR_UNDEF(BP_VAR_R);/* 先做普通除法再向下取整 */doubleazval_get_double(op1);doublebzval_get_double(op2);if(b0.0){zend_throw_error(zend_ce_division_by_zero_error,Division by zero);FREE_OP1();FREE_OP2();HANDLE_EXCEPTION();}ZEND_VM_SET_OPCODE_HANDLER.../* 省略照 ZEND_DIV 的尾部抄 */ZVAL_DOUBLE(EX_VAR(opline-result.var),floor(a/b));FREE_OP1();FREE_OP2();ZEND_VM_NEXT_OPCODE();}大白话 ZEND_VM_HANDLER 就是一条虚拟机指令的实现。op1/op2 是左右操作数干完活把结果用 ZVAL_DOUBLE 塞进 result然后ZEND_VM_NEXT_OPCODE()跳下一条指令。这里要注意除零异常和内存释放FREE_OP漏了就是 bug。4.5重新生成虚拟机执行器关键 zend_vm_def.h 只是定义真正被编译的是 zend_vm_execute.h它是自动生成的。改完定义必须重新生成 cd php-src/Zend php zend_vm_gen.php cd..make-j$(nproc)▎ 这是新手最常踩的坑改了 zend_vm_def.h 但忘了跑 zend_vm_gen.php结果改动完全不生效因为编译器读的是没更新的 ▎ zend_vm_execute.h。4.6编译器对接zend_compile.c ZEND_FLOORDIV 如果走 zend_ast_create_binary_op 这条通用路径zend_compile.c 里的 zend_compile_binary_op 通常能自动处理常量折叠。但编译期常量折叠比如6~4直接在编译时算出1需要在 Zend/zend_operators.c 里加一个floordiv_function()并在 zend_const_expr_to_zval/优化器里登记。这部分较深入门阶段可以先不做常量折叠运行期能算对就行。---五、内核漏洞修复完整流程含真实案例 PHP 内核漏洞绝大多数是 C 语言的内存安全问题三大类1.整数溢出 →缓冲区溢出最常见2.释放后使用 UAFUse-After-Free3.类型混淆/越界读写 下面用一个最典型的整数溢出走一遍完整流程。 案例一个字符串重复函数的整数溢出 假设有个内部函数这样分配内存这是漏洞的典型形态/* ❌ 有漏洞的写法 */PHP_FUNCTION(my_repeat){char*str;size_t str_len;zend_long count;ZEND_PARSE_PARAMETERS_START(2,2)Z_PARAM_STRING(str,str_len)Z_PARAM_LONG(count)ZEND_PARSE_PARAMETERS_END();/* 危险str_len * count 可能整数溢出 */size_t totalstr_len*count;char*bufemalloc(total1);// 溢出后 total 变成很小的数buf 分配得很小for(zend_long i0;icount;i){memcpy(bufi*str_len,str,str_len);// 但这里照样写大量数据 →堆溢出}...}为什么是漏洞大白话 str_len*count 这个乘法如果结果超过 size_t 上限64位机是2^64会绕回变成一个很小的数。于是 emalloc 只申请了一小块内存但下面的 memcpy 循环却往里写了海量数据把相邻内存覆盖掉——攻击者可以借此改写关键指针、执行任意代码。 第1步复现漏洞先确认它真的炸 写个触发脚本?php// poc.phpmy_repeat(AAAA,PHP_INT_MAX/2);// 让 str_len * count 溢出用带AddressSanitizer(ASAN)的版本编译来抓内存错误./configure--enable-debug CFLAGS-fsanitizeaddress -gLDFLAGS-fsanitizeaddressmake-j$(nproc)./sapi/cli/php poc.php 如果 ASAN 打印 heap-buffer-overflow漏洞确认。 第2步修复 ——用内核自带的溢出检查函数 PHP 内核早就准备好了安全的乘加函数 zend_safe_address专门防这种溢出/* ✅ 修复后的写法 */PHP_FUNCTION(my_repeat){char*str;size_t str_len;zend_long count;ZEND_PARSE_PARAMETERS_START(2,2)Z_PARAM_STRING(str,str_len)Z_PARAM_LONG(count)ZEND_PARSE_PARAMETERS_END();/* 1. count 不能为负 */if(count0){zend_argument_value_error(2,must be greater than or equal to 0);RETURN_THROWS();}/* 2. 用 zend_string_safe_alloc内部做溢出检查溢出会直接报致命错误 而不是默默返回一个小 buffer */zend_string*resultzend_string_safe_alloc(str_len,count,0,0);char*bufZSTR_VAL(result);for(zend_long i0;icount;i){memcpy(bufi*str_len,str,str_len);}ZSTR_VAL(result)[ZSTR_LEN(result)]\0;RETURN_STR(result);}大白话解释修复点-zend_string_safe_alloc(nmemb,size,len,persistent)在内部计算 nmemb*sizelen一旦溢出立即触发致命错误中止绝不会返回一个偏小的缓冲区。这是内核里防整数溢出的标准武器另一个是 zend_safe_address。-补上 count0的参数校验——负数转成size_t 会变成超大正数同样危险。-用 zend_string 而不是裸char*内存管理交给内核引用计数顺手避免内存泄漏。 第3步写回归测试.phpt 格式PHP 官方测试格式 内核改动必须配测试防止以后别人改坏。新建一个.phpt 文件--TEST--my_repeat()整数溢出修复验证--FILE--?php// 正常情况要正确var_dump(my_repeat(ab,3));// string(6) ababab// 溢出输入要被安全拦截而不是崩溃try{my_repeat(AAAA,PHP_INT_MAX);}catch(\Throwable $e){echocaught: ,get_class($e),\n;}// 负数要报参数错误try{my_repeat(a,-1);}catch(\ValueError $e){echovalue error ok\n;}?--EXPECTF--string(6)abababcaught:%s value error ok 跑测试 make test TESTSext/yourext/tests/my_repeat_overflow.phpt 大白话.phpt 是 PHP 内核的标准测试文件。--FILE--是要跑的代码--EXPECTF--是期望输出%s 是通配符匹配任意字符串。make test 会跑代码、对比输出一致才算通过。 第4步用工具二次验证 #1.再用 ASAN 跑一遍 poc确认不再 overflow./sapi/cli/php poc.php #2.跑全套相关测试确认没改坏别的 make test TESTSZend/tests/#3.如果有 valgrind查内存泄漏 valgrind--leak-checkfull./sapi/cli/php poc.php 第5步提交上游如果是给官方修 #1.基于最新的安全分支 git checkout-b fix-my-repeat-overflow PHP-8.3#2.提交信息里写清 CVE/bug 编号 git commit-am Fix integer overflow inmy_repeat(GH-XXXXX)str_len*count could overflow size_t,leading to an undersized allocationandsubsequent heap buffer overflow.Usezend_string_safe_alloc()which checksforoverflow. #3.安全类漏洞别直接发公开 PR走官方安全通道:#https://bugs.php.net →勾选 Security 选项私下报告▎ 重要原则 普通 bug 走 GitHub PR安全漏洞要走 PHP 的私密安全报告流程等官方发布补丁和 CVE 后再公开细节否则就是 ▎0day 泄露。---六、内存安全修复的几条铁律大白话总结 修内核漏洞90%的情况就是盯住这几点 ┌───────────────┬──────────────────────────────────────┬─────────────────────────────────────────────────────────┐ │ 漏洞类型 │ 看哪里 │ 怎么修 │ ├───────────────┼──────────────────────────────────────┼─────────────────────────────────────────────────────────┤ │ 整数溢出 │ 所有 a*b、ab 后拿去 emalloc │ 用 zend_safe_address/zend_string_safe_alloc │ │ │ 的地方 │ │ ├───────────────┼──────────────────────────────────────┼─────────────────────────────────────────────────────────┤ │ 越界读写 │ memcpy、数组下标、指针运算 │ 加边界检查长度用 size_t 且校验来源 │ ├───────────────┼──────────────────────────────────────┼─────────────────────────────────────────────────────────┤ │ UAF │ efree 之后还有没有人用那个指针 │ efree 后立刻置NULL理清引用计数 │ │ 释放后使用 │ │ Z_TRY_ADDREF/zval_ptr_dtor │ ├───────────────┼──────────────────────────────────────┼─────────────────────────────────────────────────────────┤ │ 类型混淆 │ 拿到 zval 不检查类型就当某类型用 │ 用前先Z_TYPE_P(zv)IS_xxx 判断 │ ├───────────────┼──────────────────────────────────────┼─────────────────────────────────────────────────────────┤ │ 内存泄漏 │ 提前return时有没有漏掉 free │ 所有出口路径都要释放--enable-debug │ │ │ │ 编译会在退出时报告泄漏 │ └───────────────┴──────────────────────────────────────┴─────────────────────────────────────────────────────────┘ 调试三件套记住就够用---enable-debug开内核内置检查退出时内存泄漏报告。-ASAN-fsanitizeaddress抓越界、UAF最强。-gdb崩溃时 bt 看调用栈gdb 配.gdbinitPHP 源码根目录自带有 printzv 等好用命令直接打印 zval 内容。---七、给你一条最短的上手路径1.WSL/Linux 上把 PHP-8.3用--enable-debug 编出来php-v 能跑。2.照第三章把^^运算符加上、测通——这一步打通改源码→重编译→生效的闭环是所有后续的基础。3.想加需要新行为的语法照第四章走完 scanner →parser →opcode →zend_vm_gen.php 全套。4.修漏洞按第五章五步法复现(ASAN)→改(safe_alloc)→写.phpt →验证 →提交。