一份完整的 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 从源码编译出来(不编译啥都白搭) 内核开发标准环境是 Linux(Windows 上建议用 WSL2,原生 Windows 编译要用 php-sdk+Visual 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 大白话解释这几步:-buildconf:PHP 用了一堆.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-r'var_dump(true ^^ false);'// bool(true)./sapi/cli/php-r'var_dump(true ^^ true);'// bool(false)./sapi/cli/php-r'var_dump(false ^^ false);'// bool(false)成了。你刚刚给 PHP 加了一个原生运算符。---四、进阶:如果新语法需要"全新行为",得加新 opcode 上面的例子偷了懒(复用xor)。如果你要加的是 PHP 里根本没有的运算,比如加一个<=>-风格的全新操作,就得走完整流程,多动两个地方。 假设我们要加一个运算符~>,含义是"整除并向下取整"($a~>$b=floor($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;op1=GET_OP1_ZVAL_PTR_UNDEF(BP_VAR_R);op2=GET_OP2_ZVAL_PTR_UNDEF(BP_VAR_R);/* 先做普通除法,再向下取整 */doublea=zval_get_double(op1);doubleb=zval_get_double(op2);if(b==0.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.释放后使用 UAF(Use-After-Free)3.类型混淆/越界读写 下面用一个最典型的整数溢出走一遍完整流程。 案例:一个字符串重复函数的整数溢出 假设有个内部函数这样分配内存(这是漏洞的典型形态):/* ❌ 有漏洞的写法 */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 total=str_len*count;char*buf=emalloc(total+1);// 溢出后 total 变成很小的数,buf 分配得很小for(zend_long i=0;i<count;i++){memcpy(buf+i*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="-fsanitize=address -g"LDFLAGS="-fsanitize=address"make-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(count<0){zend_argument_value_error(2,"must be greater than or equal to 0");RETURN_THROWS();}/* 2. 用 zend_string_safe_alloc:内部做溢出检查,溢出会直接报致命错误, 而不是默默返回一个小 buffer */zend_string*result=zend_string_safe_alloc(str_len,count,0,0);char*buf=ZSTR_VAL(result);for(zend_long i=0;i<count;i++){memcpy(buf+i*str_len,str,str_len);}ZSTR_VAL(result)[ZSTR_LEN(result)]='\0';RETURN_STR(result);}大白话解释修复点:-zend_string_safe_alloc(nmemb,size,len,persistent)在内部计算 nmemb*size+len,一旦溢出立即触发致命错误中止,绝不会返回一个偏小的缓冲区。这是内核里防整数溢出的标准武器(另一个是 zend_safe_address)。-补上 count<0的参数校验——负数转成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){echo"caught: ",get_class($e),"\n";}// 负数要报参数错误try{my_repeat("a",-1);}catch(\ValueError $e){echo"value error ok\n";}?>--EXPECTF--string(6)"ababab"caught:%s value error ok 跑测试: make test TESTS=ext/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 TESTS=Zend/tests/#3.如果有 valgrind,查内存泄漏 valgrind--leak-check=full./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、a+b 后拿去 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(-fsanitize=address):抓越界、UAF,最强。-gdb:崩溃时 bt 看调用栈,gdb 配.gdbinit(PHP 源码根目录自带)有 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 →验证 →提交。php 内核源码二次开发 语法特征新增/定制 内核漏洞修复完整流程 完整代码 全部大白话解释
张小明
前端开发工程师
从Kosaraju到Tarjan:强连通分量算法该怎么选?一张图带你理清应用场景与性能差异
从Kosaraju到Tarjan:强连通分量算法该怎么选?一张图带你理清应用场景与性能差异在编译器依赖分析、社交网络关系挖掘或是金融交易路径追踪中,强连通分量(SCC)算法扮演着关键角色。当面对百万级节点的依赖图时ÿ…
如何用SPT-AKI存档编辑器彻底掌控你的离线塔科夫游戏体验?
如何用SPT-AKI存档编辑器彻底掌控你的离线塔科夫游戏体验? 【免费下载链接】SPT-AKI-Profile-Editor Программа для редактирования профиля игрока на сервере SPT-AKI 项目地址: https://gitcode.com/gh_mirro…
告别黑边!3步为《植物大战僵尸》添加完美宽屏支持
告别黑边!3步为《植物大战僵尸》添加完美宽屏支持 【免费下载链接】PvZWidescreen Widescreen mod for Plants vs Zombies 项目地址: https://gitcode.com/gh_mirrors/pv/PvZWidescreen 还在为经典游戏《植物大战僵尸》在现代宽屏显示器上出现恼人黑边而烦恼…
如何让MacBook告别不合时宜的睡眠困扰?SleeperX智能睡眠控制终极方案
如何让MacBook告别不合时宜的睡眠困扰?SleeperX智能睡眠控制终极方案 【免费下载链接】SleeperX MacBook prevent idle/lid sleep! Hackintosh sleep on low battery capacity. 项目地址: https://gitcode.com/gh_mirrors/sl/SleeperX 你是否曾经遇到过这样的…
Wi-Fi 7路由器BE33000/21000/16000/10000命名背后的秘密:高通Networking Pro平台全解析
Wi-Fi 7路由器BE系列命名解码:从芯片到实际体验的全景指南当你走进电器商城,看到路由器包装上印着"BE33000"、"BE21000"这样夸张的数字时,是否感到一头雾水?这些数字并非营销噱头,而是Wi-Fi 7时代…
告别3.5mm!用USB数字耳机前,你必须搞懂的UAC1.0和UAC2.0核心差异
告别3.5mm!用USB数字耳机前,你必须搞懂的UAC1.0和UAC2.0核心差异当音乐爱好者第一次接触USB数字耳机时,往往会被产品参数表中的"UAC2.0支持"所吸引。这个看似简单的技术指标背后,隐藏着影响音频体验的关键差异。本文将带…