1. 项目概述为什么PHP特性是CTF的“兵家必争之地”如果你玩过CTF尤其是Web方向那你对“PHP特性”这四个字一定不会陌生。这几乎是每一场CTF比赛的“保留节目”从国内各大平台到国际知名赛事PHP相关的“奇技淫巧”总能成为解题的关键也是新手最容易“翻车”的地方。我刚开始打CTF时最头疼的就是这类题明明代码逻辑看起来很简单但就是拿不到flag后来才明白问题往往出在对PHP语言本身一些“不那么直观”的特性理解不够深。CTFshow平台上的PHP特性题目可以说是这类知识点的集大成者。它不像一些单纯的SQL注入或文件上传给你一个明确的漏洞点去突破。PHP特性题更像是在和你玩“文字游戏”或者“逻辑谜题”它考察的是你对PHP语言底层行为、函数默认处理、类型转换、运算符优先级、以及那些为了“方便”而设计的“特性”有时我们戏称为“坑”的掌握程度。很多漏洞的产生并不是因为程序员写了复杂的逻辑恰恰是因为他们依赖了PHP的“自动”和“便捷”而这些特性在安全语境下就可能变成致命的弱点。所以这篇内容的目的就是帮你把这些散落在各个题目、各种Writeup里的PHP特性知识点进行一次系统性的梳理和深度解析。我们不只讲“是什么”更要讲清楚“为什么”会这样以及在实际的CTF场景和代码审计中“怎么用”。无论你是刚入门的新手还是想查漏补缺的老手相信这份“干货”都能让你对PHP有新的认识下次再遇到这类题目时能一眼看穿出题人的“小心思”。2. PHP弱类型比较的“陷阱”与“魔术”弱类型比较是PHP特性中最经典也是CTF中最常见的考点。PHP在比较两个变量时如果类型不同它会尝试进行类型转换再进行比较。这种灵活性带来了便利也带来了无数安全隐患。2.1与的本质区别这是最基础的但必须深刻理解。松散比较先进行类型转换再比较值。严格比较先比较类型如果类型不同直接返回false类型相同再比较值。关键在于引发的类型转换规则。字符串和数字比较时PHP会尝试将字符串转换为数字。转换规则是从字符串开始部分读取数字直到遇到非数字字符为止。如果开头就不是数字则转换为0。var_dump(123abc 123); // true “123abc” - 123 var_dump(abc123 0); // true “abc123” - 0 var_dump(0e123 0e456); // true 科学计数法都视为0 var_dump(0x1a 26); // 注意字符串中的十六进制不会自动转换这里“0x1a”作为字符串转数字是0所以 false注意0e开头的字符串在松散比较时是一个经典漏洞点。因为0e会被认为是科学计数法0的n次方结果永远是0。所以“0e123456” “0e987654”会是true即使它们看起来完全不同。这在MD5碰撞md5(‘xxx’)结果为0e开头的题目中极为常见。2.2 数组、NULL与其他类型的“神奇”比较数组在与任何其他类型进行比较时结果几乎总是出人意料。var_dump([] false); // true 空数组转为布尔值false var_dump([] 0); // true 空数组转数字为0 var_dump([] ); // true 空数组转字符串为空字符串 var_dump([0] [false]); // true 比较过程数组元素0 false - true var_dump([0] [false]); // false 类型严格不同元素0是intfalse是boolCTF应用场景常用于绕过if ($a $b)这样的检查。例如题目要求$_GET[‘a’]不等于某个已知值$flag但如果你传入a[]1那么$_GET[‘a’]是一个数组数组与字符串或数字比较的结果可能是false从而意外地满足了“不相等”的条件。NULL的比较var_dump(null false); // true var_dump(null 0); // false 这是一个易错点。null转为数字是0但null 0结果是false。因为null 数字比较时会先把数字转为布尔值不规则是null与除了false本身以外的其他类型松散比较大多为false。更准确记忆null只与false和null自身松散相等。 var_dump(null 0); // false2.3strcmp()、strcasecmp()的数组漏洞strcmp()和strcasecmp()用于比较两个字符串。但如果传入的参数不是字符串比如数组它们的行为在PHP早期版本PHP5.3中会返回NULL在现代版本PHP5.3中会直接抛出警告并返回0。关键在于NULL 0在松散比较中是true。// 假设代码if (strcmp($_GET[password], $real_pwd) 0) { echo ‘flag’; } // 攻击传入 password[]1 // 结果strcmp([1], ‘real_password’) 返回 NULL 或 0 // 判断NULL 0 - true 绕过成功实操心得遇到用strcmp()、md5()、sha1()等函数比较用户输入时第一时间尝试传入数组。因为函数处理非字符串参数时往往会产生非预期的返回值NULL、false、警告等而这些返回值在后续的松散比较中可能被利用。2.4in_array()、array_search()的松散比较陷阱这两个函数默认使用松散比较。$array [‘1’ ‘2’ ‘3’]; var_dump(in_array(‘abc’ $array)); // true 因为 ‘abc’ 转数字为0 但数组中无0 所以false等等 错了。 // 正确结果应该是 false。因为 in_array(‘abc’ [‘1’‘2’‘3’]) 比较过程是’abc’ ‘1’ - false ’abc’ ‘2’ - false ’abc’ ‘3’ - false。所以返回false。 // 经典漏洞例子 $array [0, 1, 2]; var_dump(in_array(‘abc’ $array)); // true 因为 ‘abc’ 转数字为0 0在数组中。 var_dump(array_search(‘abc’ $array)); // 0 返回找到的元素的键名0。如果$array里含有数字0那么任何无法转换为数字的字符串如’abc’、’def’都会被in_array()认为存在于数组中因为’abc’ 0成立。array_search()同理会返回第一个匹配的键名。防御方法始终使用第三个参数$strict设置为true启用严格比较。in_array($needle $haystack true); array_search($needle $haystack true);3. 字符串处理与编码中的“坑”PHP的字符串函数功能强大但一些默认行为或特殊字符处理容易让人栽跟头。3.1md5()、sha1()与科学计数法碰撞前面提到0e开头的MD5值在比较时相等。这里深入一下。MD5是一种哈希函数理论上不同的输入会产生不同的输出。但存在一些特殊的字符串其MD5值恰好是0e开头纯数字的形式。var_dump(md5(‘QNKCDZO’) md5(‘240610708’)); // true // md5(‘QNKCDZO’) 0e830400451993494058024219903391 // md5(‘240610708’) 0e462097431906509019562988736854 // 松散比较时 两者都被视为科学计数法的0 所以相等。CTF场景题目要求提交两个不同的字符串但它们的MD5值要相等。直接提交这两个已知的“魔术字符串”即可绕过。对于sha1()也有类似的字符串如aaroZmOk和aaK1STfY。进阶利用当比较是严格比较时0e碰撞无效。但我们可以利用MD5函数对数组参数返回NULL的特性。如果代码是if (md5($_GET[‘a’]) md5($_GET[‘b’]))我们可以传入a[]1b[]2这样md5([1])和md5([2])都返回NULLNULL NULL成立。3.2%00空字符截断这是一个历史遗留问题在PHP版本小于5.3.4且magic_quotes_gpcOff的情况下%00URL编码的空字符在字符串中会被解析并在某些函数如include、require、文件系统函数处理时认为字符串到此结束。// 假设代码include($_GET[‘page’] . ‘.php’); // 攻击传入 page../../../etc/passwd%00 // 结果include(‘../../../etc/passwd\0.php’); // \0是空字符 // 在某些环境下 空字符后的’.php’会被忽略 从而成功包含/etc/passwd文件。现状在如今的主流环境中PHP5.3.4这个漏洞基本已修复。但在一些CTF题目为了考察这个知识点可能会特意配置旧环境。知道有这个特性即可。3.3 十六进制、八进制字符串转换intval()函数在转换字符串时如果字符串以0x开头会被当作十六进制数如果以0开头会被当作八进制数PHP 8版本后此行为已更改默认十进制。var_dump(intval(‘0x1a’)); // 26 十六进制 var_dump(intval(‘0123’)); // 83 PHP8以前是八进制(123 octal 83 decimal) PHP8后是123十进制 var_dump(intval(‘123’)); // 123 十进制 var_dump(intval(‘1e10’)); // 1 因为从’1’开始读 遇到’e’停止is_numeric()的坑这个函数不仅检查十进制数字还检查十六进制0x格式、科学计数法1e10、以及带正负号的数字。这可能导致意想不到的绕过。var_dump(is_numeric(‘0x1a’)); // true PHP7中为true PHP8中为false var_dump(is_numeric(‘1e10’)); // true var_dump(is_numeric(‘0b101’)); // false 二进制不支持如果代码用is_numeric()检测用户输入是否为“数字”然后直接将其用于SQL语句那么传入0x...十六进制或1e9可能被数据库解析为数字或表达式从而引发SQL注入。3.4 正则表达式修饰符/m与/s的差异这在多行匹配时容易混淆。/m多行模式改变了^和$的含义。默认情况下^和$匹配整个字符串的开头和结尾。在/m模式下它们匹配每一行的开头和结尾。/s单行模式改变了点号.的含义。默认情况下点号.不匹配换行符\n。在/s模式下点号.匹配包括换行符在内的所有字符。CTF应用题目可能用正则过滤关键词如preg_match(‘/^key:.*flag/is’ $input)。理解修饰符才能知道如何构造payload。例如如果正则用了/m那么^和$就可能被行首/行尾绕过。4. 变量覆盖与全局“魔法”这类特性允许通过外部输入改变程序内部的变量值是非常危险的漏洞来源。4.1extract()函数extract()函数从数组中将变量导入到当前的符号表。如果数组中的键名是字符串它将会被作为变量名。$auth false; extract($_GET); // 如果传入 ?auth1 那么变量$auth的值会被覆盖为1 if ($auth) { echo ‘Admin area!’; }危险如果extract()的参数是用户可控的如$_GET、$_POST攻击者可以覆盖任何已定义的变量包括配置变量、标志位等完全颠覆程序逻辑。4.2parse_str()函数parse_str()将查询字符串解析到变量中功能类似extract()。$var ‘initial’; parse_str(‘varhackedothertest’ $output); // 第二个参数存在 解析到数组$output // $output [‘var’‘hacked’ ‘other’‘test’]; parse_str(‘varhacked’); // 第二个参数省略 直接注册变量到当前作用域 // 现在 $var ‘hacked’;注意第二个参数省略时其行为在register_globals已废弃开启时类似会直接创建或覆盖全局变量极其危险。4.3$$的可变变量可变变量允许使用一个变量的值作为另一个变量的变量名。$var ‘hello’; $$var ‘world’; // 创建了一个新变量 $hello 值为 ‘world’ echo $hello; // 输出 ‘world’CTF漏洞场景foreach ($_GET as $key $value) { $$key $value; // 将$_GET的每个键值对注册为变量 } // 如果传入 ?flag1 则创建变量 $flag 1 // 如果传入 ?_POST[xxx]1 则可能覆盖全局$_POST数组如果之前已定义这被称为“变量覆盖漏洞”。攻击者可以覆盖关键变量如$is_admin、$config等。4.4register_globals历史特性这是一个在古老PHP版本4.2.0中默认开启后来被废弃并移除的特性。当它开启时PHP会自动将$_GET、$_POST、$_COOKIE等超全局数组中的键值对注册为全局变量。这意味着?admin1会在脚本中直接生成一个$admin变量且值为1。这是极其严重的安全隐患现代PHP已不复存在但CTF老题中可能遇到。5. 反序列化漏洞的“前奏”魔法方法与特性PHP反序列化本身是一个复杂的话题但很多CTF的入门题会考察对PHP序列化字符串格式的理解以及一些关键“魔法方法”Magic Methods的触发条件。5.1 序列化字符串格式浅析serialize()产生的字符串有其固定格式i:整数s:字符串后面跟长度a:数组后面跟元素个数和键值对O:对象后面跟类名长度、类名、属性个数和属性键值对N:NULL关键点对象序列化时只序列化属性数据不包含方法代码。但类名会被记录。5.2 魔法方法的自动执行这是反序列化漏洞的核心。当对象被反序列化unserialize()时PHP会自动调用一些特定的方法如果它们存在。__wakeup()在unserialize()时自动调用。常用于重新建立数据库连接等初始化操作。__destruct()在对象被销毁时如脚本结束、unset()自动调用。这是CTF中最常用的“入口点”因为反序列化产生的对象在脚本结束后总会销毁。__toString()当对象被当作字符串使用时如echo $obj自动调用。__get()__set()访问或设置不可访问属性时调用。__invoke()当尝试以调用函数的方式调用一个对象时调用。CTF解题思路找到代码中可控的unserialize()参数。分析代码中存在的类及其魔法方法。构造一个序列化字符串其对象属性可以控制魔法方法中的关键逻辑。通常目标是让__destruct()或__wakeup()方法去执行一些危险操作比如调用system()、eval()或者修改某个属性为flag。5.3__wakeup()的绕过CVE-2016-7124这是一个著名的漏洞。当序列化字符串中表示对象属性个数的值大于真实的属性个数时__wakeup()方法将不会被调用。// 正常序列化O:4:“Test”:1:{s:4:“data”;s:6:“normal”;} // 绕过 __wakeup O:4:“Test”:2:{s:4:“data”;s:6:“bypass”;} // 将属性个数从1改为更大的数如2这个漏洞在PHP版本 5.6.25 或 7.0.10 中存在。CTF题目常利用此绕过__wakeup()中的过滤或初始化代码让__destruct()直接执行。5.4 原生类利用PHP内置了一些类它们的方法可能在反序列化利用链中发挥作用。例如Exception/Error类它们的__toString方法会打印出对象的属性可能泄露信息。SplFileObject类可以用于读取文件。SoapClient类可以发起HTTP请求用于SSRF服务器端请求伪造。利用这些原生类有时可以在没有自定义“POP链”属性导向编程链的情况下直接达到目的比如读文件或发起网络请求。6. 文件包含与伪协议“全家桶”文件包含漏洞includerequireinclude_oncerequire_once配合PHP的封装协议Wrapper能玩出很多花样。6.1 基础包含与路径遍历include $_GET[‘file’]; // 直接包含 无任何过滤 最危险 include ‘./pages/’ . $_GET[‘file’] . ‘.php’; // 拼接后缀 需要截断或绕过路径遍历../../../etc/passwd是基础操作。如果拼接了后缀就需要用到前面讲的%00截断旧环境或者利用长度截断Windows下路径长度限制已过时。6.2 PHP封装协议的利用这是现代CTF文件包含题的核心。通过php://filter协议我们可以进行读取、编码、写入等操作。php://filter/readconvert.base64-encode/resource文件路径这是最常用的姿势。它将目标文件的内容经过base64编码后读取出来。为什么要编码因为include包含一个非PHP文件如/etc/passwd时文件内容会被直接输出。但如果文件内容包含?php ... ?标签它会被执行。通过base64编码我们可以安全地获取任何文件的源代码而不会触发执行。// Payload: ?filephp://filter/readconvert.base64-encode/resourceindex.php // 输出 index.php文件的base64编码内容 解码即可得源码。php://input与data://这两个协议允许我们将POST请求体或数据流作为代码执行。php://input读取POST请求的原始数据。需要allow_url_includeOn。// URL: ?filephp://input // POST Body: ?php system(‘ls’);? // 结果包含并执行POST体中的PHP代码。data://直接包含数据流。也需要allow_url_includeOn。// Payload: ?filedata://text/plain?php phpinfo();? // 或者编码后 ?filedata://text/plain;base64PD9waHAgcGhwaW5mbygpOz8%2b // 结果执行phpinfo()。zip://与phar://这两个协议用于包含压缩包内的文件常用于绕过文件上传后缀限制实现“压缩包内包含Webshell”。zip://zip://[压缩包绝对路径]#[压缩包内文件路径]。#需要URL编码为%23。# 1. 创建一个shell.php 内容为 ?php eval($_POST[‘cmd’]);? # 2. 将其压缩为 shell.zip # 3. 上传 shell.zip # 4. 包含 ?filezip:///var/www/html/uploads/shell.zip%23shell.phpphar://功能更强大不仅可以包含还能触发反序列化。phar://[phar文件路径]/[内部文件]。Phar是PHP的归档文件格式。6.3 包含Session文件Session文件通常存储在服务器特定目录如/tmp/文件名格式为sess_[session_id]内容是可预测的序列化格式。如果我们可以控制部分Session内容比如通过用户输入存入$_SESSION[‘name’]并且知道Session文件路径就可以通过文件包含来执行代码。利用条件1. Session文件路径已知或可预测2. 我们能向Session中写入可控数据3. 存在文件包含漏洞。6.4 包含临时文件LFI to RCE这是更高级的技巧。通过发送一个超大的、包含PHP代码的POST请求PHP会将其内容作为临时文件保存在/tmp目录如/tmp/phpXXXXXX。虽然这个文件名是随机的但我们可以通过暴力猜解或者利用/proc/self/fd/目录Linux下存储进程打开的文件描述符来包含这个临时文件从而实现远程代码执行。这通常需要脚本辅助。7. 命令执行与代码注入的“花式绕过”当题目存在eval()、system()、passthru()、exec()、shell_exec()、反引号等函数并且参数部分可控时就存在命令注入或代码注入的风险。出题人往往会设置层层过滤需要我们绕过。7.1 命令注入的连接符与通配符连接符;顺序执行前一条执行完执行后一条。逻辑与前一条成功返回0才执行后一条。||逻辑或前一条失败返回非0才执行后一条。|管道前一条命令的输出作为后一条的输入。后台运行。通配符*匹配任意字符。cat fla*可以匹配flag.phpflag.txt等。?匹配单个字符。cat fla?.php匹配flag.php。[a-z]字符范围。cat fla[a-g].php。7.2 空格绕过如果空格被过滤可以用以下字符替代${IFS}IFS是shell的内部字段分隔符默认包含空格。$IFS$9$9是第9个参数通常为空所以$IFS$9就是空格。或重定向符号在某些上下文中可以替代空格。catflag.php。{catflag.php}花括号扩展执行cat flag.php。Tab键%09URL编码有时也能作为分隔符。7.3 关键字绕过如果cat、flag、system等关键词被过滤可以尝试拼接ac;bat;cfl;dag; $a$b ${c}${d}.php-cat flag.php。变量替换/???/c?t flag.php-/bin/cat flag.php利用通配符匹配系统命令路径。编码Base64echo ‘Y2F0IGZsYWcucGhw’ | base64 -d | bash。Hexecho ‘63617420666c61672e706870’ | xxd -r -p | bash。引号与反斜杠c\at fl‘’ag.phpc”at” fl\ag.php。shell会忽略这些符号。利用已有命令morelessheadtailnltac反向catod以八进制或其他格式查看strings打印文件中可打印的字符串等。通配符终极利用/???/????64 /???/??ss??/??????。这串“天书”可能匹配/usr/bin/base64 /etc/passwd。需要根据目标系统路径猜测。7.4eval()代码注入的绕过eval()执行的是PHP代码过滤通常更严格。字符串构造如果不能直接写函数名可以用字符串拼接。// 假设 system 被过滤 $func ‘sys’ . ‘tem’; $func(‘ls’); // 等同于 system(‘ls’)异或/取反生成字符串这是无字符webshell的常用技术。通过非字母数字的字符进行异或或取反运算得到我们想要的函数名字符串。// 例如 生成 ‘phpinfo’ // 方法很多 比如从全局变量如_SERVER中提取字符拼接利用create_function()这个已废弃的函数可以动态创建匿名函数其第二个参数是函数体代码可能存在注入。create_function(‘’ ‘}system(“ls”);//’);。assert()与preg_replace(/e)这两个也是历史漏洞函数assert()会执行字符串参数preg_replace的/e修饰符会将替换字符串作为PHP代码执行。现代PHP版本已移除或默认不启用。7.5 无参数RCE这是CTF中的一种限制场景你只能调用一个函数并且不能传递参数。如何执行命令getallheaders()获取所有HTTP头我们可以将命令放在某个Header里如X-Forwarded-For: ;ls然后通过end()或current()等数组函数取出并执行。但需要system等函数可用。session_id()可以将命令写入Session ID通过Cookie的PHPSESSID设置但Session ID通常有格式限制。getenv()/apache_setenv()操作环境变量。目录遍历利用scandir()array_reverse()next()current()等函数组合在不带参数的情况下遍历目录找到flag文件然后用readfile()highlight_file()等读取。这是更常见的无参数RCE考察方式重点在于对数组指针的操作。// 读取当前目录第一个文件 highlight_file(current(scandir(‘.’))); // 读取上级目录的某个文件假设我们知道文件名 highlight_file(next(scandir(‘..’))); // 更复杂的需要多次 array_reverse next current 组合来定位文件8. 实战中的组合拳与思维提升CTF题目很少只考一个孤立的知识点往往是多个特性的组合。你需要培养一种“敏感度”看到代码就能联想到可能的漏洞链。案例思维看到或!立刻想到弱类型比较尝试0e、数组、NULL。看到strcmp()md5()sha1()比较尝试传入数组。看到is_numeric()尝试十六进制0x、科学计数法1e9。看到extract()parse_str()$$思考变量覆盖能否覆盖$is_admin、$flag等。看到unserialize()立刻找类定义和魔法方法构造POP链。看到include/require尝试路径遍历然后上php://filter读源码再尝试php://input或data://执行代码。看到eval()system()等分析过滤规则尝试各种绕过技巧拼接、编码、通配符、无参数。看到正则过滤仔细分析正则表达式和修饰符寻找逻辑漏洞或绕过方式如多行匹配、最大次数限制/.*/U等。信息收集永远不要忘记phpinfo()。如果题目有文件包含或上传点想办法看到phpinfo()页面它能告诉你allow_url_include、allow_url_fopen、open_basedir、disable_functions等关键配置直接决定你的利用方式能否成功。工具辅助手工很重要但也要善用工具。比如用Burp Suite的Intruder进行模糊测试或暴力猜解用Python脚本快速生成序列化payload或测试命令注入绕过。对于复杂的POP链可以本地搭建环境进行调试。最后保持耐心和好奇心。PHP特性漏洞千变万化新的版本也可能引入新的行为。最好的学习方法就是动手实践在CTF平台上多刷题遇到不懂的立刻查手册、看源码、和队友讨论。把这些特性内化成自己的本能反应你就能在Web安全的道路上越走越远。这份“超详解”希望能成为你手边常备的参考但真正的理解来自于你亲手解开每一道题时的思考与尝试。