PHP 魔术方法讲解

魔术方法(Magic Methods)是 PHP 中以双下划线 __ 开头的特殊方法,它们在特定条件下由 PHP 自动调用,无需手动触发。掌握它们的触发时机和参数传递机制,是理解 PHP 面向对象和反序列化漏洞的基础。


总览:魔术方法触发条件速查

魔术方法触发条件参数
__construct()new 实例化对象时构造参数
__destruct()对象被销毁时(脚本结束或无引用)
__sleep()serialize() 序列化时无,需返回数组
__wakeup()unserialize() 反序列化时
__toString()对象被当作字符串使用时(echo/print 等)无,需返回字符串
__invoke()对象被当作函数调用时调用参数
__clone()clone 克隆对象时
__call()调用不存在的方法时($方法名, $参数数组)
__callStatic()静态调用不存在的方法时($方法名, $参数数组)
__get()访问不存在的属性时($属性名)
__set()设置不存在的属性时($属性名, $属性值)
__isset()对不存在的属性使用 isset()/empty()($属性名)
__unset()对不存在的属性使用 unset()($属性名)

__construct() — 构造函数

触发时机: 使用 new 实例化对象时自动调用,且仅在实例化时触发一次。

1
2
3
4
5
6
7
8
9
10
11
12
<?php
class User {
public $username;
public function __construct($username) {
$this->username = $username;
}
}

$test = new User("benben");
$ser = serialize($test);
unserialize($ser);
?>

说明: 第 8 行 new User("benben") 触发 __construct()。注意,unserialize() 创建对象时不会触发 __construct(),因为反序列化是直接从字符串恢复对象状态,而非走正常的实例化流程。


__destruct() — 析构函数

触发时机: 对象不再被使用(无引用)或脚本执行结束时,PHP 垃圾回收机制销毁对象并自动调用。

1
2
3
4
5
6
7
8
9
10
11
<?php
class User {
public function __destruct() {
echo "__destruct()";
}
}

$test = new User("benben");
$ser = serialize($test);
unserialize($ser);
?>

两次触发的详细分析:

  1. 第一次触发: 第 8 行创建的 $test 对象。当脚本运行到 ?> 结束时,所有变量被销毁,$test 指向的对象触发 __destruct()

  2. 第二次触发: 第 10 行 unserialize($ser) 在内存中重新创建了一个新对象。但因为没有将结果赋值给任何变量(没有写 $a = unserialize($ser)),这个临时对象在当行执行完后就没有任何引用了,PHP 认为它已无用,立即销毁,触发第二次 __destruct()

安全视角: 这正是反序列化漏洞利用的关键——攻击者可以通过控制序列化数据,在 __destruct() 中执行危险操作。即使没有变量接收反序列化结果,对象也会被创建并最终触发析构。


__sleep() — 序列化前置钩子

触发时机: 对对象调用 serialize() 时,PHP 会先检查类中是否存在 __sleep()

  • 如果存在: 先执行 __sleep(),然后根据其返回值(一个属性名数组)决定序列化哪些属性。
  • 如果不存在: 默认序列化对象中所有属性(包括私有属性)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?php
class User {
const SITE = 'uusama';
public $username;
public $nickname;
private $password;

public function __construct($username, $nickname, $password) {
$this->username = $username;
$this->nickname = $nickname;
$this->password = $password;
}

public function __sleep() {
return array('username', 'nickname');
}
}

$user = new User('a', 'b', 'c');
echo serialize($user);

输出:

1
O:4:"User":2:{s:8:"username";s:1:"a";s:8:"nickname";s:1:"b";}

说明: __sleep() 返回了 array('username', 'nickname'),所以序列化结果中只包含这两个属性,$password 被忽略了。__sleep() 的作用就是精准控制哪些属性参与序列化——常用于过滤敏感数据(如密码、密钥)或执行序列化前的清理工作(如关闭数据库连接、保存文件句柄状态)。


__wakeup() — 反序列化前置钩子

触发时机: 对字符串调用 unserialize() 时,PHP 会先检查类中是否存在 __wakeup(),如果存在则先执行它,再完成反序列化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<?php
class User {
const SITE = 'uusama';
public $username;
public $nickname;
private $password;
private $order;

public function __wakeup() {
$this->password = $this->username;
}
}

$user_ser = 'O:4:"User":2:{s:8:"username";s:1:"a";s:8:"nickname";s:1:"b";}';
var_dump(unserialize($user_ser));
?>

输出:

1
2
3
4
5
6
object(User)#1 (4) {
["username"]=> string(1) "a"
["nickname"]=> string(1) "b"
["password":"User":private]=> string(1) "a"
["order":"User":private]=> NULL
}

说明: __wakeup() 在反序列化时被触发,将 password 属性的值设为 username 的值(即 "a")。这里序列化字符串只传了 2 个属性(username 和 nickname),但 __wakeup() 可以用来初始化或修正未被序列化的属性。

__destruct() 的联动: unserialize() 的完整流程是:创建对象 → __wakeup() → 对象可用 → 对象销毁时 __destruct()。所以反序列化可能同时触发这两个方法。

__wakeup() 绕过 — CVE-2016-7124

影响版本: PHP5 < 5.6.25,PHP7 < 7.0.10

原理: 当序列化字符串中声明的属性个数大于实际属性个数时,__wakeup() 会被跳过,但反序列化仍然正常执行。

1
2
3
4
5
正常情况(属性个数 = 2,会触发 __wakeup)
O:4:"test":2:{s:2:"v1";s:6:"benben";s:2:"v2";s:3:"123";}

绕过情况(属性个数改为 3,跳过 __wakeup)
O:4:"test":3:{s:2:"v1";s:6:"benben";s:2:"v2";s:3:"123";}

只需要将对象标识符后的属性个数从 2 改为 3(或其他比实际值大的数),即可绕过 __wakeup() 的执行。这在 CTF 中是经典的考点。


__toString() — 对象转字符串

触发时机: 当对象被当作字符串使用时自动调用,例如 echoprint、字符串拼接等操作。

1
2
3
4
5
6
7
8
9
10
11
12
<?php
class User {
var $benben = "this is test!!";
public function __toString() {
return '正确!';
}
}

$test = new User();
print_r($test); // 正常输出对象结构,不触发 __toString()
echo $test; // 触发 __toString(),输出"正确!"
?>

说明: print_r()var_dump() 不会触发 __toString(),因为它们的作用是调试输出对象结构。而 echo 要求参数是字符串,当传入对象时就会触发 __toString()。方法必须返回一个字符串,否则会报错。


__invoke() — 对象当函数调用

触发时机: 当对象被当作函数调用时(即 $obj() 的形式)自动触发。

1
2
3
4
5
6
7
8
9
10
11
12
<?php
class User {
var $benben = "this is test!!";
public function __invoke() {
echo '它不是函数!';
}
}

$test = new User();
echo $test->benben; // 正常访问属性,输出"this is test!!"
$test(); // 把对象当函数调用,触发 __invoke(),输出"它不是函数!"
?>

说明: $test->benben 是正常的属性访问,而 $test() 是将整个对象当作函数来调用,PHP 会自动拦截并执行 __invoke()


错误调用的拦截方法

__call() — 调用不存在的方法

触发时机: 调用一个对象中不存在的方法时自动触发。

参数: __call($方法名, $参数数组) — 第一个参数是不存在的方法名,第二个参数是调用时传入的参数数组。

1
2
3
4
5
6
7
8
9
10
11
<?php
class User {
public function __call($arg1, $arg2) {
echo "$arg1,$arg2[0]";
}
}

$test = new User();
$test->callxxx('a'); // callxxx 方法不存在,触发 __call()
// 输出:callxxx,a
?>

说明: $test->callxxx('a')callxxx 不存在,__call() 接收到 $arg1 = "callxxx"$arg2 = ["a"]

__callStatic() — 静态调用不存在的方法

触发时机: 使用 :: 静态调用符号调用一个不存在的方法时触发。

参数:__call(),为 __callStatic($方法名, $参数数组)

1
2
3
4
5
6
7
8
9
10
11
<?php
class User {
public function __callStatic($arg1, $arg2) {
echo "$arg1,$arg2[0]";
}
}

$test = new User();
$test::callxxx('a'); // 静态调用不存在的方法,触发 __callStatic()
// 输出:callxxx,a
?>

说明: :: 是 PHP 的静态调用符号(也用于调用类常量和静态属性)。此处 callxxx 不是静态方法,触发 __callStatic()

__get() — 访问不存在的属性

触发时机: 访问一个对象中不存在的属性时触发。

参数: __get($属性名) — 传入被访问的属性名。

1
2
3
4
5
6
7
8
9
10
11
<?php
class User {
public $var1;
public function __get($arg1) {
echo $arg1;
}
}

$test = new User();
$test->var2; // var2 属性不存在,触发 __get(),输出"var2"
?>

说明: $var1 是存在的属性,访问它不会触发 __get()。而 $var2 不存在,触发 __get() 并将属性名 "var2" 传入。在 CTF 中,__get() 常被用于构造 POP 链——通过访问一个不存在的属性来跳转到另一个对象的某个方法。

__set() — 设置不存在的属性

触发时机: 给一个不存在的属性赋值时触发。

参数: __set($属性名, $属性值) — 传入属性名和要赋的值。

逻辑与 __get() 对称:__get() 是读不存在的属性,__set() 是写不存在的属性。

__isset() — 对不存在的属性使用 isset()/empty()

触发时机: 对不存在的属性使用 isset()empty() 时触发。

参数: __isset($属性名) — 传入被检测的属性名。

__unset() — 对不存在的属性使用 unset()

触发时机: 对不存在的属性使用 unset() 时触发。

参数: __unset($属性名) — 传入被销毁的属性名。

1
2
3
4
5
6
7
8
9
10
11
12
<?php
class User {
private $var;
public function __unset($arg1) {
echo $arg1;
}
}

$test = new User();
unset($test->var); // $var 是 private,从外部不可见,等同于不存在,触发 __unset()
// 输出:var
?>

说明: 此处 $var 虽然在类中定义了,但它是 private 的。从类外部访问时,PHP 将其视为不可访问/不存在,因此 unset() 操作触发了 __unset()


__clone() — 克隆对象时触发

触发时机: 使用 clone 关键字克隆对象时,在新对象上自动调用。

1
2
3
4
5
6
7
8
9
10
11
<?php
class User {
private $var;
public function __clone() {
echo "__clone test";
}
}

$test = new User();
$newclass = clone($test); // 触发 __clone(),输出"__clone test"
?>

说明: clone 创建的是对象的浅拷贝__clone() 在副本对象上执行,可以用来修正深拷贝需要处理的引用类型属性(如重新初始化内部对象引用,避免两个对象共享同一个子对象)。


附录:反序列化 POP 链常见触发链路

在 CTF 和安全审计中,理解魔术方法的触发顺序是构造 POP(Property-Oriented Programming)链的前提:

1
2
3
4
unserialize() → __wakeup() → 对象可用 → __destruct()
echo $obj → __toString() → __invoke() / __call()
$obj->prop → __get() → __call() / __invoke()
$obj->method()→ __call() → __get() / __toString()

典型利用思路:

  1. 找到可利用的 __destruct()__wakeup() 作为入口
  2. 在其中寻找对其他对象属性的操作,触发 __get() / __call() / __toString()跳板
  3. 最终跳到能执行危险操作的方法(如 eval()system() 等)作为终点