反序列化学习之PHP反序列化&POP链构造

反序列化学习(一)

前言

反序列化漏洞的学习贯穿了我的整个网安学习过程,从刚开始参加纳新考核到现在,反序列化的题目一直是难题,挡在学习的路上。

这次刷完了ctfshow的反序列化漏洞的相关题目,打算借这次机会重新总结一遍反序列化漏洞的相关知识。

反序列化漏洞的种类非常的多,在很多语言环境下你都会发现序列化储存信息的方式,所以反序列化漏洞也出现在了各种情况下。

总结一下:

  1. PHP反序列化漏洞

  2. session反序列化

  3. Phar反序列化漏洞

  4. Python反序列化漏洞

  5. .net 反序列化漏洞

  6. 其他

其实我对序列化漏洞的学习也仅仅处于一知半解的程度,在这里也只是想借总结的形式发现自己的不足,完成一下之前刷题的时候偷懒没有完成的复现,如果出现不恰当、错误的地方,希望各位大佬指正。

序列化和反序列化

几乎每一篇反序列化漏洞的讲解都是从这里开始的,我这里当然也不能例外,但是我想在这里添加一部分的面向对象编程的内容。

通过JSON理解序列化与反序列化

JSON(JavaScript Object Notation) 是一种轻量级的数据交换格式。JSON采用完全独立于语言的文本格式,这些特性使JSON成为理想的数据交换语言。易于人阅读和编写,同时也易于机器解析和生成。

我们可以通过这样的简单代码生成JSON:

<?PHP
​
$arr = array("First"=>"WHOAMI","Second"=>"bigcat","Third"=>"sp4c1ous");
echo json_encode($arr);
​
?>

我们可以很容易的发现,这里其实是一个二维的数组,但是当我们通过JSON加密之后,它就变成了下面这个样子:

image.png

它变成了一串由{}包裹的字符串,通过:维持一一对应的关系,是一个 "名称 / 值对" 的表现形式。

这样的格式不仅易于在函数间传输(就像是我们平时的压缩文件),而且具有较高的可读性。那么这种将原本的数据通过某种手段进行“压缩”,并且按照一定的格式存储的过程就可以称之为序列化。

PHP中的序列化

PHP 支持通过 serialize() 函数将对象序列化为字符串保存下来,然后在需要的时候再通过 unserialize() 函数将对应字符串反序列化为对象。

为什么需要这样呢?

我们把一个实例化的对象长久地存储在了计算机的磁盘上,无论什么时候调用都能恢复原来的样子,这其实是为了解决 PHP 对象传递的一个问题,因为 PHP 文件在执行结束以后就会将对象销毁,那么如果下次有一个页面恰好要用到刚刚销毁的对象就会束手无策,总不能你永远不让它销毁,等着你吧,于是人们就想出了一种能长久保存对象的方法,这就是 PHP 的序列化。

这里就涉及到面向对象编程的一些内容了。

PHP 5 完全重写了对象模型,从而使得自 PHP 5 开始,PHP 具备了完整的面向对象编程能力。面向对象编程(即 Object Oriented Programming,简称 OOP)是一种计算机编程架构,和基于函数构建程序(也被称作函数式编程)不同,面向对象编程的思想是在程序中包含各种独立而又相互调用的对象,每一个对象都应该能够接受数据、处理数据(通常通过对象方法实现)并将数据传达给其它对象,当我们下达指令时,不再是调用函数,而是指定对象的方法。因此,在面向对象编程中,对象是程序的基本单元,一个对象包含了数据和操作数据的函数。

面向对象编程中最核心的概念就是类(Class)和对象(Object)类是对象的抽象模板,而对象是类的具体实例,比如「Laravel 精品课」是一个课程,那么课程就是一个类,而「Laravel 精品课」是这个类的一个实例对象,对象包含的数据称之为类属性(Property),操作数据的函数称之为类方法(Method),还有相关的访问控制,而这些内容在PHP中找到的容身之所,就是序列化。

写一个简单的示例:

<?PHP
​
class Car
{
    const WHEELS = 4;   // 汽车都是4个轮子
    var $seats;         // 座位
    var $doors;         // 门
    var $engine;        // 发动机
    var $brand;         // 品牌
​
    public function getBrand()
    {
        return $this->brand;
    }
​
    public function setBrand($brand): void
    {
        $this->brand = $brand;
    }
  
    public function drive()
    {
    echo "1.启动引擎..." . PHP_EOL;
    echo "2.挂D档..." . PHP_EOL;
    echo "3.放下手刹..." . PHP_EOL;
    echo "4.踩油门,出发..." . PHP_EOL;
    }
​
    public function close()
    {
    echo "1.踩刹车..." . PHP_EOL;
    echo "2.挂P档..." . PHP_EOL;
    echo "3.拉起手刹..." . PHP_EOL;
    echo "4.关闭引擎..." . PHP_EOL;
    }
}
​
$car = new Car();
var_dump(Car::WHEELS);
​
$car->seats = 5;
var_dump($car->seats);
​
$car->setBrand("奔驰");
var_dump($car->getBrand());
​
$car->drive();
$car->close();
​
$a = serialize($car);
echo $a;
?>
​
//O:3:"Car":4:{s:5:"seats";i:5;s:5:"doors";N;s:6:"engine";N;s:5:"brand";s:6:"奔驰";}

类通过关键字class进行声明,然后紧跟着类名Car,通常我们通过首字母大写来定义类名,然后另起一行,通过一对花括号定义类的具体属性和方法。

这里我们通过 var 来定义变量属性,通过 const 来定义常量属性,由于汽车都是4个轮子,所以我们通过常量 WHEELS 来定义(大写、无 $ 前缀),而座位数、门、发动机、品牌都是可变的,所以通过变量进行定义。

有了属性之后,可以通过方法进行设置和获取,即上面的set和get。

除此之外,还可以编写其他自定义方法,比如汽车的最基本功能 —— 开车,我们为此定义一个 drive 方法,再来一个熄火方法close

有了这些基本的类属性和方法后,就可以基于这个类创建具体的对象并调用对象方法执行任务了,我们通常将基于类创建对象的过程称之为实例化,在 PHP 中,我们通过 new 关键字进行类的实例化,

$car = new Car();

然后就可以操作类属性或者调用类方法了,类常量值不可更改,只能访问,在类外面访问类常量,需要通过类名 + :: + 常量名的方式:

var_dump(Car::WHEELS);

由于常量是类级别的,无需实例化即可访问。而对于对象级别的类属性(变量类型),需要通过实例化后的对象才能访问,并且访问之前,需要先设置:

$car->seats = 5;
var_dump($car->seats);

当然,如果提供了 Setters/Getters 方法,可以通过这些方法进行设置/获取,从而屏蔽实现细节:

$car->setBrand("奔驰");
var_dump($car->getBrand());

要访问类方法,直接通过对象实例 + -> + 方法名即可:

$car->drive();
$car->close();

可以看到,在 PHP 中,对象级别的属性和方法,都是通过箭头符 -> 进行访问的。

上述所有代码的打印结果如下:

image.png

上面这些最基础的方法和属性的调用,是将来在反序列化题目中极为常见的,一开始的我什么都看不明白,只能跟着writeup瞎猜,现在想想,只有学了一定的面向对象编程,才能让我们对反序列化漏洞的认识清晰一点。

那么现在我们可以序列化 然后echo输出一下看一下结果。

image.png

里面的字母和数字详解:

字母:a - array b - boolean d - double i - integer o - common object r - reference s - string C - custom object O - class N - null R - pointer reference U - unicode string

数字:可以读出来,O后数字 "3" 是Car的字符数,再之后 "4" 是序列化中包含的属性数,后面 "s" 的也为字符数 "i" 后的是int整形数字。

可以看到输出的是Car类中有四对属性/值,其中seats = 5是我们设置的,所以其后跟了i:5,中间的两个属性由于没有设置相应的值所以都为NULL,最后是 奔驰brand 。

可以发现,我们原来定义的不止这些,还有我们的类方法,甚至那个常量(这里也是我没看到过的...), 都没有在序列化后的结果中输出

我们可以据此得到一个很重要的性质: 序列化他只序列化属性,不序列化方法

这个性质就引出了两个非常重要的话题:

1. 我们在反序列化的时候一定要保证在当前的作用域环境下有该类存在

这里不得不扯出反序列化的问题,这里先简单说一下,反序列化就是将我们压缩格式化的对象还原成初始状态的过程(可以认为是解压缩的过程),因为我们没有序列化方法,因此在反序列化以后我们如果想正常使用这个对象的话我们必须要依托于这个类要在当前作用域存在的条件

2. 我们在反序列化攻击的时候也就是依托类属性进行攻击

因为没有序列化方法嘛,我们能控制的只有类的属性,因此类属性就是我们唯一的攻击入口,在我们的攻击流程中,我们就是要寻找合适的能被我们控制的属性,然后利用它本身的存在的方法,在基于属性被控制的情况下发动我们的发序列化攻击(这是我们攻击的核心思想,这里先借此机会抛出来,大家有一个印象)

我们还需要格外关注一个小知识点

访问控制在序列化中的输出

PHP 通过 public(公开)、protected(保护)、private(私有)关键字控制类属性和方法的可见性:

  • 对于通过 public 声明的属性和方法,在类以外和继承类中均可见;

  • 对于通过 protected 声明的属性和方法,仅在继承类(支持多个层级)中可见,在类以外不可见;

  • 对于通过 private 声明的属性和方法,仅在当前类内部可见,在继承类和类之外不可见。

这也是面向对象编程的内容,与类的继承有关,继承在这里可能写不到了,需要自己去学习。

我们之前通过 var 声明类属性,这是比较老的用法,是为了向后兼容 PHP 4,在 PHP 5 中,通过 var 声明的属性和方法统统被视作 public 除此之外还有protectedprivate,它们的输出结果都存在一定的差异。

序列化是要输出属性的,那这三种不同类别的属性它当然也要区分得开~

写一段测试代码

<?PHP
​
class Test
{
    public $public = 'sp4c1ous';
    protected $protected = 'sp4c1ous';
    private $private = 'sp4c1ous';
}
​
$test = new Test();
$a = serialize($test);
echo $a;
?>

输出结果是这样的:

image.png

我们来依次解析一下:

  1. public 这种是最简单的,什么都不存在,直接将结果 s:6:"public";s:8:"sp4c1ous" 输出。

  2. protected 这种方式的输出结果中,在属性名处是存在一个%00*%00结构的,所以输出结果为s:12:"%00*%00protected";s:8:"sp4c1ous"%00即NULL占用了一个字符

  3. private 这里的属性名处存在一个%00Test%00,也算是好理解,因为是内部可见所以对类进行了一个标明,输出为s:13:"%00Test%00private";s:8:"sp4c1ous"

这里直接看是看不出来的,可以var_dump或者保存下输出结果来用hexdump查看。

我们后续的攻击中,像是反序列化字符逃逸,对于字符的要求非常严格,如果把握不好字符数是很容易出错的,这里作为PHP序列化的一个补充知识点。

PHP中的反序列化

刚才已经提过了,PHP中的反序列化就是通过unserialize函数把经过serialize序列化后的结果 "复原" 。

但是通过前文我们已经知道了,序列化之序列化了属性,所以反序列化也只会把序列化中的属性反序列化 。

写个例子反序列化一下刚刚的car吧 在最后写个这(竟然echo不出来...):

$a = serialize($car);
$b = unserialize($a);
print_r($b);

image.png

可以看到就是我们可以在序列化中读出来的几个属性和值。

那么如果我们更改了序列化中的属性,输出的结果不就改变了么~

image.png

image.png

被拿下了真实的操作中不会这么的轻易,需要分析魔术方法进行POP链构造等一系列的工作,但是从本质上看,我们就是在控制它的属性,和这里无异。

PHP反序列化漏洞

1.概念解释:

PHP 反序列化漏洞又叫做 PHP 对象注入漏洞,我觉得这个表达很不直白,也不能说明根本的问题,不如我们叫他 PHP 对象的属性篡改漏洞好了~

反序列化漏洞的成因在于代码中的 unserialize() 接收的参数可控,从上面的例子看,这个函数的参数是一个序列化的对象,而序列化的对象只含有对象的属性,那我们就要利用对对象属性的篡改实现最终的攻击。

2.魔术方法 ※

重点来了:

PHP: 魔术方法 - Manual 这是PHP手册中对魔术方法的解释。

  1. __construct()被称为构造方法,也就是在创造一个对象时候,首先会去执行的一个方法,通常用于赋值等,我们很少利用这个方法。

  2. __destruct()被称为析构方法,也叫销毁方法,析构函数会在到某个对象的所有引用都被删除或者当对象被显式销毁时执行。这是我们反序列化漏洞利用时非常重要的一个方法。

    <?php
    
    class MyDestructableClass 
    {
        function __construct() {
            print "In constructor\n";
        }
    
        function __destruct() {
            print "Destroying " . __CLASS__ . "\n";
        }
    }
    
    $obj = new MyDestructableClass();

    image.png

  3. __set() __get() __isset() __unset()四个方法为属性重载。

    image.png

    我们写一个验证的代码

    class test {
    
        private $flag = '';
    
        # 用于保存重载的数据 
        private $data = array();
    
        public $filename = '';
    
        public $content = '';
    
        function __construct($filename, $content) {
            $this->filename = $filename;
            $this->content = $content;
            echo 'construct function in test class';
            echo "<br>";
        }
    
        function __destruct() {
            echo 'destruct function in test class';
            echo "<br>";
        }
    
        function __set($key, $value) {
            echo 'set function in test class';
            echo "<br>";
            $this->data[$key] = $value;
        }
    
        function __get($key) {
            echo 'get function in test class';
            echo "<br>";
            if (array_key_exists($key, $this->data)) {
                return $this->data[$key];
            } else {
                return null;
            }
        }
    
        function __isset($key) {
            echo 'isset function in test class';
            echo "<br>";
            return isset($this->data[$key]);
        }
    
        function __unset($key) {
            echo 'unset function in test class';
            echo "<br>";
            unset($this->data[$key]);
        }
    
        public function set_flag($flag) {
            $this->flag = $flag;
        }
    
        public function get_flag() {
            return $this->flag;
        }
    }

$a = new test('test.txt', 'data');

__set() 被调用

$a->var = 1;

__get() 被调用

echo $a->var;

__isset() 被调用

var_dump(isset($a->var));

__unset() 被调用

unset($a->var);

var_dump(isset($a->var));

echo "\n";

   输出结果为:
   ![image.png](https://b3logfile.com/siyuan/1621238442570/assets/image-20211003153053-6r6njhv.png)
   主要还是好好读读文档,这一部分再题目中其实也不太常用。
4. `__call()` `__callStatic()`和上面类似,为方法重载
   ![image.png](https://b3logfile.com/siyuan/1621238442570/assets/image-20211003153541-gre5r8d.png)
   类似以上介绍过的`__set()`和`__get()`,刚刚是访问不存在或者不可访问属性时候进行的调用。现在是访问不存在或者不可访问的方法时候,就不放那么多代码占空了。
5. `__sleep()` `__wakeup()` 这里是对于反序列化漏洞利用非常重要的两个方法
   ![image.png](https://b3logfile.com/siyuan/1621238442570/assets/image-20211003153909-4fd3qcm.png)
   ```php
   <?
   class test {

       private $flag = '';

       # 用于保存重载的数据 
       private $data = array();

       public $filename = '';

       public $content = '';

       function __construct($filename, $content) {
           $this->filename = $filename;
           $this->content = $content;
           echo 'construct function in test class';
           echo "<br>";
       }

       function __destruct() {
           echo 'destruct function in test class';
           echo "<br>";
       }

       # 反序列化时候触发
       function __wakeup() {
           // file_put_contents($this->filename, $this->data);
           echo 'wakeup function in test class';
           echo "<br>";
       }

       # 一般情况用在序列化操作时候,用于保留数据
       function __sleep() {
           echo 'sleep function in test class';
           echo "<br>";
           return array('flag', 'filename', 'data');
       }

       public function set_flag($flag) {
           $this->flag = $flag;
       }

       public function get_flag() {
           return $this->flag;
       }
   }

   $key = serialize(new test('test.txt', 'test'));

   var_dump($key);

   $b = unserialize($key);

   print_r($b);

返回结果如下,可以看到是先__sleep__wakeup,因为肯定是先序列化再反序列化的嘛,还是很好理解的。

image.png

  1. __toString()方法 也是非常重要的一个魔术方法

    image.png

    image.png

    看文档挺简单的,但是这里是个例如啊,__toString()可以说是利用方式最多的魔术方法。

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

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

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

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

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

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

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

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

3.为什么要提到这些魔法方法?

在我们的攻击中,反序列化函数 unserialize() 是我们攻击的入口,也就是说,只要这个参数可控,我们就能传入任何的已经序列化的对象(只要这个类在当前作用域存在我们就可以利用),而不是局限于出现 unserialize() 函数的类的对象,如果只能局限于当前类,那我们的攻击面也太狭小了,这个类不调用危险的方法我们就没法发起攻击。

但是我们又知道,你反序列化了其他的类对象以后 我们只是控制了是属性,如果你没有在完成反序列化后的代码中调用其他类对象的方法,我们还是束手无策,毕竟代码是人家写的,人家本身就是要反序列化后调用该类的某个安全的方法,你总不能改人家的代码吧,但是没关系,因为我们有魔术方法。

正如上面介绍的,魔术方法的调用是在该类序列化或者反序列化的同时自动完成的,不需要人工干预,这就非常符合我们的想法,因此只要魔术方法中出现了一些我们能利用的函数,我们就能通过反序列化中对其对象属性的操控来实现对这些函数的操控,进而达到我们发动攻击的目的。

4.利用魔术方法的攻击示例

假设有这样一段代码

<?php
class sp4c1ous {
    private $test;
    public $sp4c1ous = "i am sp4c1ous";
    function __construct() {
        $this->test = new sdpc();
    }

    function __destruct() {
        $this->test->action();
    }
}

class sdpc {
    function action() {
        echo "Welcome to sdpcsec";
    }
}

class Evil {

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

unserialize($_GET['test']);

让我们先来审计一下这一段代码,这个代码有三个类,分别为sp4c1ous、sdpc、Evil。其中,sdpc是一个简单的echo输出没有被调用,Evil倒是一个恶意的代码执行但是也没有被调用,我们重点来审计一下sdpc这个类。

它有两个魔术方法,第一个__construct(),其实我们可以直接跳过对它的分析,毕竟它永远也不会被后面的代码调用,它只不过是我们用来操纵属性的玩物罢了,第二个__destruct()就它了,用到了一个属性 test ,终于有我们可以操控的东西了!里面这里调用了一个action()方法 sdpc和Evil这两个类里都有action()方法,但是Evil 这个类中他的 action() 函数调用了 eval()

那么思路就是:我们需要将 sp4c1ous 这个类中的 test 属性篡改为 Evil 这个类的对象,然后为了 eval 能执行命令,我们还要篡改 Evil 对象的 test2 属性,将其改成我们需要的 eval 执行的命令 :

<?php
class sp4c1ous {
    private $test;
    function __construct() {
        $this->test = new Evil;
    }
}


class Evil {

    var $test2 = "phpinfo();";

}

$sp4c1ous = new sp4c1ous;
$data = serialize($sp4c1ous);
//O:8:"sp4c1ous":1:{s:14:"%00sp4c1ous%00test";O:4:"Evil":1:{s:5:"test2";s:10:"phpinfo();";}}

我们去除了一切与我们要篡改的属性无关的内容 传入 就可以看到结果啦

image.png

还是要注意一下那里的访问控制,我们生成的payload直接复制是复制不下来NULL

通过这个简单的例子总结一下寻找 PHP 反序列化漏洞的方法或者说流程

  1. 寻找 unserialize() 函数的参数是否有我们的可控点

  2. 寻找我们的反序列化的目标,重点寻找 存在 wakeup() 或 destruct() 魔法函数的类

  3. 一层一层地研究该类在魔法方法中使用的属性和属性调用的方法,看看是否有可控的属性能实现在当前调用的过程中触发的

  4. 找到我们要控制的属性了以后我们就将要用到的代码部分复制下来,然后构造序列化,发起攻击

POP 链的介绍

找POP链 这是 整个反序列化漏洞中,最容易让人产生快感的一个部分,像极了高达、拼图、乐高、魔方... 诸如此类

玩过 pwn 的同学应该对 ROP 并不陌生,ROP 的全称是面向返回编程(Return-Oriented Programing),ROP 链构造中是寻找当前系统环境中或者 内存环境里已经存在的 、具有固定地址且带有返回操作的指令集,将这些本来无害的片段拼接起来,形成一个连续的层层递进的调用链,最终达到我们的执行 libc 中函数或者是 systemcall 的目的

POP 面向属性编程(Property-Oriented Programing) 常用于上层语言构造特定调用链的方法,与二进制利用中的面向返回编程(Return-Oriented Programing)的原理相似,都是从现有运行环境中寻找一系列的代码或者指令调用,然后根据需求构成一组连续的调用链,最终达到攻击者邪恶的目的

说的再具体一点就是 ROP 是通过栈溢出实现控制指令的执行流程,而我们的反序列化是通过控制对象的属性从而实现控制程序的执行流程,进而达成利用本身无害的代码进行有害操作的目的~

实战举例

当然这个案例里面似乎少了比较关键的 unserialize() 函数,那我们就假设这个 unserialize() 在我们的第一张图片的里面,并且参数完全可控

image.png

image.png

image.png

image.png

image.png

这是看K0rz3n师傅讲PHP序列化的时候他给出的例子,有点老旧了,但是因为比较清晰,所以这里还是选择再尝试自己分析一遍这个链来呈现一下POP链,当然这样给你相应部分和你真正审计起源码包来还是不一样的,想要真的做出题来还是要继续下苦功夫啊。。

思路:

  1. 寻找 unserialize() 函数的参数是否有我们的可控点

  2. 寻找我们的反序列化的目标,重点寻找 存在 wakeup()destruct() 魔术方法的类

  3. 一层一层地研究该类在魔法方法中使用的属性和属性调用的方法,看看是否有可控的属性能实现在当前调用的过程中触发的

开始顺着这个思路打一下。

  • 一开始的时候说过我们假设这个 unserialize() 在我们的第一张图片的里面,并且参数完全可控,那么我们就从第一张图片开始分析。

  • 第一张图片中有一个_writers属性,然后通过遍历_writers属性给一个$write赋了值,但是通过$write能调用shutdown()方法来看,这个$write应该是某一个类的实例化对象(不明白这个是什么看上面的面向对象编程哦~),所以我们还需要继续往下挖掘。

  • 然后就到了第二张图,我们通过shutdown()方法找到了这样的一个子类,代码逻辑很简单,我们重点看一下它调用的属性和方法,首先第一个empty函数这里调用了_eventsToMail属性,然后又在第二个if里调用了_subjectPrependText,然后后面还有_mail_layout_layoutEventsToMail,和在第一张图片中一样,我们可以发现,这些由$this调用出来的属性,包括_mail_layout,在后续也进行了方法的调用,那么我们就要继续重复,我们跟踪这个_layout调用的render()方法进入下一个类,_mail理论上也要追下去,但是这里只是一个演示,并没有呈现出来这一块无关的部分。

  • 第三张图的这个类里我们看到了_layout调用的render()函数,这个类里有定义三个属性_inflector_inflectorEnabled_layout,但是紧接着我们就能发现,在第二个if里_inflector又调用了一个filter()方法,我们还要继续往下找这个方法。

  • 第四张图就是我们找到的filter()方法所在的类,这里也有定义两个属性_matchPattern_replacement,但是到这里,已经没有新的方法的调用了。同时这里有一个preg_replace()函数!同时它的参数就是当前类里的我们可以控制的属性 接下来我们就可以代码执行了。

  • 所以整个链就是:

    $writer->shutdown()->render()->filter()->preg_replace(我们控制的属性)->代码执行

image.png

这样看起来就很清晰了,环环相扣的感觉也是非常的棒。

到这里这一篇就结束了,后续会补充一些题目的练习(我练习),之后会对PHP反序列化进行一些补充,像是phar、session,然后再进行python反序列化和.net反序列化的学习

参考文章:

一篇文章带你深入理解漏洞之 PHP 反序列化漏洞 | K0rz3n's Blog

反序列化详解 - Site-01

PHP 类与对象、访问控制 | 面向对象编程 | PHP 入门到实战教程

PHP: 魔术方法 - Manual

  • 4
    点赞
  • 23
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值